targaryen
Version:
Test Firebase security rules without connecting to Firebase.
316 lines (253 loc) • 7.58 kB
JavaScript
/**
* Update rule evaluation fixture by trying to upload a rule (as a ".read" rule)
* and recording the result.
*/
'use strict';
const log = require('debug')('targaryen:parser');
const error = require('debug')('targaryen:parser:error');
const firebase = require('../firebase');
const set = require('lodash.set');
const parser = require('./index');
const database = require('../database');
class MatchError extends Error {
constructor(spec, result) {
let msg = `Targaryen and Firebase evaluation of "${spec.rule}" diverges.\n`;
if (result.isValid !== spec.isValid) {
msg += `The rule should be ${spec.isValid ? 'valid' : 'invalid'}`;
} else if (result.failAtRuntime !== spec.failAtRuntime) {
msg += `The rule ${spec.failAtRuntime ? 'have' : 'have no'} runtime error.`;
} else if (result.evaluateTo !== spec.evaluateTo) {
msg += `The rule should evaluate to ${spec.evaluateTo}.`;
}
super(msg);
this.spec = spec;
this.targaryen = result;
}
}
/**
* Hold the rule live evaluation result.
*/
class RuleSpec {
/**
* Evaluate a list of rules.
*
* @param {Array<{rule: string, user: string}>} rules List of rules
* @param {object} users Map of user name and their data.
* @return {Promise<Array<RuleSpec>,Error>}
*/
static evaluateRules(rules, users) {
return firebase.tokens(users).then(
tokens => rules.reduce((p, spec) => p.then(results => {
const rule = new RuleSpec(spec);
log(`Testing "${spec.rule}" with user "${spec.user}"...`);
results.push(rule);
return rule.deploy().then(
() => rule.evaluate(tokens)
).then(
() => rule.hasRuntimeError(tokens)
).then(
() => results
);
}), Promise.resolve([]))
);
}
constructor(details) {
if (!details.user) {
throw new Error('User for authentication is not defined.', details);
}
this.rule = details.rule;
this.user = details.user;
this.wildchildren = details.wildchildren;
this.data = details.data;
this.query = details.query;
this.isValid = undefined;
this.failAtRuntime = undefined;
this.evaluateTo = undefined;
}
get rules() {
const path = Object.keys(this.wildchildren || {})
.sort((a, b) => a.localeCompare(b))
.join('.');
const rules = set({}, `${path}[".read"]`, this.rule);
if (!this.query) {
return rules;
}
if (this.query.orderByChild) {
return set(rules, `${path}[".indexOn"]`, this.query.orderByChild);
}
if (this.query.orderByValue) {
return set(rules, `${path}[".indexOn"]`, '.value');
}
return rules;
}
get path() {
return Object.keys(this.wildchildren || {})
.sort((a, b) => a.localeCompare(b))
.map(k => this.wildchildren[k])
.join('/');
}
/**
* Deploy rule and data.
*
* @return {Promise<void,Error>}
*/
deploy() {
return this.deployRules().then(
() => this.deployData()
);
}
/**
* Deploy the rule.
*
* Ensure the wildchildren are set.
*
* @return {Promise<boolean,Error>}
*/
deployRules() {
log(' deploying rule...', this.rules);
return firebase.deployRules(this.rules).then(
() => true,
e => {
if (e.statusCode !== 400 || e.error === 'Could not parse auth token.') {
return Promise.reject(e.message || e.toString());
}
try {
error(JSON.parse(e.error).error.trim());
} catch (parseErr) {
error(e.message || e.toString());
}
return false;
}
).then(deployed => {
this.isValid = deployed;
log(` validity: ${this.isValid}`);
return deployed;
});
}
/**
* Deploy data.
*
* @return {Promise<void,Error>}
*/
deployData() {
if (this.isValid === false) {
return Promise.resolve();
}
log(' deploying data...');
return firebase.deployData(this.data || null).then(
() => log(' data deployed.')
).catch(
e => Promise.reject(e.message || e.toString())
);
}
/**
* Evaluate rule.
*
* Note that it evaluate the rule in read operation context and that the path
* of the read operation is not stable. It made sure all wildchildren are set,
* but their order might be random. You not use `data` or `newData` in the
* rule.
*
* To test snapshot methods, use `root`.
*
* @param {Object} tokens Map of user name to their auth id token.
* @return {Promise<boolean,Error>}
*/
evaluate(tokens) {
if (!this.isValid) {
return Promise.resolve();
}
const token = tokens[this.user];
if (token === undefined) {
return Promise.reject(new Error(`no token for ${this.user}`));
}
log(' evaluating rule...');
return firebase.canRead(this.path, token, {query: this.query})
.then(result => {
this.evaluateTo = result;
log(` evaluates to: ${result}`);
return result;
}).catch(
e => Promise.reject(e.message || e.toString())
);
}
/**
* Check Firebase evaluation of the rule encountered a runtime error.
*
* If the rule evaluated to false it might have encountered a type error. We
* check this by evaluating a superset of the rule that should evaluate to
* true. If it still evaluate to false, the rule is generating an error.
*
* @param {Object} tokens Map of user name to their auth id token.
* @return {Promise<boolean,Error>}
*/
hasRuntimeError(tokens) {
if (!this.isValid) {
return Promise.resolve(false);
}
if (this.evaluateTo) {
this.failAtRuntime = false;
return Promise.resolve(true);
}
const placebo = new RuleSpec(
Object.assign({}, this, {
rule: `(${this.rule}) || true`
})
);
log(' checking for runtime error...');
return placebo.deploy().then(
() => placebo.evaluate(tokens)
).then(() => {
this.failAtRuntime = !placebo.evaluateTo;
if (this.failAtRuntime) {
this.evaluateTo = undefined;
error(' has runtime error!');
}
return this.failAtRuntime;
});
}
/**
* Test specs against targaryen implementation.
*
* Throws if targaryen implementation doesn't match the specs.
*
* @param {object} users Map of the user name to their auth data.
*/
compare(users) {
let rule, isValid, failAtRuntime, evaluateTo;
log(` testing evaluation of "${this.rule}" with targaryen...`);
try {
rule = parser.parse(this.rule, Object.keys(this.wildchildren || {}));
isValid = true;
} catch (e) {
isValid = false;
}
if (this.isValid !== isValid) {
throw new MatchError(this, {isValid});
}
if (!isValid) {
log(` Matching!`);
return;
}
const state = Object.assign({
query: database.query(this.query),
root: database.snapshot('/', this.data || null),
now: Date.now(),
auth: users[this.user] || null
}, this.wildchildren);
try {
evaluateTo = rule.evaluate(state);
failAtRuntime = false;
} catch (e) {
failAtRuntime = true;
}
if (this.failAtRuntime !== failAtRuntime) {
throw new MatchError(this, {isValid, failAtRuntime, evaluateTo});
}
if (this.evaluateTo !== evaluateTo) {
throw new MatchError(this, {isValid, failAtRuntime, evaluateTo});
}
log(`Matching!`);
}
}
exports.test = RuleSpec.evaluateRules;