├── lib ├── parser │ ├── regexp │ │ ├── .npmignore │ │ ├── .gitignore │ │ ├── index.js │ │ ├── parser.ne │ │ └── token.js │ ├── statement │ │ ├── identifier.js │ │ ├── expression.js │ │ ├── array.js │ │ ├── literal.js │ │ ├── conditional.js │ │ ├── unary.js │ │ ├── logical.js │ │ ├── call.js │ │ ├── base.js │ │ ├── binary.js │ │ └── member.js │ ├── string-methods.js │ ├── types.js │ ├── scope.js │ ├── index.js │ └── specs.js ├── pad.js ├── paths.js ├── test-cmd.js ├── util.js ├── database │ ├── query.js │ └── results.js └── firebase.js ├── .eslintignore ├── test ├── mocha.opts ├── jasmine │ ├── .eslintrc.yml │ └── core.js ├── spec │ ├── .eslintrc.yml │ ├── lib │ │ ├── paths.js │ │ ├── parser │ │ │ ├── string-methods.js │ │ │ ├── regexp.js │ │ │ └── rule.js │ │ ├── util.js │ │ └── database │ │ │ └── query.js │ └── plugins │ │ └── chai.js ├── jest │ ├── .eslintrc.yml │ ├── __snapshots__ │ │ ├── core.test.js.snap │ │ └── matchers.test.js.snap │ ├── core.test.js │ └── matchers.test.js ├── setup.js └── integration │ ├── rules.json │ └── tests.json ├── docs ├── jasmine │ ├── examples │ │ └── spec │ │ │ ├── security │ │ │ ├── data.json │ │ │ ├── rules.json │ │ │ ├── bad-rules.json │ │ │ ├── erroring.js │ │ │ ├── passing.js │ │ │ └── failing.js │ │ │ └── support │ │ │ └── jasmine.json │ └── README.md ├── chai │ ├── examples │ │ ├── bad-rules.json │ │ ├── data.json │ │ ├── rules.json │ │ ├── erroring.js │ │ ├── passing.js │ │ └── failing.js │ └── README.md ├── targaryen │ └── README.md └── jest │ └── README.md ├── bin ├── .eslintrc.yml ├── build.sh ├── targaryen └── targaryen-specs ├── spec └── support │ └── jasmine.json ├── .travis.yml ├── LICENSE ├── .gitignore ├── .jshintrc ├── index.js ├── .eslintrc.yml ├── package.json ├── plugins ├── jasmine.js ├── jest.js └── chai.js ├── CONTRIBUTING.md ├── README.md └── USAGE.md /lib/parser/regexp/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/parser/regexp/.gitignore: -------------------------------------------------------------------------------- 1 | parser.js 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/parser/regexp/parser.js 2 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require test/setup.js 2 | --recursive 3 | --bail 4 | -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/data.json: -------------------------------------------------------------------------------- 1 | ../../../../chai/examples/data.json -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/rules.json: -------------------------------------------------------------------------------- 1 | ../../../../chai/examples/rules.json -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/bad-rules.json: -------------------------------------------------------------------------------- 1 | ../../../../chai/examples/bad-rules.json -------------------------------------------------------------------------------- /bin/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: '../.eslintrc.yml' 2 | rules: 3 | no-console: "off" 4 | no-restricted-modules: "off" 5 | -------------------------------------------------------------------------------- /docs/chai/examples/bad-rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "users": { 4 | ".read": 7 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "test/jasmine", 3 | "spec_files": [ 4 | "**/*.js" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec/security", 3 | "spec_files": [ 4 | "**/*.js" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/jasmine/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: '../../.eslintrc.yml' 2 | env: 3 | jasmine: true 4 | rules: 5 | prefer-arrow-callback: 6 | - "off" 7 | -------------------------------------------------------------------------------- /test/spec/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: '../../.eslintrc.yml' 2 | env: 3 | mocha: true 4 | globals: 5 | expect: true 6 | sinon: true 7 | rules: 8 | max-nested-callbacks: "off" 9 | no-new: "off" 10 | prefer-arrow-callback: "off" 11 | -------------------------------------------------------------------------------- /test/jest/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: '../../.eslintrc.yml' 2 | plugins: 3 | - jest 4 | env: 5 | jest/globals: true 6 | rules: 7 | jest/no-disabled-tests: warn 8 | jest/no-focused-tests: error 9 | jest/no-identical-title: error 10 | jest/valid-expect: error -------------------------------------------------------------------------------- /docs/chai/examples/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": { 3 | "password:500f6e96-92c6-4f60-ad5d-207253aee4d3": { 4 | "name": "Rickard Stark" 5 | }, 6 | "password:3403291b-fdc9-4995-9a54-9656241c835d": { 7 | "name": "Aerys the Mad", 8 | "king": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/chai/examples/rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "users": { 4 | "$user": { 5 | "innocent": { 6 | ".validate": "data.parent().child('on-fire').val() === false" 7 | }, 8 | ".read": "auth !== null", 9 | ".write": "auth !== null && root.child('users').child(auth.uid).child('king').val() === true" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "6" 5 | script: 6 | - > 7 | if [ "$TRAVIS_EVENT_TYPE" == "cron" ] && node --version | grep v6\. ; then 8 | ./bin/build.sh test:live; 9 | else 10 | echo "skipping live tests"; 11 | fi 12 | - npm run coveralls 13 | - npm run lint 14 | - > 15 | if node --version | grep v6\. ; then 16 | npm run test:plugin:jest; 17 | else 18 | echo "skipping jest plugin test"; 19 | fi 20 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Register chai plugins and augment mocha global. 3 | * 4 | * Should be used with mocha's "require" flag to be imported before any mocha 5 | * test are loaded or run. 6 | * 7 | */ 8 | 9 | 'use strict'; 10 | 11 | const chai = require('chai'); 12 | const sinon = require('sinon'); 13 | const sinonChai = require('sinon-chai'); 14 | const dirtyChai = require('dirty-chai'); 15 | 16 | chai.use(sinonChai); 17 | chai.use(dirtyChai); 18 | 19 | global.expect = chai.expect; 20 | global.sinon = sinon; 21 | -------------------------------------------------------------------------------- /docs/chai/examples/erroring.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in your app this would be require('targaryen/plugins/chai') 4 | const targaryen = require('../../../plugins/chai'); 5 | const chai = require('chai'); 6 | const path = require('path'); 7 | const rules = targaryen.json.loadSync(path.join(__dirname, 'bad-rules.json')); 8 | 9 | chai.use(targaryen); 10 | 11 | describe('An invalid set of security rules', function() { 12 | 13 | it('causes Targaryen to throw', function() { 14 | targaryen.setFirebaseData(require('./data.json')); 15 | targaryen.setFirebaseRules(rules); 16 | }); 17 | 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/erroring.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in your app this would be require('targaryen/plugins/jasmine') 4 | const targaryen = require('../../../../../plugins/jasmine'); 5 | const path = require('path'); 6 | 7 | const rules = targaryen.json.loadSync(path.join(__dirname, 'bad-rules.json')); 8 | 9 | describe('An invalid set of security rules and data', function() { 10 | 11 | beforeEach(function() { 12 | jasmine.addMatchers(targaryen.matchers); 13 | }); 14 | 15 | it('causes Targaryen to throw', function() { 16 | 17 | targaryen.setFirebaseData(require('./data.json')); 18 | targaryen.setFirebaseRules(rules); 19 | 20 | }); 21 | 22 | }); 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2014-2016 Harry Schmidt . 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /lib/pad.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | /** 6 | * Pad start of each lines. 7 | * 8 | * Options: 9 | * 10 | * - length: length of the padding (2 by default); 11 | * - seq: sequence to pad with (' ' by default); 12 | * - eol: platform specific. 13 | * 14 | * @param {string} str String to pad 15 | * @param {{length: number, seq: string, eol: string}} options Padding options 16 | * @return {string} 17 | */ 18 | exports.lines = function(str, options) { 19 | const opts = Object.assign({ 20 | length: 2, 21 | seq: ' ', 22 | eol: os.EOL 23 | }, options); 24 | const padding = opts.seq.repeat(opts.length); 25 | 26 | return str.split(opts.eol) 27 | .map(line => `${padding}${line}`) 28 | .join(opts.eol); 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | lint.html 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # Commenting this out is preferred by some people, see 25 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 26 | node_modules 27 | 28 | # Users Environment Variables 29 | .lock-wscript 30 | 31 | # Editors 32 | *.sublime-* 33 | targaryen-service-account.json 34 | targaryen-secret.json -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6, 3 | "freeze": true, 4 | "node": true, 5 | "bitwise": true, 6 | "camelcase": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "eqnull": true, 10 | "immed": true, 11 | "indent": 2, 12 | "latedef": "nofunc", 13 | "newcap": true, 14 | "noarg": true, 15 | "quotmark": "single", 16 | "regexp": true, 17 | "undef": true, 18 | "unused": true, 19 | "strict": true, 20 | "sub": true, 21 | "trailing": true, 22 | "smarttabs": true, 23 | "globals": { 24 | "after" : false, 25 | "afterEach" : false, 26 | "before" : false, 27 | "beforeEach" : false, 28 | "describe" : false, 29 | "expect" : false, 30 | "it" : false, 31 | "jasmine" : false, 32 | "pending" : false, 33 | "sinon" : false, 34 | "xdescribe" : false 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/jest/__snapshots__/core.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generic matchers toBeAllowed 1`] = ` 4 | "Expected operation to be allowed but it was denied 5 | 6 | Attempt to read /user as null. 7 | 8 | /user: read 9 | 10 | No .read rule allowed the operation. 11 | read was denied." 12 | `; 13 | 14 | exports[`generic matchers toBeAllowed 2`] = ` 15 | "Expected operation to be denied but it was allowed 16 | 17 | Attempt to write /user/1234 as {\\"uid\\":\\"1234\\"}. 18 | New Value: \\"{ 19 | \\"name\\": \\"Anna\\" 20 | }\\". 21 | 22 | /user/1234: write \\"auth.uid === $uid\\" => true 23 | auth.uid === $uid [=> true] 24 | using [ 25 | $uid = \\"1234\\" 26 | auth = {\\"uid\\":\\"1234\\"} 27 | auth.uid = \\"1234\\" 28 | ] 29 | 30 | write was allowed." 31 | `; 32 | -------------------------------------------------------------------------------- /lib/parser/statement/identifier.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling binary expressions validation and evaluation (delegated to the 3 | * scope and state objects). 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | const base = require('./base'); 10 | 11 | const Node = base.Node; 12 | const ParseError = base.ParseError; 13 | 14 | function hasOwnProperty(obj, prop) { 15 | return Object.prototype.hasOwnProperty.call(obj, prop); 16 | } 17 | 18 | class IdentifierNode extends Node { 19 | 20 | get name() { 21 | return this.astNode.name; 22 | } 23 | 24 | inferType(scope) { 25 | if (!scope.has(this.name)) { 26 | throw new ParseError(this, `${this.name} is undefined`); 27 | } 28 | 29 | return scope.getTypeOf(this.name); 30 | } 31 | 32 | evaluate(state) { 33 | if (!hasOwnProperty(state, this.name)) { 34 | throw new ParseError(this, 'unknown variable ' + this.name); 35 | } 36 | 37 | return state[this.name]; 38 | } 39 | 40 | } 41 | 42 | Node.register('Identifier', IdentifierNode); 43 | -------------------------------------------------------------------------------- /lib/parser/statement/expression.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling root expressions validation and evaluation (delegated to its 3 | * child). 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | const base = require('./base'); 10 | 11 | const Node = base.Node; 12 | 13 | class ExpressionNode extends Node { 14 | 15 | init(source, astNode, scope) { 16 | this.expression = Node.from(source, astNode.expression, scope); 17 | } 18 | 19 | inferType() { 20 | return this.expression.inferredType; 21 | } 22 | 23 | evaluate(state) { 24 | return this.expression.evaluate(state); 25 | } 26 | 27 | toString() { 28 | return this.expression.toString(); 29 | } 30 | 31 | debug(state, cb) { 32 | const ev = this.expression.debug(state, cb); 33 | const value = ev.value; 34 | const detailed = ev.detailed; 35 | 36 | cb({ 37 | type: this.astNode.type, 38 | original: this.original, 39 | detailed, 40 | value 41 | }); 42 | 43 | return {detailed, value}; 44 | } 45 | 46 | } 47 | 48 | Node.register('ExpressionStatement', ExpressionNode); 49 | -------------------------------------------------------------------------------- /test/integration/rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "posts": { 4 | /** 5 | * Post. 6 | * 7 | * Read: anybody with the correct clearance level; each post has its own level requirement 8 | * Write: by the author only. 9 | */ 10 | "$post": { 11 | ".read": "root.child('users').child(auth.uid).child('clearance-level').val() >= data.child('clearance-level').val()", 12 | ".write": "root.child('users').child(auth.uid).child('author').val() === true", 13 | ".validate": "newData.hasChildren() && newData.hasChild('date')", 14 | "date": { 15 | ".validate": " 16 | data.parent().exists() === false 17 | && newData.val() <= now 18 | " 19 | } 20 | } 21 | }, 22 | "flight-routes": { 23 | // Flight routes are public and can be added/edited bu ticket agents 24 | "$from": { 25 | "$to": { 26 | ".read": "true", 27 | ".write": "auth.ticketagent === true", 28 | ".validate": "$from !== $to" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/passing.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | // in your app this would be require('targaryen/plugins/jasmine') 5 | const targaryen = require('../../../../../plugins/jasmine'); 6 | const path = require('path'); 7 | 8 | const rules = targaryen.json.loadSync(path.join(__dirname, 'rules.json')); 9 | const users = targaryen.users; 10 | 11 | targaryen.setFirebaseData(require('./data.json')); 12 | targaryen.setFirebaseRules(rules); 13 | 14 | describe('A valid set of security rules and data', function() { 15 | 16 | beforeEach(function() { 17 | jasmine.addMatchers(targaryen.matchers); 18 | }); 19 | 20 | it('can be tested against', function() { 21 | 22 | expect(users.unauthenticated).cannotRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 23 | expect(users.password).canRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 24 | 25 | expect(users.password).cannotWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent', true); 26 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }).canWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/on-fire', true); 27 | 28 | }); 29 | 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /docs/jasmine/examples/spec/security/failing.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in your app this would be require('targaryen/plugins/jasmine') 4 | const targaryen = require('../../../../../plugins/jasmine'); 5 | const path = require('path'); 6 | 7 | const rules = targaryen.json.loadSync(path.join(__dirname, 'rules.json')); 8 | const users = targaryen.users; 9 | 10 | targaryen.setFirebaseData(require('./data.json')); 11 | targaryen.setFirebaseRules(rules); 12 | 13 | describe('A valid set of security rules and data', function() { 14 | 15 | beforeEach(function() { 16 | jasmine.addMatchers(targaryen.matchers); 17 | }); 18 | 19 | it('can have read errors', function() { 20 | expect(users.unauthenticated).canRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 21 | }); 22 | 23 | it('can have write errors', function() { 24 | expect(users.password).canWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent', true); 25 | }); 26 | 27 | it('can have validation errors', function() { 28 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }).canWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent', true); 29 | }); 30 | 31 | }); 32 | 33 | -------------------------------------------------------------------------------- /lib/parser/statement/array.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling array expression validation and evaluation. 3 | * 4 | * There's no requirement regarding its element types. 5 | * 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const base = require('./base'); 11 | 12 | const Node = base.Node; 13 | 14 | class ArrayNode extends Node { 15 | 16 | init(source, astNode, scope) { 17 | this.elements = astNode.elements.map(n => Node.from(source, n, scope)); 18 | } 19 | 20 | inferType() { 21 | return 'array'; 22 | } 23 | 24 | evaluate(state) { 25 | return this.elements.map(e => e.evaluate(state)); 26 | } 27 | 28 | toString() { 29 | const elements = this.elements.map(e => e.toString()); 30 | 31 | return `[${elements.join(', ')}]`; 32 | } 33 | 34 | debug(state, cb) { 35 | const evaluations = this.elements.map(el => el.debug(state, cb)); 36 | const value = evaluations.map(ev => ev.value); 37 | const detailed = `[${evaluations.map(ev => ev.detailed).join(', ')}]`; 38 | 39 | cb({ 40 | type: this.astNode.type, 41 | original: this.original, 42 | detailed, 43 | value 44 | }); 45 | 46 | return {detailed, value}; 47 | } 48 | 49 | } 50 | 51 | Node.register('ArrayExpression', ArrayNode); 52 | -------------------------------------------------------------------------------- /lib/parser/string-methods.js: -------------------------------------------------------------------------------- 1 | /** 2 | * String methods. 3 | * 4 | * Might have a different behaviour and have stricter type validation. 5 | * 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const replaceall = require('replaceall'); 11 | 12 | module.exports = { 13 | 14 | contains(str, substr) { 15 | if (typeof substr !== 'string') { 16 | throw new Error(`${substr} is not a string.`); 17 | } 18 | 19 | return str.indexOf(substr) !== -1; 20 | }, 21 | 22 | beginsWith(str, substr) { 23 | if (typeof substr !== 'string') { 24 | throw new Error(`${substr} is not a string.`); 25 | } 26 | 27 | return str.indexOf(substr) === 0; 28 | }, 29 | 30 | endsWith(str, substr) { 31 | if (typeof substr !== 'string') { 32 | throw new Error(`${substr} is not a string.`); 33 | } 34 | 35 | return str.indexOf(substr) + substr.length === str.length; 36 | }, 37 | 38 | replace(str, substr, replacement) { 39 | if (typeof substr !== 'string' || typeof replacement !== 'string') { 40 | throw new Error(`${substr} is not a string.`); 41 | } 42 | 43 | return replaceall(substr, replacement, str); 44 | }, 45 | 46 | matches(str, regex) { 47 | return regex.test(str); 48 | } 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /docs/chai/examples/passing.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in your app this would be require('targaryen/plugins/chai') 4 | const targaryen = require('../../../plugins/chai'); 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const users = targaryen.users; 8 | const path = require('path'); 9 | const rules = targaryen.json.loadSync(path.join(__dirname, 'rules.json')); 10 | 11 | chai.use(targaryen); 12 | 13 | describe('A valid set of security rules and data', function() { 14 | 15 | before(function() { 16 | targaryen.setFirebaseData(require('./data.json')); 17 | targaryen.setFirebaseRules(rules); 18 | }); 19 | 20 | it('can be tested against', function() { 21 | 22 | expect(users.unauthenticated).cannot.read.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 23 | expect(users.password).can.read.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 24 | 25 | expect(users.password).cannot.write(true) 26 | .to.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent'); 27 | 28 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }).can.write(true) 29 | .to.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/on-fire'); 30 | 31 | }); 32 | 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const helpers = require('./lib/util'); 5 | const util = require('util'); 6 | const database = require('./lib/database'); 7 | 8 | exports.util = helpers; 9 | exports.database = database.create; 10 | exports.store = database.store; 11 | exports.ruleset = database.ruleset; 12 | 13 | // Deprecate direct access to plugin helpers 14 | Object.defineProperties(exports, [ 15 | 'setFirebaseData', 16 | 'setFirebaseRules', 17 | 'setDebug', 18 | 'users' 19 | ].reduce((props, key) => { 20 | props[key] = { 21 | get: util.deprecate( 22 | () => helpers[key], 23 | `Deprecated: use "chai.${key}" or "jasmine.${key}" directly.` 24 | ), 25 | enumerable: true, 26 | configurable: true 27 | }; 28 | 29 | return props; 30 | }, {})); 31 | 32 | // Deprecate direct access to plugins 33 | Object.defineProperties(exports, [ 34 | 'chai', 35 | 'jasmine' 36 | ].reduce((props, key) => { 37 | const path = `./plugins/${key}`; 38 | 39 | props[key] = { 40 | get: util.deprecate( 41 | () => require(path), 42 | `Deprecated: use "const ${key} = require('targaryen/plugins/${key}');"` 43 | ), 44 | enumerable: true, 45 | configurable: true 46 | }; 47 | 48 | return props; 49 | }, {})); 50 | -------------------------------------------------------------------------------- /docs/chai/examples/failing.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in your app this would be require('targaryen/plugins/chai') 4 | const targaryen = require('../../../plugins/chai'); 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const users = targaryen.users; 8 | const path = require('path'); 9 | const rules = targaryen.json.loadSync(path.join(__dirname, 'rules.json')); 10 | 11 | chai.use(targaryen); 12 | 13 | describe('A valid set of security rules and data', function() { 14 | 15 | before(function() { 16 | targaryen.setFirebaseData(require('./data.json')); 17 | targaryen.setFirebaseRules(rules); 18 | }); 19 | 20 | it('can have read errors', function() { 21 | expect(users.unauthenticated).can.read.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 22 | }); 23 | 24 | it('can have write errors', function() { 25 | 26 | expect(users.password).can.write(true) 27 | .to.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent'); 28 | 29 | }); 30 | 31 | it('can have validation errors', function() { 32 | 33 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }).can.write(true) 34 | .to.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent'); 35 | 36 | }); 37 | 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /test/spec/lib/paths.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test path helper functions. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const paths = require('../../../lib/paths.js'); 8 | 9 | describe('paths', function() { 10 | 11 | describe('join', function() { 12 | 13 | it('should merge path', function() { 14 | expect(paths.join('foo', 'bar/baz')).to.equal('foo/bar/baz'); 15 | expect(paths.join('foo', '')).to.equal('foo'); 16 | expect(paths.join('', 'bar/baz')).to.equal('bar/baz'); 17 | }); 18 | 19 | it('should trim root path', function() { 20 | expect(paths.join('/foo/', 'bar/baz')).to.equal('foo/bar/baz'); 21 | }); 22 | 23 | it('should trim the beginning of the path', function() { 24 | expect(paths.join('foo', '/bar/baz')).to.equal('foo/bar/baz'); 25 | }); 26 | 27 | }); 28 | 29 | describe('split', function() { 30 | 31 | it('should split the path', function() { 32 | expect(paths.split('foo/bar/baz')).to.eql(['foo', 'bar', 'baz']); 33 | }); 34 | 35 | it('should no split empty path', function() { 36 | expect(paths.split('')).to.eql([]); 37 | }); 38 | 39 | it('should trim the beginning of the path', function() { 40 | expect(paths.split('/foo/bar/baz')).to.eql(['foo', 'bar', 'baz']); 41 | }); 42 | 43 | }); 44 | 45 | }); 46 | -------------------------------------------------------------------------------- /lib/paths.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Paths helpers functions to avoid multiple path separator issues. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const invalidChar = ['.', '#', '$', '[', ']']; 8 | 9 | exports.isValid = function(path) { 10 | return invalidChar.some(char => path.includes(char)) === false; 11 | }; 12 | 13 | exports.mustBeValid = function(path) { 14 | if (!exports.isValid(path)) { 15 | throw new Error(`Invalid location "${path}" contains one of [${invalidChar.join(', ')}]`); 16 | } 17 | }; 18 | 19 | exports.trimLeft = function(path) { 20 | path = path || ''; 21 | 22 | return path.replace(/^\/+/, ''); 23 | }; 24 | 25 | exports.trimRight = function(path) { 26 | path = path || ''; 27 | 28 | return path.replace(/\/+$/, ''); 29 | }; 30 | 31 | exports.trim = function(path) { 32 | return exports.trimLeft(exports.trimRight(path)); 33 | }; 34 | 35 | exports.join = function(root, path) { 36 | root = exports.trim(root); 37 | path = exports.trim(path); 38 | 39 | if (!root) { 40 | return path; 41 | } 42 | 43 | if (!path) { 44 | return root; 45 | } 46 | 47 | return root + '/' + path; 48 | }; 49 | 50 | exports.split = function(path) { 51 | path = exports.trimLeft(path); 52 | 53 | if (!path) { 54 | return []; 55 | } 56 | 57 | return path.split('/'); 58 | }; 59 | -------------------------------------------------------------------------------- /lib/parser/statement/literal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling literal expressions validation and evaluation. 3 | * 4 | * The only validation applies the RepExp flags of which on "i" is supported 5 | * by firebase. 6 | * 7 | * TODO: support of Firebase RexExp limitations. 8 | * 9 | */ 10 | 11 | 'use strict'; 12 | 13 | const base = require('./base'); 14 | const types = require('../types'); 15 | const regExp = require('../regexp'); 16 | 17 | const Node = base.Node; 18 | 19 | class LiteralNode extends Node { 20 | 21 | init() { 22 | const value = this.astNode.value; 23 | 24 | this.value = value instanceof RegExp ? LiteralNode.regExp(this.astNode.raw) : value; 25 | } 26 | 27 | inferType() { 28 | const type = types.from(this.value); 29 | const mustComply = true; 30 | 31 | this.assertType(type, ['number', 'boolean', 'string', 'null', 'RegExp'], {mustComply}); 32 | 33 | return type; 34 | } 35 | 36 | evaluate() { 37 | return this.value; 38 | } 39 | 40 | toString() { 41 | return JSON.stringify(this.value); 42 | } 43 | 44 | static regExp(rawValue) { 45 | const regExpDef = /^\/(.+)\/(.*)$/.exec(rawValue); 46 | 47 | if (!regExpDef) { 48 | throw new Error(`Unsupported RegExp literal "${rawValue}".`); 49 | } 50 | 51 | const body = regExpDef[1]; 52 | const flags = regExpDef[2]; 53 | 54 | return regExp.from(body, flags); 55 | } 56 | 57 | } 58 | 59 | Node.register('Literal', LiteralNode); 60 | -------------------------------------------------------------------------------- /lib/parser/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Type related helper methods. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const primitives = new Set(['string', 'number', 'boolean', 'null', 'primitive']); 8 | 9 | module.exports = { 10 | 11 | from(value) { 12 | if (value === null) { 13 | return 'null'; 14 | } 15 | 16 | const type = typeof value; 17 | 18 | return type === 'object' ? value.constructor.name : type; 19 | }, 20 | 21 | isString(type) { 22 | return type === 'string'; 23 | }, 24 | 25 | isNumber(type) { 26 | return type === 'number'; 27 | }, 28 | 29 | isBoolean(type) { 30 | return type === 'boolean'; 31 | }, 32 | 33 | isNull(type) { 34 | return type === 'null'; 35 | }, 36 | 37 | isPrimitive(type) { 38 | return primitives.has(type); 39 | }, 40 | 41 | isRegExp(type) { 42 | return type === 'RegExp'; 43 | }, 44 | 45 | isFuzzy(type) { 46 | return type === 'any' || type === 'primitive'; 47 | }, 48 | 49 | maybeString(type) { 50 | return type === 'string' || module.exports.isFuzzy(type); 51 | }, 52 | 53 | maybeNumber(type) { 54 | return type === 'number' || module.exports.isFuzzy(type); 55 | }, 56 | 57 | maybeBoolean(type) { 58 | return type === 'boolean' || module.exports.isFuzzy(type); 59 | }, 60 | 61 | maybeNull(type) { 62 | return type === 'null' || module.exports.isFuzzy(type); 63 | }, 64 | 65 | maybePrimitive(type) { 66 | return primitives.has(type) || module.exports.isFuzzy(type); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - node 3 | extends: xo 4 | env: 5 | node: true 6 | commonjs: true 7 | es6: true 8 | parserOptions: 9 | ecmaFeatures: 10 | jsx: false 11 | experimentalObjectRestSpread: false 12 | ecmaVersion: 2016 13 | sourceType: script 14 | rules: 15 | 16 | # possible errors 17 | eqeqeq: 18 | - error 19 | - smart 20 | no-console: 21 | - warn 22 | no-confusing-arrow: 23 | - error 24 | - allowParens: true 25 | no-eq-null: 26 | - "off" 27 | no-use-before-define: 28 | - error 29 | - functions: false 30 | classes: false 31 | node/no-unsupported-features: 32 | - error 33 | - version: 4 34 | no-var: 35 | - error 36 | strict: 37 | - error 38 | - safe 39 | 40 | # styling 41 | arrow-body-style: 42 | - error 43 | - as-needed 44 | brace-style: 45 | - error 46 | - 1tbs 47 | - allowSingleLine: true 48 | indent: 49 | - error 50 | - 2 51 | lines-around-directive: 52 | - error 53 | - always 54 | newline-after-var: 55 | - error 56 | - always 57 | object-shorthand: 58 | - error 59 | one-var: 60 | - error 61 | - initialized: never 62 | uninitialized: always 63 | padded-blocks: 64 | - "off" 65 | prefer-arrow-callback: 66 | - error 67 | - allowNamedFunctions: true 68 | prefer-const: 69 | - error 70 | space-before-function-paren: 71 | - error 72 | - never 73 | valid-jsdoc: 74 | - error 75 | - requireParamDescription: true 76 | requireReturnDescription: false 77 | requireReturn: false 78 | -------------------------------------------------------------------------------- /test/jest/__snapshots__/matchers.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`matchers toAllowRead 1`] = ` 4 | "Expected read to be allowed but it was denied 5 | 6 | Attempt to read /user as null. 7 | 8 | /user: read 9 | 10 | No .read rule allowed the operation. 11 | read was denied." 12 | `; 13 | 14 | exports[`matchers toAllowRead 2`] = ` 15 | "Expected read to be denied but it was allowed 16 | 17 | Attempt to read /public as null. 18 | 19 | /public: read \\"true\\" => true 20 | true [=> true] 21 | 22 | read was allowed." 23 | `; 24 | 25 | exports[`matchers toAllowUpdate 1`] = ` 26 | "Expected update to be denied but it was allowed 27 | 28 | Attempt to patch /user/1234 as {\\"uid\\":\\"1234\\"}. 29 | Patch: \\"{ 30 | \\"name\\": \\"Anna\\" 31 | }\\". 32 | 33 | /user/1234: write \\"auth.uid === $uid\\" => true 34 | auth.uid === $uid [=> true] 35 | using [ 36 | $uid = \\"1234\\" 37 | auth = {\\"uid\\":\\"1234\\"} 38 | auth.uid = \\"1234\\" 39 | ] 40 | 41 | patch was allowed." 42 | `; 43 | 44 | exports[`matchers toAllowWrite 1`] = ` 45 | "Expected write to be denied but it was allowed 46 | 47 | Attempt to write /user/1234 as {\\"uid\\":\\"1234\\"}. 48 | New Value: \\"{ 49 | \\"name\\": \\"Anna\\" 50 | }\\". 51 | 52 | /user/1234: write \\"auth.uid === $uid\\" => true 53 | auth.uid === $uid [=> true] 54 | using [ 55 | $uid = \\"1234\\" 56 | auth = {\\"uid\\":\\"1234\\"} 57 | auth.uid = \\"1234\\" 58 | ] 59 | 60 | write was allowed." 61 | `; 62 | -------------------------------------------------------------------------------- /test/jest/core.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Jest test definition to test targaryen Jest integration. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const targaryen = require('../../plugins/jest'); 8 | 9 | expect.extend({ 10 | toBeAllowed: targaryen.toBeAllowed 11 | }); 12 | 13 | test('getDebugDatabase()', () => { 14 | const emptyRules = {rules: {}}; 15 | const database = targaryen.getDebugDatabase(emptyRules, {}); 16 | 17 | expect(database.debug).toBe(true); 18 | }); 19 | 20 | test('getDatabase()', () => { 21 | const emptyRules = {rules: {}}; 22 | const database = targaryen.getDatabase(emptyRules, {}); 23 | 24 | expect(database.debug).toBe(false); 25 | }); 26 | 27 | describe('generic matchers', () => { 28 | test('toBeAllowed', () => { 29 | const rules = { 30 | rules: { 31 | user: { 32 | $uid: { 33 | '.read': 'auth.uid !== null', 34 | '.write': 'auth.uid === $uid' 35 | } 36 | } 37 | } 38 | }; 39 | const initialData = {}; 40 | 41 | // NOTE: Create a database with debug set to true for detailed errors 42 | const database = targaryen.getDebugDatabase(rules, initialData); 43 | 44 | expect(() => { 45 | expect(database.as(null).read('/user')).not.toBeAllowed(); 46 | }).not.toThrow(); 47 | 48 | expect(() => { 49 | expect(database.as(null).read('/user')).toBeAllowed(); 50 | }).toThrowErrorMatchingSnapshot(); 51 | 52 | expect(() => { 53 | expect(database.as({uid: '1234'}).write('/user/1234', { 54 | name: 'Anna' 55 | })).toBeAllowed(); 56 | }).not.toThrow(); 57 | 58 | expect(() => { 59 | expect(database.as({uid: '1234'}).write('/user/1234', { 60 | name: 'Anna' 61 | })).not.toBeAllowed(); 62 | }).toThrowErrorMatchingSnapshot(); 63 | }); 64 | }); 65 | 66 | -------------------------------------------------------------------------------- /test/jest/matchers.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Jest test definition to test targaryen Jest integration. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const targaryen = require('../../plugins/jest'); 8 | 9 | expect.extend({ 10 | toAllowRead: targaryen.toAllowRead, 11 | toAllowUpdate: targaryen.toAllowUpdate, 12 | toAllowWrite: targaryen.toAllowWrite 13 | }); 14 | 15 | describe('matchers', () => { 16 | const rules = {rules: { 17 | user: { 18 | $uid: { 19 | '.read': 'auth.uid !== null', 20 | '.write': 'auth.uid === $uid' 21 | } 22 | }, 23 | public: { 24 | '.read': true 25 | } 26 | }}; 27 | const initialData = {}; 28 | const database = targaryen.getDatabase(rules, initialData); 29 | 30 | test('toAllowRead', () => { 31 | expect(() => { 32 | expect(database).not.toAllowRead('/user'); 33 | }).not.toThrow(); 34 | 35 | expect(() => { 36 | expect(database).toAllowRead('/user'); 37 | }).toThrowErrorMatchingSnapshot(); 38 | 39 | expect(() => { 40 | expect(database).toAllowRead('/public'); 41 | }).not.toThrow(); 42 | 43 | expect(() => { 44 | expect(database).not.toAllowRead('/public'); 45 | }).toThrowErrorMatchingSnapshot(); 46 | }); 47 | 48 | test('toAllowWrite', () => { 49 | expect(() => { 50 | expect(database.as({uid: '1234'})).toAllowWrite('/user/1234', { 51 | name: 'Anna' 52 | }); 53 | }).not.toThrow(); 54 | 55 | expect(() => { 56 | expect(database.as({uid: '1234'})).not.toAllowWrite('/user/1234', { 57 | name: 'Anna' 58 | }); 59 | }).toThrowErrorMatchingSnapshot(); 60 | }); 61 | 62 | test('toAllowUpdate', () => { 63 | expect(() => { 64 | expect(database.as({uid: '1234'})).toAllowUpdate('/user/1234', { 65 | name: 'Anna' 66 | }); 67 | }).not.toThrow(); 68 | 69 | expect(() => { 70 | expect(database.as({uid: '1234'})).not.toAllowUpdate('/user/1234', { 71 | name: 'Anna' 72 | }); 73 | }).toThrowErrorMatchingSnapshot(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | 4 | FIXTURES_FILE="./test/spec/lib/parser/fixtures.json" 5 | SECRET_FILE="./targaryen-secret.json" 6 | 7 | function run_setup { 8 | npm install 9 | } 10 | 11 | function run_test { 12 | EXIT_STATUS=0 13 | 14 | run_live_test || EXIT_STATUS=$? 15 | npm run lint || EXIT_STATUS=$? 16 | npm run test || EXIT_STATUS=$? 17 | 18 | if [[ $EXIT_STATUS -eq 0 ]]; then 19 | echo "Tests successful!" 20 | else 21 | echo "One or more tests failed!" 22 | exit $EXIT_STATUS 23 | fi 24 | } 25 | 26 | function setup_secret { 27 | if [[ -e $SECRET_FILE ]]; then 28 | echo "using existing secret file at ${SECRET_FILE}" 29 | return; 30 | fi 31 | 32 | if [ -e ] && [ -z "$FIREBASE_SECRET" ]; then 33 | echo "FIREBASE_SECRET is not set!" 34 | exit 1 35 | fi 36 | 37 | if [ -z "$FIREBASE_PROJECT_ID" ]; then 38 | echo "FIREBASE_PROJECT_ID is not set!" 39 | exit 1 40 | fi 41 | 42 | SECRET='{ 43 | "token": "'$FIREBASE_SECRET'", 44 | "projectId":"'$FIREBASE_PROJECT_ID'" 45 | }' 46 | 47 | echo $SECRET > $SECRET_FILE 48 | } 49 | 50 | function run_live_test { 51 | setup_secret 52 | 53 | ./bin/targaryen-specs -v -i -s $FIXTURES_FILE 54 | 55 | if [[ -n "$(git status --porcelain ${FIXTURES_FILE})" ]]; then 56 | echo "Test specs need to be updated!" 57 | exit 2 58 | fi 59 | } 60 | 61 | case "$1" in 62 | 63 | setup) 64 | echo "installing targaryen dependencies..."; 65 | echo "Assume node 4 or 6 is installed!" 66 | run_setup 67 | ;; 68 | 69 | test) 70 | echo "testing Targaryen..." 71 | run_test 72 | ;; 73 | 74 | test:live) 75 | echo "testing Targaryen live..." 76 | run_live_test 77 | ;; 78 | 79 | *) 80 | echo "Continuous integration commands for targaryen" 81 | echo "" 82 | echo "Usage: $0 {setup|test|test:live}" 83 | exit 1 84 | 85 | esac 86 | -------------------------------------------------------------------------------- /test/integration/tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "posts": { 4 | "existing-post": { 5 | ".priority": 85, 6 | "title": "Can bees think?", 7 | "content": "A new study confirms that no, they cannot.", 8 | "clearance-level": 6, 9 | "date": 1420066757609 10 | }, 11 | "other-post": { 12 | ".priority": 0, 13 | "title": "President Harding Dies Several Months Ago", 14 | "content": "Details on page Z88", 15 | "clearance-level": 10, 16 | "date": 1420066757620 17 | } 18 | }, 19 | "users": { 20 | "password:ad7ebe2e-f547-4110-bda8-678af95b7efd": { 21 | "clearance-level": 6, 22 | "author": true 23 | }, 24 | "password:bb9c1467-8ad3-4b33-8913-f2b491cdbb86": { 25 | "clearance-level": 8, 26 | "ticketagent": true 27 | } 28 | } 29 | }, 30 | "users": { 31 | "an author": { "uid": "password:ad7ebe2e-f547-4110-bda8-678af95b7efd" }, 32 | "John Smith": { "uid": "password:bb9c1467-8ad3-4b33-8913-f2b491cdbb86" } 33 | }, 34 | "tests": { 35 | "posts/existing-post": { 36 | "canRead": [ "John Smith" ] 37 | }, 38 | "posts/existing-post/date": { 39 | "cannotWrite": [ 40 | { "auth": "John Smith", "data": 1420066757609 }, 41 | { "auth": "an author", "data": 1420066757609 } 42 | ] 43 | }, 44 | "posts/new-post": { 45 | "canWrite": [{ 46 | "auth": "an author", "data": { 47 | "date": { ".sv": "timestamp" } 48 | } 49 | }], 50 | "cannotWrite": [{ 51 | "auth": "John Smith", "data": { 52 | "date": { ".sv": "timestamp" } 53 | } 54 | }] 55 | }, 56 | "posts/new-post/date": { 57 | "canWrite": [ 58 | { "auth": "an author", "data": { ".sv": "timestamp" } } 59 | ], 60 | "cannotWrite": [ 61 | { "auth": "John Smith", "data": { ".sv": "timestamp" } } 62 | ] 63 | }, 64 | "posts/other-post": { 65 | "cannotRead": ["John Smith"] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/spec/lib/parser/string-methods.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test firebase string methods. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const stringMethods = require('../../../../lib/parser/string-methods'); 8 | 9 | describe('stringMethods', function() { 10 | 11 | describe('contains', function() { 12 | 13 | it('returns true if the given string contains the given substring', function() { 14 | expect(stringMethods.contains('bar', 'ar')).to.be.true(); 15 | }); 16 | 17 | it('returns false if the given string does not contain the given substring', function() { 18 | expect(stringMethods.contains('bar', 'war')).to.be.false(); 19 | }); 20 | 21 | }); 22 | 23 | describe('beginsWith', function() { 24 | 25 | it('returns true if the given string begins with the given substring', function() { 26 | expect(stringMethods.beginsWith('bar', 'ba')).to.be.true(); 27 | }); 28 | 29 | it('returns false if the given string does not begin with the given substring', function() { 30 | expect(stringMethods.beginsWith('bar', 'wa')).to.be.false(); 31 | }); 32 | 33 | }); 34 | 35 | describe('endsWith', function() { 36 | 37 | it('returns true if the given string ends with the given substring', function() { 38 | expect(stringMethods.endsWith('bar', 'ar')).to.be.true(); 39 | }); 40 | 41 | it('returns false if the given string does not end with the given substring', function() { 42 | expect(stringMethods.endsWith('bar', 'az')).to.be.false(); 43 | }); 44 | 45 | }); 46 | 47 | describe('replace', function() { 48 | 49 | it('returns a string where all instances of the target string are replaced by the replacement', function() { 50 | expect(stringMethods.replace('barar', 'ar', 'az')).to.equal('bazaz'); 51 | }); 52 | 53 | }); 54 | 55 | describe('matches', function() { 56 | 57 | it('returns true if the given string matches the given regex', function() { 58 | expect(stringMethods.matches('bar', /^ba/)).to.be.true(); 59 | }); 60 | 61 | it('returns false if the given string does not match the given regex', function() { 62 | expect(stringMethods.matches('bar', /^wa/)).to.be.false(); 63 | }); 64 | 65 | }); 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /lib/test-cmd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * targaryen cli command. 3 | * 4 | * Rule tests are define via a JSON object listing operations to simulate 5 | * against an initial data and authentication states. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const database = require('./database'); 11 | const paths = require('./paths'); 12 | 13 | function TestJig(rules, testData, now) { 14 | now = now || Date.now(); 15 | 16 | this.db = database.create(rules, testData.root, now); 17 | this.users = testData.users; 18 | this.tests = testData.tests; 19 | 20 | } 21 | 22 | TestJig.prototype._lookupAuth = function(auth) { 23 | 24 | if (this.users[auth] === null) { 25 | // Unauthenticated user 26 | return null; 27 | } 28 | 29 | if (typeof auth !== 'string') { 30 | return auth; 31 | } 32 | 33 | const desc = auth; 34 | 35 | auth = this.users[desc]; 36 | auth.$description = desc; 37 | 38 | return auth; 39 | 40 | }; 41 | 42 | TestJig.prototype.run = function() { 43 | 44 | let allResults = []; 45 | 46 | Object.keys(this.tests).forEach(function(path) { 47 | 48 | path = paths.trimLeft(path); 49 | 50 | const pathTests = this.tests[path]; 51 | const canRead = (pathTests.canRead || []).map(auth => { 52 | const result = this.db 53 | .as(this._lookupAuth(auth)) 54 | .read(path); 55 | 56 | result.expected = true; 57 | 58 | return result; 59 | }); 60 | const cannotRead = (pathTests.cannotRead || []).map(auth => { 61 | const result = this.db 62 | .as(this._lookupAuth(auth)) 63 | .read(path); 64 | 65 | result.expected = false; 66 | 67 | return result; 68 | }); 69 | const canWrite = (pathTests.canWrite || []).map(writeTest => { 70 | const result = this.db 71 | .as(this._lookupAuth(writeTest.auth)) 72 | .write(path, writeTest.data); 73 | 74 | result.expected = true; 75 | 76 | return result; 77 | }); 78 | const cannotWrite = (pathTests.cannotWrite || []).map(writeTest => { 79 | const result = this.db 80 | .as(this._lookupAuth(writeTest.auth)) 81 | .write(path, writeTest.data); 82 | 83 | result.expected = false; 84 | 85 | return result; 86 | }); 87 | 88 | allResults = allResults.concat(canRead, cannotRead, canWrite, cannotWrite); 89 | 90 | }, this); 91 | 92 | return allResults; 93 | 94 | }; 95 | 96 | module.exports = TestJig; 97 | -------------------------------------------------------------------------------- /lib/parser/regexp/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const nearley = require('nearley'); 4 | const grammar = require('./parser'); 5 | 6 | const MAIN_CTX = 'main'; 7 | const SET_CTX = 'set'; 8 | 9 | exports.from = function(source, flags) { 10 | const results = exports.parse(source); 11 | 12 | if (flags && flags !== 'i') { 13 | throw new Error(`Unsupported RegExp flags ${flags}.`); 14 | } 15 | 16 | return new RegExp(render(results[0], MAIN_CTX), flags); 17 | }; 18 | 19 | exports.parse = function(source) { 20 | const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar)); 21 | 22 | return parser.feed(source).finish(); 23 | }; 24 | 25 | function render(ast, context) { 26 | const renderer = node => render(node, context); 27 | 28 | switch (ast.type) { 29 | case 'concatenation': 30 | return ast.concatenation.map(renderer).join(''); 31 | 32 | case 'codePoint': 33 | case 'char': 34 | return escape(ast.value, context); 35 | 36 | case 'dot': 37 | case 'number': 38 | case 'charset': 39 | return ast.value; 40 | 41 | case 'endAnchor': 42 | return '$'; 43 | 44 | case 'group': 45 | return `(${renderer(ast.group)})`; 46 | 47 | case 'range': 48 | return `${renderer(ast.start)}-${renderer(ast.end)}`; 49 | 50 | case 'repeat': 51 | return renderRepeat(ast); 52 | 53 | case 'series': 54 | return `${renderer(ast.pattern)}${renderer(ast.repeat)}`; 55 | 56 | case 'set': 57 | return ast.include ? 58 | `[${ast.include.map(node => render(node, SET_CTX)).join('')}]` : 59 | `[^${ast.exclude.map(node => render(node, SET_CTX)).join('')}]`; 60 | 61 | case 'startAnchor': 62 | return '^'; 63 | 64 | case 'union': 65 | return ast.branches.map(renderer).join('|'); 66 | 67 | default: 68 | throw new Error(`unknown regexp node type ("${ast.type}").`); 69 | } 70 | } 71 | 72 | const SPECIAL = new Map([ 73 | [MAIN_CTX, new Set('+*?^$.|()[]{}\\')], 74 | [SET_CTX, new Set(']')] 75 | ]); 76 | 77 | function escape(value, context) { 78 | const special = SPECIAL.get(context); 79 | 80 | return special != null && special.has(value) ? `\\${value}` : value; 81 | } 82 | 83 | function renderRepeat(ast) { 84 | const min = ast.min; 85 | const max = ast.max; 86 | 87 | switch (max) { 88 | case min: 89 | return `{${min}}`; 90 | 91 | case Infinity: 92 | return min > 1 ? 93 | `{${min},}` : 94 | min === 0 ? '*' : '+'; 95 | 96 | case 1: 97 | return '?'; 98 | 99 | default: 100 | return `{${min},${max}}`; 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /bin/targaryen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const json = require('firebase-json'); 6 | 7 | const path = require('path'); 8 | const Table = require('cli-table'); 9 | const colors = require('colors'); 10 | const helpers = require('../lib/util'); 11 | const TestJig = require('../lib/test-cmd'); 12 | const argv = require('minimist')(process.argv.slice(2), {boolean: true}); 13 | 14 | if (argv._.length < 2) { 15 | 16 | console.error('Usage: targaryen [--verbose] [--debug] RULES_JSON_PATH TEST_JSON_PATH'); 17 | process.exit(1); 18 | 19 | } 20 | 21 | const rulesJSONPath = path.resolve(argv._[0]); 22 | const testJSONPath = path.resolve(argv._[1]); 23 | const rules = json.loadSync(rulesJSONPath, 'utf8'); 24 | const tests = require(testJSONPath); 25 | 26 | const jig = new TestJig(rules, tests); 27 | const results = jig.run(); 28 | const totalCount = results.length; 29 | const table = new Table({ 30 | head: ['Path', 'Type', 'Auth', 'Expect', 'Got'] 31 | }); 32 | let failCount = 0; 33 | 34 | results.forEach(result => { 35 | 36 | if (argv.debug) { 37 | console.log(result.info); 38 | console.log(); 39 | } 40 | 41 | const expected = result.expected ? '✓'.green : '✖'.red; 42 | const actual = result.allowed ? '✓'.green : '✖'.red; 43 | let authStr; 44 | 45 | if (result.auth && result.auth.$description) { 46 | authStr = result.auth.$description; 47 | } else if (typeof result.auth === 'string') { 48 | authStr = result.auth; 49 | } else { 50 | authStr = JSON.stringify(result.auth); 51 | } 52 | 53 | if (expected === actual) { 54 | table.push([result.path, result.type, authStr, expected, actual]); 55 | } else { 56 | table.push([ 57 | colors.bgRed(result.path), 58 | colors.bgRed(result.type), 59 | colors.bgRed(authStr), 60 | expected, 61 | actual 62 | ]); 63 | } 64 | 65 | if (result.type === 'read' && result.expected !== result.allowed) { 66 | 67 | failCount++; 68 | if (result.expected === true) { 69 | console.error(colors.red(helpers.unreadableError(result))); 70 | } else { 71 | console.error(colors.red(helpers.readableError(result))); 72 | } 73 | console.error(); 74 | 75 | } else if (result.type === 'write' && result.expected !== result.allowed) { 76 | 77 | failCount++; 78 | if (result.expected === true) { 79 | console.error(colors.red(helpers.unwritableError(result))); 80 | } else { 81 | console.error(colors.red(helpers.writableError(result))); 82 | } 83 | console.error(); 84 | 85 | } 86 | 87 | }); 88 | 89 | if (argv.verbose) { 90 | console.log(table.toString()); 91 | } 92 | 93 | console.log(); 94 | console.log(failCount + ' failures in ' + totalCount + ' tests'); 95 | 96 | process.exit(failCount > 0 ? 1 : 0); 97 | -------------------------------------------------------------------------------- /lib/parser/statement/conditional.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling tertiary expressions validation and evaluation. 3 | * 4 | * Validate the test and the two consequences nodes. 5 | * 6 | * The test inferred type must be boolean and cannot be delayed. There is no 7 | * requirement of the tertiary inferred type; it can be anything. 8 | * 9 | */ 10 | 11 | 'use strict'; 12 | 13 | const base = require('./base'); 14 | const types = require('../types'); 15 | 16 | const Node = base.Node; 17 | 18 | class ConditionalNode extends Node { 19 | 20 | init(source, astNode, scope) { 21 | this.test = Node.from(source, astNode.test, scope); 22 | this.consequent = Node.from(source, astNode.consequent, scope); 23 | this.alternate = Node.from(source, astNode.alternate, scope); 24 | } 25 | 26 | /** 27 | * Infer type of a conditional node. 28 | * 29 | * The test must inferred to a boolean. 30 | * 31 | * The two branches can have any type; they don't need to be identical. 32 | * 33 | * @return {string|FuncSignature} 34 | */ 35 | inferType() { 36 | const mustComply = true; 37 | const msg = ' condition of ? must be boolean.'; 38 | 39 | this.assertType(this.test.inferredType, 'boolean', {mustComply, msg}); 40 | 41 | const consequent = this.consequent.inferredType; 42 | const alternate = this.alternate.inferredType; 43 | 44 | if (consequent === alternate) { 45 | return consequent; 46 | } 47 | 48 | const isPrimitive = types.isPrimitive(consequent) && types.isPrimitive(alternate); 49 | 50 | return isPrimitive ? 'primitive' : 'any'; 51 | } 52 | 53 | evaluate(state) { 54 | const test = this.test.evaluate(state); 55 | 56 | return test === true ? this.consequent.evaluate(state) : this.alternate.evaluate(state); 57 | } 58 | 59 | debug(state, cb) { 60 | const test = this.test.debug(state, cb); 61 | let value, consequent, alternate; 62 | 63 | if (test.value === true) { 64 | consequent = this.consequent.debug(state, cb); 65 | alternate = {detailed: this.alternate.toString()}; 66 | value = consequent.value; 67 | } else { 68 | alternate = this.alternate.debug(state, cb); 69 | consequent = {detailed: this.consequent.toString()}; 70 | value = alternate.value; 71 | } 72 | 73 | const detailed = ( 74 | `${test.detailed} [=> ${test.value}] ?\n` + 75 | ` ${consequent.detailed} [=> ${consequent.value}] :\n` + 76 | ` ${alternate.detailed} [=> ${alternate.value}]\n` 77 | ); 78 | 79 | cb({ 80 | type: this.astNode.type, 81 | original: this.original, 82 | detailed, 83 | value 84 | }); 85 | 86 | return {detailed, value}; 87 | } 88 | 89 | toString() { 90 | return `${this.test} ?\n ${this.consequent} :\n ${this.alternate}`; 91 | } 92 | 93 | } 94 | 95 | Node.register('ConditionalExpression', ConditionalNode); 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "targaryen", 3 | "version": "3.1.0", 4 | "description": "Test Firebase security rules without connecting to Firebase.", 5 | "bin": { 6 | "targaryen": "bin/targaryen", 7 | "targaryen-specs": "bin/targaryen" 8 | }, 9 | "main": "index.js", 10 | "directories": { 11 | "bin": "bin", 12 | "lib": "lib", 13 | "test": "test" 14 | }, 15 | "files": [ 16 | "bin", 17 | "docs", 18 | "lib", 19 | "plugins", 20 | "index.js", 21 | "USAGE.md" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/goldibex/targaryen" 26 | }, 27 | "scripts": { 28 | "build": "nearleyc lib/parser/regexp/parser.ne -o lib/parser/regexp/parser.js", 29 | "coverage": "npm run build && istanbul cover --print detail node_modules/.bin/_mocha -- test/spec/", 30 | "coveralls": "npm run coverage && cat ./coverage/lcov.info | coveralls && rm -rf ./coverage", 31 | "fixtures": "./bin/targaryen-specs -v -i -s test/spec/lib/parser/fixtures.json", 32 | "format": "npm run lint -- --fix", 33 | "lint": "eslint lib/ plugins/ test/ bin/targaryen", 34 | "lint:html": "(npm run -s lint -- --max-warnings 0 -f html -o lint.html && rm lint.html) || echo 'open lint.html for linting errors'", 35 | "prepare": "npm run build", 36 | "test": "npm run build && mocha test/spec/ && jasmine && node ./bin/targaryen --verbose test/integration/rules.json test/integration/tests.json", 37 | "test:unit": "mocha test/spec/", 38 | "test:plugin:jest": "jest --colors test/jest", 39 | "test:inspect": "node --inspect --debug-brk node_modules/.bin/_mocha test/spec/" 40 | }, 41 | "author": "Harry Schmidt ", 42 | "license": "ISC", 43 | "keywords": [ 44 | "firebase", 45 | "testing", 46 | "jasmine", 47 | "mocha", 48 | "chai", 49 | "security", 50 | "rules" 51 | ], 52 | "dependencies": { 53 | "cli-table": "^0.3.1", 54 | "colors": "^1.0.3", 55 | "debug": "^2.3.3", 56 | "esprima": "^1.2.2", 57 | "firebase-json": "^0.1.0", 58 | "firebase-token-generator": "^2.0.0", 59 | "lodash.flatten": "^4.4.0", 60 | "lodash.omit": "^4.5.0", 61 | "lodash.set": "^4.3.2", 62 | "minimist": "^1.1.0", 63 | "moo": "^0.4.3", 64 | "nearley": "^2.13.0", 65 | "replaceall": "^0.1.3", 66 | "request": "^2.79.0", 67 | "request-promise-native": "^1.0.3", 68 | "strip-json-comments": "^2.0.1" 69 | }, 70 | "devDependencies": { 71 | "chai": "^3.5.0", 72 | "dirty-chai": "^1.2.2", 73 | "coveralls": "^2.11.15", 74 | "eslint": "^3.9.1", 75 | "eslint-config-xo": "^0.17.0", 76 | "eslint-plugin-jest": "^21.15.1", 77 | "eslint-plugin-node": "^2.1.3", 78 | "istanbul": "^0.4.5", 79 | "jasmine": "^2.1.1", 80 | "jest": "^22.4.3", 81 | "mocha": "^2.1.0", 82 | "sinon": "^1.17.6", 83 | "sinon-chai": "^2.8.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/parser/statement/unary.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling unary expressions validation and evaluation. 3 | * 4 | * - Only support "-" and "!" operations (doesn't support "+" operation). 5 | * - "!" only support operations on a boolean element and type validation can't 6 | * be delayed until runtime evaluation. 7 | * - "-" only support operations on a number element but validation can be 8 | * delayed. 9 | * 10 | */ 11 | 12 | 'use strict'; 13 | 14 | const base = require('./base'); 15 | const types = require('../types'); 16 | 17 | const Node = base.Node; 18 | const ParseError = base.ParseError; 19 | 20 | class UnaryNode extends Node { 21 | 22 | init(source, astNode, scope) { 23 | this.argument = Node.from(source, astNode.argument, scope); 24 | } 25 | 26 | get operator() { 27 | return this.astNode.operator; 28 | } 29 | 30 | /** 31 | * Infer the expression and argument types. 32 | * 33 | * It will throw the the type of the logical NOT operator argument is not a 34 | * number. 35 | * 36 | * A unary negation operator argument type check can be done at run time. 37 | * 38 | * @return {string} 39 | */ 40 | inferType() { 41 | const argType = this.argument.inferredType; 42 | const mustComply = true; 43 | const msg = type => `${this.operator} only operate on ${type}`; 44 | 45 | switch (this.operator) { 46 | 47 | case '!': 48 | this.assertType(argType, 'boolean', {mustComply, msg: msg('boolean')}); 49 | return 'boolean'; 50 | 51 | case '-': 52 | this.assertType(argType, 'number', {msg: msg('number')}); 53 | return 'number'; 54 | 55 | default: 56 | throw new ParseError(this, `Unary expressions may not contain "${this.operator}" operator.`); 57 | 58 | } 59 | } 60 | 61 | evaluate(state) { 62 | const value = this.argument.evaluate(state); 63 | 64 | return this.evaluateWith(state, value); 65 | } 66 | 67 | debug(state, cb) { 68 | const argument = this.argument.debug(state, cb); 69 | const value = this.evaluateWith(state, argument.value); 70 | const detailed = `${this.operator}(${argument.detailed})`; 71 | 72 | cb({ 73 | type: this.astNode.type, 74 | original: this.original, 75 | detailed, 76 | value 77 | }); 78 | 79 | return {detailed, value}; 80 | } 81 | 82 | evaluateWith(state, value) { 83 | 84 | switch (this.operator) { 85 | 86 | case '!': 87 | return !value; 88 | 89 | case '-': 90 | this.assertType(types.from(value), 'number', {mustComply: true, msg: '- only operate on number'}); 91 | return -value; 92 | 93 | default: 94 | throw new ParseError(this, `Unary expressions may not contain "${this.operator}" operator.`); 95 | 96 | } 97 | } 98 | 99 | toString() { 100 | return `${this.operator}(${this.argument})`; 101 | } 102 | 103 | } 104 | 105 | Node.register('UnaryExpression', UnaryNode); 106 | -------------------------------------------------------------------------------- /bin/targaryen-specs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Create parsing test fixtures by evaluating rules against a live Firebase DB. 4 | * 5 | * Expect in the stdin the JSON encoded rules to update. The JSON result will 6 | * be sent to stdout. 7 | * 8 | * It uses the npm "debug" package to send debug info to stderr. 9 | * 10 | */ 11 | 12 | 'use strict'; 13 | 14 | const HELP = `targaryen-specs [-v] [-h] [-s SPECS_SOURCE] [-i] [-a NEW_SPECS_TO_APPEND] 15 | 16 | Usages: 17 | 18 | # Evaluate rules live in ./fixtures.json and print them in stdout 19 | targaryen-specs -s ./fixtures.json 20 | 21 | # Evaluate rules live in ./fixtures.json and print debug info to sdterr 22 | targaryen-specs -v -s ./fixtures.json 23 | 24 | # Evaluate rules live in ./fixtures.json and save the results in place 25 | targaryen-specs -i -s ./fixtures.json 26 | 27 | # Evaluate and append new rules to ./fixtures.json 28 | targaryen-specs -i -s ./fixtures.json -a '{"tests": [{"rule": "true"}]' 29 | 30 | `; 31 | 32 | const argv = require('minimist')(process.argv.slice(2), {boolean: ['i', 'v', 'h']}); 33 | 34 | const VERBOSE = argv.v; 35 | const PRINT_HELP = argv.h; 36 | const SOURCE = argv.s; 37 | const IN_PLACE = argv.i; 38 | const NEW_SPECS = JSON.parse(argv.a || 'null'); 39 | 40 | if (PRINT_HELP) { 41 | console.error(HELP); 42 | process.exit(0); 43 | } 44 | 45 | if (VERBOSE && !process.env.DEBUG) { 46 | process.env.DEBUG = '*'; 47 | } 48 | 49 | const specs = require('../lib/parser/specs'); 50 | const path = require('path'); 51 | const fs = require('fs'); 52 | 53 | const OLD_SPECS = SOURCE ? require(path.resolve(SOURCE)) : {users: {}, tests: []}; 54 | 55 | const task = NEW_SPECS ? append(NEW_SPECS, OLD_SPECS) : update(OLD_SPECS); 56 | 57 | task.then(save).then(test).catch(e => { 58 | console.error(e); 59 | process.exit(1); 60 | }); 61 | 62 | function append(newFixtures, fixtures) { 63 | const users = Object.assign( 64 | {}, 65 | fixtures.users, 66 | newFixtures.users, 67 | {unauth: null} 68 | ); 69 | const newTests = newFixtures.tests.map( 70 | t => Object.assign({user: 'unauth'}, t) 71 | ); 72 | 73 | return specs.test(newTests, users).then(tests => ({ 74 | users, 75 | tests: fixtures.tests.concat(tests) 76 | })); 77 | } 78 | 79 | function update(fixtures) { 80 | const users = Object.assign({}, fixtures.users, {unauth: null}); 81 | const oldTests = fixtures.tests.map( 82 | t => Object.assign({user: 'unauth'}, t) 83 | ); 84 | 85 | return specs.test(oldTests, users).then(tests => ({users, tests})); 86 | } 87 | 88 | function save(fixtures) { 89 | const output = JSON.stringify(fixtures, null, 2); 90 | 91 | if (!IN_PLACE) { 92 | console.log(output); 93 | return fixtures; 94 | } 95 | 96 | fs.writeFileSync(SOURCE, output); 97 | return fixtures; 98 | } 99 | 100 | function test(fixtures) { 101 | fixtures.tests 102 | .filter(t => typeof t.compare === 'function') 103 | .forEach(t => t.compare(fixtures.users)); 104 | } 105 | -------------------------------------------------------------------------------- /test/spec/lib/util.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const util = require('../../../lib/util'); 5 | const database = require('../../../lib/database'); 6 | 7 | describe('util', function() { 8 | 9 | describe('setFirebaseData', function() { 10 | 11 | it('should throw on invalid data', function() { 12 | expect( 13 | () => util.setFirebaseData(new Date()) 14 | ).to.throw(); 15 | }); 16 | 17 | }); 18 | 19 | describe('setFirebaseRules', function() { 20 | 21 | it('should throw on invalid rules', function() { 22 | expect( 23 | () => util.setFirebaseRules({}) 24 | ).to.throw(); 25 | }); 26 | 27 | }); 28 | 29 | describe('resetFirebase', function() { 30 | 31 | beforeEach(function() { 32 | util.setFirebaseRules({rules: {}}); 33 | util.setFirebaseData(null); 34 | }); 35 | 36 | it('should reset rules and data', function() { 37 | expect(util.getFirebaseData).to.not.throw(); 38 | 39 | util.resetFirebase(); 40 | expect(util.getFirebaseData).to.throw(); 41 | }); 42 | 43 | }); 44 | 45 | ['readableError', 'unreadableError'].forEach(function(name) { 46 | const helper = util[name]; 47 | 48 | describe(name, function() { 49 | const $description = 'some user description'; 50 | const path = '/some/path'; 51 | let result; 52 | 53 | beforeEach(function() { 54 | util.setFirebaseRules({rules: {}}); 55 | util.setFirebaseData(null); 56 | result = database.results.read(path, util.getFirebaseData().as({$description})); 57 | }); 58 | 59 | it('should include result info if debug is enabled', function() { 60 | util.setDebug(true); 61 | expect(helper(result)).to.contain(result.info); 62 | }); 63 | 64 | it('should not include result info if debug is disabled', function() { 65 | util.setDebug(false); 66 | expect(helper(result)).to.not.contain(result.info); 67 | }); 68 | 69 | }); 70 | 71 | }); 72 | 73 | ['writableError', 'unwritableError'].forEach(function(name) { 74 | const helper = util[name]; 75 | 76 | describe(name, function() { 77 | const $description = 'some user description'; 78 | const path = '/some/path'; 79 | const value = 'some value'; 80 | let result; 81 | 82 | beforeEach(function() { 83 | util.setFirebaseRules({rules: {}}); 84 | util.setFirebaseData(null); 85 | 86 | const data = util.getFirebaseData().as({$description}); 87 | 88 | result = database.results.write(path, data, data, value); 89 | }); 90 | 91 | it('should include result info if debug is enabled', function() { 92 | util.setDebug(true); 93 | expect(helper(result)).to.contain(result.info); 94 | }); 95 | 96 | it('should not include result info if debug is disabled', function() { 97 | util.setDebug(false); 98 | expect(helper(result)).to.not.contain(result.info); 99 | }); 100 | 101 | }); 102 | 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /plugins/jasmine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * targaryen/plugins/jasmine - Reference implementation of a jasmine plugin for 3 | * targaryen. 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | const targaryen = require('../'); 10 | 11 | exports.setFirebaseData = targaryen.util.setFirebaseData; 12 | exports.setFirebaseRules = targaryen.util.setFirebaseRules; 13 | exports.setDebug = targaryen.util.setDebug; 14 | exports.setVerbose = targaryen.util.setVerbose; 15 | exports.users = targaryen.util.users; 16 | exports.json = require('firebase-json'); 17 | 18 | exports.matchers = { 19 | 20 | canRead() { 21 | 22 | return {compare(auth, path, options) { 23 | 24 | const data = targaryen.util.getFirebaseData().as(auth); 25 | 26 | const result = data.read(path, options); 27 | 28 | return { 29 | pass: result.allowed === true, 30 | message: targaryen.util.unreadableError(result) 31 | }; 32 | 33 | }}; 34 | 35 | }, 36 | cannotRead() { 37 | 38 | return {compare(auth, path, options) { 39 | 40 | const data = targaryen.util.getFirebaseData().as(auth); 41 | 42 | const result = data.read(path, options); 43 | 44 | return { 45 | pass: result.allowed === false, 46 | message: targaryen.util.readableError(result) 47 | }; 48 | 49 | }}; 50 | 51 | }, 52 | canWrite() { 53 | 54 | return {compare(auth, path, newData, options) { 55 | 56 | const data = targaryen.util.getFirebaseData().as(auth); 57 | 58 | const result = typeof options === 'number' ? 59 | data.write(path, newData, undefined, options) : 60 | data.write(path, newData, options); 61 | 62 | return { 63 | pass: result.allowed === true, 64 | message: targaryen.util.unwritableError(result) 65 | }; 66 | 67 | }}; 68 | 69 | }, 70 | cannotWrite() { 71 | 72 | return {compare(auth, path, newData, options) { 73 | 74 | const data = targaryen.util.getFirebaseData().as(auth); 75 | 76 | const result = typeof options === 'number' ? 77 | data.write(path, newData, undefined, options) : 78 | data.write(path, newData, options); 79 | 80 | return { 81 | pass: result.allowed === false, 82 | message: targaryen.util.writableError(result) 83 | }; 84 | 85 | }}; 86 | }, 87 | canPatch() { 88 | 89 | return {compare(auth, path, newData, options) { 90 | 91 | const data = targaryen.util.getFirebaseData().as(auth); 92 | 93 | const result = data.update(path, newData, options); 94 | 95 | return { 96 | pass: result.allowed === true, 97 | message: targaryen.util.unwritableError(result) 98 | }; 99 | 100 | }}; 101 | 102 | }, 103 | cannotPatch() { 104 | 105 | return {compare(auth, path, newData, options) { 106 | 107 | const data = targaryen.util.getFirebaseData().as(auth); 108 | 109 | const result = data.update(path, newData, options); 110 | 111 | return { 112 | pass: result.allowed === false, 113 | message: targaryen.util.writableError(result) 114 | }; 115 | 116 | }}; 117 | } 118 | 119 | }; 120 | -------------------------------------------------------------------------------- /lib/parser/scope.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scopes are used to check check the type of variables and object properties 3 | * during rules type inference. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | function hasOwnProperty(obj, prop) { 9 | return Object.prototype.hasOwnProperty.call(obj, prop); 10 | } 11 | 12 | /** 13 | * Check if a variable/property will be available during a rule evaluation. 14 | */ 15 | class Scope { 16 | 17 | /** 18 | * Scope constructor. 19 | * 20 | * @param {object} types Map variable/properties to their type. 21 | */ 22 | constructor(types) { 23 | this.types = Object.assign({}, types); 24 | } 25 | 26 | /** 27 | * Test if a variable/property is available. 28 | * 29 | * @param {string} name Scope name to test 30 | * @return {boolean} 31 | */ 32 | has(name) { 33 | return hasOwnProperty(this.types, name); 34 | } 35 | 36 | /** 37 | * Return the type of a variable/property. 38 | * 39 | * @param {string} name Name of variable/property 40 | * @return {string|FuncSignature|undefined} 41 | */ 42 | getTypeOf(name) { 43 | return this.types[name]; 44 | } 45 | 46 | /** 47 | * Create a `Scope` from a list of wildchildren. 48 | * 49 | * @param {Array} wildchildren List of wildchildren 50 | * @return {Scope} 51 | */ 52 | static from(wildchildren) { 53 | const types = {}; 54 | 55 | wildchildren.filter( 56 | name => name.startsWith('$') 57 | ).forEach( 58 | name => { types[name] = 'string'; } 59 | ); 60 | 61 | return new Scope(types); 62 | } 63 | 64 | // Map of variables available during read and write operations. 65 | static get rootTypes() { 66 | return { 67 | auth: 'any', 68 | root: 'RuleDataSnapshot', 69 | data: 'RuleDataSnapshot', 70 | now: 'number', 71 | query: 'Query' 72 | }; 73 | } 74 | 75 | // Map of extra variables available during a write operation. 76 | static get writeTypes() { 77 | return {newData: 'RuleDataSnapshot'}; 78 | } 79 | 80 | } 81 | 82 | /** 83 | * Create a new `Scope`. 84 | * 85 | * @param {object} types Map variable/properties to their type. 86 | * @return {Scope} 87 | */ 88 | exports.create = function(types) { 89 | return new Scope(types); 90 | }; 91 | 92 | /** 93 | * Create a scope for a read operation. 94 | * 95 | * @param {Array} wildchildren List of wildchildren 96 | * @return {Scope} 97 | */ 98 | exports.read = function(wildchildren) { 99 | const scope = Scope.from(wildchildren); 100 | 101 | Object.assign(scope.types, Scope.rootTypes); 102 | 103 | return scope; 104 | }; 105 | 106 | /** 107 | * Create a scope for a write operation. 108 | * 109 | * @param {Array} wildchildren List of wildchildren 110 | * @return {Scope} 111 | */ 112 | exports.write = function(wildchildren) { 113 | const scope = Scope.from(wildchildren); 114 | 115 | Object.assign(scope.types, Scope.rootTypes, Scope.writeTypes); 116 | 117 | return scope; 118 | }; 119 | 120 | /** 121 | * Create a scope for auth properties access. 122 | * @return {Scope} 123 | */ 124 | exports.any = function() { 125 | return { 126 | get hasNewData() { return false; }, 127 | has() { return true; }, 128 | getTypeOf() { return 'any'; } 129 | }; 130 | }; 131 | -------------------------------------------------------------------------------- /lib/parser/statement/logical.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling logical expressions validation and evaluation. 3 | * 4 | * Left and right child node must infer to a boolean and it cannot be delay to 5 | * run time evaluation. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const base = require('./base'); 11 | const pad = require('../../pad'); 12 | 13 | const Node = base.Node; 14 | const ParseError = base.ParseError; 15 | 16 | class LogicalNode extends Node { 17 | 18 | init(source, astNode, scope) { 19 | this.left = Node.from(source, astNode.left, scope); 20 | this.right = Node.from(source, astNode.right, scope); 21 | } 22 | 23 | get operator() { 24 | return this.astNode.operator; 25 | } 26 | 27 | /** 28 | * Infer type of the logical expression and assert it's a boolean. 29 | * 30 | * Each side of the expression must infer to a boolean: 31 | * 32 | * - `auth.uid && auth.isAdmin` would throw. 33 | * - `auth.uid != null && auth.isAdmin == true` would return `boolean`. 34 | * 35 | * @return {string} 36 | */ 37 | inferType() { 38 | const left = this.left.inferredType; 39 | const right = this.right.inferredType; 40 | const mustComply = true; 41 | const msg = side => `${side} operand of ${this.operator} must be boolean.`; 42 | 43 | this.assertType(left, 'boolean', {mustComply, msg: msg('Left')}); 44 | this.assertType(right, 'boolean', {mustComply, msg: msg('Right')}); 45 | 46 | return 'boolean'; 47 | } 48 | 49 | evaluate(state) { 50 | switch (this.operator) { 51 | 52 | case '&&': 53 | return this.left.evaluate(state) && this.right.evaluate(state); 54 | 55 | case '||': 56 | return this.left.evaluate(state) || this.right.evaluate(state); 57 | 58 | default: 59 | throw new ParseError(this, `unknown logical operator ${this.operator}`); 60 | 61 | } 62 | } 63 | 64 | debug(state, cb) { 65 | let value, left, right; 66 | 67 | switch (this.operator) { 68 | 69 | case '&&': 70 | left = this.left.debug(state, cb); 71 | value = left.value; 72 | if (!left.value) { 73 | right = {detailed: this.right.toString()}; 74 | break; 75 | } 76 | 77 | right = this.right.debug(state, cb); 78 | value = right.value; 79 | break; 80 | 81 | case '||': 82 | left = this.left.debug(state, cb); 83 | value = left.value; 84 | if (left.value) { 85 | right = {detailed: this.right.toString()}; 86 | break; 87 | } 88 | 89 | right = this.right.debug(state, cb); 90 | value = right.value; 91 | break; 92 | 93 | default: 94 | throw new ParseError(this, `unknown logical operator ${this.operator}`); 95 | 96 | } 97 | 98 | const lFormat = pad.lines(left.detailed).trimRight(); 99 | const rFormat = pad.lines(right.detailed).trim(); 100 | const detailed = `(\n${lFormat} [=> ${left.value}]\n ${this.operator} ${rFormat} [=> ${right.value}]\n)`; 101 | 102 | cb({ 103 | type: this.astNode.type, 104 | original: this.original, 105 | detailed, 106 | value 107 | }); 108 | 109 | return {value, detailed}; 110 | } 111 | 112 | toString() { 113 | const left = pad.lines(this.left.toString()).trimRight(); 114 | const right = pad.lines(this.right.toString()).trimRight(); 115 | 116 | return `(\n${left} ${this.operator}\n${right}\n)`; 117 | } 118 | 119 | } 120 | 121 | Node.register('LogicalExpression', LogicalNode); 122 | -------------------------------------------------------------------------------- /lib/parser/statement/call.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling function call expressions validation and evaluation. 3 | * 4 | * The callee must infer to a function or to a string method. The argument type 5 | * validation can be delayed to runtime evaluation (and is then delegated to 6 | * the function itself). 7 | * 8 | */ 9 | 10 | 'use strict'; 11 | 12 | const base = require('./base'); 13 | const types = require('../types'); 14 | 15 | const Node = base.Node; 16 | const ParseError = base.ParseError; 17 | 18 | class CallNode extends Node { 19 | 20 | init(source, astNode, scope) { 21 | this.callee = Node.from(source, astNode.callee, scope); 22 | this.arguments = astNode.arguments.map(n => Node.from(source, n, scope)); 23 | } 24 | 25 | inferType(scope) { 26 | const msg = 'Type error: Function call on target that is not a function.'; 27 | let functionSignature = this.callee.inferredType; 28 | 29 | if (types.isFuzzy(functionSignature)) { 30 | functionSignature = this.callee.inferAsStringMethod(); 31 | } 32 | 33 | if (typeof functionSignature !== 'object') { 34 | throw new ParseError(this, msg); 35 | } 36 | 37 | if (typeof functionSignature.args === 'function') { 38 | functionSignature.args(scope, this); 39 | 40 | return functionSignature.returnType; 41 | } 42 | 43 | if (!Array.isArray(functionSignature.args) || !functionSignature.args.length) { 44 | return functionSignature.returnType; 45 | } 46 | 47 | const expected = functionSignature.args.length; 48 | const actual = this.arguments.length; 49 | 50 | if (expected !== actual) { 51 | throw new ParseError(this, `method expects ${expected} arguments, but got ${actual} instead`); 52 | } 53 | 54 | this.arguments.forEach((arg, i) => { 55 | const expected = functionSignature.args[i]; 56 | const actual = arg.inferredType; 57 | 58 | arg.assertType(actual, expected, { 59 | msg: `method expects argument ${i + 1} to be a ${expected}, but got ${actual}` 60 | }); 61 | }); 62 | 63 | return functionSignature.returnType; 64 | } 65 | 66 | evaluate(state) { 67 | const methodArguments = this.arguments.map(arg => arg.evaluate(state)); 68 | const method = this.callee.evaluate(state); 69 | 70 | return this.evaluateWith(state, methodArguments, method); 71 | } 72 | 73 | debug(state, cb) { 74 | const argsEvaluation = this.arguments.map(arg => arg.debug(state, cb)); 75 | const method = this.callee.debug(state, cb); 76 | 77 | const methodArguments = argsEvaluation.map(ev => ev.value); 78 | const value = this.evaluateWith(state, methodArguments, method.value); 79 | 80 | const args = argsEvaluation.map(ev => ev.detailed); 81 | const detailed = `${method.detailed}(${args.join(', ')})`; 82 | 83 | cb({ 84 | type: this.astNode.type, 85 | original: this.original, 86 | detailed, 87 | value 88 | }); 89 | 90 | return {detailed, value}; 91 | } 92 | 93 | evaluateWith(state, methodArguments, method) { 94 | if (typeof method !== 'function') { 95 | throw new ParseError(this, `"${method}" is not a function or method`); 96 | } 97 | 98 | return method.apply(null, methodArguments); 99 | } 100 | 101 | toString() { 102 | const args = this.arguments.map(a => a.toString()); 103 | 104 | return `${this.callee}(${args.join(', ')})`; 105 | } 106 | 107 | } 108 | 109 | Node.register('CallExpression', CallNode); 110 | -------------------------------------------------------------------------------- /lib/parser/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Define a rule parser. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const esprima = require('esprima'); 8 | const scope = require('./scope'); 9 | const Node = require('./statement/base').Node; 10 | 11 | // Register supported expressions 12 | require('./statement/array'); 13 | require('./statement/binary'); 14 | require('./statement/call'); 15 | require('./statement/conditional'); 16 | require('./statement/expression'); 17 | require('./statement/identifier'); 18 | require('./statement/literal'); 19 | require('./statement/logical'); 20 | require('./statement/member'); 21 | require('./statement/unary'); 22 | 23 | const debugType = new Set([ 24 | 'MemberExpression', 25 | 'CallExpression', 26 | 'Identifier' 27 | ]); 28 | 29 | class Rule { 30 | 31 | constructor(rule, wildchildren, isWrite) { 32 | const ruleScope = isWrite ? scope.write(wildchildren) : scope.read(wildchildren); 33 | const ast = esprima.parse(rule, {range: true}); 34 | 35 | if (!ast.body || ast.body.length !== 1 || ast.body[0].type !== 'ExpressionStatement') { 36 | throw new Error('Rule is not a single expression.'); 37 | } 38 | 39 | this.wildchildren = wildchildren; 40 | this.root = Node.from(rule, ast.body[0], ruleScope); 41 | 42 | if (this.root.inferredType !== 'boolean') { 43 | throw new Error('Expression must evaluate to a boolean.'); 44 | } 45 | } 46 | 47 | get inferredType() { 48 | return this.root.inferredType; 49 | } 50 | 51 | toString() { 52 | return this.root.original; 53 | } 54 | 55 | inferType() { 56 | return this.root.inferredType; 57 | } 58 | 59 | evaluate(state) { 60 | return this.root.evaluate(state); 61 | } 62 | 63 | debug(state) { 64 | const evaluations = new Map([]); 65 | const result = this.root.debug(state, ev => { 66 | if (debugType.has(ev.type) === false || typeof ev.value === 'function') { 67 | return; 68 | } 69 | 70 | evaluations.set(`${ev.type}::${ev.original}`, ev); 71 | }); 72 | 73 | const value = result.value; 74 | const detailed = result.detailed; 75 | const stack = Array.from(evaluations.values()) 76 | .sort((a, b) => a.original.localeCompare(b.original)) 77 | .map(ev => `${ev.original} = ${JSON.stringify(ev.value)}`) 78 | .join('\n '); 79 | 80 | return { 81 | value, 82 | detailed: stack ? `${detailed} [=> ${value}]\nusing [\n ${stack}\n]` : `${detailed} [=> ${value}]` 83 | }; 84 | } 85 | 86 | } 87 | 88 | /** 89 | * Parse the rule and walk the expression three to infer the type of each 90 | * node and assert the expression is supported by firebase. 91 | * 92 | * If the rule inferred type is not a boolean or cannot be inferred, it will 93 | * throw. 94 | * 95 | * It will check each node in the expression tree is supported by firebase; 96 | * some expression types are not supported or have stricter rule regarding 97 | * types they support. 98 | * 99 | * Some expression allow type checking to be delayed until runtime evaluation 100 | * when the type cannot be inferred (snapshot value and auth properties). 101 | * 102 | * @param {string} rule Rule to parse 103 | * @param {array} wildchildren List of wildchildren available 104 | * @param {Boolean} isWrite Is rule meant for a write operation 105 | * @return {Rule} 106 | */ 107 | exports.parse = function(rule, wildchildren, isWrite) { 108 | return new Rule(rule, wildchildren, isWrite); 109 | }; 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Pull Requests (PR) are welcomes. 4 | 5 | 6 | ## Installing 7 | 8 | Fork [Targaryen], then: 9 | 10 | ```bash 11 | git clone git@github.com:your-user-id/targaryen.git 12 | cd targaryen 13 | git remote add upstream https://github.com/goldibex/targaryen.git 14 | npm install 15 | ``` 16 | 17 | ## Reporting a parsing error 18 | 19 | If the error relates to rule parsing and evaluation, you can use 20 | `./bin/targaryen-specs`; e.g.: 21 | ``` 22 | $ ./bin/targaryen-specs -a '{"tests": [{"rule": "1/0 > 2"}]}' 23 | { 24 | "users": { 25 | "unauth": null 26 | }, 27 | "tests": [ 28 | { 29 | "rule": "1/0 > 2", 30 | "user": "unauth", 31 | "isValid": true, 32 | "failAtRuntime": false, 33 | "evaluateTo": false 34 | } 35 | ] 36 | } 37 | { Error: Targaryen and Firebase evaluation of "1/0 > 2" diverges. 38 | The rule should evaluate to false. 39 | at MatchError (/targaryen/lib/parser/specs.js:28:5) 40 | at Rule.match (/targaryen/lib/parser/specs.js:235:13) 41 | at fixtures.tests.filter.forEach.t (/targaryen/bin/targaryen-specs:103:21) 42 | at Array.forEach (native) 43 | at test (/targaryen/bin/targaryen-specs:103:6) 44 | at process._tickCallback (internal/process/next_tick.js:103:7) 45 | spec: 46 | Rule { 47 | rule: '1/0 > 2', 48 | user: 'unauth', 49 | wildchildren: undefined, 50 | data: undefined, 51 | isValid: true, 52 | failAtRuntime: false, 53 | evaluateTo: false }, 54 | targaryen: { isValid: true, failAtRuntime: false, evaluateTo: true } } 55 | ``` 56 | 57 | To add it to the list of test fixture in `test/spec/lib/parser/fixtures.json`: 58 | ``` 59 | ./bin/targaryen-specs -s test/spec/lib/parser/fixtures.json -i -a '{"tests": [{"rule": "1/0 > 2"}]}' 60 | 61 | # or 62 | npm run fixtures -- -a '{"tests": [{"rule": "1/0 > 2"}]}' 63 | ``` 64 | 65 | For other type of bug, you should submit regular mocha tests if possible. 66 | 67 | 68 | ## Feature branch 69 | 70 | Avoid working fixes and new features in your master branch. It will prevent you 71 | from submitting focused pull request or from working on more than one 72 | fix/feature at a time. 73 | 74 | Instead, create a branch for each fix or feature: 75 | ```bash 76 | git checkout master 77 | git pull upstream master 78 | git checkout -b 79 | ``` 80 | 81 | Work and commit the fixes/features, and then push your branch: 82 | ```bash 83 | git push origin 84 | ``` 85 | 86 | Visit your fork and send a PR from that branch; the PR form URL will have this 87 | form: 88 | 89 | https://github.com/goldibex/targaryen/compare/master...: 90 | 91 | Once your PR is accepted: 92 | ```bash 93 | git checkout master 94 | git push origin --delete 95 | git branch -D 96 | git pull upstream master 97 | ``` 98 | 99 | 100 | ## Running tests 101 | 102 | ```bash 103 | npm install 104 | npm test 105 | ``` 106 | 107 | Or for coverage info: 108 | ``` 109 | npm run coverage 110 | ``` 111 | 112 | 113 | ## Linting and formatting 114 | 115 | Try to follow the style of the scripts you are editing. 116 | 117 | Before submitting a PR, run [eslint], format styling inconsistencies and fix 118 | insecure idioms: 119 | 120 | ```bash 121 | npm install 122 | 123 | # report errors 124 | npm run lint 125 | 126 | # try to fix automatically styling errors 127 | npm run format 128 | 129 | # report error in html (in lint.html) 130 | npm run lint:html 131 | ``` 132 | 133 | 134 | [Targaryen]: https://github.com/goldibex/targaryen 135 | [eslint]: http://eslint.org/ 136 | -------------------------------------------------------------------------------- /plugins/jest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * targaryen/plugins/jest - Reference implementation of a jest plugin for 3 | * targaryen. 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | const json = require('firebase-json'); 10 | const targaryen = require('../'); 11 | 12 | // Need to disable eslint rule for jest's utils: this.utils.EXPECTED_COLOR('a') 13 | /* eslint-disable new-cap */ 14 | 15 | function toBeAllowed(result) { 16 | const pass = result.allowed === true; 17 | const message = pass ? 18 | () => `Expected operation to be ${this.utils.EXPECTED_COLOR('denied')} but it was ${this.utils.RECEIVED_COLOR('allowed')}\n\n${result.info}` : 19 | () => `Expected operation to be ${this.utils.EXPECTED_COLOR('allowed')} but it was ${this.utils.RECEIVED_COLOR('denied')}\n\n${result.info}`; 20 | 21 | return { 22 | message, 23 | pass 24 | }; 25 | } 26 | 27 | function toAllowRead(database, path, options) { 28 | const result = database 29 | .with({debug: true}) 30 | .read(path, options); 31 | 32 | const pass = result.allowed === true; 33 | const message = pass ? 34 | () => `Expected ${this.utils.EXPECTED_COLOR('read')} to be ${this.utils.EXPECTED_COLOR('denied')} but it was ${this.utils.RECEIVED_COLOR('allowed')}\n\n${result.info}` : 35 | () => `Expected ${this.utils.EXPECTED_COLOR('read')} to be ${this.utils.EXPECTED_COLOR('allowed')} but it was ${this.utils.RECEIVED_COLOR('denied')}\n\n${result.info}`; 36 | 37 | return { 38 | message, 39 | pass 40 | }; 41 | } 42 | 43 | function toAllowWrite(database, path, value, options) { 44 | const result = database 45 | .with({debug: true}) 46 | .write(path, value, options); 47 | 48 | const pass = result.allowed === true; 49 | const message = pass ? 50 | () => `Expected ${this.utils.EXPECTED_COLOR('write')} to be ${this.utils.EXPECTED_COLOR('denied')} but it was ${this.utils.RECEIVED_COLOR('allowed')}\n\n${result.info}` : 51 | () => `Expected ${this.utils.EXPECTED_COLOR('write')} to be ${this.utils.EXPECTED_COLOR('allowed')} but it was ${this.utils.RECEIVED_COLOR('denied')}\n\n${result.info}`; 52 | 53 | return { 54 | message, 55 | pass 56 | }; 57 | } 58 | 59 | function toAllowUpdate(database, path, patch, options) { 60 | const result = database 61 | .with({debug: true}) 62 | .update(path, patch, options); 63 | 64 | const pass = result.allowed === true; 65 | const message = pass ? 66 | () => `Expected ${this.utils.EXPECTED_COLOR('update')} to be ${this.utils.EXPECTED_COLOR('denied')} but it was ${this.utils.RECEIVED_COLOR('allowed')}\n\n${result.info}` : 67 | () => `Expected ${this.utils.EXPECTED_COLOR('update')} to be ${this.utils.EXPECTED_COLOR('allowed')} but it was ${this.utils.RECEIVED_COLOR('denied')}\n\n${result.info}`; 68 | 69 | return { 70 | message, 71 | pass 72 | }; 73 | } 74 | 75 | /** 76 | * Expose `targaryen.database()` for conveniently creating a 77 | * database for a jest test. 78 | * 79 | * @return {Database} 80 | */ 81 | const getDatabase = targaryen.database; 82 | 83 | /** 84 | * Simple wrapper for `targaryen.database()` that also enables debug mode for 85 | * detailed error messages. 86 | * 87 | * @return {Database} 88 | */ 89 | function getDebugDatabase() { 90 | return targaryen.database.apply(this, arguments).with({debug: true}); 91 | } 92 | 93 | const jestTargaryen = { 94 | toBeAllowed, 95 | toAllowRead, 96 | toAllowWrite, 97 | toAllowUpdate, 98 | 99 | // NOTE: Exported for convenience only 100 | getDatabase, 101 | getDebugDatabase, 102 | json, 103 | users: targaryen.util.users 104 | }; 105 | 106 | module.exports = jestTargaryen; 107 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common operations for test framework. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const database = require('./database'); 8 | const pad = require('./pad'); 9 | 10 | let debug = true; 11 | let verbose = true; 12 | let data, rules, db; 13 | 14 | exports.users = { 15 | 16 | unauthenticated: null, 17 | facebook: { 18 | uid: 'facebook:f4475868-a864-4bbe-a1e4-78790cd22572', 19 | id: 1, 20 | provider: 'facebook' 21 | }, 22 | twitter: { 23 | uid: 'twitter:3678364c-e063-4a8e-87f6-b02f0f284f1f', 24 | id: 1, 25 | provider: 'twitter' 26 | }, 27 | github: { 28 | uid: 'github:766cf16c-b2b9-4dd2-9230-89e3fab0d46b', 29 | id: 1, 30 | provider: 'github' 31 | }, 32 | google: { 33 | uid: 'google:2bee04bc-1da6-4680-81d6-c10ec9442fe9', 34 | id: 1, 35 | provider: 'google' 36 | }, 37 | anonymous: { 38 | uid: 'anonymous:f426417a-2268-4319-a4d4-3ef82f3eb1c6', 39 | id: 1, 40 | provider: 'anonymous' 41 | }, 42 | password: { 43 | uid: 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3', 44 | id: 1, 45 | provider: 'password' 46 | } 47 | 48 | }; 49 | 50 | function debugInfo(msg, result, padding) { 51 | const info = debug === true ? `${msg}\n\n${result.info}` : msg; 52 | 53 | return padding == null ? info : pad.lines(info, {length: padding}); 54 | } 55 | 56 | exports.readableError = function(result, padding) { 57 | 58 | const msg = 'Expected the read operation to fail.'; 59 | 60 | return debugInfo(msg, result, padding); 61 | 62 | }; 63 | 64 | exports.unreadableError = function(result, padding) { 65 | 66 | const msg = 'Expected the read operation to succeed.'; 67 | 68 | return debugInfo(msg, result, padding); 69 | 70 | }; 71 | 72 | exports.writableError = function(result, padding) { 73 | 74 | const msg = 'Expected the write operation to fail.'; 75 | 76 | return debugInfo(msg, result, padding); 77 | 78 | }; 79 | 80 | exports.unwritableError = function(result, padding) { 81 | 82 | const msg = 'Expected the write operation to succeed.'; 83 | 84 | return debugInfo(msg, result, padding); 85 | 86 | }; 87 | 88 | exports.setDebug = function(newDebug) { 89 | debug = newDebug === true; 90 | exports.setVerbose(false); 91 | }; 92 | 93 | exports.setVerbose = function(newVerbose) { 94 | verbose = newVerbose === true; 95 | 96 | if (verbose === true) { 97 | debug = true; 98 | } 99 | 100 | if (db) { 101 | db = db.with({debug: verbose}); 102 | } 103 | }; 104 | 105 | exports.assertConfigured = function() { 106 | 107 | if (!rules || !data) { 108 | throw new Error( 109 | 'You must call setFirebaseData and setFirebaseRules before running tests!' 110 | ); 111 | } 112 | 113 | }; 114 | 115 | exports.setFirebaseData = function(value, now) { 116 | now = now || Date.now(); 117 | 118 | try { 119 | value = database.store(value, {now}); 120 | } catch (e) { 121 | db = data = undefined; 122 | throw new Error('Proposed Firebase data is not valid: ' + e.message); 123 | } 124 | 125 | data = {value, now}; 126 | db = undefined; 127 | 128 | }; 129 | 130 | exports.getFirebaseData = function() { 131 | exports.assertConfigured(); 132 | 133 | if (!db) { 134 | db = database.create(rules, data.value, data.now).with({debug: verbose}); 135 | } 136 | 137 | return db; 138 | }; 139 | 140 | exports.setFirebaseRules = function(ruleDefinition) { 141 | 142 | try { 143 | rules = database.ruleset(ruleDefinition); 144 | } catch (e) { 145 | throw new Error('Proposed Firebase rules are not valid: ' + e.message); 146 | } 147 | 148 | }; 149 | 150 | exports.resetFirebase = function() { 151 | rules = data = db = undefined; 152 | }; 153 | -------------------------------------------------------------------------------- /lib/parser/regexp/parser.ne: -------------------------------------------------------------------------------- 1 | @{% 2 | const moo = require('moo'); 3 | const token = require('./token') 4 | const lexer = moo.states({ 5 | main: { 6 | bar: '|', 7 | caret: '^', 8 | comma: ',', 9 | dollar: '$', 10 | dot: '.', 11 | closingParenthesis: ')', 12 | openingParenthesis: '(', 13 | mark: '?', 14 | plus: '+', 15 | closingBrace: '}', 16 | openingBrace: '{', 17 | star: '*', 18 | negativeSetStart: {match: '[^', push: 'set'}, 19 | positiveSetStart: {match: '[', push: 'set'}, 20 | 21 | charset: ['\\s', '\\w', '\\d', '\\S', '\\W', '\\D'], 22 | literal: /\\./, 23 | number: /0|[1-9][0-9]*/, 24 | char: /./ 25 | }, 26 | set: { 27 | setEnd: {match: ']', pop: true}, 28 | charset: ['\\s', '\\w', '\\d', '\\S', '\\W', '\\D'], 29 | range: /(?:\\.|[^\]])-(?:\\.|[^\]])/, 30 | literal: /\\./, 31 | char: /./ 32 | } 33 | }); 34 | 35 | %} 36 | 37 | @lexer lexer 38 | 39 | # End and begin anchor ("^"" and "$"") are only allowed at the end of the 40 | # expression. 41 | root -> 42 | startRE:? RE endRE:? {% token.concatenation %} 43 | 44 | # Special characters at the start the expression do not need to be escape. 45 | startRE -> 46 | %caret {% token.rename('startAnchor') %} 47 | | positionalLiteral {% id %} 48 | 49 | # 50 | endRE -> 51 | %dollar {% token.rename('endAnchor') %} 52 | 53 | RE -> 54 | union {% id %} 55 | | simpleRE {% id %} 56 | 57 | union -> 58 | RE %bar simpleRE {% token.union %} 59 | 60 | simpleRE -> 61 | concatenation {% id %} 62 | | basicRE {% id %} 63 | 64 | concatenation -> 65 | simpleRE basicRE {% token.concatenation %} 66 | 67 | basicRE -> 68 | series {% id %} 69 | | elementaryRE {% id %} 70 | 71 | series -> 72 | basicRE repeat {% token.series %} 73 | 74 | repeat -> 75 | %plus {% token.repeat(1) %} 76 | | %star {% token.repeat(0) %} 77 | | %mark {% token.repeat(0, 1) %} 78 | | %openingBrace %number %closingBrace {% data => token.repeat(data[1], data[1])(data) %} 79 | | %openingBrace %number %comma %closingBrace {% data => token.repeat(data[1])(data) %} 80 | | %openingBrace %number %comma %number %closingBrace {% data => token.repeat(data[1], data[3])(data) %} 81 | 82 | elementaryRE -> 83 | group {% id %} 84 | | char {% id %} 85 | | number {% id %} 86 | | set {% id %} 87 | | charset {% id %} 88 | | %dot {% token.create %} 89 | 90 | group -> 91 | %openingParenthesis groupRE %closingParenthesis {% token.group %} 92 | 93 | groupRE -> 94 | positionalLiteral:? RE {% token.concatenation %} 95 | 96 | char -> 97 | %char {% token.create %} 98 | | %comma {% token.char %} 99 | | %literal {% token.char %} 100 | | %closingBrace {% token.char %} 101 | 102 | number -> 103 | %number {% token.create %} 104 | 105 | charset -> 106 | %charset {% token.create %} 107 | 108 | set -> 109 | %positiveSetStart setItem:+ %setEnd {% token.set(true) %} 110 | | %negativeSetStart setItem:+ %setEnd {% token.set(false) %} 111 | 112 | setItem -> 113 | %range {% token.range %} 114 | | char {% id %} 115 | | charset {% id %} 116 | 117 | positionalLiteral -> 118 | meta {% token.char %} 119 | 120 | meta -> 121 | %bar {% id %} 122 | | %closingParenthesis {% id %} 123 | | %openingParenthesis {% id %} 124 | | %mark {% id %} 125 | | %plus {% id %} 126 | | %closingBrace {% id %} 127 | | %openingBrace {% id %} 128 | | %star {% id %} 129 | -------------------------------------------------------------------------------- /docs/targaryen/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Note 3 | 4 | The CLI utility is meant for somewhat simpler tests. If you have more complex 5 | requirements, please use targaryen as a plugin for either [Jasmine](https://github.com/goldibex/targaryen/blob/master/docs/jasmine) or [Chai](https://github.com/goldibex/targaryen/blob/master/docs/chai). 6 | 7 | ## Using Targaryen's standalone CLI 8 | 9 | 1. Run `npm install -g targaryen`. 10 | 2. Create a JSON security tests file. The file should contain a single base object 11 | with the following 3 keys: 12 | 13 | `root`: an object containing the mock data the security tests should 14 | operate on. This data can either be formatted identically to the kind of value that 15 | comes out of `exportVal`, or just given as plain values if you don't care about priorities. 16 | Additionally, the special object `{ ".sv": "timestamp" }` will be replaced with 17 | an integer containing the number of milliseconds since the epoch. 18 | 19 | An example: 20 | ```json 21 | "root": { 22 | "crimes": { 23 | "6ca5": { 24 | "type": "theft of Mona Lisa", 25 | "foiled": true, 26 | "criminal": "criminals:1", 27 | "detective": "detectives:1" 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | `users`: an object that describes the authentication state of any kind of user 34 | that might access your system. The keys should be the names by which you want to refer 35 | to the auth payload in the tests; the values should be the auth payloads themselves. 36 | 37 | For example: 38 | ```json 39 | "users": { 40 | "Sherlock Holmes": { "uid": "detectives:1", "smart": true }, 41 | "John Watson": { "uid": "detectives:2", "smart": false }, 42 | "James Moriarity": { "uid": "criminals:1", "smart": true } 43 | } 44 | ``` 45 | 46 | `tests`: an object that describes the tests, that is, operations that should or 47 | should not be possible given the current data in `root`, one of the users from `users`, 48 | and possibly some new data to be written. 49 | 50 | The keys of this object should be the paths to be tested. The values are objects with at least 51 | one of four keys, `canRead`, `canWrite`, `cannotRead`, and `cannotWrite`. The values associated 52 | with these four keys should be arrays containing strings, in the case of the read 53 | operations; or, for the write operations, objects with keys `auth` and `data`, where `auth` 54 | is the name of the auth object and `data` is the proposed new data for the location, following 55 | the format specified for `root` above. 56 | 57 | For example: 58 | ```json 59 | "tests": { 60 | "crimes/some-uncommitted-crime": { 61 | "canRead": ["Sherlock Holmes", "John Watson"], 62 | "canWrite": ["James Moriarty"] 63 | }, 64 | "crimes/6ca5/foiled": { 65 | "canWrite": [ 66 | { "auth": "Sherlock Holmes", "data": true } 67 | ], 68 | "cannotWrite": [ 69 | { "auth": "James Moriarty", "data": false } 70 | ] 71 | } 72 | } 73 | ``` 74 | 75 | 3. Run the tests with the command 76 | 77 | ```bash 78 | $ targaryen path/to/rules.json path/to/tests.json 79 | ``` 80 | 81 | Targaryen will run the tests and report any failures on stderr. 82 | 83 | If you specify `--verbose`, Targaryen will pretty-print a nicely formatted table 84 | containing a list of the tests and their status. 85 | 86 | If you specify `--debug', Targaryen will print the full debug output for each test, 87 | indicating which rules were tested and what their outcomes were. This is the same 88 | as the output of the Firebase Forge's simulator. 89 | 90 | Targaryen exits with a non-zero status code if at least one test failed, or zero if 91 | all tests succeeded. 92 | 93 | -------------------------------------------------------------------------------- /plugins/chai.js: -------------------------------------------------------------------------------- 1 | /** 2 | * targaryen/plugins/chai - Reference implementation of a chai plugin for 3 | * targaryen. 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | const targaryen = require('../'); 10 | 11 | function chaiTargaryen(chai, utils) { 12 | 13 | chai.Assertion.addProperty('can', function() { 14 | utils.flag(this, 'positivity', true); 15 | }); 16 | 17 | chai.Assertion.addProperty('cannot', function() { 18 | utils.flag(this, 'positivity', false); 19 | }); 20 | 21 | chai.Assertion.addProperty('read', function() { 22 | utils.flag(this, 'operation', 'read'); 23 | }); 24 | 25 | chai.Assertion.addChainableMethod('readAt', function(now) { 26 | const options = Object.assign({}, utils.flag(this, 'operationOptions'), {now}); 27 | 28 | utils.flag(this, 'operation', 'read'); 29 | utils.flag(this, 'operationOptions', options); 30 | 31 | }, function() { 32 | const options = Object.assign({}, utils.flag(this, 'operationOptions'), {now: null}); 33 | 34 | utils.flag(this, 'operation', 'read'); 35 | utils.flag(this, 'operationOptions', options); 36 | }); 37 | 38 | chai.Assertion.addChainableMethod('readWith', function(options) { 39 | utils.flag(this, 'operation', 'read'); 40 | utils.flag(this, 'operationOptions', options); 41 | 42 | }, function() { 43 | utils.flag(this, 'operation', 'read'); 44 | utils.flag(this, 'operationOptions', {}); 45 | }); 46 | 47 | chai.Assertion.addChainableMethod('write', function(data, options) { 48 | 49 | utils.flag(this, 'operation', 'write'); 50 | utils.flag(this, 'operationData', data); 51 | utils.flag(this, 'operationOptions', typeof options === 'number' ? {now: options} : options); 52 | 53 | }, function() { 54 | utils.flag(this, 'operation', 'write'); 55 | utils.flag(this, 'operationData', null); 56 | utils.flag(this, 'operationOptions', {}); 57 | }); 58 | 59 | chai.Assertion.addChainableMethod('patch', function(data, options) { 60 | 61 | utils.flag(this, 'operation', 'patch'); 62 | utils.flag(this, 'operationData', data); 63 | utils.flag(this, 'operationOptions', options); 64 | 65 | }, function() { 66 | utils.flag(this, 'operation', 'patch'); 67 | utils.flag(this, 'operationData', null); 68 | utils.flag(this, 'operationOptions', {}); 69 | }); 70 | 71 | chai.Assertion.addMethod('path', function(path) { 72 | 73 | targaryen.util.assertConfigured(); 74 | 75 | const auth = this._obj; 76 | const data = targaryen.util.getFirebaseData().as(auth); 77 | const operationType = utils.flag(this, 'operation'); 78 | const options = utils.flag(this, 'operationOptions'); 79 | const positivity = utils.flag(this, 'positivity'); 80 | let result, newData; 81 | 82 | switch (operationType) { 83 | 84 | case 'read': 85 | result = data.read(path, options); 86 | 87 | if (positivity) { 88 | chai.assert(result.allowed === true, targaryen.util.unreadableError(result, 6).trim()); 89 | } else { 90 | chai.assert(result.allowed === false, targaryen.util.readableError(result, 6).trim()); 91 | } 92 | 93 | return; 94 | 95 | case 'write': 96 | newData = utils.flag(this, 'operationData'); 97 | result = data.write(path, newData, options); 98 | break; 99 | 100 | case 'patch': 101 | newData = utils.flag(this, 'operationData'); 102 | result = data.update(path, newData, options); 103 | break; 104 | 105 | default: 106 | return; 107 | } 108 | 109 | if (positivity) { 110 | chai.assert(result.allowed === true, targaryen.util.unwritableError(result, 6).trim()); 111 | } else { 112 | chai.assert(result.allowed === false, targaryen.util.writableError(result, 6).trim()); 113 | } 114 | 115 | }); 116 | 117 | } 118 | 119 | chaiTargaryen.json = require('firebase-json'); 120 | chaiTargaryen.users = targaryen.util.users; 121 | chaiTargaryen.setDebug = targaryen.util.setDebug; 122 | chaiTargaryen.setVerbose = targaryen.util.setVerbose; 123 | chaiTargaryen.setFirebaseData = targaryen.util.setFirebaseData; 124 | chaiTargaryen.setFirebaseRules = targaryen.util.setFirebaseRules; 125 | 126 | module.exports = chaiTargaryen; 127 | -------------------------------------------------------------------------------- /docs/chai/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Using Targaryen with Chai and Mocha 3 | 4 | 1. Run `npm install -g mocha` and `npm install --save-dev mocha chai targaryen`. 5 | 6 | 2. Create a new directory for your security tests, like, say, `test/security`. 7 | 8 | 3. Add a new *fixture JSON* for the state of your Firebase. Call this, say, `test/security/.json`. This file will describe the state of the Firebase data store for your tests, that is, what you can get via the `root` and `data` variables in the security rules. 9 | 10 | 4. Create a new file for your first set of tests, like `test/security/.js`. 11 | 12 | 5. Add the following content to the top of the new file: 13 | 14 | ```js 15 | const chai = require('chai'); 16 | const targaryen = require('targaryen/plugins/chai'); 17 | 18 | chai.use(targaryen); 19 | 20 | describe('my security rules', function() { 21 | 22 | before(function() { 23 | targaryen.setFirebaseData(require(DATA_PATH))); 24 | targaryen.setFirebaseRules(require(RULES_PATH)); 25 | }); 26 | 27 | }); 28 | ``` 29 | 30 | where `RULES_PATH` is the path to your security rules JSON file. If your security rules are broken, Targaryen will throw an exception at this point with detailed information about what specifically is broken. 31 | 32 | 6. Write your security tests. 33 | 34 | The subject of every assertion will be the authentication state (i.e., `auth`) of the user trying the operation, so for instance, `null` would be an unauthenticated user, or a Firebase Password Login user would look like `{ uid: 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3', id: 1, provider: 'password' }`. There are symbolic versions of these in `targaryen.users`. 35 | 36 | See the API section below for details, or take a look at the example files here. 37 | 38 | 7. Run the tests using `mocha test/security/**/*.js`. 39 | 40 | ## Examples 41 | 42 | To run the examples: 43 | ``` 44 | npm install -g mocha 45 | npm install 46 | cd docs/chai 47 | mocha examples/.js 48 | ``` 49 | 50 | ## API 51 | 52 | - import with `require('targaryen/plugins/chai')`. 53 | - `chaiTargaryen.chai`: The plugin object. Load this using `chai.use(chaiTargaryen.chai)` before running any tests. 54 | - `chaiTargaryen.setFirebaseData(data: any)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`. 55 | - `chaiTargaryen.setFirebaseRules(rules: object)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules. 56 | - `chaiTargaryen.setDebug(flag: boolean)`: Failed expectations will show the result of each rule when debug is set to `true` (`true` by default). 57 | - `chaiTargaryen.setVerbose(flag: boolean)`: Failed expectations will show the detailed evaluation of each rule when verbose is set to `true`(`true` by default). 58 | - `chaiTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys: 59 | - `unauthenticated`: an unauthenticated user, i.e., `auth === null`. 60 | - `anonymous`: a user authenticated using Firebase anonymous sessions. 61 | - `password`: a user authenticated using Firebase Password Login. 62 | - `facebook`: a user authenticated by their Facebook account. 63 | - `twitter`: a user authenticated by their Twitter account. 64 | - `google`: a user authenticated by their Google account. 65 | - `github`: a user authenticated by their Github account. 66 | - `chai.Assertion.can`: asserts that this is an affirmative test, i.e., the specified operation ought to succeed. 67 | - `chai.Assertion.cannot`: asserts that this is a negative test, i.e., the specified operation ought to fail. 68 | - `chai.Assertion.read`: asserts that this test is for a read operation. 69 | - `chai.Assertion.readWith(options: {query: object, now: number})`: asserts that this test is for a read operation. 70 | - `chai.Assertion.write(data: any [, options: {now: number, priority: any}])`: asserts that this test is for a write operation. Optionally takes a Javascript object or primitive with the new data to be written (which will be in the `newData` snapshot in the rules). Otherwise it just tries with `null`. 71 | - `chai.Assertion.patch(data: {[path: string]: any} [, options: {now: number}])`: asserts that this test is for a patch (or multi-location update) operation. Optionally takes a Javascript object or primitive with the new data to be written (which will be in the `newData` snapshot in the rules). Otherwise it just tries with `null`. 72 | - `chai.Assertion.path(firebasePath: string)`: asserts the path against which the operation should be conducted. This method actually tries the damn operation. 73 | 74 | -------------------------------------------------------------------------------- /lib/parser/regexp/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _flatten = require('lodash.flatten'); 4 | const omit = require('lodash.omit'); 5 | 6 | exports.char = function(tokens) { 7 | const source = exports.create(tokens); 8 | 9 | return Object.assign(source, { 10 | type: 'char', 11 | value: source.value.slice(-1) 12 | }); 13 | }; 14 | 15 | exports.concatenation = function(tokens) { 16 | const filtered = flatten(tokens); 17 | 18 | if (filtered.length < 2) { 19 | return filtered[0]; 20 | } 21 | 22 | return merge( 23 | filtered.map(tok => (tok.type === 'concatenation' ? tok.concatenation : tok)), 24 | 'concatenation' 25 | ); 26 | }; 27 | 28 | exports.create = function(tokens) { 29 | const source = first(tokens); 30 | 31 | return source == null ? 32 | null : 33 | omit(source, ['col', 'line', 'lineBreaks', 'text', 'toString']); 34 | }; 35 | 36 | exports.group = function(tokens) { 37 | return merge(tokens, { 38 | type: 'group', 39 | group: tokens[1] 40 | }); 41 | }; 42 | 43 | const RANGE_RE = /^(\\.|.)-(\\.|.)$/; 44 | const MINUS_LENGTH = 1; 45 | 46 | exports.range = function(tokens) { 47 | const range = first(tokens); 48 | 49 | if (range == null) { 50 | return null; 51 | } 52 | 53 | const parts = RANGE_RE.exec(range.value); 54 | 55 | if (parts == null) { 56 | return null; 57 | } 58 | 59 | const start = exports.char({value: parts[1], offset: range.offset}); 60 | const end = exports.char({ 61 | value: parts[2], 62 | offset: start.offset + start.value.length + MINUS_LENGTH 63 | }); 64 | 65 | return merge(tokens, {type: 'range', start, end}); 66 | }; 67 | 68 | exports.rename = function(type) { 69 | return tokens => Object.assign(exports.create(tokens), {type}); 70 | }; 71 | 72 | exports.repeat = function(min, max) { 73 | return tokens => merge(tokens, { 74 | type: 'repeat', 75 | min: min == null ? 0 : parseInt(min, 10), 76 | max: max == null ? Infinity : parseInt(max, 10) 77 | }); 78 | }; 79 | 80 | exports.series = function(tokens) { 81 | const source = flatten(tokens); 82 | 83 | if (source == null) { 84 | return null; 85 | } 86 | 87 | if (source.length !== 2) { 88 | throw new Error('A series should have a pattern and a quantifier.'); 89 | } 90 | 91 | const pattern = source[0]; 92 | const repeat = source[1]; 93 | 94 | if (pattern.type !== 'number' || pattern.value.length === 1) { 95 | return merge([pattern, repeat], { 96 | pattern, 97 | repeat, 98 | type: 'series' 99 | }); 100 | } 101 | 102 | const partial = exports.concatenation(tail(pattern)); 103 | const series = exports.series([partial.concatenation[1], repeat]); 104 | const number = partial.concatenation[0]; 105 | 106 | return exports.concatenation([number, series]); 107 | }; 108 | 109 | exports.set = function(include) { 110 | return tokens => merge(tokens, { 111 | type: 'set', 112 | include: include ? tokens[1] : null, 113 | exclude: include ? null : tokens[1] 114 | }); 115 | }; 116 | 117 | exports.union = function(tokens) { 118 | return merge(tokens, { 119 | type: 'union', 120 | branches: flatten([tokens[0], tokens[2]].map(tok => { 121 | switch (tok.type) { 122 | case 'union': 123 | return tok.branches; 124 | 125 | default: 126 | return tok; 127 | } 128 | })) 129 | }); 130 | }; 131 | 132 | function filter(tokens) { 133 | return tokens.filter(tok => tok != null); 134 | } 135 | 136 | function first(tokens) { 137 | return Array.isArray(tokens) ? flatten(tokens)[0] : tokens; 138 | } 139 | 140 | function flatten(tokens) { 141 | return filter(_flatten(tokens)); 142 | } 143 | 144 | function merge(tokens, optionsOrType) { 145 | const flat = flatten(tokens); 146 | const ref = flat[0]; 147 | 148 | if (ref == null) { 149 | return null; 150 | } 151 | 152 | const options = typeof optionsOrType === 'string' ? 153 | {type: optionsOrType, [optionsOrType]: flat} : 154 | optionsOrType; 155 | 156 | return Object.assign({ 157 | offset: ref.offset, 158 | type: ref.type, 159 | value: flat.map(tok => tok.value).join('') 160 | }, options); 161 | } 162 | 163 | function tail(tokens) { 164 | const source = first(tokens); 165 | 166 | if (source == null) { 167 | return [null, null]; 168 | } 169 | 170 | if (source.value.length < 2) { 171 | return [null, source]; 172 | } 173 | 174 | const head = Object.assign({}, source, {value: source.value.slice(0, -1)}); 175 | const tail = { 176 | type: 'char', 177 | value: source.value[source.value.length - 1], 178 | offset: head.offset + head.value.length 179 | }; 180 | 181 | return [head, tail]; 182 | } 183 | -------------------------------------------------------------------------------- /docs/jasmine/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Using Targaryen with Jasmine 3 | 4 | 1. Run `npm install -g jasmine` and `npm install --save-dev targaryen`. 5 | 6 | 2. Create a new directory for your security tests. Jasmine likes your tests to live in the directory `spec`, so a good choice might be `spec/security`. Add this directory to `spec/support/jasmine.json`. 7 | 8 | 3. Add a new *fixture JSON* for the state of your Firebase. Call this `spec/security/.json`. This file will describe the state of the Firebase data store for your tests, that is, what you can get via the `root` and `data` variables in the security rules. 9 | 10 | 4. Create a new file for your first set of tests, like `spec/security/.js`. 11 | 12 | 5. Add the following content to the top of the new file: 13 | 14 | ```js 15 | const targaryen = require('targaryen/plugins/jasmine'); 16 | const users = targaryen.users; 17 | 18 | targaryen.setFirebaseData(require(path.join(__dirname, path.basename(__filename, '.js') + '.json'))); 19 | targaryen.setFirebaseRules(require(RULES_PATH)); 20 | 21 | describe('my security rules', function() { 22 | 23 | beforeEach(function() { 24 | jasmine.addMatchers(targaryen.matchers); 25 | }); 26 | 27 | }); 28 | ``` 29 | 30 | where `RULES_PATH` is the path to your security rules JSON file. If your security rules are broken, Targaryen will throw an exception at this point with detailed information about what specifically is broken. 31 | 32 | 6. Write your security tests. 33 | 34 | The subject of every assertion will be the authentication state (i.e., `auth`) of the user trying the operation, so for instance, `null` would be an unauthenticated user, or a Firebase Password Login user would look like `{ uid: 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3', id: 1, provider: 'password' }`. There are symbolic versions of these in `targaryen.users`. 35 | 36 | See the API section below for details, or take a look at the example files here. 37 | 38 | 7. Run the tests with `jasmine`. 39 | 40 | ## Examples 41 | 42 | To run the examples: 43 | ``` 44 | npm install -g jasmine 45 | cd targaryen/docs/jasmine/examples 46 | jasmine spec/security/.js 47 | ``` 48 | 49 | ## API 50 | 51 | - import with `require('targaryen/plugins/jasmine')`. 52 | - `jasmineTargaryen.matchers`: The plugin object. Load this using `jasmine.addMatchers(jasmineTargaryen.matchers)` before running any tests. 53 | - `jasmineTargaryen.setFirebaseData(data: any)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`. 54 | - `jasmineTargaryen.setFirebaseRules(rules: object)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules. 55 | - `jasmineTargaryen.setDebug(flag: boolean)`: Failed expectations will show the result of each rule when debug is set to `true` (`true` by default). 56 | - `jasmineTargaryen.setVerbose(flag: boolean)`: Failed expectations will show the detailed evaluation of each rule when verbose is set to `true`(`true` by default). 57 | - `jasmineTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys: 58 | - `unauthenticated`: an unauthenticated user, i.e., `auth === null`. 59 | - `anonymous`: a user authenticated using Firebase anonymous sessions. 60 | - `password`: a user authenticated using Firebase Password Login. 61 | - `facebook`: a user authenticated by their Facebook account. 62 | - `twitter`: a user authenticated by their Twitter account. 63 | - `google`: a user authenticated by their Google account. 64 | - `github`: a user authenticated by their Github account. 65 | - `expect(auth).canRead(path: string [, options: {now?: number, query?: object} ])`: asserts that the given path is readable by a user with the given authentication data. 66 | - `expect(auth).cannotRead(path: string[, options: {now?: number, query?: object} ])`: asserts that the given path is not readable by a user with the given authentication data. 67 | - `expect(auth).canWrite(path: string [, data: any [, options: {now: number, priority: any} ]])`: asserts that the given path is writable by a user with the given authentication data. Optionally takes a Javascript object containing `newData`, otherwise this will be set to `null`. 68 | - `expect(auth).cannotWrite(path: string [, data: any [, options: {now: number, priority: any} ]])`: asserts that the given path is not writable by a user with the given authentication data. Optionally takes a Javascript object containing `newData`, otherwise this will be set to `null`. 69 | - `expect(auth).canPatch(path: string, patch: {[path: string]: any} [, options: {now: number} ])`: asserts that the given patch (or multi-location update) operation is writable by a user with the given authentication data. 70 | - `expect(auth).cannotPatch(path: string, patch: {[path: string]: any} [, options: {now: number} ])`: asserts that the given patch (or multi-location update) operation is writable by a user with the given authentication data. 71 | 72 | -------------------------------------------------------------------------------- /test/spec/lib/database/query.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test firebase query validation. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const query = require('../../../../lib/database/query'); 8 | 9 | describe('Query', function() { 10 | 11 | it('should order by key by default', function() { 12 | expect(query.create()).to.include({ 13 | orderByKey: true, 14 | orderByPriority: false, 15 | orderByValue: false, 16 | orderByChild: null 17 | }); 18 | }); 19 | 20 | ['orderByKey', 'orderByPriority', 'orderByValue'].forEach(function(order) { 21 | it(`should throw when setting ${order} to false`, function() { 22 | expect(() => query.create({[order]: false})).to.throw(); 23 | }); 24 | }); 25 | 26 | it('should throw when setting orderByChild to null', function() { 27 | expect(() => query.create({orderByChild: null})).to.throw(); 28 | }); 29 | 30 | it('should set orderBy to orderByKey', function() { 31 | expect(query.create({orderByKey: true})).to.include({ 32 | orderByKey: true, 33 | orderByValue: false, 34 | orderByPriority: false, 35 | orderByChild: null 36 | }); 37 | }); 38 | 39 | it('should set orderBy to orderByValue', function() { 40 | expect(query.create({orderByValue: true})).to.include({ 41 | orderByKey: false, 42 | orderByValue: true, 43 | orderByPriority: false, 44 | orderByChild: null 45 | }); 46 | }); 47 | 48 | it('should set orderBy to orderByPriority', function() { 49 | expect(query.create({orderByPriority: true})).to.include({ 50 | orderByKey: false, 51 | orderByValue: false, 52 | orderByPriority: true, 53 | orderByChild: null 54 | }); 55 | }); 56 | 57 | it('should set orderBy to a child name', function() { 58 | expect(query.create({orderByChild: 'foo'})).to.include({ 59 | orderByKey: false, 60 | orderByValue: false, 61 | orderByPriority: false, 62 | orderByChild: 'foo' 63 | }); 64 | }); 65 | 66 | it('should throw if the child name is not a string', function() { 67 | expect(() => query.create({orderByChild: 1})).to.throw(); 68 | }); 69 | 70 | ['startAt', 'endAt', 'equalTo'].forEach(function(key) { 71 | [null, 'foo', 2, true].forEach(function(value) { 72 | it(`should allow to set ${key} to ${value === null ? 'null' : typeof value}`, function() { 73 | expect(() => query.create({[key]: value})).not.to.throws(); 74 | }); 75 | }); 76 | 77 | it(`should throw if setting ${key} to an object`, function() { 78 | expect(() => query.create({[key]: {foo: 1}})).to.throws(); 79 | }); 80 | }); 81 | 82 | ['limitToFirst', 'limitToLast'].forEach(function(key) { 83 | [null, 2].forEach(function(value) { 84 | it(`should allow to set ${key} to ${value === null ? 'null' : typeof value}`, function() { 85 | expect(() => query.create({[key]: value})).not.to.throws(); 86 | }); 87 | }); 88 | 89 | ['foo', true, {foo: 1}].forEach(function(value) { 90 | it(`should throw if setting ${key} to ${Object.isExtensible(value) ? 'object' : typeof value}`, function() { 91 | expect(() => query.create({[key]: value})).to.throws(); 92 | }); 93 | }); 94 | }); 95 | 96 | it('should throw when setting an unknown property', function() { 97 | expect(() => query.create({foo: 1})).to.throws(); 98 | }); 99 | 100 | describe('#toParams', function() { 101 | [{ 102 | msg: 'should return an empty object by default' 103 | }, { 104 | msg: 'should return order by child params', 105 | q: {orderByChild: 'foo'}, 106 | params: {orderBy: '"foo"'} 107 | }, { 108 | msg: 'should return order by value params', 109 | q: {orderByValue: true}, 110 | params: {orderBy: '"$value"'} 111 | }, { 112 | msg: 'should return order by priority params', 113 | q: {orderByPriority: true}, 114 | params: {orderBy: '"$priority"'} 115 | }, { 116 | msg: 'should return endAt param', 117 | q: {endAt: 'foo'}, 118 | params: {endAt: '"foo"'} 119 | }, { 120 | msg: 'should return startAt param', 121 | q: {startAt: 'foo'}, 122 | params: {startAt: '"foo"'} 123 | }, { 124 | msg: 'should return equalTo param', 125 | q: {equalTo: 'foo'}, 126 | params: {equalTo: '"foo"'} 127 | }, { 128 | msg: 'should return limitToFirst param', 129 | q: {limitToFirst: 10}, 130 | params: {limitToFirst: '10'} 131 | }, { 132 | msg: 'should return limitToLast param', 133 | q: {limitToLast: 10}, 134 | params: {limitToLast: '10'} 135 | }, { 136 | msg: 'should return all param', 137 | q: {orderByValue: true, startAt: 'foo', endAt: 'bar', limitToLast: 10}, 138 | params: {orderBy: '"$value"', startAt: '"foo"', endAt: '"bar"', limitToLast: '10'} 139 | }].forEach(t => { 140 | it(t.msg, function() { 141 | expect(query.create(t.q).toParams()).to.eql(t.params || {}); 142 | }); 143 | }); 144 | }); 145 | 146 | }); 147 | -------------------------------------------------------------------------------- /docs/jest/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Using Targaryen with Jest 3 | 4 | 1. Run `npm install -g jest` and `npm install --save-dev targaryen`. 5 | 6 | 2. Create a new directory for your security tests (**NOTE**: Jest defaults to look for tests inside of `__tests__` folders, or in files that end in `.spec.js` or `.test.js`). 7 | 8 | 3. Add a new *fixture JSON* for the state of your Firebase. Call this `spec/security/.json`. This file will describe the state of the Firebase data store for your tests, that is, what you can get via the `root` and `data` variables in the security rules. 9 | 10 | 4. Create a new file for your first set of tests, like `spec/security/.spec.js`. 11 | 12 | 5. Add the following content to the top of the new file: 13 | 14 | ```js 15 | // user-rules.spec.js 16 | const targaryen = require('targaryen/plugins/jest'); 17 | 18 | expect.extend({ 19 | toAllowRead: targaryen.toAllowRead, 20 | toAllowUpdate: targaryen.toAllowUpdate, 21 | toAllowWrite: targaryen.toAllowWrite, 22 | }); 23 | 24 | const RULES_PATH = 'database.rules.json'; 25 | const rules = targaryen.json.loadSync(RULES_PATH); 26 | const initialData = require(path.join(__dirname, path.basename(__filename, '.spec.js') + '.json')); 27 | 28 | test('basic', () => { 29 | const database = targaryen.getDatabase(rules, initialData); 30 | 31 | expect(database.as(targaryen.users.unauthenticated)).not.toAllowRead('/user'); 32 | expect(database.as(targaryen.users.unauthenticated)).toAllowRead('/public'); 33 | expect(database.as(targaryen.users.facebook)).toAllowRead('/user'); 34 | expect(database.as({ uid: '1234'})).toAllowWrite('/user/1234', { 35 | name: 'Anna', 36 | }); 37 | }); 38 | ``` 39 | 40 | where `RULES_PATH` is the path to your security rules JSON file. If your security rules are broken, Targaryen will throw an exception at this point with detailed information about what specifically is broken. 41 | 42 | 6. Write your security tests. 43 | 44 | The subject of every assertion will be the authentication state (i.e., `auth`) of the user trying the operation, so for instance, `null` would be an unauthenticated user, or a Firebase Password Login user would look like `{ uid: 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3', id: 1, provider: 'password' }`. There are symbolic versions of these in `targaryen.users`. 45 | 46 | See the API section below for details, or take a look at the example files here. 47 | 48 | 7. Run the tests with `jest`. 49 | 50 | ## API 51 | 52 | - import with `require('targaryen/plugins/jest')`. 53 | - `jestTargaryen.toAllowRead`, `jestTargaryen.toAllowWrite`, `jestTargaryen.toAllowUpdate`, `jestTargaryen.toBeAllowed`: The jest matchers. Load them using `expect.extend({toAllowRead: targaryen.toAllowRead, toAllowWrite: targaryen.toAllowWrite, toAllowUpdate: targaryen.toAllowUpdate, toBeAllowed: targaryen.toBeAllowed});` before running any tests. 54 | - `jestTargaryen.getDatabase(rules: object|Ruleset, data: object|DataNode, now: null|number): Database`: Wrapper for `targaryen.database()`. 55 | - `jestTargaryen.getDebugDatabase(rules: object|Ruleset, data: object|DataNode, now: null|number): Database`: Wrapper for `targaryen.database()` that also enables debug mode. Use this if you write your tests using the generic matcher `toBeAllowed()`. 56 | - `jestTargaryen.json`: Export of `firebase-json` to allow parsing of firebase rule files. 57 | - `jestTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys: 58 | - `unauthenticated`: an unauthenticated user, i.e., `auth === null`. 59 | - `anonymous`: a user authenticated using Firebase anonymous sessions. 60 | - `password`: a user authenticated using Firebase Password Login. 61 | - `facebook`: a user authenticated by their Facebook account. 62 | - `twitter`: a user authenticated by their Twitter account. 63 | - `google`: a user authenticated by their Google account. 64 | - `github`: a user authenticated by their Github account. 65 | - `expect(auth).canRead(path: string [, options: {now?: number, query?: object} ])`: asserts that the given path is readable by a user with the given authentication data. 66 | - `expect(auth).cannotRead(path: string[, options: {now?: number, query?: object} ])`: asserts that the given path is not readable by a user with the given authentication data. 67 | - `expect(auth).canWrite(path: string [, data: any [, options: {now: number, priority: any} ]])`: asserts that the given path is writable by a user with the given authentication data. Optionally takes a Javascript object containing `newData`, otherwise this will be set to `null`. 68 | - `expect(auth).cannotWrite(path: string [, data: any [, options: {now: number, priority: any} ]])`: asserts that the given path is not writable by a user with the given authentication data. Optionally takes a Javascript object containing `newData`, otherwise this will be set to `null`. 69 | - `expect(auth).canPatch(path: string, patch: {[path: string]: any} [, options: {now: number} ])`: asserts that the given patch (or multi-location update) operation is writable by a user with the given authentication data. 70 | - `expect(auth).cannotPatch(path: string, patch: {[path: string]: any} [, options: {now: number} ])`: asserts that the given patch (or multi-location update) operation is writable by a user with the given authentication data. 71 | 72 | -------------------------------------------------------------------------------- /lib/database/query.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a query object with its default values. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const orderedBy = Symbol('orderBy'); 8 | const orderByKey = Symbol('orderByKey'); 9 | const orderByValue = Symbol('orderByValue'); 10 | const orderByPriority = Symbol('orderByPriority'); 11 | 12 | /** 13 | * Holds read query parameters. 14 | */ 15 | class Query { 16 | 17 | get orderByKey() { 18 | return this[orderedBy] === orderByKey; 19 | } 20 | 21 | get orderByValue() { 22 | return this[orderedBy] === orderByValue; 23 | } 24 | 25 | get orderByPriority() { 26 | return this[orderedBy] === orderByPriority; 27 | } 28 | 29 | get orderByChild() { 30 | const value = this[orderedBy]; 31 | 32 | return typeof value === 'string' ? value : null; 33 | } 34 | 35 | /** 36 | * Creates an instance of Query. 37 | * 38 | * @param {Partial} query Query parameters. 39 | */ 40 | constructor(query) { 41 | this[orderedBy] = orderByKey; 42 | this.startAt = null; 43 | this.endAt = null; 44 | this.equalTo = null; 45 | this.limitToFirst = null; 46 | this.limitToLast = null; 47 | 48 | mergeQueries(this, query); 49 | Object.freeze(this); 50 | } 51 | 52 | /** 53 | * Convert Query to Firebase REST parameters. 54 | * 55 | * @returns {object} 56 | */ 57 | toParams() { 58 | const orderBy = orderByParam(this); 59 | const seed = orderBy == null ? {} : {orderBy: JSON.stringify(orderBy)}; 60 | 61 | return [ 62 | 'startAt', 63 | 'endAt', 64 | 'equalTo', 65 | 'limitToFirst', 66 | 'limitToLast' 67 | ].reduce((q, key) => { 68 | const value = this[key]; 69 | 70 | if (value != null) { 71 | q[key] = JSON.stringify(value); 72 | } 73 | 74 | return q; 75 | }, seed); 76 | } 77 | } 78 | 79 | /** 80 | * Return a Query object. 81 | * 82 | * @param {Partial} query Partial query parameters 83 | * @returns {Query} 84 | */ 85 | exports.create = function(query) { 86 | return new Query(query); 87 | }; 88 | 89 | /** 90 | * Validate and merge partial query parameters to a query object. 91 | * 92 | * @param {Query} query Query to merges properties to 93 | * @param {Partial} options Partial query parameters 94 | * @returns {Query} 95 | */ 96 | function mergeQueries(query, options) { 97 | if (options == null) { 98 | return query; 99 | } 100 | 101 | Object.keys(options).forEach(key => { 102 | let label = key; 103 | let value = options[key]; 104 | 105 | validateProp(key, value); 106 | 107 | switch (key) { 108 | case 'orderByKey': 109 | label = orderedBy; 110 | value = orderByKey; 111 | break; 112 | 113 | case 'orderByPriority': 114 | label = orderedBy; 115 | value = orderByPriority; 116 | break; 117 | 118 | case 'orderByValue': 119 | label = orderedBy; 120 | value = orderByValue; 121 | break; 122 | 123 | case 'orderByChild': 124 | label = orderedBy; 125 | break; 126 | 127 | default: 128 | break; 129 | } 130 | 131 | query[label] = value; 132 | }); 133 | 134 | return query; 135 | } 136 | 137 | /** 138 | * Validate a query property 139 | * 140 | * @param {string} key Property name 141 | * @param {any} value Property value 142 | */ 143 | function validateProp(key, value) { 144 | const type = typeof value; 145 | 146 | switch (key) { 147 | case 'orderByKey': 148 | case 'orderByPriority': 149 | case 'orderByValue': 150 | if (!value) { 151 | throw new Error(`"query.${key}" should not be set to false. Set it off by setting an other order.`); 152 | } 153 | break; 154 | 155 | case 'orderByChild': 156 | if (value == null) { 157 | throw new Error('"query.orderByChild" should not be set to null. Set it off by setting an other order.'); 158 | } 159 | if (type !== 'string') { 160 | throw new Error('"query.orderByChild" should be a string or null.'); 161 | } 162 | break; 163 | 164 | case 'startAt': 165 | case 'endAt': 166 | case 'equalTo': 167 | if ( 168 | value !== null && 169 | type !== 'string' && 170 | type !== 'number' && 171 | type !== 'boolean' 172 | ) { 173 | throw new Error(`query.${key} should be a string, a number, a boolean or null.`); 174 | } 175 | break; 176 | 177 | case 'limitToFirst': 178 | case 'limitToLast': 179 | if (value != null && type !== 'number') { 180 | throw new Error(`query.${key} should be a number or null.`); 181 | } 182 | break; 183 | 184 | default: 185 | throw new Error(`"${key}" is not a query parameter.`); 186 | } 187 | } 188 | 189 | /** 190 | * Returns the property to order by or the ordering keyword ("$value" or 191 | * "$priority"). 192 | * 193 | * Never return "$key" since it's the default ordering. 194 | * 195 | * @param {Query} query Query to find ordering for. 196 | * @returns {string|void} 197 | */ 198 | function orderByParam(query) { 199 | const value = query[orderedBy]; 200 | 201 | switch (value) { 202 | case orderByKey: 203 | return; 204 | 205 | case orderByValue: 206 | return '$value'; 207 | 208 | case orderByPriority: 209 | return '$priority'; 210 | 211 | default: 212 | return value; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /lib/parser/statement/base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Define an abstract Node. 3 | * 4 | * It include a method to register each Node implementation mapping to the 5 | * expression is handle; it avoids circular reference. 6 | * 7 | * @typedef {{name: string, returnType: string, args: function|array}} FuncSignature 8 | * 9 | */ 10 | 11 | 'use strict'; 12 | 13 | const types = require('../types'); 14 | const expressionTypes = new Map([]); 15 | 16 | /** 17 | * Error reporting an invalid expression. 18 | */ 19 | class ParseError extends Error { 20 | 21 | constructor(node, msg) { 22 | super(node.original + ': ' + msg); 23 | 24 | this.start = node.astNode.range[0]; 25 | this.end = node.astNode.range[1]; 26 | } 27 | 28 | } 29 | 30 | /** 31 | * A Node must ensure an expression is supported by firebase an valid. It then 32 | * must evaluate the expression. 33 | * 34 | * During instantiation, a node must walk each children expression and should 35 | * attempt to infer its type. 36 | * 37 | * The type of its children must be validated. If they are invalid it must 38 | * throw an Error. Some expressions can delay type validation until runtime 39 | * evaluation and will infer their type to 'any' or 'primitive' in this case. 40 | * 41 | */ 42 | class Node { 43 | 44 | constructor(source, astNode, scope) { 45 | this.original = source.slice(astNode.range[0], astNode.range[1]); 46 | this.astNode = astNode; 47 | this.inferredType = undefined; 48 | 49 | this.init(source, astNode, scope); 50 | this.inferredType = this.inferType(scope); 51 | } 52 | 53 | /** 54 | * AST node type. 55 | * 56 | * @return {string} This expression node type 57 | */ 58 | get type() { 59 | return this.astNode.type; 60 | } 61 | 62 | /** 63 | * Hook called before type inferring. 64 | * 65 | * Should be used to setup children nodes. 66 | * 67 | * @param {string} source Rule getting evaluated 68 | * @param {object} astNode ast node to represent 69 | * @param {object} scope list of defined variable/properties. 70 | * @return {void} 71 | */ 72 | init() {} 73 | 74 | /** 75 | * Infer type of the expression the node describe. 76 | * 77 | * Should return the type as a string or as an object when the expression 78 | * infer to a function. 79 | * 80 | * @abstract 81 | * @param {object} scope list of defined variable/properties. 82 | * @return {string|FuncSignature} 83 | */ 84 | inferType() { 85 | throw new ParseError(this, `inferring ${this.astNode.type} is not supported`); 86 | } 87 | 88 | /** 89 | * Evaluate expression. 90 | * 91 | * @abstract 92 | * @param {object} state Available variables 93 | * @return {string|number|boolean|RegExp|object} 94 | */ 95 | evaluate() { 96 | throw new ParseError(this, `evaluating ${this.astNode.type} is not supported`); 97 | } 98 | 99 | /** 100 | * Return the original expression. 101 | * 102 | * @return {string} 103 | */ 104 | toString() { 105 | return this.original; 106 | } 107 | 108 | /** 109 | * Yield every evaluation and return formatted representation of the expression. 110 | * 111 | * @param {object} state Available variables 112 | * @param {function({original: string, detailed: string, type: string, value: any}): void} cb Evaluation accumulator 113 | * @return {string} 114 | */ 115 | debug(state, cb) { 116 | const value = this.evaluate(state); 117 | const detailed = this.toString(); 118 | 119 | cb({ 120 | type: this.astNode.type, 121 | original: this.original, 122 | detailed, 123 | value 124 | }); 125 | 126 | return {detailed, value}; 127 | } 128 | 129 | /** 130 | * Helper checking a type is one of an allowed list of type. 131 | * 132 | * It will return `true` when it is and false it could be one. An exception 133 | * is raised when the type is not allowed or when the `mustComply` option is 134 | * set to `true` (false by default) and the type is fuzzy. 135 | * 136 | * @param {string} nodeType Type to check 137 | * @param {string|array} allowedTypes Allowed types 138 | * @param {{mustComply: boolean, msg: string}} opts Options 139 | * @return {boolean} 140 | */ 141 | assertType(nodeType, allowedTypes, opts) { 142 | allowedTypes = [].concat(allowedTypes); 143 | opts = opts || {}; 144 | 145 | if (!allowedTypes.length) { 146 | throw new Error('No allowed type(s) provided'); 147 | } 148 | 149 | if (allowedTypes.some(t => t === nodeType)) { 150 | return true; 151 | } 152 | 153 | if (!opts.mustComply && types.isFuzzy(nodeType)) { 154 | return false; 155 | } 156 | 157 | throw new ParseError(this, opts.msg || `Expected type(s) "${allowedTypes.join(', ')}"; Got "${nodeType}." `); 158 | } 159 | 160 | /** 161 | * Parse a js AST. 162 | * 163 | * @param {string} source Original rule getting parsed 164 | * @param {object} astNode AST of the node to evaluate 165 | * @param {Scope} scope Context scope 166 | * @return {Node} 167 | */ 168 | static from(source, astNode, scope) { 169 | const Klass = expressionTypes.get(astNode.type); 170 | 171 | if (!Klass) { 172 | throw new Error(`${astNode.type} is not supported.`); 173 | } 174 | 175 | return new Klass(source, astNode, scope); 176 | } 177 | 178 | static register(type, Klass) { 179 | expressionTypes.set(type, Klass); 180 | } 181 | 182 | } 183 | 184 | exports.Node = Node; 185 | exports.ParseError = ParseError; 186 | -------------------------------------------------------------------------------- /lib/database/results.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helpers for each operation type. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const paths = require('../paths'); 8 | const pad = require('../pad'); 9 | const logsKey = Symbol('raw log list'); 10 | 11 | /** 12 | * Hold an evaluation result. 13 | */ 14 | class Result { 15 | 16 | constructor(path, operationType, db, opts) { 17 | const isRead = operationType === 'read'; 18 | 19 | opts = opts || {}; 20 | 21 | if (!isRead && !opts.newDatabase) { 22 | throw new Error('The resulting database should be provided.'); 23 | } 24 | 25 | this.path = path; 26 | this.auth = isRead ? db.auth : opts.newDatabase.auth; 27 | this.type = operationType; 28 | this[logsKey] = []; 29 | this.permitted = false; 30 | this.validated = true; 31 | this.database = db; 32 | this.newDatabase = opts.newDatabase; 33 | this.newValue = opts.newValue; 34 | } 35 | 36 | get logs() { 37 | if (this[logsKey].some(r => r.kind === this.permissionType)) { 38 | return this[logsKey]; 39 | } 40 | 41 | return [{path: paths.trim(this.path), hasNoRules: true}].concat(this[logsKey]); 42 | } 43 | 44 | get allowed() { 45 | return this.permitted && this.validated; 46 | } 47 | 48 | get permissionType() { 49 | return this.type === 'read' ? 'read' : 'write'; 50 | } 51 | 52 | get info() { 53 | const info = [ 54 | this.description, 55 | this.evaluations 56 | ]; 57 | 58 | if (this.allowed) { 59 | info.push(`${this.type} was allowed.`); 60 | 61 | return info.join('\n'); 62 | } 63 | 64 | if (!this.permitted) { 65 | info.push(`No .${this.permissionType} rule allowed the operation.`); 66 | } 67 | 68 | if (!this.validated) { 69 | info.push('One or more .validate rules disallowed the operation.'); 70 | } 71 | 72 | info.push(`${this.type} was denied.`); 73 | 74 | return info.join('\n'); 75 | } 76 | 77 | get description() { 78 | const op = `Attempt to ${this.type} ${this.path} as ${JSON.stringify(this.auth)}.\n`; 79 | 80 | switch (this.type) { 81 | 82 | case 'write': 83 | return `${op}New Value: "${JSON.stringify(this.newValue, undefined, 2)}".\n`; 84 | 85 | case 'patch': 86 | return `${op}Patch: "${JSON.stringify(this.newValue, undefined, 2)}".\n`; 87 | 88 | default: 89 | return op; 90 | 91 | } 92 | } 93 | 94 | get evaluations() { 95 | return this.logs.map(r => { 96 | if (r.hasNoRules === true) { 97 | return `/${r.path}: ${this.permissionType} \n`; 98 | } 99 | 100 | const header = `/${r.path}: ${r.kind} "${r.rule}"`; 101 | const result = r.error == null ? r.value : r.error; 102 | 103 | if (r.detailed == null) { 104 | return `${header} => ${result}\n`; 105 | } 106 | 107 | return `${header} => ${result}\n${pad.lines(r.detailed)}\n`; 108 | }).join('\n'); 109 | } 110 | 111 | get root() { 112 | return this.database.snapshot('/'); 113 | } 114 | 115 | get newRoot() { 116 | return this.newDatabase.snapshot('/'); 117 | } 118 | 119 | get data() { 120 | return this.database.snapshot(this.path); 121 | } 122 | 123 | get newData() { 124 | return this.newDatabase.snapshot(this.path); 125 | } 126 | 127 | /** 128 | * Logs the evaluation result. 129 | * 130 | * @param {string} path The rule path 131 | * @param {string} kind The rule kind 132 | * @param {NodeRule} rule The rule 133 | * @param {{value: boolean, error: Error, detailed: string}} result The evaluation result 134 | */ 135 | add(path, kind, rule, result) { 136 | 137 | this[logsKey].push({ 138 | path, 139 | kind, 140 | rule: rule.toString(), 141 | value: result.value == null ? false : result.value, 142 | error: result.error, 143 | detailed: result.detailed 144 | }); 145 | 146 | switch (kind) { 147 | 148 | case 'validate': 149 | this.validated = this.validated && result.value === true; 150 | break; 151 | 152 | case 'write': 153 | case 'read': 154 | this.permitted = this.permitted || result.value === true; 155 | break; 156 | 157 | /* istanbul ignore next */ 158 | default: 159 | throw new Error(`Unknown type: ${kind}`); 160 | 161 | } 162 | 163 | } 164 | 165 | } 166 | 167 | /** 168 | * Create the result for a read operation. 169 | * 170 | * @param {string} path Path to node to read 171 | * @param {Database} data Database to read 172 | * @return {Result} 173 | */ 174 | exports.read = function(path, data) { 175 | return new Result(path, 'read', data); 176 | }; 177 | 178 | /** 179 | * Create the result for a write operation. 180 | * 181 | * @param {string} path Path to node to write 182 | * @param {Database} data Database to edit 183 | * @param {Database} newDatabase Resulting database 184 | * @param {any} newValue Value to edit with 185 | * @return {Result} 186 | */ 187 | exports.write = function(path, data, newDatabase, newValue) { 188 | return new Result(path, 'write', data, {newDatabase, newValue}); 189 | }; 190 | 191 | /** 192 | * Create the result for a patch operation from a map of write evaluation 193 | * result. 194 | * 195 | * @param {string} path Path to node to patch 196 | * @param {Database} data Database to edit 197 | * @param {object} patch Values to edit with 198 | * @param {array} writeResults List of write evaluation result to merge. 199 | * @return {Result} 200 | */ 201 | exports.update = function(path, data, patch, writeResults) { 202 | const result = new Result(path, 'patch', data, {newDatabase: data, newValue: patch}); 203 | 204 | result.permitted = true; 205 | 206 | writeResults.forEach(r => { 207 | result.newDatabase = r.newDatabase; 208 | result[logsKey] = result[logsKey].concat(r.logs); 209 | result.permitted = r.permitted && result.permitted; 210 | result.validated = r.validated && result.validated; 211 | }); 212 | 213 | return result; 214 | }; 215 | -------------------------------------------------------------------------------- /lib/firebase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client helper for a live Firebase DB. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const FirebaseTokenGenerator = require('firebase-token-generator'); 8 | const fs = require('fs'); 9 | const log = require('debug')('targaryen:firebase'); 10 | const path = require('path'); 11 | const qs = require('querystring'); 12 | const request = require('request-promise-native'); 13 | 14 | const dbQuery = require('./database/query'); 15 | 16 | const SECRET_PATH = process.env.TARGARYEN_SECRET_PATH || path.resolve('targaryen-secret.json'); 17 | let CACHED_SECRET; 18 | 19 | function readFile(filePath) { 20 | return new Promise((resolve, reject) => { 21 | fs.readFile(filePath, (err, content) => { 22 | if (err) { 23 | reject(err); 24 | } else { 25 | resolve(content); 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | function loadSecret(options) { 32 | if (options && options.secret) { 33 | return Promise.resolve(options.secret); 34 | } 35 | 36 | if (CACHED_SECRET !== undefined) { 37 | return Promise.resolve(CACHED_SECRET); 38 | } 39 | 40 | return readFile(SECRET_PATH).then( 41 | content => JSON.parse(content), 42 | () => Promise.reject(new Error(`Failed to load ${SECRET_PATH}! 43 | 44 | You need to create a firebase project to run the live tests. This project 45 | must be empty; the rules and the data will be reset for each tests. 46 | DO NOT USE YOUR PRODUCTION DATABASE. 47 | 48 | You should then create a JSON encoded file at "./targaryen-secret.json"; 49 | it should define "token" (your firebase database secret) and "projectId" 50 | (your project ID). 51 | 52 | You can create a database secret from: 53 | 54 | project console > settings > service account > database secrets 55 | 56 | 57 | You can save this file at an other location and save the path as the 58 | TARGARYEN_SECRET_PATH environment variable. 59 | 60 | `)) 61 | ).then(secret => { 62 | CACHED_SECRET = secret; 63 | 64 | return secret; 65 | }); 66 | } 67 | 68 | /** 69 | * Deploy rules to a Firebase Database. 70 | * 71 | * By default it will look for the Firebase secret key in "./secret.json" 72 | * (should hold the "projectId" and "secret") 73 | * 74 | * @param {object|string} rules Rules to upload 75 | * @param {{secret: {projectId: string, token: string}}} options Client options 76 | * @return {Promise} 77 | */ 78 | exports.deployRules = function(rules, options) { 79 | options = options || {}; 80 | 81 | return loadSecret(options).then(secret => { 82 | const databaseURL = `https://${secret.projectId}.firebaseio.com`; 83 | 84 | const uri = `${databaseURL}/.settings/rules.json?auth=${secret.token}`; 85 | const method = 'PUT'; 86 | const body = typeof rules === 'string' ? rules : JSON.stringify({rules}, undefined, 2); 87 | 88 | return request({uri, method, body}); 89 | }); 90 | }; 91 | 92 | /** 93 | * Deploy data to a Firebase Database. 94 | * 95 | * By default it will look for the Firebase secret key in "./secret.json" 96 | * (should hold the "projectId" and "secret"). 97 | * 98 | * @param {any} data root data to import 99 | * @param {{secret: {projectId: string, token: string}}} options Client options 100 | * @return {Promise} 101 | */ 102 | exports.deployData = function(data, options) { 103 | options = options || {}; 104 | 105 | return loadSecret(options).then(secret => { 106 | const databaseURL = `https://${secret.projectId}.firebaseio.com`; 107 | 108 | const uri = `${databaseURL}/.json?auth=${secret.token}`; 109 | const method = 'PUT'; 110 | const body = JSON.stringify(data || null); 111 | 112 | return request({uri, method, body}); 113 | }); 114 | }; 115 | 116 | /** 117 | * Create legacy id token for firebase REST api authentication. 118 | * 119 | * By default it will look for the Firebase secret key in "./secret.json" 120 | * (should hold the "projectId" and "secret"). 121 | * 122 | * @param {object} users Map of name to auth object. 123 | * @param {{secret: {projectId: string, token: string}}} options Client options 124 | * @return {Promise} 125 | */ 126 | exports.tokens = function(users, options) { 127 | options = options || {}; 128 | 129 | return loadSecret(options).then(secret => { 130 | const tokenGenerator = new FirebaseTokenGenerator(secret.token); 131 | 132 | return Object.keys(users || {}).reduce((tokens, name) => { 133 | const user = users[name]; 134 | 135 | tokens[name] = user ? tokenGenerator.createToken(user) : null; 136 | 137 | return tokens; 138 | }, {}); 139 | }); 140 | }; 141 | 142 | /** 143 | * Test a path can be read with the given id token. 144 | * 145 | * Resolve to true if it can or false if it couldn't. Reject if there was an 146 | * issue with the request. 147 | * 148 | * By default it will look for the Firebase secret key in "./secret.json" 149 | * (should hold the "projectId" and "secret"). 150 | * 151 | * @param {string} path Path to read. 152 | * @param {string} token Legacy id token to use. 153 | * @param {{secret: {projectId: string}, query: object}} options Client options 154 | * @return {Promise} 155 | */ 156 | exports.canRead = function(path, token, options) { 157 | options = options || {}; 158 | 159 | return loadSecret(options).then(secret => { 160 | const method = 'GET'; 161 | const databaseURL = `https://${secret.projectId}.firebaseio.com`; 162 | const query = prepareQuery(options.query, token); 163 | const uri = `${databaseURL}/${path}.json?${qs.stringify(query)}`; 164 | const logURI = `${databaseURL}/${path}.json?${qs.stringify(secureQuery(query))}`; 165 | 166 | log(`${method} ${logURI}`); 167 | 168 | return request({uri, method}).then( 169 | () => true, 170 | e => { 171 | if (e.statusCode === 403 || e.statusCode === 401) { 172 | return false; 173 | } 174 | 175 | return Promise.reject(e); 176 | } 177 | ); 178 | }); 179 | }; 180 | 181 | function prepareQuery(query, token) { 182 | const result = dbQuery.create(query).toParams(); 183 | 184 | if (token) { 185 | result.auth = token; 186 | } 187 | 188 | return result; 189 | } 190 | 191 | function secureQuery(query) { 192 | return Object.assign( 193 | {}, 194 | query, 195 | query.auth == null ? {} : {auth: query.auth.slice(0, 6)} 196 | ); 197 | } 198 | -------------------------------------------------------------------------------- /lib/parser/statement/binary.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling binary expressions validation and evaluation. 3 | * 4 | * Requirements vary depending of the operator. However they can all delay type 5 | * validation till runtime evaluation. But even then type restrictions are 6 | * stricter than JS': 7 | * 8 | * - addition only support operations on non null string and number. 9 | * - other arithmetic operations only support non null number. 10 | * - (in)equality operations support must types except RegExp. 11 | * - comparison operations support nullable string and number; it also require 12 | * the type on each side of the operation. 13 | * 14 | */ 15 | 16 | 'use strict'; 17 | 18 | const base = require('./base'); 19 | const types = require('../types'); 20 | 21 | const Node = base.Node; 22 | const ParseError = base.ParseError; 23 | 24 | class BinaryNode extends Node { 25 | 26 | get operator() { 27 | return this.astNode.operator; 28 | } 29 | 30 | init(source, astNode, scope) { 31 | this.left = Node.from(source, astNode.left, scope); 32 | this.right = Node.from(source, astNode.right, scope); 33 | } 34 | 35 | inferType() { 36 | switch (this.operator) { 37 | 38 | case '+': 39 | this.assertInferredTypes(BinaryNode.additionTypes); 40 | 41 | if (types.isString(this.left.inferredType) || types.isString(this.right.inferredType)) { 42 | return 'string'; 43 | } else if (types.isNumber(this.left.inferredType) && types.isNumber(this.right.inferredType)) { 44 | return 'number'; 45 | } 46 | 47 | return 'primitive'; 48 | 49 | case '-': 50 | case '*': 51 | case '/': 52 | case '%': 53 | this.assertInferredTypes(BinaryNode.arithmeticTypes); 54 | 55 | return 'number'; 56 | 57 | case '==': 58 | case '===': 59 | case '!=': 60 | case '!==': 61 | this.assertInferredTypes(BinaryNode.equalityTypes); 62 | return 'boolean'; 63 | 64 | case '>': 65 | case '>=': 66 | case '<': 67 | case '<=': 68 | this.assertInferredTypes(BinaryNode.comparisonTypes, true); 69 | return 'boolean'; 70 | 71 | default: 72 | throw new ParseError(this, `Unknown operator "${this.operator}".`); 73 | 74 | } 75 | } 76 | 77 | toString() { 78 | return `${this.left} ${this.operator} ${this.right}`; 79 | } 80 | 81 | evaluate(state) { 82 | const left = this.left.evaluate(state); 83 | const right = this.right.evaluate(state); 84 | 85 | return this.evaluateWith(state, left, right); 86 | } 87 | 88 | debug(state, cb) { 89 | const left = this.left.debug(state, cb); 90 | const right = this.right.debug(state, cb); 91 | 92 | const value = this.evaluateWith(state, left.value, right.value); 93 | const detailed = `${left.detailed} ${this.operator} ${right.detailed}`; 94 | 95 | cb({ 96 | type: this.astNode.type, 97 | original: this.original, 98 | detailed, 99 | value 100 | }); 101 | 102 | return {detailed, value}; 103 | } 104 | 105 | evaluateWith(state, left, right) { 106 | const lType = types.from(left); 107 | const rType = types.from(right); 108 | const mustComply = true; 109 | const assertTypes = allowed => this.assertBranchTypes(lType, rType, allowed, mustComply); 110 | const assertSameType = () => { 111 | if (lType !== rType) { 112 | throw new ParseError(this, `Invalid ${this.operator} expression: Left and right sides types are different.`); 113 | } 114 | }; 115 | 116 | switch (this.operator) { 117 | 118 | case '+': 119 | assertTypes(BinaryNode.additionTypes); 120 | return left + right; 121 | 122 | case '-': 123 | assertTypes(BinaryNode.arithmeticTypes); 124 | return left - right; 125 | 126 | case '/': 127 | assertTypes(BinaryNode.arithmeticTypes); 128 | return right === 0 ? NaN : left / right; 129 | 130 | case '*': 131 | assertTypes(BinaryNode.arithmeticTypes); 132 | return left * right; 133 | 134 | case '%': 135 | assertTypes(BinaryNode.arithmeticTypes); 136 | return left % right; 137 | 138 | case '!=': 139 | case '!==': 140 | assertTypes(BinaryNode.equalityTypes); 141 | return left !== right; 142 | 143 | case '==': 144 | case '===': 145 | assertTypes(BinaryNode.equalityTypes); 146 | return left === right; 147 | 148 | case '>': 149 | assertTypes(BinaryNode.comparisonTypes); 150 | assertSameType(); 151 | return left > right; 152 | 153 | case '>=': 154 | assertTypes(BinaryNode.comparisonTypes); 155 | assertSameType(); 156 | return left >= right; 157 | 158 | case '<': 159 | assertTypes(BinaryNode.comparisonTypes); 160 | assertSameType(); 161 | return left < right; 162 | 163 | case '<=': 164 | assertTypes(BinaryNode.comparisonTypes); 165 | assertSameType(); 166 | return left <= right; 167 | 168 | default: 169 | throw new ParseError(this, `unknown binary operator "${this.operator}"`); 170 | 171 | } 172 | } 173 | 174 | assertInferredTypes(allowedTypes, sameType) { 175 | const left = this.left.inferredType; 176 | const right = this.right.inferredType; 177 | 178 | this.assertBranchTypes(left, right, allowedTypes); 179 | 180 | if ( 181 | !sameType || 182 | types.isFuzzy(left) || 183 | types.isFuzzy(right) 184 | ) { 185 | return; 186 | } 187 | 188 | if (left !== right) { 189 | throw new ParseError(this, `Invalid ${this.operator} expression: Left and right sides types are different.`); 190 | } 191 | 192 | } 193 | 194 | assertBranchTypes(left, right, allowedTypes, mustComply) { 195 | allowedTypes = [].concat(allowedTypes); 196 | 197 | const msg = side => `Invalid ${this.operator} expression: ${side} operand is not a ${allowedTypes.join(' or ')}`; 198 | 199 | this.assertType(left, allowedTypes, {mustComply, msg: msg('Left')}); 200 | this.assertType(right, allowedTypes, {mustComply, msg: msg('Right')}); 201 | } 202 | 203 | static get additionTypes() { 204 | return ['string', 'number']; 205 | } 206 | 207 | static get arithmeticTypes() { 208 | return 'number'; 209 | } 210 | 211 | static get equalityTypes() { 212 | return ['string', 'number', 'boolean', 'null', 'Object']; 213 | } 214 | 215 | static get comparisonTypes() { 216 | return ['string', 'number', 'null']; 217 | } 218 | 219 | } 220 | 221 | Node.register('BinaryExpression', BinaryNode); 222 | -------------------------------------------------------------------------------- /test/jasmine/core.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Jasmine test definition to test targaryen Jasmine integration. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const targaryen = require('../../plugins/jasmine'); 8 | const util = require('../../lib/util'); 9 | 10 | describe('the targaryen Jasmine plugin', function() { 11 | 12 | beforeEach(function() { 13 | jasmine.addMatchers(targaryen.matchers); 14 | util.resetFirebase(); 15 | }); 16 | 17 | it('should throw if rules are not set', function() { 18 | targaryen.setFirebaseData(null); 19 | 20 | expect(() => expect(null).canRead('foo')).toThrow(); 21 | expect(() => expect(null).cannotRead('foo')).toThrow(); 22 | expect(() => expect(null).canWrite('foo', 7)).toThrow(); 23 | expect(() => expect(null).cannotWrite('foo', 7)).toThrow(); 24 | }); 25 | 26 | it('should throw if data is not set', function() { 27 | targaryen.setFirebaseRules({rules: {'.read': true, '.write': true}}); 28 | 29 | expect(() => expect(null).canRead('foo')).toThrow(); 30 | expect(() => expect(null).canWrite('foo', 7)).toThrow(); 31 | 32 | targaryen.setFirebaseRules({rules: {'.read': false, '.write': false}}); 33 | 34 | expect(() => expect(null).cannotRead('foo')).toThrow(); 35 | expect(() => expect(null).cannotWrite('foo', 7)).toThrow(); 36 | }); 37 | 38 | it('should test read access', function() { 39 | targaryen.setFirebaseData(null); 40 | targaryen.setFirebaseRules({rules: { 41 | '.read': false, 42 | foo: { 43 | bar: { 44 | '.read': 'auth.uid !== null' 45 | } 46 | } 47 | }}); 48 | 49 | expect(targaryen.users.unauthenticated).cannotRead('/'); 50 | expect(targaryen.users.unauthenticated).cannotRead('/foo'); 51 | expect(targaryen.users.unauthenticated).cannotRead('/foo/bar'); 52 | 53 | expect(targaryen.users.facebook).cannotRead('/'); 54 | expect(targaryen.users.facebook).cannotRead('/foo'); 55 | expect(targaryen.users.facebook).canRead('/foo/bar'); 56 | }); 57 | 58 | it('should test write access', function() { 59 | targaryen.setFirebaseData(null); 60 | targaryen.setFirebaseRules({rules: { 61 | '.write': false, 62 | foo: { 63 | bar: { 64 | '.write': 'auth.uid !== null', 65 | '.validate': 'newData.val() > 1' 66 | } 67 | } 68 | }}); 69 | 70 | expect(targaryen.users.unauthenticated).cannotWrite('/', 2); 71 | expect(targaryen.users.unauthenticated).cannotWrite('/foo', 2); 72 | expect(targaryen.users.unauthenticated).cannotWrite('/foo/bar', 1); 73 | expect(targaryen.users.unauthenticated).cannotWrite('/foo/bar', 2); 74 | 75 | expect(targaryen.users.facebook).cannotWrite('/', 2); 76 | expect(targaryen.users.facebook).cannotWrite('/foo', 2); 77 | expect(targaryen.users.facebook).cannotWrite('/foo/bar', 1); 78 | expect(targaryen.users.facebook).canWrite('/foo/bar', 2); 79 | }); 80 | 81 | it('should test multi write access', function() { 82 | targaryen.setFirebaseData(null); 83 | targaryen.setFirebaseRules({rules: { 84 | '.write': false, 85 | foo: { 86 | $key: { 87 | '.write': 'auth.uid !== null', 88 | '.validate': 'newData.val() > 1' 89 | } 90 | } 91 | }}); 92 | 93 | expect(targaryen.users.unauthenticated).cannotPatch('/', {foo: {bar: 2, baz: 2}}); 94 | expect(targaryen.users.unauthenticated).cannotPatch('/', {'foo/bar': 1, 'foo/baz': 2}); 95 | expect(targaryen.users.unauthenticated).cannotPatch('/', {'foo/bar': 2, 'foo/baz': 2}); 96 | expect(targaryen.users.unauthenticated).cannotPatch('/foo', {bar: 1, baz: 2}); 97 | expect(targaryen.users.unauthenticated).cannotPatch('/foo', {bar: 2, baz: 2}); 98 | 99 | expect(targaryen.users.facebook).cannotPatch('/', {foo: {bar: 2, baz: 2}}); 100 | expect(targaryen.users.facebook).cannotPatch('/', {'foo/bar': 1, 'foo/baz': 2}); 101 | expect(targaryen.users.facebook).canPatch('/', {'foo/bar': 2, 'foo/baz': 2}); 102 | expect(targaryen.users.facebook).cannotPatch('/foo', {bar: 1, baz: 2}); 103 | expect(targaryen.users.facebook).canPatch('/foo', {bar: 2, baz: 2}); 104 | }); 105 | 106 | it('can set operation time stamp', function() { 107 | targaryen.setFirebaseData({foo: 2000}); 108 | targaryen.setFirebaseRules({ 109 | rules: { 110 | $key: { 111 | '.read': 'data.val() > now', 112 | '.write': 'newData.val() == now' 113 | } 114 | } 115 | }); 116 | 117 | expect(null).canRead('/foo', {now: 1000}); 118 | expect(null).cannotRead('/foo'); 119 | 120 | expect(null).canWrite('/foo', {'.sv': 'timestamp'}, {now: 1000}); 121 | expect(null).canWrite('/foo', {'.sv': 'timestamp'}); 122 | 123 | expect(null).canWrite('/foo', 1000, {now: 1000}); 124 | expect(null).cannotWrite('/foo', 1000, {now: 2000}); 125 | 126 | expect(null).canPatch('/', {foo: {'.sv': 'timestamp'}}, {now: 1000}); 127 | expect(null).canPatch('/', {foo: {'.sv': 'timestamp'}}); 128 | 129 | expect(null).canPatch('/', {foo: 1000}, {now: 1000}); 130 | expect(null).cannotPatch('/', {foo: 1000}, {now: 2000}); 131 | }); 132 | 133 | it('can set operation time stamp (legacy)', function() { 134 | targaryen.setFirebaseData({foo: 2000}); 135 | targaryen.setFirebaseRules({ 136 | rules: { 137 | $key: { 138 | '.read': 'data.val() > now', 139 | '.write': 'newData.val() == now' 140 | } 141 | } 142 | }); 143 | 144 | expect(null).canRead('/foo', 1000); 145 | expect(null).cannotRead('/foo'); 146 | 147 | expect(null).canWrite('/foo', {'.sv': 'timestamp'}, 1000); 148 | expect(null).canWrite('/foo', {'.sv': 'timestamp'}); 149 | 150 | expect(null).canWrite('/foo', 1000, 1000); 151 | expect(null).cannotWrite('/foo', 1000, 2000); 152 | 153 | expect(null).canPatch('/', {foo: {'.sv': 'timestamp'}}, 1000); 154 | expect(null).canPatch('/', {foo: {'.sv': 'timestamp'}}); 155 | 156 | expect(null).canPatch('/', {foo: 1000}, 1000); 157 | expect(null).cannotPatch('/', {foo: 1000}, 2000); 158 | }); 159 | 160 | it('can set read query parameters', function() { 161 | targaryen.setFirebaseData(null); 162 | targaryen.setFirebaseRules({rules: { 163 | '.read': 'query.orderByChild == "owner" && query.equalTo == auth.uid' 164 | }}); 165 | 166 | expect(null).cannotRead('/'); 167 | expect({uid: 'bob'}).cannotRead('/'); 168 | expect({uid: 'bob'}).canRead('/', {query: { 169 | orderByChild: 'owner', 170 | equalTo: 'bob' 171 | }}); 172 | }); 173 | 174 | it('can set write priority', function() { 175 | targaryen.setFirebaseData(null); 176 | targaryen.setFirebaseRules({rules: { 177 | '.write': 'newData.getPriority() != null' 178 | }}); 179 | 180 | expect(null).cannotWrite('/', 'foo'); 181 | expect(null).canWrite('/', 'foo', {priority: 1}); 182 | }); 183 | 184 | }); 185 | -------------------------------------------------------------------------------- /lib/parser/statement/member.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node handling Object's property expressions validation and evaluation. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const stringMethods = require('../string-methods'); 8 | const base = require('./base'); 9 | const types = require('../types'); 10 | const scopeFactory = require('../scope'); 11 | 12 | const Node = base.Node; 13 | const ParseError = base.ParseError; 14 | 15 | class MemberNode extends Node { 16 | 17 | static get properties() { 18 | return { 19 | 20 | Query: { 21 | orderByKey: 'boolean', 22 | orderByPriority: 'boolean', 23 | orderByValue: 'boolean', 24 | orderByChild: 'string', 25 | startAt: 'primitive', 26 | endAt: 'primitive', 27 | equalTo: 'primitive', 28 | limitToFirst: 'number', 29 | limitToLast: 'number' 30 | }, 31 | 32 | string: { 33 | contains: {name: 'contains', args: ['string'], returnType: 'boolean'}, 34 | beginsWith: {name: 'beginsWith', args: ['string'], returnType: 'boolean'}, 35 | endsWith: {name: 'endsWith', args: ['string'], returnType: 'boolean'}, 36 | replace: {name: 'replace', args: ['string', 'string'], returnType: 'string'}, 37 | toLowerCase: {name: 'toLowerCase', args: [], returnType: 'string'}, 38 | toUpperCase: {name: 'toUpperCase', args: [], returnType: 'string'}, 39 | matches: {name: 'matches', args: ['RegExp'], returnType: 'boolean'}, 40 | length: 'number' 41 | }, 42 | 43 | RuleDataSnapshot: { 44 | val: {name: 'val', args: [], returnType: 'primitive'}, 45 | child: {name: 'child', args: ['string'], returnType: 'RuleDataSnapshot'}, 46 | parent: {name: 'parent', args: [], returnType: 'RuleDataSnapshot'}, 47 | hasChild: {name: 'hasChild', args: ['string'], returnType: 'boolean'}, 48 | hasChildren: { 49 | name: 'hasChildren', 50 | args(scope, fnNode, mustComply) { 51 | 52 | if (fnNode.arguments.length === 0) { 53 | return; 54 | } 55 | 56 | if (fnNode.arguments.length > 1) { 57 | throw new ParseError(fnNode, 'Too many arguments to hasChildren'); 58 | } 59 | 60 | const keyList = fnNode.arguments[0]; 61 | 62 | if (!keyList.elements) { 63 | throw new ParseError(fnNode, 'hasChildren takes 1 argument: an array of strings'); 64 | } 65 | 66 | if (keyList.elements.length === 0) { 67 | throw new ParseError(fnNode, 'hasChildren got an empty array, expected some strings in there'); 68 | } 69 | 70 | keyList.elements.forEach( 71 | element => fnNode.assertType(element.inferredType, 'string', { 72 | mustComply, 73 | msg: 'hasChildren got an array with a non-string value' 74 | }) 75 | ); 76 | 77 | }, 78 | returnType: 'boolean' 79 | }, 80 | exists: {name: 'exists', args: [], returnType: 'boolean'}, 81 | getPriority: {name: 'getPriority', args: [], returnType: 'any'}, 82 | isNumber: {name: 'isNumber', args: [], returnType: 'boolean'}, 83 | isString: {name: 'isString', args: [], returnType: 'boolean'}, 84 | isBoolean: {name: 'iBoolean', args: [], returnType: 'boolean'} 85 | } 86 | 87 | }; 88 | } 89 | 90 | init(source, astNode, scope) { 91 | this.object = Node.from(source, astNode.object, scope); 92 | 93 | const msg = `No such method/property ${this.astNode.property.name}`; 94 | let objectType = this.object.inferredType; 95 | 96 | this.assertType(objectType, ['Query', 'RuleDataSnapshot', 'string'], {msg}); 97 | 98 | if (types.isPrimitive(objectType)) { 99 | objectType = this.object.inferredType = 'string'; 100 | } 101 | 102 | if (this.computed) { 103 | this.property = Node.from(source, astNode.property, scope); 104 | return; 105 | } 106 | 107 | try { 108 | this.property = Node.from( 109 | source, 110 | astNode.property, 111 | objectType === 'any' ? scopeFactory.any() : scopeFactory.create(MemberNode.properties[objectType]) 112 | ); 113 | } catch (e) { 114 | throw new ParseError(this, msg); 115 | } 116 | } 117 | 118 | get computed() { 119 | return this.astNode.computed; 120 | } 121 | 122 | inferType() { 123 | if (!this.computed) { 124 | return this.property.inferredType; 125 | } 126 | 127 | const msg = 'Invalid property access.'; 128 | const objectType = this.object.inferredType; 129 | 130 | if (types.isFuzzy(objectType)) { 131 | return 'any'; 132 | } 133 | 134 | if (this.property.type !== 'Literal') { 135 | throw new ParseError(this, msg); 136 | } 137 | 138 | const scope = MemberNode.properties[objectType]; 139 | 140 | if (scope == null) { 141 | throw new ParseError(this, msg); 142 | } 143 | 144 | const type = scope[this.property.value]; 145 | 146 | if (type == null) { 147 | throw new ParseError(this, msg); 148 | } 149 | 150 | return type; 151 | } 152 | 153 | inferAsStringMethod() { 154 | this.object.inferredType = 'string'; 155 | 156 | if (!this.computed) { 157 | this.property.inferredType = this.property.inferType(scopeFactory.create(MemberNode.properties.string)); 158 | } 159 | 160 | return this.inferType(); 161 | } 162 | 163 | evaluate(state) { 164 | const object = this.object.evaluate(state); 165 | const key = this.computed ? this.property.evaluate(state) : this.property.name; 166 | 167 | return this.evaluateWith(state, object, key); 168 | } 169 | 170 | debug(state, cb) { 171 | const object = this.object.debug(state, cb); 172 | let detailed, key; 173 | 174 | if (this.computed) { 175 | const ev = this.property.debug(state, cb); 176 | 177 | key = ev.value; 178 | detailed = `${object.detailed}[${ev.detailed}]`; 179 | } else { 180 | key = this.property.name; 181 | detailed = `${object.detailed}.${key}`; 182 | } 183 | 184 | const value = this.evaluateWith(state, object.value, key); 185 | 186 | cb({ 187 | type: this.astNode.type, 188 | original: this.original, 189 | detailed, 190 | value 191 | }); 192 | 193 | return {detailed, value}; 194 | } 195 | 196 | evaluateWith(state, object, key) { 197 | const isPatched = types.isString(types.from(object)) && stringMethods[key]; 198 | const property = isPatched ? stringMethods[key] : (object == null ? null : object[key]); 199 | 200 | if (property === undefined) { 201 | return null; 202 | } 203 | 204 | if (typeof property !== 'function') { 205 | return property; 206 | } 207 | 208 | return isPatched ? property.bind(null, object) : property.bind(object); 209 | } 210 | 211 | toString() { 212 | return this.computer ? `${this.object}[${this.property}]` : `${this.object}.${this.property}`; 213 | } 214 | 215 | } 216 | 217 | Node.register('MemberExpression', MemberNode); 218 | -------------------------------------------------------------------------------- /test/spec/plugins/chai.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mocha test definition to test targaryen chai integration. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const chai = require('chai'); 8 | const targaryen = require('../../../plugins/chai'); 9 | const util = require('../../../lib/util'); 10 | 11 | describe('Chai plugin', function() { 12 | 13 | before(function() { 14 | chai.use(targaryen); 15 | }); 16 | 17 | beforeEach(function() { 18 | util.resetFirebase(); 19 | util.setVerbose(true); 20 | }); 21 | 22 | it('should throw if rules are not set', function() { 23 | targaryen.setFirebaseData(null); 24 | 25 | expect(() => expect(null).can.read.path('foo')).to.throw(); 26 | expect(() => expect(null).cannot.read.path('foo')).to.throw(); 27 | expect(() => expect(null).can.write(7).to.path('foo')).to.throw(); 28 | expect(() => expect(null).cannot.write(7).to.path('foo')).to.throw(); 29 | }); 30 | 31 | it('should throw if data is not set', function() { 32 | targaryen.setFirebaseRules({rules: {'.read': true, '.write': true}}); 33 | 34 | expect(() => expect(null).can.read.path('foo')).to.throw(); 35 | expect(() => expect(null).can.write(7).to.path('foo')).to.throw(); 36 | 37 | targaryen.setFirebaseRules({rules: {'.read': false, '.write': false}}); 38 | 39 | expect(() => expect(null).cannot.read.path('foo')).to.throw(); 40 | expect(() => expect(null).cannot.write(7).to.path('foo')).to.throw(); 41 | }); 42 | 43 | it('should test read access', function() { 44 | targaryen.setFirebaseData(null); 45 | targaryen.setFirebaseRules({rules: { 46 | '.read': false, 47 | foo: { 48 | bar: { 49 | '.read': 'auth.uid !== null' 50 | } 51 | } 52 | }}); 53 | 54 | expect(targaryen.users.unauthenticated).cannot.read.path('/'); 55 | expect(targaryen.users.unauthenticated).cannot.read.path('/foo'); 56 | expect(targaryen.users.unauthenticated).cannot.read.path('/foo/bar'); 57 | 58 | expect(targaryen.users.facebook).cannot.read.path('/'); 59 | expect(targaryen.users.facebook).cannot.read.path('/foo'); 60 | expect(targaryen.users.facebook).can.read.path('/foo/bar'); 61 | }); 62 | 63 | it('should test read access with provided query', function() { 64 | targaryen.setFirebaseData(null); 65 | targaryen.setFirebaseRules({rules: { 66 | '.read': 'query.orderByChild == "owner" && query.equalTo == auth.uid' 67 | }}); 68 | 69 | expect(targaryen.users.unauthenticated).cannot.read.path('/'); 70 | expect(targaryen.users.facebook).cannot.read.path('/'); 71 | expect(targaryen.users.facebook).can.readWith({query: { 72 | orderByChild: 'owner', 73 | equalTo: targaryen.users.facebook.uid 74 | }}).path('/'); 75 | }); 76 | 77 | it('should test write access', function() { 78 | targaryen.setFirebaseData(null); 79 | targaryen.setFirebaseRules({rules: { 80 | '.write': false, 81 | foo: { 82 | bar: { 83 | '.write': 'auth.uid !== null', 84 | '.validate': 'newData.val() > 1' 85 | } 86 | } 87 | }}); 88 | 89 | expect(targaryen.users.unauthenticated).cannot.write(2).to.path('/'); 90 | expect(targaryen.users.unauthenticated).cannot.write(2).to.path('/foo'); 91 | expect(targaryen.users.unauthenticated).cannot.write(1).to.path('/foo/bar'); 92 | expect(targaryen.users.unauthenticated).cannot.write(2).to.path('/foo/bar'); 93 | 94 | expect(targaryen.users.facebook).cannot.write(2).to.path('/'); 95 | expect(targaryen.users.facebook).cannot.write(2).to.path('/foo'); 96 | expect(targaryen.users.facebook).cannot.write(1).to.path('/foo/bar'); 97 | expect(targaryen.users.facebook).can.write(2).to.path('/foo/bar'); 98 | }); 99 | 100 | it('can set priority', function() { 101 | targaryen.setFirebaseData(null); 102 | targaryen.setFirebaseRules({rules: { 103 | '.write': 'newData.getPriority() != null' 104 | }}); 105 | 106 | expect(null).cannot.write('foo').to.path('/'); 107 | expect(null).can.write('foo', {priority: 10}).to.path('/'); 108 | }); 109 | 110 | it('should test multi write access', function() { 111 | targaryen.setFirebaseData(null); 112 | targaryen.setFirebaseRules({rules: { 113 | '.write': false, 114 | foo: { 115 | $key: { 116 | '.write': 'auth.uid !== null', 117 | '.validate': 'newData.val() > 1' 118 | } 119 | } 120 | }}); 121 | 122 | expect(targaryen.users.unauthenticated).cannot.patch({foo: {bar: 2, baz: 2}}).path('/'); 123 | expect(targaryen.users.unauthenticated).cannot.patch({'foo/bar': 1, 'foo/baz': 2}).path('/'); 124 | expect(targaryen.users.unauthenticated).cannot.patch({'foo/bar': 2, 'foo/baz': 2}).path('/'); 125 | expect(targaryen.users.unauthenticated).cannot.patch({bar: 1, baz: 2}).path('/foo'); 126 | expect(targaryen.users.unauthenticated).cannot.patch({bar: 2, baz: 2}).path('/foo'); 127 | 128 | expect(targaryen.users.facebook).cannot.patch({foo: {bar: 2, baz: 2}}).path('/'); 129 | expect(targaryen.users.facebook).cannot.patch({'foo/bar': 1, 'foo/baz': 2}).path('/'); 130 | expect(targaryen.users.facebook).can.patch({'foo/bar': 2, 'foo/baz': 2}).path('/'); 131 | expect(targaryen.users.facebook).cannot.patch({bar: 1, baz: 2}).path('/foo'); 132 | expect(targaryen.users.facebook).can.patch({bar: 2, baz: 2}).path('/foo'); 133 | }); 134 | 135 | it('can set operation time stamp', function() { 136 | targaryen.setFirebaseData({foo: 2000}); 137 | targaryen.setFirebaseRules({ 138 | rules: { 139 | $key: { 140 | '.read': 'data.val() > now', 141 | '.write': 'newData.val() == now' 142 | } 143 | } 144 | }); 145 | 146 | expect(null).can.readAt(1000).path('/foo'); 147 | expect(null).cannot.read.path('/foo'); 148 | 149 | expect(null).can.write({'.sv': 'timestamp'}, {now: 1000}).path('/foo'); 150 | expect(null).can.write({'.sv': 'timestamp'}).path('/foo'); 151 | 152 | expect(null).can.write(1000, {now: 1000}).path('/foo'); 153 | expect(null).cannot.write(2000, {now: 1000}).path('/foo'); 154 | 155 | expect(null).can.patch({foo: {'.sv': 'timestamp'}}, {now: 1000}).path('/'); 156 | expect(null).can.patch({foo: {'.sv': 'timestamp'}}).path('/'); 157 | 158 | expect(null).can.patch({foo: 1000, bar: 1000}, {now: 1000}).path('/'); 159 | expect(null).cannot.patch({foo: 1000, bar: 1000}, {now: 2000}).path('/'); 160 | }); 161 | 162 | it('can set operation time stamp (legacy)', function() { 163 | targaryen.setFirebaseData({foo: 2000}); 164 | targaryen.setFirebaseRules({ 165 | rules: { 166 | $key: { 167 | '.read': 'data.val() > now', 168 | '.write': 'newData.val() == now' 169 | } 170 | } 171 | }); 172 | 173 | expect(null).can.readAt(1000).path('/foo'); 174 | expect(null).cannot.read.path('/foo'); 175 | 176 | expect(null).can.write({'.sv': 'timestamp'}, 1000).path('/foo'); 177 | expect(null).can.write({'.sv': 'timestamp'}).path('/foo'); 178 | 179 | expect(null).can.write(1000, 1000).path('/foo'); 180 | expect(null).cannot.write(2000, 1000).path('/foo'); 181 | 182 | expect(null).can.patch({foo: {'.sv': 'timestamp'}}, 1000).path('/'); 183 | expect(null).can.patch({foo: {'.sv': 'timestamp'}}).path('/'); 184 | 185 | expect(null).can.patch({foo: 1000, bar: 1000}, 1000).path('/'); 186 | expect(null).cannot.patch({foo: 1000, bar: 1000}, 2000).path('/'); 187 | }); 188 | 189 | }); 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | targaryen 3 | ========= 4 | 5 | [![Build Status](https://travis-ci.org/goldibex/targaryen.svg)](https://travis-ci.org/goldibex/targaryen) 6 | 7 | Completely and thoroughly test your Firebase security rules without connecting to Firebase. 8 | 9 | ## Usage 10 | 11 | All you need to do is supply the security rules and some mock data, then write tests describing the expected behavior of the rules. Targaryen will interpret the rules and run the tests. 12 | 13 | ```js 14 | const assert = require('assert'); 15 | const targaryen = require('targaryen'); 16 | 17 | const rules = { 18 | rules: { 19 | foo: { 20 | '.write': 'true' 21 | } 22 | } 23 | }; 24 | const data = {foo: 1}; 25 | const auth = {uid: 'someuid'}; 26 | 27 | const database = targaryen.database(rules, data).as(auth).with({debug: true}); 28 | const {allowed, newDatabase, info} = database.write('/foo', 2); 29 | 30 | console.log('Rule evaluations:\n', info); 31 | assert.ok(allowed); 32 | 33 | assert.equal(newDatabase.rules, database.rules); 34 | assert.equal(newDatabase.root.foo.$value(), 2); 35 | assert.equal(newDatabase.auth, auth); 36 | ``` 37 | 38 | Targaryen provides three convenient ways to run tests: 39 | 40 | - as a standalone command-line utility: 41 | 42 | ```bash 43 | targaryen path/to/rules.json path/to/tests.json 44 | ``` 45 | 46 | - as a set of custom matchers for [Jasmine](https://jasmine.github.io): 47 | 48 | ```js 49 | const targaryen = require('targaryen/plugins/jasmine'); 50 | const rules = targaryen.json.loadSync(RULES_PATH); 51 | 52 | describe('my security rules', function() { 53 | 54 | beforeEach(function() { 55 | jasmine.addMatchers(targaryen.matchers); 56 | targaryen.setFirebaseData(require(DATA_PATH)); 57 | targaryen.setFirebaseRules(rules); 58 | }); 59 | 60 | it('should allow authenticated user to read all data', function() { 61 | expect({uid: 'foo'}).canRead('/'); 62 | expect(null).cannotRead('/'); 63 | }) 64 | 65 | }); 66 | ``` 67 | 68 | - as a plugin for [Chai](http://chaijs.com). 69 | 70 | ```js 71 | const chai = require('chai'); 72 | const targaryen = require('targaryen/plugins/chai'); 73 | const expect = chai.expect; 74 | const rules = targaryen.json.loadSync(RULES_PATH); 75 | 76 | chai.use(targaryen); 77 | 78 | describe('my security rules', function() { 79 | 80 | before(function() { 81 | targaryen.setFirebaseData(require(DATA_PATH)); 82 | targaryen.setFirebaseRules(rules); 83 | }); 84 | 85 | it('should allow authenticated user to read all data', function() { 86 | expect({uid: 'foo'}).can.read.path('/'); 87 | expect(null).cannot.read.path('/'); 88 | }) 89 | 90 | }); 91 | ``` 92 | 93 | - or as a set of custom matchers for [Jest](https://facebook.github.io/jest/): 94 | 95 | ```js 96 | const targaryen = require('targaryen/plugins/jest'); 97 | const rules = targaryen.json.loadSync(RULES_PATH); 98 | 99 | expect.extend({ 100 | toAllowRead: targaryen.toAllowRead, 101 | toAllowUpdate: targaryen.toAllowUpdate, 102 | toAllowWrite: targaryen.toAllowWrite 103 | }); 104 | 105 | describe('my security rules', function() { 106 | const database = targaryen.getDatabase(rules, require(DATA_PATH)); 107 | 108 | it('should allow authenticated user to read all data', function() { 109 | expect(database.as({uid: 'foo'})).toAllowRead('/'); 110 | expect(database.as(null)).not.toAllowRead('/'); 111 | }) 112 | 113 | }); 114 | ``` 115 | 116 | When a test fails, you get detailed debug information that explains why the read/write operation succeeded/failed. 117 | 118 | See [USAGE.md](https://github.com/goldibex/targaryen/blob/master/USAGE.md) for more information. 119 | 120 | 121 | ## How does Targaryen work? 122 | 123 | Targaryen statically analyzes your security rules using [esprima](http://esprima.org). It then conducts two passes over the abstract syntax tree. The first pass, during the parsing process, checks the types of variables and the syntax of the rules for correctness. The second pass, during the testing process, evaluates the expressions in the security rules given a set of state variables (the RuleDataSnapshots, auth data, the present time, and any wildchildren). 124 | 125 | 126 | ## Install 127 | 128 | ```shell 129 | npm install targaryen@3 130 | ``` 131 | 132 | 133 | ## API 134 | 135 | - `targaryen.database(rules: object|Ruleset, data: object|DataNode, now: null|number): Database` 136 | 137 | Creates a set of rules and initial data to simulate read, write and update of operations. 138 | 139 | The Database objects are immutable; to get an updated Database object with different the user auth data, rules, data or timestamp, use its `with(options)` method. 140 | 141 | - `Database.prototype.with({rules: {rules: object}, data: any, auth: null|object, now: number, debug: boolean}): Database` 142 | 143 | Extends the database object with new rules, data, auth data, or time stamp. 144 | 145 | - `Database.prototype.as(auth: null|object): Database` 146 | 147 | Extends the database object with auth data. 148 | 149 | - `Database.prototype.read(path: string, options: {now: number, query: object}): Result` 150 | 151 | Simulates a read operation. 152 | 153 | - `Database.prototype.write(path: string, value: any, options: {now: number, priority: any}): Result` 154 | 155 | Simulates a write operation. 156 | 157 | - `Database.prototype.update(path: string, patch: object, options: {now: number}): Result` 158 | 159 | Simulates an update operation (including multi-location update). 160 | 161 | - `Result: {path: string, auth: any, allowed: boolean, info: string, database: Database, newDatabase: Database, newValue: any}` 162 | 163 | It holds: 164 | 165 | - `path`: operation path; 166 | - `auth`: operation authentication data; 167 | - `type`: operation authentication type (read|write|patch); 168 | - `allowed`: success status; 169 | - `info`: rule evaluation info; 170 | - `database`: original database. 171 | 172 | For write and update operations, it also includes: 173 | 174 | - `newDatabase`: the resulting database; 175 | - `newValue`: the value written to the database. 176 | 177 | - `targaryen.store(data: object|DataNode, options: {now: number|null, path: string|null, priority: string|number|null}): DataNode` 178 | 179 | Can be used to create the database root ahead of time and check its validity. 180 | 181 | The `path` option defines the relative path of the data from the root; e.g. `targaryen.store(1, {path: 'foo/bar/baz'})` is equivalent to `targaryen.store({foo: {bar: {baz: 1}}})`. 182 | 183 | - `targaryen.store(rules: object|Ruleset): Ruleset` 184 | 185 | Can be used to create the database rule set ahead of time and check its validity. 186 | 187 | - `targaryen.util` 188 | 189 | Set of helper functions used by the `jasmine` and `chai` plugins reference implementations. 190 | 191 | 192 | ## Why is it named Targaryen? 193 | 194 | > There were trials. Of a sort. Lord Rickard demanded trial by combat, and the 195 | > king granted the request. Stark armored himself as for battle, thinking to 196 | > duel one of the Kingsguard. Me, perhaps. Instead they took him to the throne 197 | > room and suspended him from the rafters while two of Aerys's pyromancers 198 | > kindled a flame beneath him. The king told him that *fire* was the champion 199 | > of House Targaryen. So all Lord Rickard needed to do to prove himself 200 | > innocent of treason was... well, not burn. 201 | 202 | George R.R. Martin, *A Clash of Kings,* chapter 55, New York: Bantam Spectra, 1999. 203 | 204 | ## License 205 | 206 | [ISC](https://github.com/goldibex/targaryen/blob/master/LICENSE). 207 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Standalone 4 | 5 | Install Targaryen locally and run it like so: 6 | 7 | ```bash 8 | $ npm install -g targaryen 9 | ... 10 | $ targaryen path/to/rules.json path/to/tests.json 11 | 12 | 0 failures in 20 tests 13 | ``` 14 | 15 | [See the docs](https://github.com/goldibex/targaryen/blob/master/docs/targaryen) or [take a look at Targaryen's own integration tests](https://github.com/goldibex/targaryen/blob/master/test/integration/tests.json) to learn more. 16 | 17 | targaryen exits with a non-zero error code if the tests failed, or zero if they passed. 18 | 19 | ## With a test framework 20 | 21 | To use either Jasmine or Chai, you'll need to get the Targaryen API. This is as 22 | simple as 23 | 24 | ```bash 25 | npm install --save-dev targaryen@3 26 | ``` 27 | 28 | followed by 29 | 30 | ```js 31 | var targaryen = require('targaryen'); 32 | ``` 33 | 34 | Before your tests start, you need to call two different methods: 35 | 36 | `targaryen.setFirebaseData(data)`: set the database state for the test. `data` is a plain old Javascript object containing whatever data you want to be accessible via the `root` and `data` objects in the security rules. You can either use the data format of Firebase's `exportVal` (i.e., with ".value" and ".priority" keys) or just a plain Javascript object. The plain object will be converted to the Firebase format. 37 | 38 | `targaryen.setFirebaseRules(rules)`: set the database rules for the test. `rules` is a plain old Javascript object with the contents `rules.json`, so you can just say `targaryen.setFirebaseRules(require('./rules.json'))` and be on your way. 39 | 40 | ### Chai 41 | 42 | Docs are at [docs/chai](https://github.com/goldibex/targaryen/blob/master/docs/chai). A quick example: 43 | 44 | ```js 45 | 46 | var chai = require('chai'), 47 | expect = chai.expect, 48 | targaryen = require('targaryen'); 49 | 50 | chai.use(targaryen.chai); 51 | 52 | describe('A set of rules and data', function() { 53 | 54 | before(function() { 55 | 56 | // when you call setFirebaseData, you can either use the data format 57 | // of `exportVal` (i.e., with ".value" and ".priority" keys) or just a plain 58 | // Javascript object. The plain object will be converted to the Firebase format. 59 | 60 | targaryen.setFirebaseData({ 61 | users: { 62 | 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3': { 63 | name: { 64 | '.value': 'Rickard Stark', 65 | '.priority': 2 66 | } 67 | }, 68 | 'password:3403291b-fdc9-4995-9a54-9656241c835d': { 69 | name: 'Mad Aerys', 70 | king: true 71 | } 72 | } 73 | }); 74 | 75 | // any logged-in user can read a user object, but only the king can write them! 76 | targaryen.setFirebaseRules({ 77 | rules: { 78 | users: { 79 | '.read': 'auth !== null', 80 | '.write': "root.child('users').child(auth.uid).child('king').val() === true" 81 | } 82 | } 83 | }); 84 | 85 | }); 86 | 87 | it('can be tested', function() { 88 | 89 | expect(targaryen.users.unauthenticated) 90 | .cannot.read.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 91 | 92 | expect(targaryen.users.password) 93 | .can.read.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 94 | 95 | expect(targaryen.users.password) 96 | .cannot.write(true).to.path('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent'); 97 | 98 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }) 99 | .can.write(true).to.path('users/password:3403291b-fdc9-4995-9a54-9656241c835d/on-fire'); 100 | 101 | expect(targaryen.users.password) 102 | .cannot.patch('/', { 103 | 'users/password:3403291b-fdc9-4995-9a54-9656241c835d/on-fire': null, 104 | 'users/password:3403291b-fdc9-4995-9a54-9656241c835d/innocent': true 105 | }); 106 | 107 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d' }) 108 | .can.patch('/', { 109 | 'users/password:3403291b-fdc9-4995-9a54-9656241c835d/on-fire': true, 110 | 'users/password:3403291b-fdc9-4995-9a54-9656241c835d/innocent': null 111 | }); 112 | 113 | }); 114 | 115 | }); 116 | 117 | ``` 118 | 119 | ### Jasmine 120 | 121 | Docs are at [docs/jasmine](https://github.com/goldibex/targaryen/blob/master/docs/jasmine). A quick example: 122 | 123 | ```js 124 | 125 | var targaryen = require('targaryen'); 126 | 127 | // see Chai example above for format 128 | targaryen.setFirebaseData(...); 129 | targaryen.setFirebaseRules(...); 130 | 131 | 132 | describe('A set of rules and data', function() { 133 | 134 | beforeEach(function() { 135 | jasmine.addMatchers(targaryen.jasmine.matchers); 136 | }); 137 | 138 | it('can be tested', function() { 139 | 140 | expect(targaryen.users.unauthenticated) 141 | .cannotRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 142 | 143 | expect(targaryen.users.password) 144 | .canRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 145 | 146 | expect(targaryen.users.password) 147 | .cannotWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent', true); 148 | 149 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'}) 150 | .canWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire', true); 151 | 152 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'}) 153 | .canPatch('/', { 154 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire': true, 155 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent': null 156 | }); 157 | 158 | expect({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'}) 159 | .cannotPatch('/', { 160 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire': null, 161 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent': true 162 | }); 163 | 164 | }); 165 | 166 | }); 167 | 168 | ``` 169 | 170 | ### Jest 171 | 172 | Docs are at [docs/jest](https://github.com/goldibex/targaryen/blob/master/docs/jest). A quick example: 173 | 174 | ```js 175 | 176 | const targaryen = require('targaryen/plugins/jest'); 177 | 178 | // see Chai example above for format 179 | const rules = targaryen.json.loadSync(RULES_PATH); 180 | const data = require(DATA_PATH); 181 | 182 | expect.extend({ 183 | toAllowRead: targaryen.toAllowRead, 184 | toAllowUpdate: targaryen.toAllowUpdate, 185 | toAllowWrite: targaryen.toAllowWrite 186 | }); 187 | 188 | describe('A set of rules and data', function() { 189 | const database = targaryen.getDatabase(rules, data); 190 | 191 | it('should allow authenticated user to read all data', function() { 192 | expect(database.as({uid: 'foo'})).toAllowRead('/'); 193 | expect(database.as(null)).not.toAllowRead('/'); 194 | }) 195 | 196 | it('can be tested', function() { 197 | 198 | expect(database.as(targaryen.users.unauthenticated)) 199 | .not.toAllowRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 200 | 201 | expect(database.as(targaryen.users.password)) 202 | .toAllowRead('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3'); 203 | 204 | expect(database.as(targaryen.users.password)) 205 | .not.toAllowWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent', true); 206 | 207 | expect(database.as({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'})) 208 | .toAllowWrite('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire', true); 209 | 210 | expect(database.as({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'})) 211 | .toAllowUpdate('/', { 212 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire': true, 213 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent': null 214 | }); 215 | 216 | expect(database.as({ uid: 'password:3403291b-fdc9-4995-9a54-9656241c835d'})) 217 | .not.toAllowUpdate('/', { 218 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/onFire': null, 219 | 'users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent': true 220 | }); 221 | 222 | }); 223 | 224 | }); 225 | ``` 226 | -------------------------------------------------------------------------------- /test/spec/lib/parser/regexp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const regexp = require('../../../../lib/parser/regexp/index'); 4 | 5 | describe('regexp', function() { 6 | 7 | [{ 8 | title: 'basic expression', 9 | expression: { 10 | type: 'concatenation', 11 | value: 'abc', 12 | offset: 0, 13 | concatenation: [{ 14 | type: 'char', 15 | value: 'a', 16 | offset: 0 17 | }, { 18 | type: 'char', 19 | value: 'b', 20 | offset: 1 21 | }, { 22 | type: 'char', 23 | value: 'c', 24 | offset: 2 25 | }] 26 | } 27 | }, { 28 | title: 'start anchor', 29 | expression: { 30 | type: 'concatenation', 31 | value: '^a', 32 | offset: 0, 33 | concatenation: [{ 34 | type: 'startAnchor', 35 | value: '^', 36 | offset: 0 37 | }, { 38 | type: 'char', 39 | value: 'a', 40 | offset: 1 41 | }] 42 | } 43 | }, { 44 | title: 'end anchor', 45 | expression: { 46 | type: 'concatenation', 47 | value: 'a$', 48 | offset: 0, 49 | concatenation: [{ 50 | type: 'char', 51 | value: 'a', 52 | offset: 0 53 | }, { 54 | type: 'endAnchor', 55 | value: '$', 56 | offset: 1 57 | }] 58 | } 59 | }, { 60 | title: 'union', 61 | expression: { 62 | type: 'union', 63 | value: 'a|b|(c|d)', 64 | offset: 0, 65 | branches: [{ 66 | type: 'char', 67 | value: 'a', 68 | offset: 0 69 | }, { 70 | type: 'char', 71 | value: 'b', 72 | offset: 2 73 | }, { 74 | type: 'group', 75 | value: '(c|d)', 76 | offset: 4, 77 | group: { 78 | type: 'union', 79 | value: 'c|d', 80 | offset: 5, 81 | branches: [{ 82 | type: 'char', 83 | value: 'c', 84 | offset: 5 85 | }, { 86 | type: 'char', 87 | value: 'd', 88 | offset: 7 89 | }] 90 | } 91 | }] 92 | } 93 | }, { 94 | title: 'a series', 95 | expression: { 96 | type: 'concatenation', 97 | value: 'a+b', 98 | offset: 0, 99 | concatenation: [{ 100 | type: 'series', 101 | value: 'a+', 102 | offset: 0, 103 | pattern: { 104 | type: 'char', 105 | value: 'a', 106 | offset: 0 107 | }, 108 | repeat: { 109 | type: 'repeat', 110 | value: '+', 111 | offset: 1, 112 | min: 1, 113 | max: Infinity 114 | } 115 | }, { 116 | type: 'char', 117 | value: 'b', 118 | offset: 2 119 | }] 120 | } 121 | }, { 122 | title: 'number', 123 | expression: { 124 | value: '10+|111', 125 | offset: 0, 126 | type: 'union', 127 | branches: [{ 128 | value: '10+', 129 | offset: 0, 130 | type: 'concatenation', 131 | concatenation: [{ 132 | type: 'number', 133 | value: '1', 134 | offset: 0 135 | }, { 136 | type: 'series', 137 | value: '0+', 138 | offset: 1, 139 | pattern: { 140 | type: 'char', 141 | value: '0', 142 | offset: 1 143 | }, 144 | repeat: { 145 | type: 'repeat', 146 | value: '+', 147 | offset: 2, 148 | min: 1, 149 | max: Infinity 150 | } 151 | }] 152 | }, { 153 | value: '111', 154 | offset: 4, 155 | type: 'number' 156 | }] 157 | } 158 | }, { 159 | title: 'groups', 160 | expression: { 161 | type: 'group', 162 | value: '(ab(c)+)', 163 | offset: 0, 164 | group: { 165 | type: 'concatenation', 166 | value: 'ab(c)+', 167 | offset: 1, 168 | concatenation: [{ 169 | type: 'char', 170 | value: 'a', 171 | offset: 1 172 | }, { 173 | type: 'char', 174 | value: 'b', 175 | offset: 2 176 | }, { 177 | type: 'series', 178 | value: '(c)+', 179 | offset: 3, 180 | pattern: { 181 | type: 'group', 182 | offset: 3, 183 | value: '(c)', 184 | group: { 185 | type: 'char', 186 | value: 'c', 187 | offset: 4 188 | } 189 | }, 190 | repeat: { 191 | type: 'repeat', 192 | offset: 6, 193 | value: '+', 194 | min: 1, 195 | max: Infinity 196 | } 197 | }] 198 | } 199 | } 200 | }, { 201 | title: 'set', 202 | expression: { 203 | type: 'series', 204 | value: '[-a-z?]*', 205 | offset: 0, 206 | repeat: { 207 | type: 'repeat', 208 | min: 0, 209 | max: Infinity, 210 | offset: 7, 211 | value: '*' 212 | }, 213 | pattern: { 214 | type: 'set', 215 | value: '[-a-z?]', 216 | offset: 0, 217 | exclude: null, 218 | include: [{ 219 | type: 'char', 220 | value: '-', 221 | offset: 1 222 | }, { 223 | type: 'range', 224 | value: 'a-z', 225 | offset: 2, 226 | start: { 227 | type: 'char', 228 | value: 'a', 229 | offset: 2 230 | }, 231 | end: { 232 | type: 'char', 233 | value: 'z', 234 | offset: 4 235 | } 236 | }, { 237 | type: 'char', 238 | value: '?', 239 | offset: 5 240 | }] 241 | } 242 | } 243 | }, { 244 | title: 'literal closing brace', 245 | expression: { 246 | type: 'char', 247 | value: '}', 248 | offset: 0 249 | } 250 | }].forEach(function(test) { 251 | it(`should parse ${test.title}`, function() { 252 | const results = regexp.parse(test.expression.value); 253 | 254 | expect(results).to.have.length(1); 255 | expect(results[0]).to.eql(test.expression); 256 | }); 257 | }); 258 | 259 | [{ 260 | title: 'basic regexp', 261 | source: 'abc', 262 | expected: 'abc' 263 | }, { 264 | title: 'escaped characters', 265 | source: '\\(abc\\)', 266 | expected: '\\(abc\\)' 267 | }, { 268 | title: 'charset', 269 | source: '\\wbc', 270 | expected: '\\wbc' 271 | }, { 272 | title: 'dot', 273 | source: '.bc', 274 | expected: '.bc' 275 | }, { 276 | title: 'positive set', 277 | source: '[-a-z]bc', 278 | expected: '[-a-z]bc' 279 | }, { 280 | title: 'negative set', 281 | source: '[^\\W]bc', 282 | expected: '[^\\W]bc' 283 | }, { 284 | title: 'plus', 285 | source: 'a+b{1,}', 286 | expected: 'a+b+' 287 | }, { 288 | title: 'start', 289 | source: 'a*b{0,}', 290 | expected: 'a*b*' 291 | }, { 292 | title: 'mark', 293 | source: 'a?b{0,1}', 294 | expected: 'a?b?' 295 | }, { 296 | title: 'quantifier', 297 | source: 'a{2}b{2,}c{2,10}', 298 | expected: 'a{2}b{2,}c{2,10}' 299 | }, { 300 | title: 'group', 301 | source: '(foo)+', 302 | expected: '(foo)+' 303 | }, { 304 | title: 'union', 305 | source: '(a|b|c)', 306 | expected: '(a|b|c)' 307 | }, { 308 | title: 'anchors', 309 | source: '^abc$', 310 | expected: '^abc$' 311 | }, { 312 | title: 'ambiguous group', 313 | source: '(?:foo)', 314 | expected: '(\\?:foo)' 315 | }, { 316 | title: 'set', 317 | source: '[----z\\]]', 318 | expected: '[----z\\]]' 319 | }, { 320 | title: 'number', 321 | source: 'foo{2,100}|123', 322 | expected: 'foo{2,100}|123' 323 | }].forEach(function(test) { 324 | it(`should convert ${test.title}`, function() { 325 | const re = regexp.from(test.source); 326 | 327 | expect(re.source).to.equal(test.expected); 328 | }); 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /test/spec/lib/parser/rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test firebase rule parsing and evaluation. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const parser = require('../../../../lib/parser'); 8 | const database = require('../../../../lib/database'); 9 | const fixtures = require('./fixtures'); 10 | 11 | describe('Rule', function() { 12 | 13 | describe('constructor', function() { 14 | 15 | it('accepts valid rule expressions', function() { 16 | 17 | fixtures.tests.filter(d => d.isValid).forEach(function(details) { 18 | 19 | expect(function() { 20 | parser.parse(details.rule, Object.keys(details.wildchildren || {})); 21 | }, details.rule).not.to.throw(); 22 | 23 | }); 24 | 25 | }); 26 | 27 | it('rejects invalid rule expressions', function() { 28 | 29 | fixtures.tests.filter(d => !d.isValid).forEach(function(details) { 30 | 31 | expect(function() { 32 | parser.parse(details.rule, Object.keys(details.wildchildren || {})); 33 | }, details.rule).to.throw(); 34 | 35 | }); 36 | 37 | }); 38 | 39 | it('should provide global scope when infering a computed property type', function() { 40 | expect( 41 | () => parser.parse('root[$foo]() == null', ['$foo']) 42 | ).to.throw(/Invalid property access/); 43 | }); 44 | 45 | }); 46 | 47 | describe('#evaluate', function() { 48 | 49 | it('returns the correct result of evaluating the rule given the variable scope', function() { 50 | 51 | fixtures.tests.filter(d => d.isValid && !d.failAtRuntime).forEach(function(details) { 52 | const state = Object.assign({ 53 | query: database.query(details.query), 54 | root: database.snapshot('/', details.data || null), 55 | now: Date.now(), 56 | auth: fixtures.users[details.user] || null 57 | }, details.wildchildren); 58 | const rule = parser.parse(details.rule, Object.keys(details.wildchildren || {})); 59 | 60 | expect(rule.evaluate(state), details.rule).to.equal(details.evaluateTo); 61 | }); 62 | 63 | }); 64 | 65 | it('throw on runtime type error', function() { 66 | 67 | fixtures.tests.filter(d => d.isValid && d.failAtRuntime).forEach(function(details) { 68 | const state = Object.assign({ 69 | root: database.snapshot('/', details.data || null), 70 | now: Date.now(), 71 | auth: fixtures.users[details.user] || null 72 | }, details.wildchildren); 73 | const rule = parser.parse(details.rule, Object.keys(details.wildchildren || {})); 74 | 75 | expect(() => rule.evaluate(state), details.rule).to.throw(); 76 | }); 77 | 78 | }); 79 | 80 | describe('with logical expression', function() { 81 | 82 | it('should evaluate each branch lazily', function() { 83 | const fail = parser.parse('auth.foo > 1 || true', []); 84 | const pass = parser.parse('true || auth.foo > 1', []); 85 | const scope = {auth: null}; 86 | 87 | expect(() => fail.evaluate(scope)).to.throw(); 88 | expect(() => pass.evaluate(scope)).to.not.throw(); 89 | expect(pass.evaluate(scope)).to.be.true(); 90 | }); 91 | 92 | }); 93 | 94 | }); 95 | 96 | describe('#debug', function() { 97 | 98 | it('should format literal', function() { 99 | const rule = parser.parse('true', []); 100 | const state = {}; 101 | 102 | expect(rule.debug(state).detailed).to.equal('true [=> true]'); 103 | }); 104 | 105 | it('should format binary', function() { 106 | const rule = parser.parse('2 > 1', []); 107 | const state = {}; 108 | 109 | expect(rule.debug(state).detailed).to.equal('2 > 1 [=> true]'); 110 | }); 111 | 112 | it('should format string call', function() { 113 | const rule = parser.parse('"foo".contains("o")', []); 114 | const state = {}; 115 | 116 | expect(rule.debug(state).detailed).to.equal( 117 | '"foo".contains("o") [=> true]\n' + 118 | 'using [\n' + 119 | ' "foo".contains("o") = true\n' + 120 | ']' 121 | ); 122 | }); 123 | 124 | it('should format snapshot call', function() { 125 | const rule = parser.parse('root.hasChildren()', []); 126 | const state = {root: database.snapshot('/', null)}; 127 | 128 | expect(rule.debug(state).detailed).to.equal( 129 | 'root.hasChildren() [=> false]\n' + 130 | 'using [\n' + 131 | ' root = {"path":"","exists":false}\n' + 132 | ' root.hasChildren() = false\n' + 133 | ']' 134 | ); 135 | }); 136 | 137 | it('should format ternary expression 1/2', function() { 138 | const rule = parser.parse('auth.isAdmin === true ? true : root.child("open").exists()', []); 139 | const state = {root: database.snapshot('/', null), auth: {isAdmin: true}}; 140 | 141 | expect(rule.debug(state).detailed).to.equal( 142 | 'auth.isAdmin === true [=> true] ?\n' + 143 | ' true [=> true] :\n' + 144 | ' root.child("open").exists() [=> undefined]\n' + 145 | ' [=> true]\n' + 146 | 'using [\n' + 147 | ' auth = {"isAdmin":true}\n' + 148 | ' auth.isAdmin = true\n' + 149 | ']' 150 | ); 151 | }); 152 | 153 | it('should format ternary expression 2/2', function() { 154 | const rule = parser.parse('auth.isAdmin === false ? root.child("open").exists() : true', []); 155 | const state = {root: database.snapshot('/', null), auth: {isAdmin: true}}; 156 | 157 | expect(rule.debug(state).detailed).to.equal( 158 | 'auth.isAdmin === false [=> false] ?\n' + 159 | ' root.child("open").exists() [=> undefined] :\n' + 160 | ' true [=> true]\n' + 161 | ' [=> true]\n' + 162 | 'using [\n' + 163 | ' auth = {"isAdmin":true}\n' + 164 | ' auth.isAdmin = true\n' + 165 | ']' 166 | ); 167 | }); 168 | 169 | it('should format indentifier', function() { 170 | const rule = parser.parse('$foo == "bar"', ['$foo']); 171 | const state = {$foo: 'bar'}; 172 | 173 | expect(rule.debug(state).detailed).to.equal( 174 | '$foo == "bar" [=> true]\n' + 175 | 'using [\n' + 176 | ' $foo = "bar"\n' + 177 | ']' 178 | ); 179 | }); 180 | 181 | it('should format logical expression', function() { 182 | const rule = parser.parse( 183 | 'root.hasChild("foo") || root.hasChild("bar") || root.hasChild("baz")', 184 | [] 185 | ); 186 | const state = {root: database.snapshot('/', {bar: true})}; 187 | 188 | expect(rule.debug(state).detailed).to.equal( 189 | '(\n' + 190 | ' (\n' + 191 | ' root.hasChild("foo") [=> false]\n' + 192 | ' || root.hasChild("bar") [=> true]\n' + 193 | ' ) [=> true]\n' + 194 | ' || root.hasChild("baz") [=> undefined]\n' + 195 | ') [=> true]\n' + 196 | 'using [\n' + 197 | ' root = {"path":"","exists":true}\n' + 198 | ' root.hasChild("bar") = true\n' + 199 | ' root.hasChild("foo") = false\n' + 200 | ']' 201 | ); 202 | }); 203 | 204 | it('should format unary expressions', function() { 205 | const rule = parser.parse('!(root.exists())', []); 206 | const state = {root: database.snapshot('/', null)}; 207 | 208 | expect(rule.debug(state).detailed).to.equal( 209 | '!(root.exists()) [=> true]\n' + 210 | 'using [\n' + 211 | ' root = {"path":"","exists":false}\n' + 212 | ' root.exists() = false\n' + 213 | ']' 214 | ); 215 | }); 216 | 217 | it('should format array expressions', function() { 218 | const rule = parser.parse('root.hasChildren(["foo",auth.bar])', []); 219 | const state = {root: database.snapshot('/', null), auth: {bar: 'baz'}}; 220 | 221 | expect(rule.debug(state).detailed).to.equal( 222 | 'root.hasChildren(["foo", auth.bar]) [=> false]\n' + 223 | 'using [\n' + 224 | ' auth = {"bar":"baz"}\n' + 225 | ' auth.bar = "baz"\n' + 226 | ' root = {"path":"","exists":false}\n' + 227 | ' root.hasChildren(["foo",auth.bar]) = false\n' + 228 | ']' 229 | ); 230 | }); 231 | 232 | }); 233 | 234 | }); 235 | -------------------------------------------------------------------------------- /lib/parser/specs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update rule evaluation fixture by trying to upload a rule (as a ".read" rule) 3 | * and recording the result. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const log = require('debug')('targaryen:parser'); 9 | const error = require('debug')('targaryen:parser:error'); 10 | const firebase = require('../firebase'); 11 | const set = require('lodash.set'); 12 | const parser = require('./index'); 13 | const database = require('../database'); 14 | 15 | class MatchError extends Error { 16 | 17 | constructor(spec, result) { 18 | let msg = `Targaryen and Firebase evaluation of "${spec.rule}" diverges.\n`; 19 | 20 | if (result.isValid !== spec.isValid) { 21 | msg += `The rule should be ${spec.isValid ? 'valid' : 'invalid'}`; 22 | } else if (result.failAtRuntime !== spec.failAtRuntime) { 23 | msg += `The rule ${spec.failAtRuntime ? 'have' : 'have no'} runtime error.`; 24 | } else if (result.evaluateTo !== spec.evaluateTo) { 25 | msg += `The rule should evaluate to ${spec.evaluateTo}.`; 26 | } 27 | 28 | super(msg); 29 | 30 | this.spec = spec; 31 | this.targaryen = result; 32 | } 33 | 34 | } 35 | 36 | /** 37 | * Hold the rule live evaluation result. 38 | */ 39 | class RuleSpec { 40 | 41 | /** 42 | * Evaluate a list of rules. 43 | * 44 | * @param {Array<{rule: string, user: string}>} rules List of rules 45 | * @param {object} users Map of user name and their data. 46 | * @return {Promise,Error>} 47 | */ 48 | static evaluateRules(rules, users) { 49 | return firebase.tokens(users).then( 50 | tokens => rules.reduce((p, spec) => p.then(results => { 51 | const rule = new RuleSpec(spec); 52 | 53 | log(`Testing "${spec.rule}" with user "${spec.user}"...`); 54 | 55 | results.push(rule); 56 | 57 | return rule.deploy().then( 58 | () => rule.evaluate(tokens) 59 | ).then( 60 | () => rule.hasRuntimeError(tokens) 61 | ).then( 62 | () => results 63 | ); 64 | }), Promise.resolve([])) 65 | ); 66 | } 67 | 68 | constructor(details) { 69 | 70 | if (!details.user) { 71 | throw new Error('User for authentication is not defined.', details); 72 | } 73 | 74 | this.rule = details.rule; 75 | this.user = details.user; 76 | this.wildchildren = details.wildchildren; 77 | this.data = details.data; 78 | this.query = details.query; 79 | this.isValid = undefined; 80 | this.failAtRuntime = undefined; 81 | this.evaluateTo = undefined; 82 | } 83 | 84 | get rules() { 85 | const path = Object.keys(this.wildchildren || {}) 86 | .sort((a, b) => a.localeCompare(b)) 87 | .join('.'); 88 | 89 | const rules = set({}, `${path}[".read"]`, this.rule); 90 | 91 | if (!this.query) { 92 | return rules; 93 | } 94 | 95 | if (this.query.orderByChild) { 96 | return set(rules, `${path}[".indexOn"]`, this.query.orderByChild); 97 | } 98 | 99 | if (this.query.orderByValue) { 100 | return set(rules, `${path}[".indexOn"]`, '.value'); 101 | } 102 | 103 | return rules; 104 | } 105 | 106 | get path() { 107 | return Object.keys(this.wildchildren || {}) 108 | .sort((a, b) => a.localeCompare(b)) 109 | .map(k => this.wildchildren[k]) 110 | .join('/'); 111 | } 112 | 113 | /** 114 | * Deploy rule and data. 115 | * 116 | * @return {Promise} 117 | */ 118 | deploy() { 119 | return this.deployRules().then( 120 | () => this.deployData() 121 | ); 122 | } 123 | 124 | /** 125 | * Deploy the rule. 126 | * 127 | * Ensure the wildchildren are set. 128 | * 129 | * @return {Promise} 130 | */ 131 | deployRules() { 132 | log(' deploying rule...', this.rules); 133 | 134 | return firebase.deployRules(this.rules).then( 135 | () => true, 136 | e => { 137 | if (e.statusCode !== 400 || e.error === 'Could not parse auth token.') { 138 | return Promise.reject(e.message || e.toString()); 139 | } 140 | 141 | try { 142 | error(JSON.parse(e.error).error.trim()); 143 | } catch (parseErr) { 144 | error(e.message || e.toString()); 145 | } 146 | 147 | return false; 148 | } 149 | ).then(deployed => { 150 | this.isValid = deployed; 151 | 152 | log(` validity: ${this.isValid}`); 153 | 154 | return deployed; 155 | }); 156 | } 157 | 158 | /** 159 | * Deploy data. 160 | * 161 | * @return {Promise} 162 | */ 163 | deployData() { 164 | 165 | if (this.isValid === false) { 166 | return Promise.resolve(); 167 | } 168 | 169 | log(' deploying data...'); 170 | 171 | return firebase.deployData(this.data || null).then( 172 | () => log(' data deployed.') 173 | ).catch( 174 | e => Promise.reject(e.message || e.toString()) 175 | ); 176 | } 177 | 178 | /** 179 | * Evaluate rule. 180 | * 181 | * Note that it evaluate the rule in read operation context and that the path 182 | * of the read operation is not stable. It made sure all wildchildren are set, 183 | * but their order might be random. You not use `data` or `newData` in the 184 | * rule. 185 | * 186 | * To test snapshot methods, use `root`. 187 | * 188 | * @param {Object} tokens Map of user name to their auth id token. 189 | * @return {Promise} 190 | */ 191 | evaluate(tokens) { 192 | 193 | if (!this.isValid) { 194 | return Promise.resolve(); 195 | } 196 | 197 | const token = tokens[this.user]; 198 | 199 | if (token === undefined) { 200 | return Promise.reject(new Error(`no token for ${this.user}`)); 201 | } 202 | 203 | log(' evaluating rule...'); 204 | 205 | return firebase.canRead(this.path, token, {query: this.query}) 206 | .then(result => { 207 | this.evaluateTo = result; 208 | 209 | log(` evaluates to: ${result}`); 210 | 211 | return result; 212 | }).catch( 213 | e => Promise.reject(e.message || e.toString()) 214 | ); 215 | 216 | } 217 | 218 | /** 219 | * Check Firebase evaluation of the rule encountered a runtime error. 220 | * 221 | * If the rule evaluated to false it might have encountered a type error. We 222 | * check this by evaluating a superset of the rule that should evaluate to 223 | * true. If it still evaluate to false, the rule is generating an error. 224 | * 225 | * @param {Object} tokens Map of user name to their auth id token. 226 | * @return {Promise} 227 | */ 228 | hasRuntimeError(tokens) { 229 | if (!this.isValid) { 230 | return Promise.resolve(false); 231 | } 232 | 233 | if (this.evaluateTo) { 234 | this.failAtRuntime = false; 235 | 236 | return Promise.resolve(true); 237 | } 238 | 239 | const placebo = new RuleSpec( 240 | Object.assign({}, this, { 241 | rule: `(${this.rule}) || true` 242 | }) 243 | ); 244 | 245 | log(' checking for runtime error...'); 246 | 247 | return placebo.deploy().then( 248 | () => placebo.evaluate(tokens) 249 | ).then(() => { 250 | this.failAtRuntime = !placebo.evaluateTo; 251 | 252 | if (this.failAtRuntime) { 253 | this.evaluateTo = undefined; 254 | error(' has runtime error!'); 255 | } 256 | 257 | return this.failAtRuntime; 258 | }); 259 | } 260 | 261 | /** 262 | * Test specs against targaryen implementation. 263 | * 264 | * Throws if targaryen implementation doesn't match the specs. 265 | * 266 | * @param {object} users Map of the user name to their auth data. 267 | */ 268 | compare(users) { 269 | let rule, isValid, failAtRuntime, evaluateTo; 270 | 271 | log(` testing evaluation of "${this.rule}" with targaryen...`); 272 | 273 | try { 274 | rule = parser.parse(this.rule, Object.keys(this.wildchildren || {})); 275 | isValid = true; 276 | } catch (e) { 277 | isValid = false; 278 | } 279 | 280 | if (this.isValid !== isValid) { 281 | throw new MatchError(this, {isValid}); 282 | } 283 | 284 | if (!isValid) { 285 | log(` Matching!`); 286 | return; 287 | } 288 | 289 | const state = Object.assign({ 290 | query: database.query(this.query), 291 | root: database.snapshot('/', this.data || null), 292 | now: Date.now(), 293 | auth: users[this.user] || null 294 | }, this.wildchildren); 295 | 296 | try { 297 | evaluateTo = rule.evaluate(state); 298 | failAtRuntime = false; 299 | } catch (e) { 300 | failAtRuntime = true; 301 | } 302 | 303 | if (this.failAtRuntime !== failAtRuntime) { 304 | throw new MatchError(this, {isValid, failAtRuntime, evaluateTo}); 305 | } 306 | 307 | if (this.evaluateTo !== evaluateTo) { 308 | throw new MatchError(this, {isValid, failAtRuntime, evaluateTo}); 309 | } 310 | 311 | log(`Matching!`); 312 | } 313 | } 314 | 315 | exports.test = RuleSpec.evaluateRules; 316 | --------------------------------------------------------------------------------