From 3cb85eb0d157cb420fecd13b26b943aa477c2f58 Mon Sep 17 00:00:00 2001 From: Damien Lebrun Date: Thu, 27 Oct 2016 02:34:41 +0200 Subject: [PATCH 1/2] Refactor data access --- index.js | 4 +- lib/helpers.js | 8 - lib/rule-data-snapshot.js | 337 ----------------- lib/ruleset.js | 70 ++-- lib/store.js | 550 ++++++++++++++++++++++++++++ lib/test-helpers.js | 30 +- lib/test-jig.js | 4 +- test/spec/lib/chai.js | 1 - test/spec/lib/helpers.js | 44 +-- test/spec/lib/parser/rule.js | 16 +- test/spec/lib/rule-data-snapshot.js | 373 +++++-------------- test/spec/lib/ruleset.js | 177 ++++----- test/spec/lib/store.js | 252 +++++++++++++ 13 files changed, 1056 insertions(+), 810 deletions(-) delete mode 100644 lib/rule-data-snapshot.js create mode 100644 lib/store.js create mode 100644 test/spec/lib/store.js diff --git a/index.js b/index.js index 12ed34c..4429673 100644 --- a/index.js +++ b/index.js @@ -8,9 +8,7 @@ module.exports = { setFirebaseRules: helpers.setFirebaseRules, setDebug: helpers.setDebug, users: helpers.userDefinitions, + utils: helpers, chai: require('./lib/chai'), jasmine: require('./lib/jasmine'), - Ruleset: require("./lib/ruleset.js"), - DataSnapshot: require("./lib/rule-data-snapshot.js"), - helpers: require("./lib/helpers.js") }; diff --git a/lib/helpers.js b/lib/helpers.js index 7d8770d..35081f0 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -40,11 +40,3 @@ exports.pathSplitter = function(path) { return path.split('/'); }; - -exports.makeNewDataSnap = function() { - var RuleDataSnapshot = require('./rule-data-snapshot'); - - console.log('makeNewDataSnap is deprecated. Use RuleDataSnapshot.create instead.'); - - return RuleDataSnapshot.create.apply(null, arguments); -}; diff --git a/lib/rule-data-snapshot.js b/lib/rule-data-snapshot.js deleted file mode 100644 index 150046a..0000000 --- a/lib/rule-data-snapshot.js +++ /dev/null @@ -1,337 +0,0 @@ - -'use strict'; - -var merge = require('lodash.mergewith'), - helpers = require('./helpers'); - -function literal(value, priority) { - return { - '.value': value, - '.priority': priority !== undefined ? priority : null - }; -} - -function isObject(obj) { - return ( - obj && - typeof obj == 'object' && - obj.constructor === Object - ); -} - -function isEmptyObj (obj) { - if (!isObject(obj)) { - return false; - } - - const length = Object.keys(obj).length; - - return length === 0 || length === 1 && obj.hasOwnProperty('.priority'); -} - -function coerce(v) { - return isEmptyObj(v) ? null : v; -} - -function isNullNode(node) { - return node == null || node['.value'] === null || isEmptyObj(node); -} - -function prune(obj) { - if (!isObject(obj)) { - return obj; - } - - const dest = Object.keys(obj).reduce(function(dest, key) { - const value = prune(obj[key]); - - if (!isNullNode(value)) { - dest[key] = value; - } - - return dest; - }, {}); - - return isNullNode(dest) ? literal(null) : dest; -} - -function RuleDataSnapshot(data, pathOrNow, path) { - - var now = pathOrNow; - - if (typeof now === 'string') { - path = pathOrNow; - now = Date.now(); - } else if (isNaN(now)) { - now = Date.now(); - } - - if (typeof path === 'string' && path.charAt(0) === '/') { - // remove any leading slash from the snapshot, it screws us up downstream - path = path.slice(1); - } - - this._data = data; - this._timestamp = now; - this._path = path; - -} - -RuleDataSnapshot.create = function(path, newData, now) { - const empty = new RuleDataSnapshot(); - - return empty.set(path, newData, now); -}; - -RuleDataSnapshot.convert = function(data, now) { - - return (function firebaseify(node, ts) { - - node = coerce(node); - - if (typeof node !== 'object' || node === null) { - - return literal(node); - - } else if (node.hasOwnProperty('.value')) { - return node; - } else if (node.hasOwnProperty('.sv')) { - - // server value. right now that just means timestamp - if (node['.sv'] === 'timestamp') { - - return literal(ts, node['.priority']); - - } else { - throw new Error('Unrecognized server value "' + node['.sv'] + '"'); - } - - } else { - - var newObj = { - '.priority': node.hasOwnProperty('.priority') ? node['.priority'] : null - }; - - Object.keys(node).forEach(function(key) { - if (key != '.priority') { - newObj[key] = firebaseify(node[key], ts); - } - }); - - return newObj; - - } - - })(data, now || Date.now()); - -}; - -RuleDataSnapshot.prototype.prune = function() { - return new RuleDataSnapshot(prune(this._data), this._timestamp, this._path); -}; - -RuleDataSnapshot.prototype.merge = function(other) { - if (this._path || other._path) { - throw new Error('can only merge top-level RuleDataSnapshots'); - } - - var data = merge({}, this._data); - - data = merge(data, other._data, function customizer(oldNode, newNode) { - var oldNodeIsLiteral = oldNode && oldNode['.value'] !== undefined; - var newNodeIsLiteral = newNode && newNode['.value'] !== undefined; - var oldPriority, hasNewPriority; - - if (!oldNodeIsLiteral && !newNodeIsLiteral) { - // let lodash.mergeWith merge old and new node. - return undefined; - } - - oldPriority = oldNode && oldNode['.priority']; - hasNewPriority = newNode && newNode.hasOwnProperty('.priority'); - - if (hasNewPriority || oldPriority == null) { - // replace the old node (no merge). - return merge({}, newNode); - } - - // replace the old node but preserve its priority - return merge({'.priority': oldPriority}, newNode); - }); - - return new RuleDataSnapshot(prune(data), other._timestamp); -}; - -RuleDataSnapshot.prototype.set = function(path, newData, now) { - - now = now || Date.now(); - path = helpers.trim(path); - newData = RuleDataSnapshot.convert(newData, now); - - if (path.length === 0) { - return new RuleDataSnapshot(prune(newData), now); - } - - let data = merge({}, this._data); - let currentNode = data; - - helpers.pathSplitter(path).forEach(function(key, i, pathParts) { - const isLast = pathParts.length - i === 1; - - - if (isLast) { - const nodePriority = currentNode[key] && currentNode[key]['.priority'] || null; - - currentNode[key] = newData; - currentNode[key]['.priority'] = nodePriority; - - return; - } - - if (!currentNode[key] || currentNode[key].hasOwnProperty('.value')) { - currentNode[key] = {}; - } - - currentNode = currentNode[key]; - }); - - return new RuleDataSnapshot(prune(data), now) -} - -RuleDataSnapshot.prototype._getVal = function() { - - var pathParts; - if (this._path) { - pathParts = this._path.split('/'); - } else { - pathParts = []; - } - - return (function traverse(tree) { - - var nextKey; - if ( (nextKey = pathParts.shift()) ) { - - if (tree.hasOwnProperty(nextKey)) { - return traverse(tree[nextKey]); - } else { - return literal(null); - } - - } else { - - // end of the line - if (tree) { - return Object.assign({}, tree); - } else { - return literal(null); - } - - } - - })(this._data); - -}; - - -RuleDataSnapshot.prototype.val = function() { - - var rawVal = this._getVal(); - - return (function traverse(rawData) { - - if (rawData.hasOwnProperty('.value')) { - return rawData['.value']; - } else { - - var value = {}; - Object.keys(rawData) - .filter(function(k) { return k.charAt(0) !== '.'; }) - .forEach(function(key) { - value[key] = traverse(rawData[key]); - }); - - return value; - - } - - })(rawVal); - -}; - - -RuleDataSnapshot.prototype.getPriority = function() { - return this._getVal()['.priority']; -}; - - -RuleDataSnapshot.prototype.exists = function() { - return this.val() !== null; -}; - - -RuleDataSnapshot.prototype.child = function(childPath) { - var newPath; - if (this._path) { - newPath = [this._path, childPath].join('/'); - } else { - newPath = childPath; - } - return new RuleDataSnapshot(this._data, this._timestamp, newPath); -}; - - -RuleDataSnapshot.prototype.parent = function() { - - if (this._path) { - var parentPath = this._path.split('/').slice(0, -1).join('/'); - return new RuleDataSnapshot(this._data, this._timestamp, parentPath); - } else { - return null; - } - -}; - - -RuleDataSnapshot.prototype.hasChild = function(name) { - return this.child(name).exists(); -}; - - -RuleDataSnapshot.prototype.hasChildren = function(names) { - - if (names !== undefined && !Array.isArray(names)) { - throw new Error('Non-array value supplied to hasChildren'); - } - - if (names === undefined) { - return Object.keys(this._getVal()).filter(function(key) { - return key.charAt(0) !== '.'; - }).length > 0; - - } else { - - return names.every(function(name) { - return this.child(name).exists(); - }, this); - - } - -}; - - -RuleDataSnapshot.prototype.isNumber = function() { - return typeof this.val() === 'number'; -}; - - -RuleDataSnapshot.prototype.isString = function() { - return typeof this.val() === 'string'; -}; - - -RuleDataSnapshot.prototype.isBoolean = function() { - return typeof this.val() === 'boolean'; -}; - -module.exports = RuleDataSnapshot; diff --git a/lib/ruleset.js b/lib/ruleset.js index fd96ed3..1dab89e 100644 --- a/lib/ruleset.js +++ b/lib/ruleset.js @@ -1,10 +1,9 @@ 'use strict'; -var Rule = require('./parser/rule'), - RuleDataSnapshot = require('./rule-data-snapshot'), - pathSplitter = require('./helpers').pathSplitter, - pathMerger = require('./helpers').pathMerger; +const Rule = require('./parser/rule'); +const pathSplitter = require('./helpers').pathSplitter; +const pathMerger = require('./helpers').pathMerger; var validRuleKinds = { '.read': true, @@ -167,13 +166,13 @@ Ruleset.prototype.get = function(path, kind) { }; -Ruleset.prototype.tryRead = function(path, root, auth, now) { +Ruleset.prototype.tryRead = function(path, initialData, auth, now) { var state = { auth: auth === undefined ? null : auth, now: now || Date.now(), - root: root, - data: root.child(path) + root: initialData.snapshot('/'), + data: initialData.snapshot(path) }; // get the rules @@ -234,16 +233,15 @@ Ruleset.prototype.tryRead = function(path, root, auth, now) { }; -Ruleset.prototype.tryWrite = function(path, root, newData, auth, skipWrite, skipValidate, skipOnNoValue, now) { +Ruleset.prototype.tryWrite = function(path, initialData, newValue, auth, skipWrite, skipValidate, skipOnNoValue, now) { // write encompasses both the cascading write rules and the // non-cascading validate rules now = now || Date.now(); - var newDataRoot = root.set(path, newData, now); - - var result = { + const newData = initialData.set(path, newValue, undefined, now); + const result = { path: path, type: 'write', info: '', @@ -251,27 +249,26 @@ Ruleset.prototype.tryWrite = function(path, root, newData, auth, skipWrite, skip auth: auth === undefined ? null : auth, writePermitted: skipWrite || false, validated: true, - data: root.child(path), - newData: newDataRoot.child(path), - root: root, - newRoot: newDataRoot + data: initialData.snapshot(path), + newData: newData.snapshot(path), + root: initialData.snapshot('/'), + newRoot: newData.snapshot('/') }; - return this._tryWrite(path, root, newDataRoot, result, skipWrite, skipValidate, skipOnNoValue); + return this._tryWrite(path, initialData, newData, result, skipWrite, skipValidate, skipOnNoValue); }; -Ruleset.prototype.tryPatch = function(path, root, newData, auth, skipWrite, skipValidate, skipOnNoValue, now) { - var newDataRoot = root, - pathsToTest = [], - pathResults, - result; +Ruleset.prototype.tryPatch = function(path, initialData, newValues, auth, skipWrite, skipValidate, skipOnNoValue, now) { + const pathsToTest = []; + let newData = initialData; + let pathResults, result; now = now || Date.now(); - Object.keys(newData).forEach(function(endPath){ + Object.keys(newValues).forEach(function(endPath){ var pathToNode = pathMerger(path, endPath); - newDataRoot = newDataRoot.set(pathToNode, newData[endPath], now); + newData = newData.set(pathToNode, newValues[endPath], undefined, now); pathsToTest.push(pathToNode); }); @@ -286,7 +283,7 @@ Ruleset.prototype.tryPatch = function(path, root, newData, auth, skipWrite, skip validated: true }; - return this._tryWrite(pathToNode, root, newDataRoot, pathResult, skipWrite, skipValidate, skipOnNoValue); + return this._tryWrite(pathToNode, initialData, newData, pathResult, skipWrite, skipValidate, skipOnNoValue); }, this); if (pathResults.length === 0) { @@ -302,7 +299,7 @@ Ruleset.prototype.tryPatch = function(path, root, newData, auth, skipWrite, skip } else { result = pathResults.reduce(function(result, pathResult) { result.path = path; - result.newData = newData; + result.newData = newValues; result.info += pathResult.info; result.allowed = result.allowed && pathResult.allowed; result.writePermitted = result.writePermitted && pathResult.writePermitted; @@ -312,17 +309,17 @@ Ruleset.prototype.tryPatch = function(path, root, newData, auth, skipWrite, skip }); } - result.data = root.child(path); - result.newData = newDataRoot.child(path); - result.root = root; - result.newRoot = newDataRoot; + result.data = initialData.snapshot(path); + result.newData = newData.snapshot(path); + result.root = initialData.snapshot('/'); + result.newRoot = newData.snapshot('/'); - return result + return result; }; -Ruleset.prototype._tryWrite = function(path, root, newDataRoot, result, skipWrite, skipValidate, skipOnNoValue) { +Ruleset.prototype._tryWrite = function(path, initialData, newData, result, skipWrite, skipValidate, skipOnNoValue) { // walk down the rules hierarchy -- to get the .write and .validate rules along here (function traverse(pathParts, remainingPath, rules, wildchildren) { @@ -333,10 +330,10 @@ Ruleset.prototype._tryWrite = function(path, root, newDataRoot, result, skipWrit var state = Object.assign({ auth: result.auth, - now: newDataRoot._timestamp, - root: root, - data: root.child(currentPath), - newData: newDataRoot.child(currentPath) + now: newData.timestamp, + root: initialData.snapshot('/'), + data: initialData.snapshot(currentPath), + newData: newData.snapshot(currentPath) }, wildchildren); if (!skipWrite && !result.writePermitted && rules.hasOwnProperty('.write')) { @@ -403,7 +400,8 @@ Ruleset.prototype._tryWrite = function(path, root, newDataRoot, result, skipWrit } else { - var val = newDataRoot.child(currentPath).val(); + const val = newData.snapshot(currentPath).val(); + if (typeof val === 'object' && val !== null) { Object.keys(val).forEach(function(key) { diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..4698395 --- /dev/null +++ b/lib/store.js @@ -0,0 +1,550 @@ + +'use strict'; + +const helpers = require('./helpers'); +const primitives = new Set(['string', 'number', 'boolean']); +const invalidChar = ['.', '$', '[', ']', '#', '/']; + +/** + * Test the value is plain object. + * + * @param {any} value Value to test + * @return {boolean} + */ +function isObject(value) { + return value && (typeof value === 'object') && value.constructor === Object; +} + +/** + * Test the value is a primitive value supported by Firebase. + * + * @param {any} value Value to test + * @return {boolean} + */ +function isPrimitive(value) { + return primitives.has(typeof value); +} + +/** + * Test the name is valid key name. + * + * @param {string} key Key name to test + * @return {boolean} + */ +function isValidKey(key) { + return !invalidChar.some(c => key.includes(c)); +} + +/** + * Test the value is a server value. + * + * @param {object} value Value to test + * @return {boolean} + */ +function isServerValue(value) { + return isObject(value) && value['.sv'] !== undefined; +} + +/** + * Convert a server value. + * + * @param {object} value Value to convert + * @param {number} now Operation current timestamp + * @return {number} + */ +function convertServerValue(value, now) { + if (value['.sv'] !== 'timestamp') { + throw new Error(`invalid server value type "${value}".`); + } + + return now; +} + +// DataNode private property keys. +const valueKey = Symbol('.value'); +const priorityKey = Symbol('.priority'); +let nullNode; + +/** + * A DataNode contains the priority of a node, and its primitive value or + * the node children. + */ +class DataNode { + + /** + * DataNode constructor. + * + * The created node will be frozen. + * + * @param {object} value The node value + * @param {number} now The current update timestamp + */ + constructor(value, now) { + this[priorityKey] = value['.priority']; + this[valueKey] = value['.value']; + + const keys = value['.value'] === undefined ? Object.keys(value) : []; + + keys.filter(isValidKey).forEach(key => { + const childNode = DataNode.from(value[key], undefined, now); + + if (childNode.$isNull()) { + return; + } + + this[key] = childNode; + }); + + if (this[valueKey] === undefined && Object.keys(this).length === 0) { + this[valueKey] = null; + } + + Object.freeze(this); + } + + /** + * Create a DataNode representing a null value. + * + * @return {DataNode} + */ + static null() { + if (!nullNode) { + nullNode = new DataNode({'.value': null}); + } + + return nullNode; + } + + /** + * Create a DataNode from a compatible value. + * + * The value can be primitive value supported by Firebase, an object in the + * Firebase data export format, a plain object, a DataNode or a mix of them. + * + * @param {any} value The node value + * @param {any} [priority] The node priority + * @param {number} [now] The current update timestamp + * @return {DataNode} + */ + static from(value, priority, now) { + + if (value instanceof DataNode) { + return value; + } + + if (value == null || value['.value'] === null) { + return DataNode.null(); + } + + if (isPrimitive(value)) { + return new DataNode({'.value': value, '.priority': priority}); + } + + if (!isObject(value)) { + throw new Error(`Invalid data node type: ${value} (${typeof value})`); + } + + priority = priority || value['.priority']; + + if (isPrimitive(value['.value'])) { + return new DataNode({'.value': value['.value'], '.priority': priority}); + } + + now = now || Date.now(); + + if (isServerValue(value)) { + return new DataNode({'.value': convertServerValue(value, now), '.priority': priority}); + } + + return new DataNode( + Object.assign({}, value, {'.priority': priority, '.value': undefined}), + now + ); + } + + /** + * Returns the node priority. + * + * @return {any} + */ + $priority() { + return this[priorityKey]; + } + + /** + * Returns the node value of a primitive or a plain object. + * + * @return {any} + */ + $value() { + if (this[valueKey] !== undefined) { + return this[valueKey]; + } + + return Object.keys(this).reduce( + (acc, key) => Object.assign(acc, {[key]: this[key].$value()}), + {} + ); + } + + /** + * Returns true if the node represent a null value. + * + * @return {boolean} + */ + $isNull() { + return this[valueKey] === null; + } + + /** + * Returns true if the the node represent a primitive value (including null). + * + * @return {boolean} [description] + */ + $isPrimitive() { + return this[valueKey] !== undefined; + } + +} + +/** + * Hold the data and the timestamp of its last update. + */ +class Database { + + /** + * Database constructor. + * + * Takes a node tree, a plain object or Firebase export data format. + * + * It returns an immutable object. + * + * @param {any} value The database data + * @param {number} [now] The database current timestamps + */ + constructor(value, now) { + + this.timestamp = now || Date.now(); + this.root = DataNode.from(value, undefined, this.timestamp); + + Object.freeze(this); + + } + + /** + * Returns a RuleDataSnapshot containing data from a database location. + * + * @param {string} path The database location + * @return {RuleDataSnapshot} + */ + snapshot(path) { + return new RuleDataSnapshot(this, path); + } + + /** + * Returns a DataNode at a database location. + * + * @param {string} path The DataNode location + * @return {DataNode} + */ + get(path) { + return helpers.pathSplitter(path).reduce( + (parent, key) => parent[key] || DataNode.null(), + this.root + ); + } + + /** + * Return a copy of the database with the data replaced at the path location. + * + * @param {string} path The data location + * @param {any} value The replacement value + * @param {any} [priority] The node priority + * @param {number} [now] This update timestamp + * @return {Database} + */ + set(path, value, priority, now) { + + path = helpers.trim(path); + now = now || Date.now(); + + if (!path) { + return new Database(value, now); + } + + const newNode = DataNode.from(value, priority, now); + + if (newNode.$isNull()) { + return this.remove(path, now); + } + + const newRoot = {}; + let currSrc = this.root; + let currDest = newRoot; + + helpers.pathSplitter(path).forEach((key, i, pathParts) => { + + const siblings = Object.keys(currSrc).filter(k => k !== key); + const isLast = pathParts.length - i === 1; + + siblings.forEach(k => (currDest[k] = currSrc[k])); + + currSrc = currSrc[key] || {}; + + currDest[key] = isLast ? newNode : {}; + currDest = currDest[key]; + + }); + + return new Database(newRoot, now); + + } + + /** + * Return a copy of the database with the data removed at the path location. + * + * The node itself should be removed and any parent node becoming null as + * a result. + * + * @param {string} path Data location to remove + * @param {number} now This operation timestamp + * @return {Database} + */ + remove(path, now) { + + path = helpers.trim(path); + + if (!path) { + return new Database(null, now); + } + + const newRoot = {}; + let currSrc = this.root; + let dest = () => newRoot; + + helpers.pathSplitter(path).every((key) => { + const siblings = Object.keys(currSrc).filter(k => k !== key); + + // If the path doesn't exist from this point, + // or the only item is the one to remove, + // abort iteration. + if (siblings.length === 0) { + return false; + } + + // Or copy other items + const currDest = dest(); + + siblings.forEach(k => (currDest[k] = currSrc[k])); + + currSrc = currSrc[key]; + + // We will only create the next level if there's anything to copy. + dest = () => (currDest[key] = {}); + + return true; + + }); + + return new Database(newRoot, now || Date.now()); + + } + +} + +// RuleDataSnapshot private property keys. +const dataKey = Symbol('data'); +const pathKey = Symbol('path'); +const nodeKey = Symbol('node'); + +/** + * A DataSnapshot contains data from a database location. + */ +class RuleDataSnapshot { + + /** + * RuleDataSnapshot constructor. + * + * It returns an immutable object. + * + * @param {Database} data A Database object representing the database at a specific time + * @param {string} path Path to the data location + */ + constructor(data, path) { + this[dataKey] = data; + this[pathKey] = helpers.trim(path); + } + + /** + * Private getter to the DataNode at that location. + * + * The Data is only retrieved if needed. + * + * @type {DataNode} + */ + get [nodeKey]() { + return this[dataKey].get(this[pathKey]); + } + + /** + * Returns the data. + * + * @todo check how Firebase behave when the node is not a Primitive value. + * @return {object|string|number|boolean|null} + */ + val() { + return this[nodeKey].$value(); + } + + /** + * Gets the priority value of the data in this DataSnapshot + * + * @return {string|number|null} + */ + getPriority() { + return this[nodeKey].$priority(); + } + + /** + * Returns true if this DataSnapshot contains any data. + * + * @return {boolean} + */ + exists() { + return this[nodeKey].$isNull() === false; + } + + /** + * Gets another DataSnapshot for the location at the specified relative path. + * + * @param {string} childPath Relative path from the node to the child node + * @return {RuleDataSnapshot} + */ + child(childPath) { + const newPath = helpers.pathMerger(this[pathKey], childPath); + + return new RuleDataSnapshot(this[dataKey], newPath); + } + + /** + * Returns a RuleDataSnapshot for the node direct parent. + * + * @return {RuleDataSnapshot} + */ + parent() { + if (!this[pathKey]) { + return null; + } + + const path = this[pathKey].split('/').slice(0, -1).join('/'); + + return new RuleDataSnapshot(this[dataKey], path); + } + + /** + * Returns true if the specified child path has (non-null) data + * + * @param {string} path Relative path to child node. + * @return {boolean} + */ + hasChild(path) { + return this.child(path).exists(); + } + + /** + * Tests the node children existence. + * + * If no path list if provided, it tests if the node has any children. + * + * @param {array} [paths] Optional non-empty array of relative path to children to test. + * @return {boolean} + */ + hasChildren(paths) { + const node = this[nodeKey]; + + if (node.$isPrimitive()) { + return false; + } + + if (!paths) { + return Object.keys(node).length > 0; + } + + if (!paths.length) { + throw new Error('`hasChildren()` requires a non empty array.'); + } + + return paths.every(path => this.hasChild(path)); + } + + /** + * Returns true the node represent a number. + * + * @return {boolean} + */ + isNumber() { + const val = this[nodeKey].$value(); + + return typeof val === 'number'; + } + + /** + * Returns true the node represent a string. + * + * @return {boolean} + */ + isString() { + const val = this[nodeKey].$value(); + + return typeof val === 'string'; + } + + /** + * Returns true the node represent a boolean. + * + * @return {boolean} + */ + isBoolean() { + const val = this[nodeKey].$value(); + + return typeof val === 'boolean'; + } + +} + +/** + * Create a new Database. + * + * It Takes a plain object or Firebase export data format. + * + * @param {any} data The initial data + * @param {{path: string, priority: any, now: number}} options Data conversion options. + * @return {[type]} [description] + */ +exports.create = function(data, options) { + options = options || {}; + + if (!options.path || options.path === '/') { + return new Database(data, options.now); + } + + const root = new Database(null); + + return root.set(options.path, data, options.priority, options.now); +}; + +/** + * Create a snapshot to a database value. + * + * Meant to help transition of the tests to version 3. + * + * @param {string} path Snapshot location + * @param {any} value Snapshot value + * @param {number} now Timestamp for server value conversion + * @return {RuleDataSnapshot} + */ +exports.snapshot = function(path, value, now) { + const data = exports.create(value, {now, path}); + + return new RuleDataSnapshot(data); +}; diff --git a/lib/test-helpers.js b/lib/test-helpers.js index 1bc5b25..bf16dee 100644 --- a/lib/test-helpers.js +++ b/lib/test-helpers.js @@ -1,12 +1,13 @@ 'use strict'; -var root, rules, - debug = true, - Ruleset = require('./ruleset'), - RuleDataSnapshot = require('./rule-data-snapshot'); +const Ruleset = require('./ruleset'); +const store = require('./store'); -var userDefinitions = exports.userDefinitions = { +let debug = true; +let data, rules; + +const userDefinitions = exports.userDefinitions = { unauthenticated: null, facebook: { @@ -131,18 +132,19 @@ exports.setDebug = function(newDebug) { exports.assertConfigured = function() { - if (!rules || !root) { - throw new Error('You must call setFirebaseData and ' + - 'setFirebaseRules before running tests!'); + if (!rules || !data) { + throw new Error( + 'You must call setFirebaseData and setFirebaseRules before running tests!' + ); } }; -exports.setFirebaseData = function(data, now) { +exports.setFirebaseData = function(value, now) { now = now || Date.now(); try { - root = new RuleDataSnapshot(RuleDataSnapshot.convert(data, now), now); + data = store.create(value, now); } catch(e) { throw new Error('Proposed Firebase data is not valid: ' + e.message); } @@ -150,7 +152,7 @@ exports.setFirebaseData = function(data, now) { }; exports.getFirebaseData = function() { - return root; + return data; }; exports.setFirebaseRules = function(ruleDefinition) { @@ -167,6 +169,8 @@ exports.getFirebaseRules = function() { return rules; }; -exports.makeNewDataSnap = function() { - return RuleDataSnapshot.create.apply(null, arguments) +exports.makeNewStore = store.create; + +exports.makeNewRuleSet = function(ruleDefinition) { + return new Ruleset(ruleDefinition); }; diff --git a/lib/test-jig.js b/lib/test-jig.js index ecde7b9..c6f2f11 100644 --- a/lib/test-jig.js +++ b/lib/test-jig.js @@ -1,7 +1,7 @@ 'use strict'; -var RuleDataSnapshot = require('./rule-data-snapshot'), +var store = require('./store'), Ruleset = require('./ruleset'); @@ -9,7 +9,7 @@ function TestJig(rules, testData, now) { now = now || Date.now(); this.ruleset = new Ruleset(rules); - this.root = new RuleDataSnapshot(RuleDataSnapshot.convert(testData.root, now), now); + this.root = store.create(testData.root, now); this.users = testData.users; this.tests = testData.tests; diff --git a/test/spec/lib/chai.js b/test/spec/lib/chai.js index 73cc829..f3d249e 100644 --- a/test/spec/lib/chai.js +++ b/test/spec/lib/chai.js @@ -74,7 +74,6 @@ describe('Chai plugin', function() { }); describe('when properly configured', function() { - var now; beforeEach(function() { diff --git a/test/spec/lib/helpers.js b/test/spec/lib/helpers.js index 5401e3c..60e6c58 100644 --- a/test/spec/lib/helpers.js +++ b/test/spec/lib/helpers.js @@ -1,7 +1,6 @@ 'use strict'; -var helpers = require('../../../lib/helpers.js'), - RuleDataSnapshot = require('../../../lib/rule-data-snapshot'); +const helpers = require('../../../lib/helpers.js'); describe('helpers', function() { @@ -40,45 +39,4 @@ describe('helpers', function() { }); - describe('makeNewDataSnap', function() { - - it('should create a snapshot for the path', function() { - var snapshot = helpers.makeNewDataSnap('foo/bar/baz', 1); - - expect(snapshot.val()).to.eql({ - foo: { - bar: { - baz: 1 - } - } - }); - }); - - it('should trim the begining of the path', function() { - var snapshot = helpers.makeNewDataSnap('/foo/bar/baz', 1); - - expect(snapshot.val()).to.eql({ - foo: { - bar: { - baz: 1 - } - } - }); - }); - - it('should convert timestamp server values', function() { - var now = 12345000, - snapshot = helpers.makeNewDataSnap('foo/bar/baz', {'.sv': 'timestamp'}, now); - - expect(snapshot.val()).to.eql({ - foo: { - bar: { - baz: now - } - } - }); - }); - - }); - }); diff --git a/test/spec/lib/parser/rule.js b/test/spec/lib/parser/rule.js index ee0f710..7c1c2d6 100644 --- a/test/spec/lib/parser/rule.js +++ b/test/spec/lib/parser/rule.js @@ -1,8 +1,8 @@ 'use strict'; -var Rule = require('../../../../lib/parser/rule'), - RuleDataSnapshot = require('../../../../lib/rule-data-snapshot'); +const Rule = require('../../../../lib/parser/rule'); +const store = require('../../../../lib/store'); var testWildchildren = ['$here', '$there']; var validRules = [ @@ -85,33 +85,33 @@ var ruleEvaluationTests = [{ }, { rule: 'root.val() == "bar"', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': 'bar' }) }, + scope: { root: store.snapshot('/', { '.value': 'bar' }) }, result: true }, { rule: 'root.val().contains("ba")', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': 'bar' }) }, + scope: { root: store.snapshot('/', { '.value': 'bar' }) }, result: true }, { rule: 'root.val().matches(/^ba/)', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': 'bar' }) }, + scope: { root: store.snapshot('/', { '.value': 'bar' }) }, result: true }, { rule: 'root.val().matches(/^wa/)', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': 'bar' }) }, + scope: { root: store.snapshot('/', { '.value': 'bar' }) }, result: false }, { rule: 'root.isNumber()', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': null }) }, + scope: { root: store.snapshot('/', { '.value': null }) }, result: true, skipOnNoValue: true }, { rule: 'root.isString()', wildchildren: [], - scope: { root: new RuleDataSnapshot({ '.value': null }) }, + scope: { root: store.snapshot('/', { '.value': null }) }, result: false }, { rule: 'auth.foo[$bar] == true', diff --git a/test/spec/lib/rule-data-snapshot.js b/test/spec/lib/rule-data-snapshot.js index 3786f0b..a90a07e 100644 --- a/test/spec/lib/rule-data-snapshot.js +++ b/test/spec/lib/rule-data-snapshot.js @@ -1,61 +1,42 @@ 'use strict'; -var RuleDataSnapshot = require('../../../lib/rule-data-snapshot'); - -var rootObj = { - '.priority': 'hello', - users: { - 'password:c7ec6752-45b3-404f-a2b9-7df07b78d28e': { - '.priority': 1, - name: { '.value': 'Sherlock Holmes' }, - genius: { '.value': true }, - arrests: { '.value': 70 } - }, - 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3': { - '.priority': 2, - name: { '.value': 'John Watson' } - }, - 'password:3403291b-fdc9-4995-9a54-9656241c835d': { - '.priority': 0, - name: { '.value': 'Inspector Lestrade'}, - arrests: { '.value': 35 } - }, - 'password:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx': { - '.priority': 0, - '.value': null - } - } -}; - -var root = new RuleDataSnapshot(rootObj); +const store = require('../../../lib/store'); describe('RuleDataSnapshot', function() { - - it('should take the server timestamp as optional argument', function() { - var now = 12345000, - snapshot1 = new RuleDataSnapshot({}), - snapshot2 = new RuleDataSnapshot({}, 'foo/bar'), - snapshot3 = new RuleDataSnapshot({}, now), - snapshot4 = new RuleDataSnapshot({}, now, 'foo/bar'); - - expect(snapshot1._timestamp).not.to.be.NaN; - expect(snapshot1._path).to.be.undefined; - - expect(snapshot2._timestamp).not.to.be.NaN; - expect(snapshot2._path).to.equal('foo/bar'); - - expect(snapshot3._timestamp).to.equal(now); - expect(snapshot3._path).to.be.undefined; - - expect(snapshot4._timestamp).to.equal(now); - expect(snapshot4._path).to.equal('foo/bar'); + let root; + + beforeEach(function() { + root = store.snapshot('/', { + '.priority': 'hello', + users: { + 'password:c7ec6752-45b3-404f-a2b9-7df07b78d28e': { + '.priority': 1, + name: { '.value': 'Sherlock Holmes' }, + genius: { '.value': true }, + arrests: { '.value': 70 } + }, + 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3': { + '.priority': 2, + name: { '.value': 'John Watson' } + }, + 'password:3403291b-fdc9-4995-9a54-9656241c835d': { + '.priority': 0, + name: { '.value': 'Inspector Lestrade'}, + arrests: { '.value': 35 } + }, + 'password:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx': { + '.priority': 0, + '.value': null + } + } + }); }); describe('create', function() { it('should create a new snapshot', function() { - expect(RuleDataSnapshot.create('foo/bar/baz', 1).val()).to.eql({ + expect(store.snapshot('foo/bar/baz', 1).val()).to.eql({ foo: { bar: { baz: 1 @@ -66,197 +47,6 @@ describe('RuleDataSnapshot', function() { }); - describe('convert', function() { - - it('converts plain Javascript objects into Firebase data format', function() { - - expect(RuleDataSnapshot.convert(true)).to.deep.equal({ - '.value': true, - '.priority': null - }); - - expect(RuleDataSnapshot.convert({ foo: { bar: true, baz: true, quxEmpty: {}, quxNull: null} })) - .to.deep.equal({ - '.priority': null, - foo: { - '.priority': null, - bar: { - '.value': true, - '.priority': null - }, - baz: { - '.value': true, - '.priority': null - }, - quxEmpty: { - '.value': null, - '.priority': null - }, - quxNull: { - '.value': null, - '.priority': null - } - } - - }); - - }); - - it('transparently handles values for which value and priority are already set', function() { - - expect(RuleDataSnapshot.convert({ foo: { '.value': true, '.priority': 5}, bar: 8 })) - .to.deep.equal({ - '.priority': null, - foo: { - '.value': true, - '.priority': 5 - }, - bar: { - '.value': 8, - '.priority': null - } - }); - }); - - it('transparently handles objects for which a priority is set in the root', function() { - expect(RuleDataSnapshot.convert({ '.priority': 100, foo: { '.value': true, '.priority': 5}, bar: 8 })) - .to.deep.equal({ - '.priority': 100, - foo: { - '.value': true, - '.priority': 5 - }, - bar: { - '.value': 8, - '.priority': null - } - }); - }); - - it('converts "timestamp" server value', function() { - var now = 12345000; - - expect(RuleDataSnapshot.convert({'.sv': 'timestamp'}, now)).to.deep.equal({ - '.value': now, - '.priority': null - }); - }); - }); - - describe('#merge', function() { - - it('should merge snapshot data', function() { - var snapshot1 = new RuleDataSnapshot({foo: {'.value': 1}}), - snapshot2 = new RuleDataSnapshot({bar: {'.value': 2}}), - mergedSnapshot = snapshot1.merge(snapshot2); - - expect(mergedSnapshot).not.to.equal(snapshot1); - expect(mergedSnapshot).not.to.equal(snapshot2); - expect(mergedSnapshot.child('foo').val()).to.equal(1); - expect(mergedSnapshot.child('bar').val()).to.equal(2); - }); - - it('should conserve the timestamp', function() { - var now = 12345000, - snapshot1 = new RuleDataSnapshot({foo: {'.value': 1}}, now - 1000), - snapshot2 = new RuleDataSnapshot({bar: {'.value': 2}}, now), - mergedSnapshot = snapshot1.merge(snapshot2); - - expect(mergedSnapshot._timestamp).to.equal(now); - }); - - it('can set a node to null', function() { - var patch = new RuleDataSnapshot({users: {'.value': null, '.priority': null}}); - var newDataRoot = root.merge(patch); - - expect(newDataRoot.child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e').exists()).to.be.false; - }); - - it('treats empty object as null', function() { - var patch = new RuleDataSnapshot(RuleDataSnapshot.convert({users: {}})); - var newDataRoot = root.merge(patch); - - expect(newDataRoot.child('users').exists()).to.be.false; - }); - - it('can override null', function() { - var patch = new RuleDataSnapshot({users: { - 'password:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx': { - name: {'.value': 'James Moriarty'}, - genius: {'.value': true}, - arrests: {'.value': 0 } - } - }}); - var newDataRoot = root.merge(patch); - - - expect(newDataRoot.child('users/password:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx').val()).to.deep.equal({ - name: 'James Moriarty', - genius: true, - arrests: 0 - }); - }); - - it('can override other literal value and keep their priority', function() { - var patch = new RuleDataSnapshot({ - users: { - 'password:c7ec6752-45b3-404f-a2b9-7df07b78d28e': { - '.value': 'xyz' - } - } - }); - var newDataRoot = root.merge(patch); - - expect(newDataRoot.child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e').getPriority()).to.equal(1); - }); - - }); - - describe('#set', function() { - - it('should replace the root data', function() { - expect(root.set('/', {foo: 1}).val()).to.eql({foo: 1}); - }); - - it('should replace a node', function() { - const path = 'users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e'; - const newRoot = root.set(path, { - name: 'Sherlock Holmes', - genius: true - }); - - expect(newRoot.child(path).val()).to.eql({ - name: 'Sherlock Holmes', - genius: true - }); - }); - - it('should replace a literal node', function() { - const path = 'users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e/arrests'; - const newRoot = root.set(path, {first: 1887, second: 1887}); - - expect(newRoot.child(path).val()).to.eql({first: 1887, second: 1887}); - }); - - it('should replace node with a literal', function() { - const path = 'users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e/arrests'; - const newRoot = root.set(path, null); - - expect(newRoot.child(path).val()).to.equal(null); - }); - - it('should preserve the priority', function() { - const path = 'users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e'; - const newRoot = root.set(path, { - name: 'Sherlock Holmes', - genius: true - }); - - expect(newRoot.child(path).getPriority()).to.equal(1); - }); - - }); - describe('#val', function() { it('gets the value at the specified path', function() { @@ -265,8 +55,7 @@ describe('RuleDataSnapshot', function() { users: { 'password:c7ec6752-45b3-404f-a2b9-7df07b78d28e': { name: 'Sherlock Holmes', genius: true, arrests: 70 }, 'password:500f6e96-92c6-4f60-ad5d-207253aee4d3': { name: 'John Watson' }, - 'password:3403291b-fdc9-4995-9a54-9656241c835d': { name: 'Inspector Lestrade', arrests: 35 }, - 'password:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx': null + 'password:3403291b-fdc9-4995-9a54-9656241c835d': { name: 'Inspector Lestrade', arrests: 35 } } }); @@ -285,15 +74,12 @@ describe('RuleDataSnapshot', function() { describe('#child', function() { it('gets a new data snapshot for the specified child key', function() { - expect(root.child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3').child('name').val()) - .to.equal('John Watson'); - }); - - it('should conserve the timestamp', function() { - var now = 12345000, - snapshot = new RuleDataSnapshot({foo: {'.value': 1}}, now); - - expect(snapshot.child('foo')._timestamp).to.equal(now); + expect( + root + .child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3') + .child('name') + .val() + ).to.equal('John Watson'); }); }); @@ -302,10 +88,12 @@ describe('RuleDataSnapshot', function() { it('gets the parent of the snap', function() { - expect(root.child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/name').parent().val()) - .to.deep.equal({ - name: 'John Watson' - }); + expect( + root + .child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/name') + .parent() + .val() + ).to.deep.equal({name: 'John Watson'}); }); @@ -313,13 +101,6 @@ describe('RuleDataSnapshot', function() { expect(root.parent()).to.be.null; }); - it('should conserve the timestamp', function() { - var now = 12345000, - snapshot = new RuleDataSnapshot({foo: {'.value': 1}}, now); - - expect(snapshot.child('foo').parent()._timestamp).to.equal(now); - }); - }); describe('#exists', function() { @@ -351,11 +132,27 @@ describe('RuleDataSnapshot', function() { describe('with no arguments', function() { it('returns true if the path has any children at all', function() { - expect(root.child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3').hasChildren()).to.be.true; + expect( + root + .child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3') + .hasChildren() + ).to.be.true; }); it('returns false if the path has no children', function() { - expect(root.child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/name').hasChildren()).to.be.false; + expect( + root + .child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/name') + .hasChildren() + ).to.be.false; + }); + + }); + + describe('with an empty array', function() { + + it('should should throw', function() { + expect(() => root.hasChildren([])).to.throw(); }); }); @@ -364,14 +161,20 @@ describe('RuleDataSnapshot', function() { it('returns true if the path has all the specified children', function() { - expect(root.child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e').hasChildren(['name', 'genius', 'arrests'])) - .to.be.true; + expect( + root + .child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e') + .hasChildren(['name', 'genius', 'arrests']) + ).to.be.true; }); it('returns false if the path is missing even one of the specified children', function() { - expect(root.child('users/password:3403291b-fdc9-4995-9a54-9656241c835d').hasChildren(['name', 'genius', 'arrests'])) - .to.be.false; + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d') + .hasChildren(['name', 'genius', 'arrests']) + ).to.be.false; }); }); @@ -381,11 +184,19 @@ describe('RuleDataSnapshot', function() { describe('#isNumber', function() { it('returns true if the value at the path has type number', function() { - expect(root.child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/arrests').isNumber()).to.be.true; + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/arrests') + .isNumber() + ).to.be.true; }); it('returns false if the value at the path does not have type number', function() { - expect(root.child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/arrests').isNumber()).to.be.false; + expect( + root + .child('users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/arrests') + .isNumber() + ).to.be.false; }); }); @@ -393,11 +204,19 @@ describe('RuleDataSnapshot', function() { describe('#isBoolean', function() { it('returns true if the value at the path has type boolean', function() { - expect(root.child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e/genius').isBoolean()).to.be.true; + expect( + root + .child('users/password:c7ec6752-45b3-404f-a2b9-7df07b78d28e/genius') + .isBoolean() + ).to.be.true; }); it('returns false if the value at the path does not have type boolean', function() { - expect(root.child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name').isBoolean()).to.be.false; + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name') + .isBoolean() + ).to.be.false; }); }); @@ -405,11 +224,19 @@ describe('RuleDataSnapshot', function() { describe('#isString', function() { it('returns true if the value at the path has type string', function() { - expect(root.child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name').isString()).to.be.true; + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name') + .isString() + ).to.be.true; }); it('returns false if the value at the path does not have type string', function() { - expect(root.child('users/password:3403291b-fdc9-4995-9a54-9656241c835d').isString()).to.be.false; + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d') + .isString() + ).to.be.false; }); }); diff --git a/test/spec/lib/ruleset.js b/test/spec/lib/ruleset.js index c0bb072..1bece06 100644 --- a/test/spec/lib/ruleset.js +++ b/test/spec/lib/ruleset.js @@ -1,8 +1,8 @@ 'use strict'; -var Ruleset = require('../../../lib/ruleset'), - RuleDataSnapshot = require('../../../lib/rule-data-snapshot'); +const Ruleset = require('../../../lib/ruleset'); +const store = require('../../../lib/store'); function getRuleset() { @@ -57,9 +57,9 @@ function getRuleset() { } -function getRoot() { +function getData() { - return new RuleDataSnapshot({ + return store.create({ 'foo': { 'firstChild': { '.priority': 0, @@ -192,18 +192,30 @@ describe('Ruleset', function() { }); describe('rule evaluation', function(){ + let initialData; + + beforeEach(function() { + initialData = store.create({'a': 1}); + }); it('should fail on error in validate', function() { - var root = new RuleDataSnapshot(RuleDataSnapshot.convert({'a': 1})), - rules = new Ruleset({rules: {".write": "true", "a": {".validate": "newData.val().contains('one') === true"}}}), - result = rules.tryWrite('/a', root, 2, {}); + const rules = new Ruleset({ + rules: { + '.write': true, + a: { + '.validate': 'newData.val().contains("one") === true' + } + } + }); + const result = rules.tryWrite('/a', initialData, 2, {}); + expect(result.allowed).to.be.false; }); it('should treat nonexistent properties of "auth" as null', function(){ - var root = new RuleDataSnapshot(RuleDataSnapshot.convert({'a': 1})), - rules = new Ruleset({rules: {'.write': 'auth.x === null'}}), - result = rules.tryWrite('/a', root, 2, {}); + const rules = new Ruleset({rules: {'.write': 'auth.x === null'}}); + const result = rules.tryWrite('/a', initialData, 2, {}); + expect(result.allowed).to.be.true; }); @@ -256,30 +268,25 @@ describe('Ruleset', function() { }); describe('#tryRead', function() { - - var rules; + const auth = null; + let rules, initialData; before(function() { rules = getRuleset(); + initialData = getData(); }); it('returns the result of attempting to read the given path with the given DB state', function() { - var root = getRoot(), - auth = null; - - expect(rules.tryRead('foo/firstChild/baz', root, auth).allowed).to.be.true; - expect(rules.tryRead('foo/secondChild/baz', root, auth).allowed).to.be.false; + expect(rules.tryRead('foo/firstChild/baz', initialData, auth).allowed).to.be.true; + expect(rules.tryRead('foo/secondChild/baz', initialData, auth).allowed).to.be.false; }); it('should propagate variables in path', function() { - var root = getRoot(), - auth = null; - - expect(rules.tryRead('nested/one/two', root, auth).allowed).to.be.false; - expect(rules.tryRead('nested/one/one', root, auth).allowed).to.be.true; + expect(rules.tryRead('nested/one/two', initialData, auth).allowed).to.be.false; + expect(rules.tryRead('nested/one/one', initialData, auth).allowed).to.be.true; }); @@ -287,74 +294,76 @@ describe('Ruleset', function() { describe('#tryWrite', function() { - - var rules, _now; - - before(function() { - rules = getRuleset(); - }); + const _now = Date.now; + const noAuth = null; + const superAuth = {id: 1}; + let rules, initialData; beforeEach(function() { - _now = Date.now; + let now = 1000; - var now = 1000; - - Date.now = function() { - return now++; - } + Date.now = () => now++; }); afterEach(function() { Date.now = _now; }); + beforeEach(function() { + rules = getRuleset(); + initialData = getData(); + }); + it('should match "now" with the server timestamp', function() { - var root = getRoot(), - newData = {'.sv': 'timestamp'}, - noAuth = null; + const newData = {'.sv': 'timestamp'}; - expect(rules.tryWrite('timestamp/foo', root, newData, noAuth).allowed).to.be.true; + expect(rules.tryWrite('timestamp/foo', initialData, newData, noAuth).allowed).to.be.true; }); it('returns the result of attempting to write the given path with the given DB state and new data', function() { - var root = getRoot(), - newData = { 'wut': { '.value': true } }, - noAuth = null, - superAuth = { id: 1 }; + const newData = {'wut': {'.value': true}}; - expect(rules.tryWrite('foo/firstChild', root, newData, noAuth).allowed).to.be.false; - expect(rules.tryWrite('foo/firstChild', root, newData, superAuth).allowed).to.be.true; + expect(rules.tryWrite('foo/firstChild', initialData, newData, noAuth).allowed).to.be.false; + expect(rules.tryWrite('foo/firstChild', initialData, newData, superAuth).allowed).to.be.true; }); it('should propagate variables in path', function() { - var root = getRoot(), - auth = null; + expect(rules.tryWrite('nested/one/two', initialData, {id: {'.value': 'two'}}, noAuth).allowed).to.be.false; + expect(rules.tryWrite('nested/one/one', initialData, {id: {'.value': 'one'}}, noAuth).allowed).to.be.true; + expect(rules.tryWrite('nested/one/one', initialData, {id: {'.value': 'two'}}, noAuth).allowed).to.be.false; - expect(rules.tryWrite('nested/one/two', root, {id: {'.value': 'two'}}, auth).allowed).to.be.false; - expect(rules.tryWrite('nested/one/one', root, {id: {'.value': 'one'}}, auth).allowed).to.be.true; - expect(rules.tryWrite('nested/one/one', root, {id: {'.value': 'two'}}, auth).allowed).to.be.false; }); it('should prune null keys', function(){ - var root = new RuleDataSnapshot(RuleDataSnapshot.convert({'a': 1, 'b': 2})), - rules = new Ruleset({rules: {'.write': true}}); + initialData = store.create({'a': 1, 'b': 2}); + rules = new Ruleset({rules: {'.write': true}}); + + expect( + rules.tryWrite('/a', initialData, null, noAuth).newRoot.val() + ).to.be.deep.equal( + {'b': 2} + ); - expect(rules.tryWrite('/a', root, null, null).newRoot.val()).to.be.deep.equal({'b': 2}); - expect(rules.tryWrite('/', root, {'a': 1, 'b': {}}, null).newRoot.val()).to.be.deep.equal({'a': 1}); + expect( + rules.tryWrite('/', initialData, {'a': 1, 'b': {}}, noAuth).newRoot.val() + ).to.be.deep.equal( + {'a': 1} + ); }); it('should prune null keys deeply', function(){ - var root = new RuleDataSnapshot(RuleDataSnapshot.convert({'a': {'b': 2}})), - rules = new Ruleset({rules: {'.write': true}}), - result = rules.tryWrite('/a/b', root, null, null); + initialData = store.create({'a': {'b': 2}}); + rules = new Ruleset({rules: {'.write': true}}); + + const result = rules.tryWrite('/a/b', initialData, null, noAuth); expect(result.newRoot.val()).to.be.deep.equal(null); expect(result.newRoot.child('a').val()).to.be.null; @@ -365,38 +374,32 @@ describe('Ruleset', function() { }); it('should replace a node, not merge it', function() { - var root = getRoot(), - auth = null, - result; - - result = rules.tryWrite('mixedType/first', root, { + let result = rules.tryWrite('mixedType/first', initialData, { type: {'.value': 'b'}, b: {'.value': 1} - }, auth); + }, noAuth); + expect(result.newData.val()).to.eql({type: 'b', b: 1}); expect(result.allowed).to.be.true; - result = rules.tryWrite('mixedType/first', root, { + result = rules.tryWrite('mixedType/first', initialData, { type: {'.value': 'a'}, b: {'.value': 1} - }, auth) + }, noAuth); + expect(result.allowed).to.be.false; }); }); describe('#tryPatch', function() { - - var rules, root, auth, _now; + const _now = Date.now; + let rules, initialData, auth; beforeEach(function() { - _now = Date.now; - - var now = 1000; + let now = 1000; - Date.now = function() { - return now++; - } + Date.now = () => now++; }); afterEach(function() { @@ -430,7 +433,8 @@ describe('Ruleset', function() { } } }); - root = new RuleDataSnapshot({ + + initialData = store.create({ foo: { bar: { '.value': true @@ -461,16 +465,16 @@ describe('Ruleset', function() { }); it('should match "now" with the server timestamp', function() { - var newData = { + const newData = { 'timestamps/foo': {'.sv': 'timestamp'}, 'timestamps/bar': {'.sv': 'timestamp'}, 'timestamps/baz': 12345000 }; - expect(rules.tryPatch('/', root, newData, null).allowed).to.be.false; - + expect(rules.tryPatch('/', initialData, newData, null).allowed).to.be.false; delete newData['timestamps/baz']; - expect(rules.tryPatch('/', root, newData, null).allowed).to.be.true; + + expect(rules.tryPatch('/', initialData, newData, null).allowed).to.be.true; }); it('should allow validate write', function() { @@ -479,20 +483,20 @@ describe('Ruleset', function() { 'foo/fooz': false }; - expect(rules.tryPatch('/', root, newData, auth).allowed).to.be.true - expect(rules.tryPatch('/', root, newData, null).allowed).to.be.false + expect(rules.tryPatch('/', initialData, newData, auth).allowed).to.be.true; + expect(rules.tryPatch('/', initialData, newData, null).allowed).to.be.false; newData['foo/bar'] = false; - expect(rules.tryPatch('/', root, newData, auth).allowed).to.be.false + expect(rules.tryPatch('/', initialData, newData, auth).allowed).to.be.false; }); it('should propagate variables in path', function() { - expect(rules.tryPatch('nested/one/one', root, {foo: 2}, auth).allowed).to.be.true; - expect(rules.tryPatch('nested/one/two', root, {foo: 2}, auth).allowed).to.be.false; + expect(rules.tryPatch('nested/one/one', initialData, {foo: 2}, auth).allowed).to.be.true; + expect(rules.tryPatch('nested/one/two', initialData, {foo: 2}, auth).allowed).to.be.false; }); it('should handle empty patch', function() { - const result = rules.tryPatch('nested/one/one', root, {}, auth) + const result = rules.tryPatch('nested/one/one', initialData, {}, auth); expect(result.allowed).to.be.true; expect(result.newData.val()).to.eql({foo: 1}); @@ -500,9 +504,10 @@ describe('Ruleset', function() { it('should prune null keys deeply', function(){ - var root = new RuleDataSnapshot(RuleDataSnapshot.convert({'a': {'b': 2}})), - rules = new Ruleset({rules: {'.write': true}}), - result = rules.tryPatch('/', root, {'/a/b': {}}, null); + initialData = store.create({'a': {'b': 2}}); + rules = new Ruleset({rules: {'.write': true}}); + + const result = rules.tryPatch('/', initialData, {'/a/b': {}}, null); expect(result.newRoot.val()).to.be.deep.equal(null); expect(result.newRoot.child('a').val()).to.be.null; diff --git a/test/spec/lib/store.js b/test/spec/lib/store.js new file mode 100644 index 0000000..a762a21 --- /dev/null +++ b/test/spec/lib/store.js @@ -0,0 +1,252 @@ +'use strict'; + +const store = require('../../../lib/store'); + +describe('store', function() { + const _now = Date.now; + + beforeEach(function() { + let now = 1000; + + Date.now = () => now++; + }); + + afterEach(function() { + Date.now = _now; + }); + + it('should create an empty tree by default', function() { + expect(store.create().root.$value()).to.equal(null); + expect(store.create(null).root.$value()).to.equal(null); + expect(store.create({}).root.$value()).to.equal(null); + }); + + it('should create a three', function() { + const plain = { + a: 1, + b: { + c: { + d: 2 + } + } + }; + const data = store.create(plain); + + expect(data.root.$value()).to.eql(plain); + expect(data.root.a.$value()).to.equal(1); + expect(data.root.b.$value()).to.eql({c: {d: 2}}); + expect(data.root.b.c.$value()).to.eql({d: 2}); + expect(data.root.b.c.d.$value()).to.equal(2); + }); + + it('should create a three at a path', function() { + const data = store.create(2, {path: 'b/c/d'}); + + expect(data.root.$value()).to.eql({b: {c: {d: 2}}}); + }); + + [true, 'two', 3].forEach(function(v) { + it(`should let ${typeof v} be used as value`, function() { + expect(() => store.create(v)).to.not.throw(); + expect(() => store.create({v})).to.not.throw(); + }); + }); + + [new Date(), [1,2,3], /foo/].forEach(function(v) { + it(`should not let ${v.constructor.name} be used as value`, function() { + expect(() => store.create(v)).to.throw(); + expect(() => store.create({v})).to.throw(); + }); + }); + + describe('server value replacement', function() { + + it('should handle time stamps', function() { + const plain = { + a: {'.sv': 'timestamp'}, + b: { + c: { + d: {'.sv': 'timestamp'} + } + } + }; + const data = store.create(plain, {now: 1234}); + + expect(data.timestamp).to.equal(1234); + expect(data.root.a.$value()).to.equal(1234); + expect(data.root.b.c.d.$value()).to.equal(1234); + }); + + it('should throw with unknown type', function() { + const plain = {a: {'.sv': 'foo'}}; + + expect(() => store.create(plain)).to.throw(); + }); + + }); + + describe('#set', function() { + let data; + + beforeEach(function() { + data = store.create({ + a: 1, + b: { + c: { + d: 2 + } + } + }); + }); + + it('should return a new tree with an updated root', function() { + const newData = data.set('/', 3); + + expect(data.root.a.$value()).to.equal(1); + expect(newData.root.$value()).to.equal(3); + }); + + it('should return a new tree with updated values', function() { + const newData = data.set('a', 3); + + expect(data.root.a.$value()).to.equal(1); + expect(newData.root.a.$value()).to.equal(3); + }); + + it('should return a new tree with updated deep values', function() { + const newData = data.set('b/c/d', 3); + + expect(data.root.b.c.d.$value()).to.equal(2); + expect(newData.root.b.c.d.$value()).to.equal(3); + }); + + it('should return a new tree with removed branches', function() { + const newData = data.set('a', null); + + expect(data.root.a.$value()).to.equal(1); + expect(newData.root).not.to.have.property('a'); + }); + + it('should return a new tree without empty branches', function() { + const newData = data.set('b/c', {d: null, e: null}); + + expect(data.root.b.c.d.$value()).to.equal(2); + expect(newData.root).not.to.have.property('b'); + }); + + }); + + describe('#remove', function() { + let data; + + beforeEach(function() { + data = store.create({ + a: 1, + b: { + c: {d: 2}, + e: 3 + } + }); + }); + + it('should return a new tree with an updated root', function() { + const newData = data.remove('/'); + + expect(data.root.a.$value()).to.equal(1); + expect(newData.root.$value()).to.equal(null); + }); + + it('should return a new tree with updated values', function() { + const newData = data.remove('a'); + + expect(data.root.a.$value()).to.equal(1); + expect(newData.root.$value()).to.eql({b: {c: {d: 2}, e: 3}}); + }); + + it('should return a new tree with updated deep values', function() { + const newData = data.remove('b/c/d'); + + expect(data.root.b.c.d.$value()).to.equal(2); + expect(newData.root.$value()).to.eql({a: 1, b: {e: 3}}); + }); + + }); + + describe('#get', function() { + let data; + + beforeEach(function() { + data = store.create(2, {path: 'b/c/d'}); + }); + + it('should return the node at specific path', function() { + expect(data.get('b/c/d').$value()).to.equal(2); + }); + + it('should return a node if no node exists at a specific path', function() { + expect(data.get('foo/bar').$value()).to.equal(null); + }); + + }); + + describe('#root', function() { + + describe('#$priority', function() { + const priority = 1; + let data; + + it('should return the node priority', function() { + data = store.create({ + a: 1, + b: { + c: { + d: { + '.value': 2, + '.priority': priority + } + } + } + }); + expect(data.root.a.$priority()).to.be.undefined; + expect(data.root.b.c.d.$priority()).to.equal(priority); + }); + + it('should return the node priority set with explicite priority', function() { + data = store.create().set('a', 3, priority); + + expect(data.root.a.$priority()).to.equal(priority); + }); + + it('should return the node priority of a timestamp', function() { + const plain = { + a: { + '.sv': 'timestamp', + '.priority': priority + } + }; + + data = store.create(plain, {now: 1234}); + + expect(data.root.a.$value()).to.equal(1234); + expect(data.root.a.$priority()).to.equal(priority); + }); + + }); + + describe('#$isPrimitive', function() { + let data; + + beforeEach(function() { + data = store.create({a: 1}).set('b/c/d', 2); + }); + + it('should return the node isPrimitive', function() { + expect(data.root.a.$isPrimitive()).to.be.true; + expect(data.root.b.$isPrimitive()).to.be.false; + }); + + }); + + }); + +}); From 1dbb475839242df15d2a416eedd4eb08d0d80439 Mon Sep 17 00:00:00 2001 From: Damien Lebrun Date: Mon, 31 Oct 2016 11:35:05 +0100 Subject: [PATCH 2/2] Refactor ruleset operations --- lib/ruleset.js | 798 ++++++++++++++++------------ lib/store.js | 44 ++ package.json | 4 +- test/jasmine/core.js | 8 +- test/setup.js | 10 +- test/spec/lib/.eslintrc.yml | 1 + test/spec/lib/rule-data-snapshot.js | 12 + test/spec/lib/ruleset.js | 655 ++++++++++++++++++++--- test/spec/lib/store.js | 47 ++ 9 files changed, 1160 insertions(+), 419 deletions(-) diff --git a/lib/ruleset.js b/lib/ruleset.js index 1dab89e..58293c0 100644 --- a/lib/ruleset.js +++ b/lib/ruleset.js @@ -2,443 +2,577 @@ 'use strict'; const Rule = require('./parser/rule'); -const pathSplitter = require('./helpers').pathSplitter; -const pathMerger = require('./helpers').pathMerger; - -var validRuleKinds = { - '.read': true, - '.write': true, - '.validate': true, - '.indexOn': true, - '.name': true -}; - -function ruleError(rulePath, message) { - - var err = new Error(rulePath.join('/') + ': ' + message); - return err; +const helpers = require('./helpers'); + +/** + * Rule parsing related error. + * + * Holds the the path to the rule in the rules set and append it to the error + * message. + * + */ +class RuleError extends Error { + + constructor(stack, message) { + super(`${stack.join('/')}: ${message}`); + this.path = stack; + } } -function Ruleset(rulesDefinition) { +/** + * Test the value is an object. + * + * @param {any} value Value to test + * @return {boolean} + */ +function isObject(value) { + return value && (typeof value === 'object'); +} - if (typeof rulesDefinition !== 'object' || - rulesDefinition === null || - Object.keys(rulesDefinition).length !== 1 || - !rulesDefinition.hasOwnProperty('rules')) { - throw new Error('Rules definition must have a single root object with property "rules"'); - } +/** + * Test the the rule as the type and of an existing kind (read/write/validate/indexOn). + * + * @param {array} stack Path to the rule in the rule set. + * @param {string} kind The rule kind (read/write/validate/indexOn) + * @param {any} value The rule value + */ +function testRuleType(stack, kind, value) { + const ruleType = typeof value; + + switch (kind) { + + case '.indexOn': + if (ruleType !== 'string' && !Array.isArray(value)) { + throw new RuleError(stack, `Expected .indexOn to be a string or an Array, got ${ruleType}`); + } - var newRulesObject = {}; + if (Array.isArray(value) && value.some(i => typeof i !== 'string')) { + throw new RuleError(stack, `Expected .indexOn an Array of string, got ${value.map(x => typeof x).join(', ')}`); + } - // traverse, parse and validate all the rules - (function traverse(rulesObject, newRulesObject, stack) { + return; - Object.keys(rulesObject).forEach(function(key) { + case '.read': + case '.write': + case '.validate': + if (ruleType !== 'string' && ruleType !== 'boolean') { + throw new RuleError(stack, `Expected .indexOn to be a string or a boolean, got ${ruleType}`); + } + return; - stack.push(key); + default: + throw new RuleError(stack, `Invalid rule types: ${kind}`); - if (key.charAt(0) === '.') { + } +} - if (!validRuleKinds.hasOwnProperty(key)) { - throw ruleError(stack, 'invalid rule type "' + key + '"'); - } else if (key === '.indexOn') { +/** + * Hold a tree of read/write/validate rules. + * + * Used to simulate a firebase read, write or patch (update) operation. + * + */ +class Ruleset { + + /** + * Ruleset constructor. + * + * Should throw if the definition cannot be publish on Firebase. + * + * @param {object} rulesDefinition A rule set object. + */ + constructor(rulesDefinition) { + + if (!rulesDefinition) { + throw new Error('No rules definition provided'); + } - if (!(Array.isArray(rulesObject[key]) || typeof rulesObject[key] === 'string')) { - throw ruleError(stack, 'indexOn expects a string or array, but got ' + typeof rulesObject[key]); - } + if (!isObject(rulesDefinition)) { + throw new Error('Rules definition must be an object'); + } - } else if (typeof rulesObject[key] !== 'boolean' && typeof rulesObject[key] !== 'string') { - throw ruleError(stack, 'expected string or boolean, but got ' + typeof rulesObject[key]); - } + if (!rulesDefinition.rules || Object.keys(rulesDefinition).length !== 1) { + throw new Error('Rules definition must have a single root object with property "rules"'); + } - if (key === '.read' || key === '.write' || key === '.validate') { + this.rules = new RuleNode([], rulesDefinition.rules); - try { + Object.freeze(this); + } - // get all the wildchildren out of the stack - var wildchildren = stack.filter(function(key) { - return key.charAt(0) === '$'; - }); + /** + * Simulate a read (on/once) operation. + * + * It will traverse the tree from the root to the the node to access until + * it finds a '.read' rule which evaluating to true. + * + * The operation is allowed if it found a read rule evaluating to true. + * + * @param {string} path Path to the node to read + * @param {Database} data Database to read + * @param {object|null} auth User data to simulate + * @param {number} [now] Timestamp of the read operation + * @return {{info: string, allowed: boolean}} + */ + tryRead(path, data, auth, now) { + + const result = Result.read(path, data, auth); + let state = { + root: result.root, + auth: result.auth, + now: now || Date.now() + }; - newRulesObject[key] = new Rule(rulesObject[key].toString(), wildchildren, key === '.write' || key === '.validate'); + this.rules.$traverse(path, (currentPath, rules, wildchildren) => { - } catch(e) { - throw ruleError(stack, e.message); - } + if (!rules.$read) { + return; + } - } + state = Object.assign({}, state, wildchildren, {data: data.snapshot(currentPath)}); + Ruleset.evaluate(rules, 'read', currentPath, state, result); - } else { + return result.allowed; - if (typeof rulesObject[key] !== 'object' || rulesObject[key] === 'null') { - throw ruleError(stack, 'should be object, but got ' + - rulesObject[key] === null ? null : typeof rulesObject[key]); - } else { + }); - // handle wildchild lookups - if (key.charAt(0) === '$') { + return result; + } - // rename this to the "wild" operator on this path - if (!newRulesObject.hasOwnProperty('$')) { + /** + * Simulate a write operation. + * + * It will traverse the tree from the root to the the node to update an + * evaluate each '.write' rules until one permits the operation and each + * '.validate' rules. It also evaluate the '.validate' rules any new node + * (the new node and its children). + * + * The operation is allowed if any visited '.write' rule evaluate to true and + * if no visited '.validate' rule evaluate to false. + * + * + * @param {string} path Path to the node to write + * @param {Database} data Database to edit + * @param {any} newValue Replacement value + * @param {object|null} auth User data to simulate + * @param {number} [now] Timestamp of the write operation + * @return {{info: string, allowed: boolean}} + */ + tryWrite(path, data, newValue, auth, now) { + now = now || Date.now(); + + const newData = data.set(path, newValue, undefined, now); + + return this.evaluateWrite(path, data, newData, newValue, auth, now); + } - newRulesObject.$ = {}; - newRulesObject.$['.name'] = key; + /** + * Similate a patch (update) operation + * + * Update the database with the patch data and then test each updated + * node could be written and is valid. + * + * It similar to a serie of write operation except that all changes happens + * at once. + * + * @param {string} path Path to the node to write + * @param {Database} data Database to edit + * @param {object} patch Map of path/value to update the database. + * @param {object|null} auth User data to simulate + * @param {number} [now] Timestamp of the write operation + * @return {{info: string, allowed: boolean}} + */ + tryPatch(path, data, patch, auth, now) { + const pathsToTest = []; + let newData = data; + + now = now || Date.now(); + + Object.keys(patch).forEach(function(endPath){ + var pathToNode = helpers.pathMerger(path, endPath); + + newData = newData.set(pathToNode, patch[endPath], undefined, now); + pathsToTest.push(pathToNode); + }); - traverse(rulesObject[key], newRulesObject.$, stack); + const results = pathsToTest.map(p => this.evaluateWrite(p, data, newData, patch, auth, now)); - } else { - throw ruleError(stack, 'there can only be one wildchild at a given path'); - } + return Result.patch(path, data, newData, patch, auth, results); + } - } else { + /** + * Evaluate the '.write' and '.validate' rules for `#tryWrite` and `#tryPatch`. + * + * @param {string} path Path to evaluate + * @param {Database} data Original data + * @param {Database} newData Resulting data + * @param {any} newValue Plain value (for the result) + * @param {object|null} auth User data to simulate + * @param {number} now Operation timestamp + * @return {Result} + * @private + */ + evaluateWrite(path, data, newData, newValue, auth, now) { + const stop = true; + const result = Result.write(path, data, newData, newValue, auth); + let state = { + root: result.root, + auth: result.auth, + now: now + }; - newRulesObject[key] = {}; - traverse(rulesObject[key], newRulesObject[key], stack); + this.rules.$traverse(path, (currentPath, rules, wildchildren) => { - } + state = Object.assign({}, state, wildchildren, { + data: data.snapshot(currentPath), + newData: newData.snapshot(currentPath) + }); - } + if (result.writePermitted && !state.newData.exists()) { + return stop; + } + if (!result.writePermitted) { + Ruleset.evaluate(rules, 'write', currentPath, state, result); } - stack.pop(); + if (state.newData.exists()) { + Ruleset.evaluate(rules, 'validate', currentPath, state, result); + } + return !stop; }); - })(rulesDefinition.rules, newRulesObject, []); + if (!result.newData.exists()) { + return result; + } - this._rules = newRulesObject; + newData.walk(path, snap => { + const childPath = snap.toString(); + const child = this.rules.$child(childPath); -} + if (!child || !snap.exists()) { + // Note that it only stop walking down that branch; the callback will be + // called with siblings. + return stop; + } -Ruleset.prototype.get = function(path, kind) { + state = Object.assign({}, state, child.wildchildren, { + data: data.snapshot(childPath), + newData: snap + }); - var rules = []; + Ruleset.evaluate(child.rules, 'validate', childPath, state, result); - if (kind.charAt(0) !== '.') { - kind = '.' + kind; - } + return !stop; + }); - (function traverse(ruleNode, currentPath, remainingPath) { + return result; + } - var rule = ruleNode[kind]; - if (rule === undefined) { - rule = null; + /** + * Helper function evaluating a rule and logging the result. + * + * @param {Rule} rules Rule to evaluate + * @param {string} kind Rule Kind + * @param {string} path Data path + * @param {object} state State to evaluate the rule with + * @param {Result} result Result object to log the result to + * @private + */ + static evaluate(rules, kind, path, state, result) { + const rule = rules[`$${kind}`]; + + if (!rule) { + return; } - var ruleDef = { - path: '/' + currentPath.join('/'), - rule: rule - }; + try { + const allowed = rule.evaluate(state); - if (ruleNode['.name']) { - ruleDef.wildchild = { - name: ruleNode['.name'], - value: currentPath[currentPath.length-1] - }; + result.add(path, kind, rule, allowed); + } catch(e) { + result.add(path, kind, rule, e); } + } - rules.push(ruleDef); - - if (remainingPath.length > 0) { +} - var pathPart = remainingPath[0]; +/** + * Represent a Rule Node + */ +class RuleNode { + + /** + * RuleNode constructor. + * + * @param {array} stack Path to the rule in the ruleset. + * @param {object} rules Node rules and its children + */ + constructor(stack, rules) { + + if (!rules) { + throw new RuleError(stack, 'no rule provided'); + } - if (ruleNode.hasOwnProperty(pathPart) || ruleNode.hasOwnProperty('$')) { + if (!isObject(rules)) { + throw new RuleError(stack, `rules should be an object, got ${typeof rules}`); + } - var subnode = ruleNode[pathPart]; - if (subnode === undefined) { - subnode = ruleNode.$; - } + // validate rule kinds + const ruleKinds = Object.keys(rules).filter(k => k.startsWith('.')); - if (typeof subnode === 'object') { - traverse(subnode, currentPath.concat(pathPart), remainingPath.slice(1)); - } + ruleKinds.forEach(k => testRuleType(stack, k, rules[k])); - } + // validate wildchild + const wildchildren = stack.filter(k => k.startsWith('$')); + const wildchild = Object.keys(rules).filter(k => k.startsWith('$')); + if (wildchild.length > 1) { + throw new RuleError(stack, 'There can only be one wildchild at a given path.'); } - })(this._rules, [], pathSplitter(path)); - - return rules; - -}; + const wildchildName = wildchild.pop(); -Ruleset.prototype.tryRead = function(path, initialData, auth, now) { - - var state = { - auth: auth === undefined ? null : auth, - now: now || Date.now(), - root: initialData.snapshot('/'), - data: initialData.snapshot(path) - }; + if (wildchildName && wildchildren.indexOf(wildchildName) > -1) { + throw new RuleError(stack, 'got identical wildchild names in the stack.'); + } - // get the rules - var rules = this.get(path, 'read'); + // Setup flag and parse rules. + const isWrite = true; + const name = stack.slice(-1).pop(); - var result = { - path: path, - auth: state.auth, - type: 'read', - info: '', - allowed: false, - data: state.data, - root: state.root - }; + Object.defineProperties(this, { + $name: {value: name}, + $isWildchild: {value: name && name.startsWith('$')}, + $read: {value: rules['.read'] != null ? new Rule(rules['.read'].toString(), wildchildren, !isWrite) : null}, + $write: {value: rules['.write'] != null ? new Rule(rules['.write'].toString(), wildchildren, isWrite) : null}, + $validate: {value: rules['.validate'] != null ? new Rule(rules['.validate'].toString(), wildchildren, isWrite) : null} + }); - for (var i = 0; i < rules.length; i++) { + // setup children rules + const childrens = Object.keys(rules).filter(k => !k.startsWith('.') && !k.startsWith('$')); - var ruleDef = rules[i]; - result.info += ruleDef.path; + childrens.forEach(k => (this[k] = new RuleNode(stack.concat(k), rules[k]))); + this.$wildchild = wildchildName ? new RuleNode(stack.concat(wildchildName), rules[wildchildName]) : null; - if (ruleDef.wildchild) { - state[ruleDef.wildchild.name] = ruleDef.wildchild.value; - } + Object.freeze(this); + } - if (ruleDef.rule === null) { - result.info += ':\n'; - } else { - result.info += '/: "' + ruleDef.rule.toString() + '"\n'; - - var thisRuleResult; - try { - thisRuleResult = ruleDef.rule.evaluate(state); - } catch(e) { - result.info += e.message + '\n'; - thisRuleResult = false; + /** + * Find a children rule applying to the name. + * + * @param {string} name Path to the the rule node + * @param {object} wildchildren Map of wildchild name/value to extend + * @return {{child: RuleNode, wildchildren: object}|void} + */ + $child(name, wildchildren) { + wildchildren = wildchildren || {}; + + const parts = helpers.pathSplitter(name); + let rules = this; + + for (let i = 0; i < parts.length; i++) { + let key = parts[i]; + + if (rules[key]) { + rules = rules[key]; + continue; } - result.info += ' => ' + thisRuleResult + '\n'; - - if (thisRuleResult === true) { - result.allowed = true; - break; + if (!rules.$wildchild) { + return; } + rules = rules.$wildchild; + wildchildren = Object.assign({}, wildchildren, {[rules.$name]: key}); } + return {rules, wildchildren}; } - if (result.allowed) { - result.info += 'Read was allowed.'; - } else { - result.info += 'No .read rule allowed the operation.\n'; - result.info += 'Read was denied.'; - } - - return result; - -}; - - -Ruleset.prototype.tryWrite = function(path, initialData, newValue, auth, skipWrite, skipValidate, skipOnNoValue, now) { - - // write encompasses both the cascading write rules and the - // non-cascading validate rules - - now = now || Date.now(); - - const newData = initialData.set(path, newValue, undefined, now); - const result = { - path: path, - type: 'write', - info: '', - allowed: false, - auth: auth === undefined ? null : auth, - writePermitted: skipWrite || false, - validated: true, - data: initialData.snapshot(path), - newData: newData.snapshot(path), - root: initialData.snapshot('/'), - newRoot: newData.snapshot('/') - }; - - return this._tryWrite(path, initialData, newData, result, skipWrite, skipValidate, skipOnNoValue); -}; + /** + * Traverse the path and yield each node on the way. + * + * The callback function can return `true` to stop traversing the path. + * + * @param {string} path Path to traverse + * @param {object} [wildchildren] Map of wildchild name/value to extend + * @param {function(path: string, rules: RuleNode, wildchildren: object): boolean} cb Receive each node traversed. + */ + $traverse(path, wildchildren, cb) { + let currentPath = ''; + let currentRules = this; + + if (typeof wildchildren === 'function') { + cb = wildchildren; + wildchildren = {}; + } -Ruleset.prototype.tryPatch = function(path, initialData, newValues, auth, skipWrite, skipValidate, skipOnNoValue, now) { - const pathsToTest = []; - let newData = initialData; - let pathResults, result; + cb(currentPath, currentRules, wildchildren); - now = now || Date.now(); + const stop = true; - Object.keys(newValues).forEach(function(endPath){ - var pathToNode = pathMerger(path, endPath); + helpers.pathSplitter(path).some(key => { + const child = currentRules.$child(key, wildchildren); - newData = newData.set(pathToNode, newValues[endPath], undefined, now); - pathsToTest.push(pathToNode); - }); + if (!child) { + return stop; + } - pathResults = pathsToTest.map(function(pathToNode) { - var pathResult = { - path: pathToNode, - type: 'patch', - info: '', - allowed: false, - auth: auth === undefined ? null : auth, - writePermitted: skipWrite || false, - validated: true - }; + currentPath = helpers.pathMerger(currentPath, key); + currentRules = child.rules; + wildchildren = child.wildchildren; - return this._tryWrite(pathToNode, initialData, newData, pathResult, skipWrite, skipValidate, skipOnNoValue); - }, this); - - if (pathResults.length === 0) { - result = { - path: path, - type: 'patch', - info: '', - allowed: true, - auth: auth === undefined ? null : auth, - writePermitted: skipWrite || false, - validated: true - }; - } else { - result = pathResults.reduce(function(result, pathResult) { - result.path = path; - result.newData = newValues; - result.info += pathResult.info; - result.allowed = result.allowed && pathResult.allowed; - result.writePermitted = result.writePermitted && pathResult.writePermitted; - result.validated = result.validated && pathResult.validated; - - return result; + return cb(currentPath, currentRules, wildchildren); }); } - result.data = initialData.snapshot(path); - result.newData = newData.snapshot(path); - result.root = initialData.snapshot('/'); - result.newRoot = newData.snapshot('/'); - - return result; - -}; - - -Ruleset.prototype._tryWrite = function(path, initialData, newData, result, skipWrite, skipValidate, skipOnNoValue) { - - // walk down the rules hierarchy -- to get the .write and .validate rules along here - (function traverse(pathParts, remainingPath, rules, wildchildren) { - - var currentPath = pathParts.join('/'), - rule, - nextPathPart; - - var state = Object.assign({ - auth: result.auth, - now: newData.timestamp, - root: initialData.snapshot('/'), - data: initialData.snapshot(currentPath), - newData: newData.snapshot(currentPath) - }, wildchildren); - - if (!skipWrite && !result.writePermitted && rules.hasOwnProperty('.write')) { - - rule = rules['.write']; - result.info += '/' + currentPath + ':.write: "' + rule + '"\n'; - - try { +} - if (rule.evaluate(state) === true) { +/** + * Hold an evaluation result. + */ +class Result { + + /** + * Create the result for a read operation. + * + * @param {string} path Path to node to read + * @param {Database} data Database to read + * @param {object|null} auth User data to simulate + * @return {Result} + */ + static read(path, data, auth) { + return new Result(path, 'read', data, undefined, undefined, auth); + } - result.writePermitted = true; - result.info += ' => true\n'; + /** + * Create the result for a write operation. + * + * @param {string} path Path to node to write + * @param {Database} data Database to edit + * @param {Database} newData Resulting database + * @param {any} newValue Value to edit with + * @param {object|null} auth User data to simulate + * @return {Result} + */ + static write(path, data, newData, newValue, auth) { + return new Result(path, 'write', data, newData, newValue, auth); + } - } else { - result.info += ' => false\n'; - } + /** + * Create the result for a patch operation from w serie of write evaluation + * result. + * + * @param {string} path Path to node to patch + * @param {Database} data Database to edit + * @param {Database} newData Resulting database + * @param {object} patch Values to edit with + * @param {object|null} auth User data to simulate + * @param {array} writeResults List of write eveluation result to merge. + * @return {Result} + */ + static patch(path, data, newData, patch, auth, writeResults) { + const result = new Result(path, 'patch', data, newData, patch, auth); + + result.writePermitted = true; + + writeResults.forEach(r => { + result.logs = result.logs.concat(r.logs); + result.writePermitted = r.writePermitted && result.writePermitted; + result.validated = r.validated && result.validated; + }); - } catch(e) { - result.writePermitted = false; - result.info += e.message + '\n'; - } + return result; + } + constructor(path, operationType, data, newData, newValue, auth) { + const isRead = operationType === 'read'; + + this.path = path; + this.auth = auth; + this.type = operationType; + this.logs = []; + this.readPermitted = isRead ? false : true; + this.writePermitted = isRead ? true : false; + this.validated = true; + this.data = data.snapshot(path); + this.root = data.snapshot('/'); + + if (isRead) { + return; } - if ( - !skipValidate && - state.newData.exists() && - rules.hasOwnProperty('.validate') && - result.validated - ) { - - rule = rules['.validate']; - result.info += '/' + pathParts.join('/') + ':.validate: "' + rule.toString() + '"\n'; - - try { - - if (rule.evaluate(state, skipOnNoValue) === true) { - result.info += ' => true\n'; - } else { + this.newValue = newValue; + this.newData = newData.snapshot(path); + this.newRoot = newData.snapshot('/'); + } - result.validated = false; - result.info += ' => false\n'; + get allowed() { + return this.readPermitted && this.writePermitted && this.validated; + } - } + get info() { + let logs = this.logs + .map(r => `/${r.path}: ${r.kind} "${r.rule}"\n => ${r.result}`) + .join('\n'); - } catch(e) { - result.validated = false; - result.info += e.message + '\n'; - } + if (this.allowed) { + return `${logs}\n${this.type} was allowed.`; + } + if (!this.writePermitted) { + logs += '\nNo .write rule allowed the operation.'; } - if (( nextPathPart = remainingPath.shift() )) { + if (!this.readPermitted) { + logs += '\nNo .read rule allowed the operation.'; + } - if (rules.hasOwnProperty(nextPathPart)) { - traverse(pathParts.concat(nextPathPart), remainingPath, rules[nextPathPart], wildchildren); - } else if (rules.hasOwnProperty('$')) { - // wildchild. - wildchildren = Object.assign({}, wildchildren); - wildchildren[rules.$['.name']] = nextPathPart; - traverse(pathParts.concat(nextPathPart), remainingPath, rules.$, wildchildren); - } + if (!this.validated) { + logs += '\nNo .validate rule allowed the operation.'; + } - } else { + return `${logs}\n${this.type} was denied.`; + } - const val = newData.snapshot(currentPath).val(); + /** + * Logs the evaluation result. + * + * @param {string} path The rule path + * @param {string} kind The rule kind + * @param {NodeRule} rule The rule + * @param {boolean|Error} result The evaluation result + */ + add(path, kind, rule, result) { + this.logs.push({path, kind, result, rule: rule.toString()}); - if (typeof val === 'object' && val !== null) { + const success = result instanceof Error ? false : result; - Object.keys(val).forEach(function(key) { + switch (kind) { - if (rules.hasOwnProperty(key)) { - traverse(pathParts.concat(key), remainingPath, rules[key], wildchildren); - } else if (rules.hasOwnProperty('$')) { - // wildchild. - wildchildren = Object.assign({}, wildchildren); - wildchildren[rules.$['.name']] = key; - traverse(pathParts.concat(key), remainingPath, rules.$, wildchildren); - } + case 'validate': + this.validated = this.validated && success; + break; - }); + case 'write': + this.writePermitted = this.writePermitted || success; + break; - } + case 'read': + this.readPermitted = this.readPermitted || success; + break; + /* istanbul ignore next */ + default: + throw new Error(`Unknown type: ${kind}`); } - })([], pathSplitter(path), this._rules, {}); - - result.allowed = result.writePermitted && result.validated; - - if (!result.writePermitted) { - result.info += 'No .write rule allowed the operation.\n'; - result.info += 'Write was denied.'; - } else if (!result.validated) { - result.info += 'Validation failed.\n'; - result.info += 'Write was denied.'; - } else if (!result.allowed) { - result.info += 'Write was denied.'; } - return result; - -}; - +} module.exports = Ruleset; diff --git a/lib/store.js b/lib/store.js index 4698395..a4a4fc8 100644 --- a/lib/store.js +++ b/lib/store.js @@ -205,6 +205,28 @@ class DataNode { return this[valueKey] !== undefined; } + /** + * Yield every child nodes. + * + * The callback can return true to cancel descending down a branch. Sibling + * nodes would still get yield. + * + * @param {string} path Path to the current node + * @param {function(path: string, parentPath: string, nodeKey: string): boolean} cb Callback receiving a node path + */ + $walk(path, cb) { + Object.keys(this).forEach(key => { + const nodePath = helpers.pathMerger(path, key); + const stop = cb(nodePath, path, key); + + if (stop) { + return; + } + + this[key].$walk(nodePath, cb); + }); + } + } /** @@ -350,6 +372,19 @@ class Database { } + /** + * Walk each child nodes from the path and yield each of them as snapshot. + * + * The callback can returns true to cancel yield of the child value of the + * currently yield snapshot. + * + * @param {string} path starting node path (doesn't get yield). + * @param {function(snap: RuleDataSnapshot): boolean} cb Callback receiving a snapshot. + */ + walk(path, cb) { + this.get(path).$walk(path, nodePath => cb(this.snapshot(nodePath))); + } + } // RuleDataSnapshot private property keys. @@ -510,6 +545,15 @@ class RuleDataSnapshot { return typeof val === 'boolean'; } + /** + * Return the snapshot path. + * + * @return {string} + */ + toString() { + return this[pathKey]; + } + } /** diff --git a/package.json b/package.json index a609432..9647147 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "eslint-plugin-node": "^2.1.3", "istanbul": "^0.4.5", "jasmine": "^2.1.1", - "mocha": "^2.1.0" + "mocha": "^2.1.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" } } diff --git a/test/jasmine/core.js b/test/jasmine/core.js index bb8d350..794a3e9 100644 --- a/test/jasmine/core.js +++ b/test/jasmine/core.js @@ -281,16 +281,14 @@ describe('the targaryen Jasmine plugin', function() { }); it("should not be able to delete part of /test in a multi-update", function () { - expect({uid:'anyone'}).cannotWrite('/', { - "test": { - "bool": null - }, + expect({uid:'anyone'}).cannotPatch('/', { + "test/bool": null, "canDelete": null }); }); it("should be able to delete as part of a multi-path write", function () { - expect({uid:'anyone'}).canWrite('/', { + expect({uid:'anyone'}).canPatch('/', { "test": { "bool": false, "number": 5 diff --git a/test/setup.js b/test/setup.js index 2ea758c..a396495 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,2 +1,10 @@ +'use strict'; -global.expect = require('chai').expect; +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +chai.use(sinonChai); + +global.expect = chai.expect; +global.sinon = sinon; diff --git a/test/spec/lib/.eslintrc.yml b/test/spec/lib/.eslintrc.yml index e65eac9..ac3cb5c 100644 --- a/test/spec/lib/.eslintrc.yml +++ b/test/spec/lib/.eslintrc.yml @@ -3,3 +3,4 @@ env: mocha: true globals: expect: true + sinon: true diff --git a/test/spec/lib/rule-data-snapshot.js b/test/spec/lib/rule-data-snapshot.js index a90a07e..af1d484 100644 --- a/test/spec/lib/rule-data-snapshot.js +++ b/test/spec/lib/rule-data-snapshot.js @@ -241,4 +241,16 @@ describe('RuleDataSnapshot', function() { }); + describe('toString', function() { + + it('should return the snapshot path', function() { + expect( + root + .child('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name') + .toString() + ).to.equal('users/password:3403291b-fdc9-4995-9a54-9656241c835d/name'); + }); + + }); + }); diff --git a/test/spec/lib/ruleset.js b/test/spec/lib/ruleset.js index 1bece06..b3fb625 100644 --- a/test/spec/lib/ruleset.js +++ b/test/spec/lib/ruleset.js @@ -114,6 +114,16 @@ var invalidRulesets = { '.indexOn': true } }, + 'include null nodes': { + rules: { + 'foo': null + } + }, + 'include primitive nodes': { + rules: { + 'foo': 'true' + } + }, 'include unknown props': { rules: { '.read': true, @@ -125,66 +135,354 @@ var invalidRulesets = { '.read': '$somewhere === true' } }, + 'set rules to numbers': { + rules: { + '.read': 1 + } + }, 'set index to an object': { rules: { '.indexOn': {} } }, + 'set index to an array of number': { + rules: { + '.indexOn': [1,2,3] + } + }, 'include unknown variables': { rules: { - ".validate": "something.val() + 1 === date.val()", - ".write": "something.val() / 2 > 0" + '.validate': 'something.val() + 1 === date.val()', + '.write': 'something.val() / 2 > 0' } }, 'include rules composed with unknown variables': { rules: { - ".validate": "auth != null && something.val() + 1 === date.val()", - ".write": "auth != null && something.val() / 2 > 0" + '.validate': 'auth != null && something.val() + 1 === date.val()', + '.write': 'auth != null && something.val() / 2 > 0' + } + }, + 'include duplicated wildchlidren': { + rules: { + $uid: { + foo: { + $uid: { + '.write': true + } + } + } + } + }, + 'include multiple wildchlidren on the same node': { + rules: { + foo: { + $uid: { + '.write': true + }, + $foo: { + '.write': true + } + } } } }; -var validRulesets = [{ - rules: {} -}, { - rules: { - '.read': true, - '.write': true, - '.indexOn': 'wut', - '.validate': true - } -}, { - rules: { - '.indexOn': ['wut', 'the', 'heck'] - } -}, { - rules: { - } -}, { - rules: { - ".validate": "newData.val() + 1 === data.val()", - ".write": "newData.val() / 2 > 0" +var validRulesets = { + 'sets an empty rules property': {rules: {}}, + 'defines valid read/write/indexOn/validate rules': { + rules: { + '.read': true, + '.write': true, + '.indexOn': 'wut', + '.validate': true + } + }, + 'includes array indexes': { + rules: { + '.indexOn': ['wut', 'the', 'heck'] + } + }, + 'uses addition of unknow types': { + rules: { + '.validate': 'newData.val() + 1 === data.val()', + '.write': 'newData.val() / 2 > 0' + } + }, + 'uses wildchildren': { + rules: { + $uid: { + foo: { + $other: { + '.write': true + } + } + } + } } -}]; +}; describe('Ruleset', function() { describe('constructor', function() { Object.keys(invalidRulesets).forEach(function(reason) { - it(`rejects when rulesets ${reason}`, function() { - expect(function() { - return new Ruleset(invalidRulesets[reason]); - }).to.throw(); + it(`should rejects when rulesets ${reason}`, function() { + expect(() => new Ruleset(invalidRulesets[reason])).to.throw(); }); }); - it('accepts valid rulesets', function() { + Object.keys(validRulesets).forEach(function(reason) { + it(`accepts accept a rulesets when it ${reason}`, function() { + expect(() => new Ruleset(validRulesets[reason])).to.not.throw(); + }); + }); - validRulesets.forEach(function(rules) { - expect(function() { - return new Ruleset(rules); - }).not.to.throw(); + it('should define a tree', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + '.write': false, + b: { + '.validate': true + } + } + } + }); + + expect(ruleset.rules.$read.toString()).to.equal('true'); + expect(ruleset.rules.a.$write.toString()).to.equal('false'); + expect(ruleset.rules.a.b.$validate.toString()).to.equal('true'); + }); + + it('should define a tree with wildchildren', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + '.write': false, + $b: { + '.validate': true + } + } + } + }); + + expect(ruleset.rules.a.$wildchild.$validate.toString()).to.equal('true'); + expect(ruleset.rules.a.$wildchild.$name).to.equal('$b'); + expect(ruleset.rules.a.$wildchild.$isWildchild).to.be.true; + }); + + }); + + describe('#rules', function() { + + describe('#$child', function() { + + it('should return rules for a direct child node', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + '.write': false + }, + $b: { + '.validate': true + } + } + }); + + expect(ruleset.rules.$child('a').rules).to.equal(ruleset.rules.a); + }); + + it('should return rules for a direct wildchild node', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + '.write': false + }, + $b: { + '.validate': true + } + } + }); + const child = ruleset.rules.$child('foo'); + + expect(child.rules).to.equal(ruleset.rules.$wildchild); + expect(child.wildchildren).to.eql({$b: 'foo'}); + }); + + it('should return rules for a deep child node', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + $b: { + $c: { + d: { + '.read': true + } + } + } + } + } + }); + const child = ruleset.rules.$child('a/foo/bar/d'); + + expect(child.rules).to.equal(ruleset.rules.a.$wildchild.$wildchild.d); + expect(child.wildchildren).to.eql({$b: 'foo', $c: 'bar'}); + }); + + it('should return rules for a direct wildchild node', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + '.write': false + }, + $b: { + '.validate': true + } + } + }); + const child = ruleset.rules.$child('foo'); + + expect(child.rules).to.equal(ruleset.rules.$wildchild); + expect(child.wildchildren).to.eql({$b: 'foo'}); + }); + + }); + + describe('#$traverse', function() { + + it('should yield each node on its path', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + b: { + c: { + no: { + '.read': true + } + } + }, + no: { + '.read': true + } + }, + no: { + '.read': true + } + } + }); + const cb = sinon.spy(); + + ruleset.rules.$traverse('a/b/c', cb); + + expect(cb).to.have.been.calledWith('', ruleset.rules, {}); + expect(cb).to.have.been.calledWith('a', ruleset.rules.a); + expect(cb).to.have.been.calledWith('a/b', ruleset.rules.a.b); + expect(cb).to.have.been.calledWith('a/b/c', ruleset.rules.a.b.c); + + expect(cb).to.have.callCount(4); + }); + + it('should yield wildchild nodes on its path', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + $b: { + $c: { + '.read': true + } + } + } + } + }); + const cb = sinon.spy(); + + ruleset.rules.$traverse('a/foo/bar', cb); + + expect(cb).to.have.been.calledWith('', ruleset.rules, {}); + expect(cb).to.have.been.calledWith('a', ruleset.rules.a, {}); + expect(cb).to.have.been.calledWith('a/foo', ruleset.rules.a.$wildchild, {$b: 'foo'}); + expect(cb).to.have.been.calledWith('a/foo/bar', ruleset.rules.a.$wildchild.$wildchild, {$b: 'foo', $c: 'bar'}); + + expect(cb).to.have.callCount(4); + }); + + it('should extend wildchildren list', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + $b: { + $c: { + '.read': true + } + } + } + } + }); + const cb = sinon.spy(); + + ruleset.rules.a.$wildchild.$traverse('bar', {$b: 'foo'}, cb); + + expect(cb).to.have.been.calledWith('', ruleset.rules.a.$wildchild, {$b: 'foo'}); + expect(cb).to.have.been.calledWith('bar', ruleset.rules.a.$wildchild.$wildchild, {$b: 'foo', $c: 'bar'}); + expect(cb).to.have.callCount(2); + }); + + it('should yield each node in descending or', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + b: { + c: { + '.read': true + } + } + } + } + }); + const cb = sinon.spy(); + + ruleset.rules.$traverse('a/b/c', cb); + + expect(cb.getCall(0).args[0]).to.equal(''); + expect(cb.getCall(1).args[0]).to.equal('a'); + expect(cb.getCall(2).args[0]).to.equal('a/b'); + expect(cb.getCall(3).args[0]).to.equal('a/b/c'); + }); + + it('should allow traversing to stop', function() { + const ruleset = new Ruleset({ + rules: { + '.read': true, + a: { + b: { + c: { + '.read': true + } + } + } + } + }); + const cb = sinon.stub(); + + cb.returns(false); + cb.withArgs('a').returns(true); + + ruleset.rules.$traverse('a/b/c', cb); + + expect(cb).to.have.been.calledWith('', ruleset.rules); + expect(cb).to.have.been.calledWith('a', ruleset.rules.a); + + expect(cb).to.have.callCount(2); }); }); @@ -221,52 +519,6 @@ describe('Ruleset', function() { }); - describe('#get', function() { - - it('gets all the rules along a given node path', function() { - - var rules = getRuleset(); - - var readRules = rules.get('foo/bar/baz/quux', 'read'); - expect(readRules.length).to.equal(4); - expect(readRules[0].path).to.equal('/'); - expect(readRules[1].path).to.equal('/foo'); - expect(readRules[2].path).to.equal('/foo/bar'); - expect(readRules[3].path).to.equal('/foo/bar/baz'); - - var writeRules = rules.get('foo/bar/baz/quux', 'write'); - expect(writeRules.length).to.equal(4); - expect(writeRules[0].path).to.equal('/'); - expect(writeRules[0].rule).to.be.null; - expect(writeRules[1].path).to.equal('/foo'); - expect(writeRules[2].path).to.equal('/foo/bar'); - expect(writeRules[3].path).to.equal('/foo/bar/baz'); - expect(writeRules[3].rule).to.be.null; - - }); - it('gets all the rules along a given node path even if path starts with "/"', function() { - - var rules = getRuleset(); - - var readRules = rules.get('/foo/bar/baz/quux', 'read'); - expect(readRules.length).to.equal(4); - expect(readRules[0].path).to.equal('/'); - expect(readRules[1].path).to.equal('/foo'); - expect(readRules[2].path).to.equal('/foo/bar'); - expect(readRules[3].path).to.equal('/foo/bar/baz'); - - var writeRules = rules.get('/foo/bar/baz/quux', 'write'); - expect(writeRules.length).to.equal(4); - expect(writeRules[0].path).to.equal('/'); - expect(writeRules[0].rule).to.be.null; - expect(writeRules[1].path).to.equal('/foo'); - expect(writeRules[2].path).to.equal('/foo/bar'); - expect(writeRules[3].path).to.equal('/foo/bar/baz'); - expect(writeRules[3].rule).to.be.null; - - }); - }); - describe('#tryRead', function() { const auth = null; let rules, initialData; @@ -290,6 +542,71 @@ describe('Ruleset', function() { }); + it('should traverse all read rules', function() { + const rules = new Ruleset({ + rules: { + '.read': 'false', + $a: { + '.read': 'false', + $b: { + '.read': 'false', + $c: { + '.read': 'false' + } + } + } + } + }); + const result = rules.tryRead('foo/bar/baz', initialData, auth); + + expect(result.logs.map(r => r.path)).to.eql([ + '', + 'foo', + 'foo/bar', + 'foo/bar/baz' + ]); + }); + + it('should traverse all read rules', function() { + const rules = new Ruleset({ + rules: { + '.read': 'false', + $a: { + '.read': 'true', + $b: { + '.read': 'true', + $c: { + '.read': 'true' + } + } + } + } + }); + const result = rules.tryRead('foo/bar/baz', initialData, auth); + + expect(result.logs.map(r => r.path)).to.eql(['', 'foo']); + }); + + it('should only evaluate read rules', function() { + const rules = new Ruleset({ + rules: { + '.read': 'false', + $a: { + '.write': 'true', + $b: { + '.write': 'true', + $c: { + '.read': 'true' + } + } + } + } + }); + const result = rules.tryRead('foo/bar/baz', initialData, auth); + + expect(result.logs.map(r => r.path)).to.eql(['', 'foo/bar/baz']); + }); + }); @@ -390,6 +707,184 @@ describe('Ruleset', function() { expect(result.allowed).to.be.false; }); + it('should traverse all write rules', function() { + const rules = new Ruleset({ + rules: { + '.write': 'false', + $a: { + '.write': 'false', + $b: { + '.write': 'false', + $c: { + '.write': 'false', + 'd': { + '.write': 'false' + } + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), true, noAuth); + + expect(result.logs.map(r => r.path)).to.eql([ + '', + 'foo', + 'foo/bar', + 'foo/bar/baz' + ]); + }); + + it('should traverse write rules until write is permitted', function() { + const rules = new Ruleset({ + rules: { + '.write': 'false', + $a: { + '.write': 'true', + $b: { + '.write': 'true' + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), true, noAuth); + + expect(result.logs.map(r => r.path)).to.eql(['', 'foo']); + }); + + it('should only traverse node with write rules', function() { + const rules = new Ruleset({ + rules: { + '.write': 'false', + $a: { + '.read': 'false', + $b: { + '.read': 'true', + $c: { + '.write': 'true' + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), true, noAuth); + + expect(result.logs.map(r => r.path)).to.eql(['', 'foo/bar/baz']); + }); + + it('should traverse/walk all validate rules', function() { + const rules = new Ruleset({ + rules: { + '.validate': 'true', + '.write': 'true', + $a: { + '.validate': 'true', + $b: { + '.validate': 'true', + $c: { + '.validate': 'true', + d: { + '.validate': 'true' + }, + e: { + '.validate': 'false', + f: { + '.validate': 'true' + } + } + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), {d: true, e: {f: true}}, noAuth); + + expect(result.logs.filter(r => r.kind === 'validate').map(r => r.path)).to.eql([ + '', + 'foo', + 'foo/bar', + 'foo/bar/baz', + 'foo/bar/baz/d', + 'foo/bar/baz/e', + 'foo/bar/baz/e/f' + ]); + }); + + it('should only traverse/walk node with validate rules', function() { + const rules = new Ruleset({ + rules: { + $a: { + '.read': 'false', + $b: { + '.read': 'false', + $c: { + '.read': 'false', + d: { + '.validate': 'false' + }, + e: { + '.read': 'false', + f: { + '.validate': 'false' + } + } + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), {d: true, e: {f: true}}, noAuth); + + expect(result.logs.filter(r => r.kind === 'validate').map(r => r.path)).to.eql([ + 'foo/bar/baz/d', + 'foo/bar/baz/e/f' + ]); + }); + + it('should only traverse/walk node with existing value to write', function() { + const rules = new Ruleset({ + rules: { + $a: { + $b: { + $c: { + d: { + '.validate': 'false' + }, + e: { + '.validate': 'false', + f: { + '.validate': 'false' + } + } + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), {d: true}, noAuth); + + expect(result.logs.filter(r => r.kind === 'validate').map(r => r.path)).to.eql(['foo/bar/baz/d']); + }); + + it('should stop traverse/walk when write is permitted and there is no data to validate', function() { + const rules = new Ruleset({ + rules: { + $a: { + '.write': 'true', + $b: { + '.write': 'true', + $c: { + '.validate': 'false' + } + } + } + } + }); + let result = rules.tryWrite('foo/bar/baz', store.create(), null, noAuth); + + expect(result.logs.map(r => r.path)).to.eql(['foo']); + }); + }); describe('#tryPatch', function() { diff --git a/test/spec/lib/store.js b/test/spec/lib/store.js index a762a21..c55eff6 100644 --- a/test/spec/lib/store.js +++ b/test/spec/lib/store.js @@ -189,6 +189,53 @@ describe('store', function() { }); + describe('#walk', function() { + let data; + + beforeEach(function() { + data = store.create({ + a: 1, + b: { + c: 2, + d: { + e: { + f: 3 + } + } + } + }); + }); + + it('should yield each child nodes as a snapshot', function() { + const snaps = []; + + data.walk('b', s => {snaps.push(s.toString());}); + + expect(snaps.sort()).to.eql(['b/c', 'b/d', 'b/d/e', 'b/d/e/f']); + }); + + it('should yield nodes in descending order', function() { + const snaps = []; + + data.walk('b/d', s => {snaps.push(s.toString());}); + + expect(snaps).to.eql(['b/d/e', 'b/d/e/f']); + }); + + it('should stop yield children when the callback return true', function() { + const snaps = []; + + data.walk('b/d', s => { + snaps.push(s.toString()); + + return true; + }); + + expect(snaps).to.eql(['b/d/e']); + }); + + }); + describe('#root', function() { describe('#$priority', function() {