├── .gitignore ├── db ├── schema │ ├── patch-001-000.sql │ ├── patch-000-001.sql │ ├── patch-002-001.sql │ └── patch-001-002.sql └── mysql.js ├── promise.js ├── test ├── db_server_stub.js ├── client-then.js ├── local │ ├── ping.js │ ├── log-stats.js │ ├── incorrect-patch-level.js │ ├── error.js │ ├── mysql_tests.js │ └── db_tests.js ├── test_server.js ├── remote │ ├── basic.js │ └── account.js ├── ptaptest.js └── fake.js ├── README.md ├── bin ├── server.js └── db_patcher.js ├── config.js ├── package.json ├── log.js ├── error.js ├── scripts └── tap-coverage.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage.html 3 | -------------------------------------------------------------------------------- /db/schema/patch-001-000.sql: -------------------------------------------------------------------------------- 1 | -- -- drop the dbMetadata table 2 | -- DROP TABLE dbMetadata; 3 | -------------------------------------------------------------------------------- /promise.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = require('bluebird') 6 | -------------------------------------------------------------------------------- /test/db_server_stub.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | require('../bin/server') 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firefox Accounts DB Server, MySql Backend 2 | 3 | Memory backend (for testing) for the [fxa-auth-db-server](https://github.com/mozilla/fxa-auth-db-server/). 4 | 5 | Currently this is a work in progress so docs will appear here soon. 6 | 7 | ## License 8 | 9 | MPL 2.0 10 | -------------------------------------------------------------------------------- /db/schema/patch-000-001.sql: -------------------------------------------------------------------------------- 1 | -- Create the 'dbMetadata' table. 2 | -- Note: This should be the only thing in this initial patch. 3 | 4 | CREATE TABLE dbMetadata ( 5 | name VARCHAR(255) NOT NULL PRIMARY KEY, 6 | value VARCHAR(255) NOT NULL 7 | ) ENGINE=InnoDB; 8 | 9 | INSERT INTO dbMetadata SET name = 'schema-patch-level', value = '1'; 10 | -------------------------------------------------------------------------------- /db/schema/patch-002-001.sql: -------------------------------------------------------------------------------- 1 | -- -- drop tables 2 | 3 | -- DROP TABLE passwordChangeTokens; 4 | -- DROP TABLE passwordForgotTokens; 5 | -- DROP TABLE accountResetTokens; 6 | -- DROP TABLE keyFetchTokens; 7 | -- DROP TABLE sessionTokens; 8 | -- DROP TABLE accounts; 9 | 10 | -- UPDATE dbMetadata SET value = '1' WHERE name = 'schema-patch-level'; 11 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | var config = require('../config') 2 | var createServer = require('fxa-auth-db-server') 3 | var error = require('../error') 4 | var log = require('../log')(config.logLevel, 'db-api') 5 | var DB = require('../db/mysql')(log, error) 6 | var version = require('../package.json').version 7 | 8 | function shutdown() { 9 | process.nextTick(process.exit) 10 | } 11 | 12 | // defer to allow ass code coverage results to complete processing 13 | if (process.env.ASS_CODE_COVERAGE) { 14 | process.on('SIGINT', shutdown) 15 | } 16 | 17 | DB.connect(config).done( 18 | function (db) { 19 | var server = createServer(version, db, log, config.port, config.host) 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /test/client-then.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var restify = require('restify') 5 | var P = require('../promise.js') 6 | 7 | var ops = [ 'head', 'get', 'post', 'put', 'del' ] 8 | 9 | module.exports = function createClient(cfg) { 10 | var client = restify.createJsonClient(cfg) 11 | 12 | // create a thenable version of each operation 13 | ops.forEach(function(name) { 14 | client[name + 'Then'] = function() { 15 | var p = P.defer() 16 | var args = Array.prototype.slice.call(arguments, 0) 17 | args.push(function(err, req, res, obj) { 18 | if (err) return p.reject(err) 19 | p.resolve({ req: req, res: res, obj: obj }) 20 | }) 21 | client[name].apply(this, args) 22 | return p.promise 23 | } 24 | }) 25 | 26 | return client 27 | } 28 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = require('rc')( 6 | 'fxa_db', 7 | { 8 | logLevel: 'trace', 9 | host: "127.0.0.1", 10 | port: 8000, 11 | patchKey: 'schema-patch-level', 12 | patchLevel: 2, 13 | master: { 14 | user: 'root', 15 | password: '', 16 | database: 'fxa', 17 | host: '127.0.0.1', 18 | port: 3306, 19 | connectionLimit: 10, 20 | waitForConnections: true, 21 | queueLimit: 100 22 | }, 23 | slave: { 24 | user: 'root', 25 | password: '', 26 | database: 'fxa', 27 | host: '127.0.0.1', 28 | port: 3306, 29 | connectionLimit: 10, 30 | waitForConnections: true, 31 | queueLimit: 100 32 | } 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxa-auth-db-mysql", 3 | "version": "0.0.0", 4 | "description": "MySQL backend for the fxa-auth-db-server", 5 | "main": "index.js", 6 | "repository": "mozilla/fxa-auth-db-mysql", 7 | "bin": { 8 | "fxa-auth-db-mysql": "db_patcher.js" 9 | }, 10 | "scripts": { 11 | "test": "node ./bin/db_patcher.js &>/dev/null && ./scripts/tap-coverage.js test/local test/remote" 12 | }, 13 | "author": "Mozilla (https://mozilla.org/)", 14 | "license": { 15 | "name": "MPL 2.0", 16 | "url": "https://raw.githubusercontent.com/mozilla/fxa-auth-db-mysql/master/LICENSE" 17 | }, 18 | "dependencies": { 19 | "bluebird": "2.1.3", 20 | "bunyan": "0.23.1", 21 | "fxa-auth-db-server": "git://github.com/dannycoates/fxa-auth-db-server.git#56b07758c2d", 22 | "mysql": "2.3.2", 23 | "rc": "0.4.0", 24 | "request": "2.36.0" 25 | }, 26 | "devDependencies": { 27 | "ass": "0.0.4", 28 | "restify": "2.8.1", 29 | "tap": "0.4.11", 30 | "uuid": "1.4.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/local/ping.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | var test = require('../ptaptest') 7 | var error = require('../../error') 8 | var config = require('../../config') 9 | var log = { trace: console.log, error: console.log } 10 | var DB = require('../../db/mysql')(log, error) 11 | 12 | DB.connect(config) 13 | .then( 14 | function (db) { 15 | 16 | test( 17 | 'ping', 18 | function (t) { 19 | t.plan(1); 20 | return db.ping() 21 | .then(function(account) { 22 | t.pass('Got the ping ok') 23 | }, function(err) { 24 | t.fail('Should not have arrived here') 25 | }) 26 | } 27 | ) 28 | 29 | test( 30 | 'teardown', 31 | function (t) { 32 | return db.close() 33 | } 34 | ) 35 | 36 | } 37 | ) 38 | -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var util = require('util') 6 | var Logger = require('bunyan') 7 | 8 | function Overdrive(options) { 9 | Logger.call(this, options) 10 | } 11 | util.inherits(Overdrive, Logger) 12 | 13 | Overdrive.prototype.stat = function (stats) { 14 | stats.op = 'stat' 15 | this.info(stats) 16 | } 17 | 18 | module.exports = function (level, name) { 19 | var logStreams = [{ stream: process.stderr, level: level }] 20 | name = name || 'db-api' 21 | 22 | var log = new Overdrive( 23 | { 24 | name: name, 25 | streams: logStreams 26 | } 27 | ) 28 | 29 | process.stdout.on( 30 | 'error', 31 | function (err) { 32 | if (err.code === 'EPIPE') { 33 | log.emit('error', err) 34 | } 35 | } 36 | ) 37 | 38 | Object.keys(console).forEach( 39 | function (key) { 40 | console[key] = function () { 41 | var json = { op: 'console', message: util.format.apply(null, arguments) } 42 | if(log[key]) { 43 | log[key](json) 44 | } 45 | else { 46 | log.warn(json) 47 | } 48 | } 49 | } 50 | ) 51 | 52 | return log 53 | } 54 | -------------------------------------------------------------------------------- /error.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var inherits = require('util').inherits 6 | 7 | function AppError(options) { 8 | this.message = options.message 9 | this.errno = options.errno 10 | this.error = options.error 11 | this.code = options.code 12 | if (options.stack) this.stack = options.stack 13 | } 14 | inherits(AppError, Error) 15 | 16 | AppError.prototype.toString = function () { 17 | return 'Error: ' + this.message 18 | } 19 | 20 | AppError.duplicate = function () { 21 | return new AppError( 22 | { 23 | code: 409, 24 | error: 'Conflict', 25 | errno: 101, 26 | message: 'Record already exists' 27 | } 28 | ) 29 | } 30 | 31 | AppError.notFound = function () { 32 | return new AppError( 33 | { 34 | code: 404, 35 | error: 'Not Found', 36 | errno: 116, 37 | message: 'Not Found' 38 | } 39 | ) 40 | } 41 | 42 | AppError.wrap = function (err) { 43 | return new AppError( 44 | { 45 | code: 500, 46 | error: 'Internal Server Error', 47 | errno: err.errno, 48 | message: err.code, 49 | stack: err.stack 50 | } 51 | ) 52 | } 53 | 54 | module.exports = AppError 55 | -------------------------------------------------------------------------------- /scripts/tap-coverage.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | if (!process.env.NO_COVERAGE) { 8 | var ass = require('ass').enable( { 9 | // exclude files in /client/ and /test/ from code coverage 10 | exclude: [ '/client/', '/test' ] 11 | }); 12 | } 13 | 14 | var path = require('path'), 15 | spawn = require('child_process').spawn, 16 | fs = require('fs'); 17 | 18 | var p = spawn(path.join(path.dirname(__dirname), 'node_modules', '.bin', 'tap'), 19 | process.argv.slice(2), { stdio: 'inherit' }); 20 | 21 | p.on('close', function(code) { 22 | if (!process.env.NO_COVERAGE) { 23 | ass.report('json', function(err, r) { 24 | console.log("code coverage:", r.percent + "%"); 25 | process.stdout.write("generating coverage.html: "); 26 | var start = new Date(); 27 | ass.report('html', function(err, html) { 28 | fs.writeFileSync(path.join(path.dirname(__dirname), 'coverage.html'), 29 | html); 30 | process.stdout.write("complete in " + 31 | ((new Date() - start) / 1000.0).toFixed(1) + "s\n"); 32 | process.exit(code); 33 | }); 34 | }); 35 | } else { 36 | process.exit(code); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /test/local/log-stats.js: -------------------------------------------------------------------------------- 1 | require('ass') 2 | var test = require('../ptaptest') 3 | var P = require('../../promise') 4 | var error = require('../../error') 5 | var config = require('../../config') 6 | 7 | config.logLevel = 'info' 8 | config.statInterval = 100 9 | 10 | var log = require('../../log')(config.logLevel, 'db-api') 11 | 12 | // monkeypatch log.stat to hook into db/mysql.js:statInterval 13 | var dfd = P.defer() 14 | log.stat = function(stats) { 15 | dfd.resolve(stats) 16 | } 17 | 18 | var DB = require('../../db/mysql')(log, error) 19 | 20 | DB.connect(config) 21 | .then( 22 | function (db) { 23 | 24 | test( 25 | 'db/mysql logs stats periodically', 26 | function (t) { 27 | t.plan(4); 28 | return dfd.promise 29 | .then( 30 | function(stats) { 31 | t.type(stats, 'object', 'stats is an object') 32 | t.equal(stats.stat, 'mysql', 'stats.stat is mysql') 33 | t.equal(stats.errors, 0, 'have no errors') 34 | t.equal(stats.connections, 1, 'have one connection') 35 | }, 36 | function(err) { 37 | t.fail('this should never happen ' + err) 38 | } 39 | ) 40 | } 41 | ) 42 | 43 | test( 44 | 'teardown', 45 | function () { 46 | return db.close() 47 | } 48 | ) 49 | } 50 | ) 51 | -------------------------------------------------------------------------------- /test/local/incorrect-patch-level.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | var P = require('../../promise') 7 | var test = require('tap').test 8 | var error = require('../../error') 9 | var config = require('../../config') 10 | var log = { trace: console.log, error: console.log } 11 | var DB = require('../../db/mysql')(log, error) 12 | 13 | config.patchLevel = 1000000 14 | 15 | DB.connect(config) 16 | .then( 17 | function (db) { 18 | test( 19 | 'the connect should fail and we will never get here', 20 | function (t) { 21 | t.fail('DB.connect should have failed on an incorrect patchVersion') 22 | t.end() 23 | db.close() 24 | } 25 | ) 26 | }, 27 | function(err) { 28 | test( 29 | 'an incorrect patchVersion should throw', 30 | function (t) { 31 | debugger 32 | t.type(err, 'object', 'err is an object') 33 | t.ok(err instanceof Error, 'err is instanceof Error') 34 | t.equals(err.message, 'dbIncorrectPatchLevel', 'err.message is dbIncorrectPatchLevel') 35 | t.end() 36 | // defer to allow node-tap to finish its work 37 | process.nextTick(process.exit) 38 | } 39 | ) 40 | } 41 | ) 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/test_server.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var cp = require('child_process') 5 | var request = require('request') 6 | 7 | function TestServer(config) { 8 | this.url = 'http://127.0.0.1:' + config.port 9 | this.server = null 10 | } 11 | 12 | function waitLoop(testServer, url, cb) { 13 | request( 14 | url + '/', 15 | function (err, res, body) { 16 | if (err) { 17 | if (err.errno !== 'ECONNREFUSED') { 18 | console.log('ERROR: unexpected result from ' + url) 19 | console.log(err) 20 | return cb(err) 21 | } 22 | return setTimeout(waitLoop.bind(null, testServer, url, cb), 100) 23 | } 24 | if (res.statusCode !== 200) { 25 | console.log('ERROR: bad status code: ' + res.statusCode) 26 | return cb(res.statusCode) 27 | } 28 | return cb() 29 | } 30 | ) 31 | } 32 | 33 | TestServer.prototype.start = function (cb) { 34 | if (!this.server) { 35 | this.server = cp.spawn( 36 | 'node', 37 | ['./db_server_stub'], 38 | { 39 | cwd: __dirname, 40 | stdio: 'ignore' 41 | } 42 | ) 43 | } 44 | 45 | waitLoop(this, this.url, function (err) { 46 | if (err) { 47 | cb(err) 48 | } else { 49 | cb(null) 50 | } 51 | }) 52 | } 53 | 54 | TestServer.prototype.stop = function () { 55 | if (this.server) { 56 | this.server.kill('SIGINT') 57 | } 58 | } 59 | 60 | module.exports = TestServer 61 | -------------------------------------------------------------------------------- /test/local/error.js: -------------------------------------------------------------------------------- 1 | require('ass') 2 | var test = require('../ptaptest') 3 | 4 | test( 5 | 'bufferize module', 6 | function (t) { 7 | t.plan(22); 8 | var error = require('../../error') 9 | t.type(error, 'function', 'error module returns a function') 10 | 11 | var duplicate = error.duplicate() 12 | t.type(duplicate, 'object', 'duplicate returns an object') 13 | t.ok(duplicate instanceof error, 'is an instance of error') 14 | t.equals(duplicate.code, 409) 15 | t.equals(duplicate.errno, 101) 16 | t.equals(duplicate.message, 'Record already exists') 17 | t.equals(duplicate.error, 'Conflict') 18 | t.equals(duplicate.toString(), 'Error: Record already exists') 19 | 20 | var notFound = error.notFound() 21 | t.type(notFound, 'object', 'notFound returns an object') 22 | t.ok(notFound instanceof error, 'is an instance of error') 23 | t.equals(notFound.code, 404) 24 | t.equals(notFound.errno, 116) 25 | t.equals(notFound.message, 'Not Found') 26 | t.equals(notFound.error, 'Not Found') 27 | t.equals(notFound.toString(), 'Error: Not Found') 28 | 29 | var err = new Error('Something broke.') 30 | err.code = 'ER_QUERY_INTERRUPTED' 31 | err.errno = 1317 32 | var wrap = error.wrap(err) 33 | t.type(wrap, 'object', 'wrap returns an object') 34 | t.ok(wrap instanceof error, 'is an instance of error') 35 | t.equals(wrap.code, 500) 36 | t.equals(wrap.errno, 1317) 37 | t.equals(wrap.message, 'ER_QUERY_INTERRUPTED') 38 | t.equals(wrap.error, 'Internal Server Error') 39 | t.equals(wrap.toString(), 'Error: ER_QUERY_INTERRUPTED') 40 | 41 | t.end() 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /test/remote/basic.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var test = require('tap').test 5 | var restify = require('restify') 6 | var TestServer = require('../test_server') 7 | var pkg = require('../../package.json') 8 | var config = require('../../config') 9 | var clientThen = require('../client-then') 10 | 11 | var cfg = { 12 | port: 8000 13 | } 14 | var testServer = new TestServer(cfg) 15 | var client = clientThen({ url : 'http://127.0.0.1:' + cfg.port }) 16 | 17 | test( 18 | 'startup', 19 | function (t) { 20 | testServer.start(function (err) { 21 | t.type(testServer.server, 'object', 'test server was started') 22 | t.equal(err, null, 'no errors were returned') 23 | t.end() 24 | }) 25 | } 26 | ) 27 | 28 | test( 29 | 'top level info', 30 | function (t) { 31 | client.getThen('/') 32 | .then(function(r) { 33 | t.equal(r.res.statusCode, 200, 'returns a 200') 34 | t.equal(r.obj.version, pkg.version, 'Version reported is the same a package.json') 35 | t.deepEqual(r.obj, { version : pkg.version }, 'Object contains no other fields') 36 | t.end() 37 | }) 38 | } 39 | ) 40 | 41 | test( 42 | 'heartbeat', 43 | function (t) { 44 | client.getThen('/__heartbeat__') 45 | .then(function (r) { 46 | t.deepEqual(r.obj, {}, 'Heartbeat contains an empty object and nothing unexpected') 47 | t.end() 48 | }) 49 | } 50 | ) 51 | 52 | test( 53 | 'teardown', 54 | function (t) { 55 | testServer.stop() 56 | t.equal(testServer.server.killed, true, 'test server has been killed') 57 | t.end() 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /test/ptaptest.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /* 6 | * A promise-ified version of tap.test. 7 | * 8 | * This module provides a 'test' function that operates just like tap.test, but 9 | * will properly close a promise if the test returns one. This makes it easier 10 | * to ensure that any unhandled errors cause the test to fail. Use like so: 11 | * 12 | * var test = require('./ptap') 13 | * 14 | * test( 15 | * 'an example test', 16 | * function (t) { 17 | * return someAPI.thingThatReturnsPromise() 18 | * .then(function(result) { 19 | * t.assertEqual(result, 42) 20 | * }) 21 | * } 22 | * ) 23 | * 24 | * Because the test function returns a promise, we get the following for free: 25 | * 26 | * * wait for the promise to resolve, and call t.end() when it does 27 | * * check for unhandled errors and fail the test if they occur 28 | * 29 | */ 30 | 31 | // support code coverage 32 | require('ass'); 33 | 34 | var tap = require('tap') 35 | 36 | module.exports = function(name, testfunc) { 37 | var wrappedtestfunc = function(t) { 38 | var res = testfunc(t) 39 | if (typeof res !== 'undefined') { 40 | if (typeof res.done === 'function') { 41 | res.done( 42 | function() { 43 | t.end() 44 | }, 45 | function(err) { 46 | t.fail(err.message || err.error || err) 47 | t.end() 48 | } 49 | ) 50 | } 51 | } 52 | } 53 | return tap.test(name, wrappedtestfunc) 54 | } 55 | -------------------------------------------------------------------------------- /test/fake.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var crypto = require('crypto') 5 | var uuid = require('uuid') 6 | 7 | function hex16() { return crypto.randomBytes(16).toString('hex') } 8 | function hex32() { return crypto.randomBytes(32).toString('hex') } 9 | function hex64() { return crypto.randomBytes(64).toString('hex') } 10 | function hex96() { return crypto.randomBytes(96).toString('hex') } 11 | 12 | module.exports.newUserDataHex = function() { 13 | var data = {} 14 | 15 | // account 16 | data.accountId = hex16() 17 | data.account = { 18 | email: hex16() + '@example.com', 19 | emailCode: hex16(), 20 | emailVerified: false, 21 | verifierVersion: 1, 22 | verifyHash: hex32(), 23 | authSalt: hex32(), 24 | kA: hex32(), 25 | wrapWrapKb: hex32(), 26 | verifierSetAt: Date.now(), 27 | } 28 | 29 | // sessionToken 30 | data.sessionTokenId = hex32() 31 | data.sessionToken = { 32 | data : hex32(), 33 | uid : data.accountId, 34 | createdAt: Date.now(), 35 | } 36 | 37 | // keyFetchToken 38 | data.keyFetchTokenId = hex32() 39 | data.keyFetchToken = { 40 | authKey : hex32(), 41 | uid : data.accountId, 42 | keyBundle : hex96(), 43 | createdAt: Date.now(), 44 | } 45 | 46 | // accountResetToken 47 | data.accountResetTokenId = hex32() 48 | data.accountResetToken = { 49 | data : hex32(), 50 | uid : data.accountId, 51 | createdAt: Date.now(), 52 | } 53 | 54 | // passwordChangeToken 55 | data.passwordChangeTokenId = hex32() 56 | data.passwordChangeToken = { 57 | data : hex32(), 58 | uid : data.accountId, 59 | createdAt: Date.now(), 60 | } 61 | 62 | // passwordForgotToken 63 | data.passwordForgotTokenId = hex32() 64 | data.passwordForgotToken = { 65 | data : hex32(), 66 | uid : data.accountId, 67 | passCode : hex16(), 68 | tries : 1, 69 | createdAt: Date.now(), 70 | } 71 | 72 | return data 73 | } 74 | -------------------------------------------------------------------------------- /db/schema/patch-001-002.sql: -------------------------------------------------------------------------------- 1 | -- create all tables 2 | 3 | CREATE TABLE IF NOT EXISTS accounts ( 4 | uid BINARY(16) PRIMARY KEY, 5 | normalizedEmail VARCHAR(255) NOT NULL UNIQUE KEY, 6 | email VARCHAR(255) NOT NULL, 7 | emailCode BINARY(16) NOT NULL, 8 | emailVerified BOOLEAN NOT NULL DEFAULT FALSE, 9 | kA BINARY(32) NOT NULL, 10 | wrapWrapKb BINARY(32) NOT NULL, 11 | authSalt BINARY(32) NOT NULL, 12 | verifyHash BINARY(32) NOT NULL, 13 | verifierVersion TINYINT UNSIGNED NOT NULL, 14 | verifierSetAt BIGINT UNSIGNED NOT NULL, 15 | createdAt BIGINT UNSIGNED NOT NULL 16 | ) ENGINE=InnoDB; 17 | 18 | CREATE TABLE IF NOT EXISTS sessionTokens ( 19 | tokenId BINARY(32) PRIMARY KEY, 20 | tokenData BINARY(32) NOT NULL, 21 | uid BINARY(16) NOT NULL, 22 | createdAt BIGINT UNSIGNED NOT NULL, 23 | INDEX session_uid (uid) 24 | ) ENGINE=InnoDB; 25 | 26 | CREATE TABLE IF NOT EXISTS keyFetchTokens ( 27 | tokenId BINARY(32) PRIMARY KEY, 28 | authKey BINARY(32) NOT NULL, 29 | uid BINARY(16) NOT NULL, 30 | keyBundle BINARY(96) NOT NULL, 31 | createdAt BIGINT UNSIGNED NOT NULL, 32 | INDEX key_uid (uid) 33 | ) ENGINE=InnoDB; 34 | 35 | CREATE TABLE IF NOT EXISTS accountResetTokens ( 36 | tokenId BINARY(32) PRIMARY KEY, 37 | tokenData BINARY(32) NOT NULL, 38 | uid BINARY(16) NOT NULL UNIQUE KEY, 39 | createdAt BIGINT UNSIGNED NOT NULL 40 | ) ENGINE=InnoDB; 41 | 42 | CREATE TABLE IF NOT EXISTS passwordForgotTokens ( 43 | tokenId BINARY(32) PRIMARY KEY, 44 | tokenData BINARY(32) NOT NULL, 45 | uid BINARY(16) NOT NULL UNIQUE KEY, 46 | passCode BINARY(16) NOT NULL, 47 | createdAt BIGINT UNSIGNED NOT NULL, 48 | tries SMALLINT UNSIGNED NOT NULL 49 | ) ENGINE=InnoDB; 50 | 51 | CREATE TABLE IF NOT EXISTS passwordChangeTokens ( 52 | tokenId BINARY(32) PRIMARY KEY, 53 | tokenData BINARY(32) NOT NULL, 54 | uid BINARY(16) NOT NULL, 55 | createdAt BIGINT UNSIGNED NOT NULL, 56 | INDEX session_uid (uid) 57 | ) ENGINE=InnoDB; 58 | 59 | UPDATE dbMetadata SET value = '2' WHERE name = 'schema-patch-level'; 60 | -------------------------------------------------------------------------------- /test/local/mysql_tests.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | var P = require('../../promise') 7 | var test = require('../ptaptest') 8 | var error = require('../../error') 9 | var config = require('../../config') 10 | var log = { trace: console.log, error: console.log } 11 | var DB = require('../../db/mysql')(log, error) 12 | 13 | DB.connect(config) 14 | .then( 15 | function (db) { 16 | 17 | test( 18 | 'ping', 19 | function (t) { 20 | t.plan(1); 21 | return db.ping() 22 | .then(function(account) { 23 | t.pass('Got the ping ok') 24 | }, function(err) { 25 | t.fail('Should not have arrived here') 26 | }) 27 | } 28 | ) 29 | 30 | test( 31 | 'a select on an unknown table should result in an error', 32 | function (t) { 33 | var query = 'SELECT mumble as id FROM mumble.mumble WHERE mumble = ?' 34 | var param = 'mumble' 35 | db.read(query, param) 36 | .then( 37 | function(result) { 38 | t.plan(1) 39 | t.fail('Should not have arrived here for an invalid select') 40 | }, 41 | function(err) { 42 | t.plan(5) 43 | t.ok(err, 'we have an error') 44 | t.equal(err.code, 500) 45 | t.equal(err.errno, 1146) 46 | t.equal(err.error, 'Internal Server Error') 47 | t.equal(err.message, 'ER_NO_SUCH_TABLE') 48 | } 49 | ) 50 | } 51 | ) 52 | 53 | test( 54 | 'an update to an unknown table should result in an error', 55 | function (t) { 56 | var query = 'UPDATE mumble.mumble SET mumble = ?' 57 | var param = 'mumble' 58 | 59 | db.write(query, param) 60 | .then( 61 | function(result) { 62 | t.plan(1) 63 | t.fail('Should not have arrived here for an invalid update') 64 | }, 65 | function(err) { 66 | t.plan(5) 67 | t.ok(err, 'we have an error') 68 | t.equal(err.code, 500) 69 | t.equal(err.errno, 1146) 70 | t.equal(err.error, 'Internal Server Error') 71 | t.equal(err.message, 'ER_NO_SUCH_TABLE') 72 | } 73 | ) 74 | } 75 | ) 76 | 77 | test( 78 | 'an transaction to update an unknown table should result in an error', 79 | function (t) { 80 | var sql = 'UPDATE mumble.mumble SET mumble = ?' 81 | var param = 'mumble' 82 | 83 | function query(connection, sql, params) { 84 | var d = P.defer() 85 | connection.query( 86 | sql, 87 | params || [], 88 | function (err, results) { 89 | if (err) { return d.reject(err) } 90 | d.resolve(results) 91 | } 92 | ) 93 | return d.promise 94 | } 95 | 96 | db.transaction( 97 | function (connection) { 98 | return query(connection, sql, param) 99 | }) 100 | .then( 101 | function(result) { 102 | t.plan(1) 103 | t.fail('Should not have arrived here for an invalid update') 104 | }, 105 | function(err) { 106 | t.plan(5) 107 | t.ok(err, 'we have an error') 108 | t.equal(err.code, 500) 109 | t.equal(err.errno, 1146) 110 | t.equal(err.error, 'Internal Server Error') 111 | t.equal(err.message, 'ER_NO_SUCH_TABLE') 112 | } 113 | ) 114 | } 115 | ) 116 | 117 | test( 118 | 'retryable does retry when the errno is matched', 119 | function (t) { 120 | var query = 'UPDATE mumble.mumble SET mumble = ?' 121 | var param = 'mumble' 122 | 123 | var callCount = 0 124 | 125 | var writer = function() { 126 | ++callCount 127 | return db.write(query, param) 128 | .then( 129 | function(result) { 130 | t.fail('this query should never succeed!') 131 | }, 132 | function(err) { 133 | t.ok(true, 'we got an error') 134 | t.equal(err.code, 500) 135 | t.equal(err.errno, 1146) 136 | t.equal(err.error, 'Internal Server Error') 137 | t.equal(err.message, 'ER_NO_SUCH_TABLE') 138 | throw err 139 | } 140 | ) 141 | } 142 | 143 | db.retryable_(writer, [ 1146 ]) 144 | .then( 145 | function(result) { 146 | t.fail('This should never happen, even with a retry ' + callCount) 147 | t.end() 148 | }, 149 | function(err) { 150 | t.equal(callCount, 2, 'the function was retried') 151 | t.end() 152 | } 153 | ) 154 | } 155 | ) 156 | 157 | test( 158 | 'teardown', 159 | function (t) { 160 | return db.close() 161 | } 162 | ) 163 | 164 | } 165 | ) 166 | -------------------------------------------------------------------------------- /bin/db_patcher.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var path = require('path') 6 | var fs = require('fs') 7 | var mysql = require('mysql') 8 | var P = require('../promise.js') 9 | var options = require('../config') 10 | var log = require('../log')(options.logLevel, 'db-patcher') 11 | 12 | var schemaDir = path.join(__dirname, '..', 'db', 'schema') 13 | var patches = {} 14 | var files = fs.readdirSync(schemaDir) 15 | files.forEach(function(filename) { 16 | var from, to 17 | var m = filename.match(/^patch-(\d+)-(\d+)\.sql$/) 18 | if (m) { 19 | from = parseInt(m[1], 10) 20 | to = parseInt(m[2], 10) 21 | patches[from] = patches[from] || {} 22 | patches[from][to] = fs.readFileSync(path.join(schemaDir, filename), { encoding: 'utf8'}) 23 | } 24 | else { 25 | console.warn("Startup error: Unknown file in schema/ directory - '%s'", filename) 26 | process.exit(2) 27 | } 28 | }) 29 | 30 | // To run any patches we need to switch multipleStatements on 31 | options.master.multipleStatements = true 32 | 33 | // when creating the database, we need to connect without a database name 34 | var database = options.master.database 35 | delete options.master.database 36 | 37 | var client = mysql.createConnection(options.master) 38 | 39 | createDatabase() 40 | .then(changeUser) 41 | .then(checkDbMetadataExists) 42 | .then(readDbPatchLevel) 43 | .then(patchToRequiredLevel) 44 | .then(closeAndReconnect) 45 | .done( 46 | function() { 47 | log.info('Patching complete') 48 | }, 49 | function(err) { 50 | log.fatal(err) 51 | process.exit(2) 52 | } 53 | ) 54 | 55 | // helper functions 56 | function createDatabase() { 57 | var d = P.defer() 58 | log.trace( { op: 'MySql.createSchema:CreateDatabase' } ) 59 | client.query( 60 | 'CREATE DATABASE IF NOT EXISTS ' + database + ' CHARACTER SET utf8 COLLATE utf8_unicode_ci', 61 | function (err) { 62 | if (err) { 63 | log.error({ op: 'MySql.createSchema:CreateDatabase', err: err.message }) 64 | return d.reject(err) 65 | } 66 | d.resolve() 67 | } 68 | ) 69 | return d.promise 70 | } 71 | 72 | function changeUser() { 73 | var d = P.defer() 74 | log.trace( { op: 'MySql.createSchema:ChangeUser' } ) 75 | client.changeUser( 76 | { 77 | user : options.master.user, 78 | password : options.master.password, 79 | database : database 80 | }, 81 | function (err) { 82 | if (err) { 83 | log.error({ op: 'MySql.createSchema:ChangeUser', err: err.message }) 84 | return d.reject(err) 85 | } 86 | d.resolve() 87 | } 88 | ) 89 | return d.promise 90 | } 91 | 92 | function checkDbMetadataExists() { 93 | log.trace( { op: 'MySql.createSchema:CheckDbMetadataExists' } ) 94 | var d = P.defer() 95 | var query = "SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE table_schema = ? AND table_name = 'dbMetadata'" 96 | client.query( 97 | query, 98 | [ database ], 99 | function (err, result) { 100 | if (err) { 101 | log.trace( { op: 'MySql.createSchema:MakingTheSchema', err: err.message } ) 102 | return d.reject(err) 103 | } 104 | d.resolve(result[0].count === 0 ? false : true) 105 | } 106 | ) 107 | return d.promise 108 | } 109 | 110 | function readDbPatchLevel(dbMetadataExists) { 111 | log.trace( { op: 'MySql.createSchema:ReadDbPatchLevel' } ) 112 | if ( dbMetadataExists === false ) { 113 | // the table doesn't exist, so start at patch level 0 114 | return P.resolve(0) 115 | } 116 | 117 | // find out what patch level the database is currently at 118 | var d = P.defer() 119 | var query = "SELECT value FROM dbMetadata WHERE name = ?" 120 | client.query( 121 | query, 122 | [ options.patchKey ], 123 | function(err, result) { 124 | if (err) { 125 | log.trace( { op: 'MySql.createSchema:ReadDbPatchLevel', err: err.message } ) 126 | return d.reject(err) 127 | } 128 | // convert the patch level from a string to a number 129 | return d.resolve(+result[0].value) 130 | } 131 | ) 132 | return d.promise 133 | } 134 | 135 | function patchToRequiredLevel(currentPatchLevel) { 136 | log.trace( { op: 'MySql.createSchema:PatchToRequiredLevel' } ) 137 | 138 | // if we don't need any patches 139 | if ( options.patchLevel === currentPatchLevel ) { 140 | log.trace( { op: 'MySql.createSchema:PatchToRequiredLevel', patch: 'No patch required' } ) 141 | return P.resolve() 142 | } 143 | 144 | // We don't want any reverse patches to be automatically applied, so 145 | // just emit a warning and carry on. 146 | if ( options.patchLevel < currentPatchLevel ) { 147 | log.warn( { op: 'MySql.createSchema:PatchToRequiredLevel', err: 'Reverse patch required - must be done manually' } ) 148 | return P.resolve() 149 | } 150 | 151 | log.trace({ 152 | op: 'MySql.createSchema:PatchToRequiredLevel', 153 | msg1: 'Patching from ' + currentPatchLevel + ' to ' + options.patchLevel 154 | }) 155 | 156 | var promise = P.resolve() 157 | var patchesToApply = [] 158 | 159 | // First, loop through all the patches we need to apply 160 | // to make sure they exist. 161 | while ( currentPatchLevel < options.patchLevel ) { 162 | // check that this patch exists 163 | if ( !patches[currentPatchLevel][currentPatchLevel+1] ) { 164 | log.fatal({ 165 | op: 'MySql.createSchema:PatchToRequiredLevel', 166 | err: 'Patch from level ' + currentPatchLevel + ' to ' + (currentPatchLevel+1) + ' does not exist' 167 | }); 168 | process.exit(2) 169 | } 170 | patchesToApply.push({ 171 | sql : patches[currentPatchLevel][currentPatchLevel+1], 172 | from : currentPatchLevel, 173 | to : currentPatchLevel+1, 174 | }) 175 | currentPatchLevel += 1 176 | } 177 | 178 | // now apply each patch 179 | patchesToApply.forEach(function(patch) { 180 | promise = promise.then(function() { 181 | var d = P.defer() 182 | log.trace({ op: 'MySql.createSchema:PatchToRequiredLevel', msg1: 'Updating DB for patch ' + patch.from + ' to ' + patch.to }) 183 | client.query( 184 | patch.sql, 185 | function(err) { 186 | if (err) return d.reject(err) 187 | d.resolve() 188 | } 189 | ) 190 | return d.promise 191 | }) 192 | }) 193 | 194 | return promise 195 | } 196 | 197 | function closeAndReconnect() { 198 | var d = P.defer() 199 | log.trace( { op: 'MySql.createSchema:CloseAndReconnect' } ) 200 | client.end( 201 | function (err) { 202 | if (err) { 203 | log.error({ op: 'MySql.createSchema:Closed', err: err.message }) 204 | return d.reject(err) 205 | } 206 | 207 | // create the mysql class 208 | log.trace( { op: 'MySql.createSchema:ResolvingWithNewClient' } ) 209 | d.resolve('ok') 210 | } 211 | ) 212 | return d.promise 213 | } 214 | -------------------------------------------------------------------------------- /test/remote/account.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | var uuid = require('uuid') 5 | var restify = require('restify') 6 | var test = require('tap').test 7 | 8 | var fake = require('../fake') 9 | var TestServer = require('../test_server') 10 | var config = require('../../config') 11 | var clientThen = require('../client-then') 12 | 13 | function emailToHex(email) { 14 | return Buffer(email).toString('hex') 15 | } 16 | 17 | var cfg = { 18 | port: 8000 19 | } 20 | var testServer = new TestServer(cfg) 21 | var client = clientThen({ url : 'http://127.0.0.1:' + cfg.port }) 22 | 23 | test( 24 | 'startup', 25 | function (t) { 26 | t.plan(2) 27 | testServer.start(function (err) { 28 | t.type(testServer.server, 'object', 'test server was started') 29 | t.equal(err, null, 'no errors were returned') 30 | t.end() 31 | }) 32 | } 33 | ) 34 | 35 | function respOk(t, r) { 36 | t.equal(r.res.statusCode, 200, 'returns a 200') 37 | t.equal(r.res.headers['content-type'], 'application/json', 'json is returned') 38 | } 39 | 40 | function respOkEmpty(t, r) { 41 | t.equal(r.res.statusCode, 200, 'returns a 200') 42 | t.equal(r.res.headers['content-type'], 'application/json', 'json is returned') 43 | t.deepEqual(r.obj, {}, 'Returned object is empty') 44 | } 45 | 46 | function testNotFound(t, err) { 47 | t.equal(err.statusCode, 404, 'returns a 404') 48 | t.deepEqual(err.body, { message : 'Not Found' }, 'Object contains no other fields') 49 | } 50 | 51 | test( 52 | 'account not found', 53 | function (t) { 54 | t.plan(2) 55 | client.getThen('/account/hello-world') 56 | .then(function(r) { 57 | t.fail('This request should have failed (instead it suceeded)') 58 | t.end() 59 | }, function(err) { 60 | testNotFound(t, err) 61 | t.end() 62 | }) 63 | } 64 | ) 65 | 66 | test( 67 | 'add account, retrieve it, delete it', 68 | function (t) { 69 | t.plan(31) 70 | var user = fake.newUserDataHex() 71 | client.putThen('/account/' + user.accountId, user.account) 72 | .then(function(r) { 73 | respOkEmpty(t, r) 74 | return client.getThen('/account/' + user.accountId) 75 | }) 76 | .then(function(r) { 77 | respOk(t, r) 78 | 79 | var account = r.obj 80 | var fields = 'accountId,email,emailCode,kA,verifierVersion,verifyHash,authSalt'.split(',') 81 | fields.forEach(function(f) { 82 | t.equal(user.account[f], account[f], 'Both Fields ' + f + ' are the same') 83 | }) 84 | t.equal(user.account.emailVerified, !!account.emailVerified, 'Both fields emailVerified are the same') 85 | }, function(err) { 86 | t.fail('Error for some reason:' + err) 87 | }) 88 | .then(function() { 89 | return client.headThen('/emailRecord/' + emailToHex(user.account.email)) 90 | }) 91 | .then(function(r) { 92 | respOkEmpty(t, r) 93 | return client.getThen('/emailRecord/' + emailToHex(user.account.email)) 94 | }) 95 | .then(function(r) { 96 | respOk(t, r) 97 | var account = r.obj 98 | var fields = 'accountId,email,emailCode,kA,verifierVersion,verifyHash,authSalt'.split(',') 99 | fields.forEach(function(f) { 100 | t.equal(user.account[f], account[f], 'Both Fields ' + f + ' are the same') 101 | }) 102 | t.equal(user.account.emailVerified, !!account.emailVerified, 'Both fields emailVerified are the same') 103 | }) 104 | .then(function() { 105 | return client.delThen('/account/' + user.accountId) 106 | }) 107 | .then(function(r) { 108 | respOk(t, r) 109 | // now make sure this record no longer exists 110 | return client.headThen('/emailRecord/' + emailToHex(user.account.email)) 111 | }) 112 | .then(function(r) { 113 | t.fail('Should not be here, since this account no longer exists') 114 | }, function(err) { 115 | t.equal(err.toString(), 'NotFoundError', 'Account not found (no body due to being a HEAD request') 116 | t.deepEqual(err.body, {}, 'Body contains nothing since this is a HEAD request') 117 | t.deepEqual(err.statusCode, 404, 'Status Code is 404') 118 | }) 119 | .done(function() { 120 | t.end() 121 | }, function(err) { 122 | t.fail(err) 123 | t.end() 124 | }) 125 | } 126 | ) 127 | 128 | test( 129 | 'session token handling', 130 | function (t) { 131 | t.plan(14) 132 | var user = fake.newUserDataHex() 133 | client.putThen('/account/' + user.accountId, user.account) 134 | .then(function() { 135 | return client.getThen('/sessionToken/' + user.sessionTokenId) 136 | }) 137 | .then(function(r) { 138 | t.fail('A non-existant session token should not have returned anything') 139 | }, function(err) { 140 | t.pass('No session token exists yet') 141 | return client.putThen('/sessionToken/' + user.sessionTokenId, user.sessionToken) 142 | }) 143 | .then(function(r) { 144 | respOk(t, r) 145 | return client.getThen('/sessionToken/' + user.sessionTokenId) 146 | }) 147 | .then(function(r) { 148 | var token = r.obj 149 | 150 | // tokenId is not returned from db.sessionToken() 151 | t.deepEqual(token.tokenData, user.sessionToken.data, 'token data matches') 152 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 153 | t.ok(token.createdAt, 'Got a createdAt') 154 | t.equal(!!token.emailVerified, user.account.emailVerified) 155 | t.equal(token.email, user.account.email) 156 | t.deepEqual(token.emailCode, user.account.emailCode) 157 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 158 | 159 | // now delete it 160 | return client.delThen('/sessionToken/' + user.sessionTokenId) 161 | }) 162 | .then(function(r) { 163 | respOk(t, r) 164 | // now make sure the token no longer exists 165 | return client.getThen('/sessionToken/' + user.sessionTokenId) 166 | }) 167 | .then(function(r) { 168 | t.fail('Fetching the non-existant sessionToken should have failed') 169 | t.end() 170 | }, function(err) { 171 | testNotFound(t, err) 172 | t.end() 173 | }) 174 | } 175 | ) 176 | 177 | test( 178 | 'key fetch token handling', 179 | function (t) { 180 | t.plan(13) 181 | var user = fake.newUserDataHex() 182 | client.putThen('/account/' + user.accountId, user.account) 183 | .then(function() { 184 | return client.getThen('/keyFetchToken/' + user.keyFetchTokenId) 185 | }) 186 | .then(function(r) { 187 | t.fail('A non-existant session token should not have returned anything') 188 | }, function(err) { 189 | t.pass('No session token exists yet') 190 | return client.putThen('/keyFetchToken/' + user.keyFetchTokenId, user.keyFetchToken) 191 | }) 192 | .then(function(r) { 193 | respOk(t, r) 194 | return client.getThen('/keyFetchToken/' + user.keyFetchTokenId) 195 | }) 196 | .then(function(r) { 197 | var token = r.obj 198 | 199 | // tokenId is not returned from db.keyFetchToken() 200 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 201 | t.deepEqual(token.authKey, user.keyFetchToken.authKey, 'authKey matches') 202 | t.deepEqual(token.keyBundle, user.keyFetchToken.keyBundle, 'keyBundle matches') 203 | t.ok(token.createdAt, 'Got a createdAt') 204 | t.equal(!!token.emailVerified, user.account.emailVerified) 205 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 206 | 207 | // now delete it 208 | return client.delThen('/keyFetchToken/' + user.keyFetchTokenId) 209 | }) 210 | .then(function(r) { 211 | respOk(t, r) 212 | // now make sure the token no longer exists 213 | return client.getThen('/keyFetchToken/' + user.keyFetchTokenId) 214 | }) 215 | .then(function(r) { 216 | t.fail('Fetching the non-existant keyFetchToken should have failed') 217 | t.end() 218 | }, function(err) { 219 | testNotFound(t, err) 220 | t.end() 221 | }) 222 | } 223 | ) 224 | 225 | test( 226 | 'account reset token handling', 227 | function (t) { 228 | t.plan(11) 229 | var user = fake.newUserDataHex() 230 | client.putThen('/account/' + user.accountId, user.account) 231 | .then(function() { 232 | return client.getThen('/accountResetToken/' + user.accountResetTokenId) 233 | }) 234 | .then(function(r) { 235 | t.fail('A non-existant session token should not have returned anything') 236 | }, function(err) { 237 | t.pass('No session token exists yet') 238 | return client.putThen('/accountResetToken/' + user.accountResetTokenId, user.accountResetToken) 239 | }) 240 | .then(function(r) { 241 | respOk(t, r) 242 | return client.getThen('/accountResetToken/' + user.accountResetTokenId) 243 | }) 244 | .then(function(r) { 245 | var token = r.obj 246 | 247 | // tokenId is not returned from db.accountResetToken() 248 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 249 | t.deepEqual(token.tokenData, user.accountResetToken.data, 'token data matches') 250 | t.ok(token.createdAt, 'Got a createdAt') 251 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 252 | 253 | // now delete it 254 | return client.delThen('/accountResetToken/' + user.accountResetTokenId) 255 | }) 256 | .then(function(r) { 257 | respOk(t, r) 258 | // now make sure the token no longer exists 259 | return client.getThen('/accountResetToken/' + user.accountResetTokenId) 260 | }) 261 | .then(function(r) { 262 | t.fail('Fetching the non-existant accountResetToken should have failed') 263 | t.end() 264 | }, function(err) { 265 | testNotFound(t, err) 266 | t.end() 267 | }) 268 | } 269 | ) 270 | 271 | test( 272 | 'password change token handling', 273 | function (t) { 274 | t.plan(11) 275 | var user = fake.newUserDataHex() 276 | client.putThen('/account/' + user.accountId, user.account) 277 | .then(function() { 278 | return client.getThen('/passwordChangeToken/' + user.passwordChangeTokenId) 279 | }) 280 | .then(function(r) { 281 | t.fail('A non-existant session token should not have returned anything') 282 | }, function(err) { 283 | t.pass('No session token exists yet') 284 | return client.putThen('/passwordChangeToken/' + user.passwordChangeTokenId, user.passwordChangeToken) 285 | }) 286 | .then(function(r) { 287 | respOk(t, r) 288 | return client.getThen('/passwordChangeToken/' + user.passwordChangeTokenId) 289 | }) 290 | .then(function(r) { 291 | var token = r.obj 292 | 293 | // tokenId is not returned from db.passwordChangeToken() 294 | t.deepEqual(token.tokenData, user.passwordChangeToken.data, 'token data matches') 295 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 296 | t.ok(token.createdAt, 'Got a createdAt') 297 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 298 | 299 | // now delete it 300 | return client.delThen('/passwordChangeToken/' + user.passwordChangeTokenId) 301 | }) 302 | .then(function(r) { 303 | respOk(t, r) 304 | // now make sure the token no longer exists 305 | return client.getThen('/passwordChangeToken/' + user.passwordChangeTokenId) 306 | }) 307 | .then(function(r) { 308 | t.fail('Fetching the non-existant passwordChangeToken should have failed') 309 | t.end() 310 | }, function(err) { 311 | testNotFound(t, err) 312 | t.end() 313 | }) 314 | } 315 | ) 316 | 317 | test( 318 | 'password forgot token handling', 319 | function (t) { 320 | t.plan(19) 321 | var user = fake.newUserDataHex() 322 | client.putThen('/account/' + user.accountId, user.account) 323 | .then(function() { 324 | return client.getThen('/passwordForgotToken/' + user.passwordForgotTokenId) 325 | }) 326 | .then(function(r) { 327 | t.fail('A non-existant session token should not have returned anything') 328 | }, function(err) { 329 | t.pass('No session token exists yet') 330 | return client.putThen('/passwordForgotToken/' + user.passwordForgotTokenId, user.passwordForgotToken) 331 | }) 332 | .then(function(r) { 333 | respOk(t, r) 334 | return client.getThen('/passwordForgotToken/' + user.passwordForgotTokenId) 335 | }) 336 | .then(function(r) { 337 | var token = r.obj 338 | 339 | // tokenId is not returned from db.passwordForgotToken() 340 | t.deepEqual(token.tokenData, user.passwordForgotToken.data, 'token data matches') 341 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 342 | t.ok(token.createdAt, 'Got a createdAt') 343 | t.deepEqual(token.passCode, user.passwordForgotToken.passCode) 344 | t.equal(token.tries, user.passwordForgotToken.tries, 'Tries is correct') 345 | t.equal(token.email, user.account.email) 346 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 347 | 348 | // now update this token (with extra tries) 349 | user.passwordForgotToken.tries += 1 350 | return client.postThen('/passwordForgotToken/' + user.passwordForgotTokenId + '/update', user.passwordForgotToken) 351 | }) 352 | .then(function(r) { 353 | respOk(t, r) 354 | 355 | // re-fetch this token 356 | return client.getThen('/passwordForgotToken/' + user.passwordForgotTokenId) 357 | }) 358 | .then(function(r) { 359 | var token = r.obj 360 | 361 | // tokenId is not returned from db.passwordForgotToken() 362 | t.deepEqual(token.tokenData, user.passwordForgotToken.data, 'token data matches') 363 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 364 | t.ok(token.createdAt, 'Got a createdAt') 365 | t.deepEqual(token.passCode, user.passwordForgotToken.passCode) 366 | t.equal(token.tries, user.passwordForgotToken.tries, 'Tries is correct (now incremented)') 367 | t.equal(token.email, user.account.email) 368 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 369 | 370 | // now delete it 371 | return client.delThen('/passwordForgotToken/' + user.passwordForgotTokenId) 372 | }) 373 | .then(function(r) { 374 | respOk(t, r) 375 | // now make sure the token no longer exists 376 | return client.getThen('/passwordForgotToken/' + user.passwordForgotTokenId) 377 | }) 378 | .then(function(r) { 379 | t.fail('Fetching the non-existant passwordForgotToken should have failed') 380 | t.end() 381 | }, function(err) { 382 | testNotFound(t, err) 383 | t.end() 384 | }) 385 | } 386 | ) 387 | 388 | test( 389 | 'password forgot token verified', 390 | function (t) { 391 | t.plan(16) 392 | var user = fake.newUserDataHex() 393 | client.putThen('/account/' + user.accountId, user.account) 394 | .then(function(r) { 395 | respOk(t, r) 396 | return client.putThen('/passwordForgotToken/' + user.passwordForgotTokenId, user.passwordForgotToken) 397 | }) 398 | .then(function(r) { 399 | respOk(t, r) 400 | // now, verify the password (which inserts the accountResetToken) 401 | user.accountResetToken.tokenId = user.accountResetTokenId 402 | return client.postThen('/passwordForgotToken/' + user.passwordForgotTokenId + '/verified', user.accountResetToken) 403 | }) 404 | .then(function(r) { 405 | respOk(t, r) 406 | // check the accountResetToken exists 407 | return client.getThen('/accountResetToken/' + user.accountResetTokenId) 408 | }) 409 | .then(function(r) { 410 | var token = r.obj 411 | 412 | // tokenId is not returned from db.accountResetToken() 413 | t.deepEqual(token.uid, user.accountId, 'token belongs to this account') 414 | t.deepEqual(token.tokenData, user.accountResetToken.data, 'token data matches') 415 | t.ok(token.createdAt, 'Got a createdAt') 416 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 417 | 418 | // make sure then passwordForgotToken no longer exists 419 | return client.getThen('/passwordForgotToken/' + user.passwordForgotTokenId) 420 | }) 421 | .then(function(r) { 422 | t.fail('Fetching the non-existant passwordForgotToken should have failed') 423 | }, function(err) { 424 | testNotFound(t, err) 425 | // and check that the account has been verified 426 | return client.getThen('/emailRecord/' + emailToHex(user.account.email)) 427 | }) 428 | .then(function(r) { 429 | respOk(t, r) 430 | var account = r.obj 431 | t.equal(true, !!account.emailVerified, 'emailVerified is now true') 432 | }) 433 | .then(function(r) { 434 | t.pass('All password forgot token verified tests passed') 435 | t.end() 436 | }, function(err) { 437 | t.fail(err) 438 | t.end() 439 | }) 440 | } 441 | ) 442 | 443 | test( 444 | 'teardown', 445 | function (t) { 446 | t.plan(1) 447 | testServer.stop() 448 | t.equal(testServer.server.killed, true, 'test server has been killed') 449 | t.end() 450 | } 451 | ) 452 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /db/mysql.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var mysql = require('mysql') 6 | var P = require('../promise') 7 | 8 | module.exports = function (log, error) { 9 | 10 | // http://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html 11 | var LOCK_ERRNOS = [ 1205, 1206, 1213, 1689 ] 12 | 13 | // make a pool of connections that we can draw from 14 | function MySql(options) { 15 | 16 | this.patchLevel = 0 17 | // poolCluster will remove the pool after `removeNodeErrorCount` errors. 18 | // We don't ever want to remove a pool because we only have one pool 19 | // for writing and reading each. Connection errors are mostly out of our 20 | // control for automatic recovery so monitoring of 503s is critical. 21 | // Since `removeNodeErrorCount` is Infinity `canRetry` must be false 22 | // to prevent inifinite retry attempts. 23 | this.poolCluster = mysql.createPoolCluster( 24 | { 25 | removeNodeErrorCount: Infinity, 26 | canRetry: false 27 | } 28 | ) 29 | 30 | // Use separate pools for master and slave connections. 31 | this.poolCluster.add('MASTER', options.master) 32 | this.poolCluster.add('SLAVE', options.slave) 33 | this.getClusterConnection = P.promisify(this.poolCluster.getConnection, this.poolCluster) 34 | 35 | 36 | this.statInterval = setInterval( 37 | reportStats.bind(this), 38 | options.statInterval || 15000 39 | ) 40 | this.statInterval.unref() 41 | } 42 | 43 | function reportStats() { 44 | var nodes = Object.keys(this.poolCluster._nodes).map( 45 | function (name) { 46 | return this.poolCluster._nodes[name] 47 | }.bind(this) 48 | ) 49 | var stats = nodes.reduce( 50 | function (totals, node) { 51 | totals.errors += node.errorCount 52 | totals.connections += node.pool._allConnections.length 53 | totals.queue += node.pool._connectionQueue.length 54 | totals.free += node.pool._freeConnections.length 55 | return totals 56 | }, 57 | { 58 | stat: 'mysql', 59 | errors: 0, 60 | connections: 0, 61 | queue: 0, 62 | free: 0 63 | } 64 | ) 65 | log.stat(stats) 66 | } 67 | 68 | // this will be called from outside this file 69 | MySql.connect = function(options) { 70 | // check that the database patch level is what we expect (or one above) 71 | var mysql = new MySql(options) 72 | 73 | return mysql.readOne("SELECT value FROM dbMetadata WHERE name = ?", options.patchKey) 74 | .then( 75 | function (result) { 76 | mysql.patchLevel = +result.value 77 | if ( 78 | mysql.patchLevel < options.patchLevel || 79 | mysql.patchLevel > options.patchLevel + 1 80 | ) { 81 | throw new Error('dbIncorrectPatchLevel') 82 | } 83 | log.trace({ 84 | op: 'MySql.connect', 85 | patchLevel: mysql.patchLevel, 86 | patchLevelRequired: options.patchLevel 87 | }) 88 | return mysql 89 | } 90 | ) 91 | } 92 | 93 | MySql.prototype.close = function () { 94 | this.poolCluster.end() 95 | clearInterval(this.statInterval) 96 | return P.resolve() 97 | } 98 | 99 | MySql.prototype.ping = function () { 100 | return this.getConnection('MASTER') 101 | .then( 102 | function(connection) { 103 | var d = P.defer() 104 | connection.ping( 105 | function (err) { 106 | connection.release() 107 | return err ? d.reject(err) : d.resolve() 108 | } 109 | ) 110 | return d.promise 111 | } 112 | ) 113 | } 114 | 115 | // CREATE 116 | var CREATE_ACCOUNT = 'INSERT INTO accounts' + 117 | ' (uid, normalizedEmail, email, emailCode, emailVerified, kA, wrapWrapKb,' + 118 | ' authSalt, verifierVersion, verifyHash, verifierSetAt, createdAt)' + 119 | ' VALUES (?, LOWER(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' 120 | 121 | MySql.prototype.createAccount = function (uid, data) { 122 | data.normalizedEmail = data.email 123 | data.createdAt = data.verifierSetAt = Date.now() 124 | 125 | return this.write( 126 | CREATE_ACCOUNT, 127 | [ 128 | uid, 129 | data.normalizedEmail, 130 | data.email, 131 | data.emailCode, 132 | data.emailVerified, 133 | data.kA, 134 | data.wrapWrapKb, 135 | data.authSalt, 136 | data.verifierVersion, 137 | data.verifyHash, 138 | data.verifierSetAt, 139 | data.createdAt 140 | ] 141 | ) 142 | } 143 | 144 | var CREATE_SESSION_TOKEN = 'INSERT INTO sessionTokens' + 145 | ' (tokenId, tokenData, uid, createdAt)' + 146 | ' VALUES (?, ?, ?, ?)' 147 | 148 | MySql.prototype.createSessionToken = function (tokenId, sessionToken) { 149 | return this.write( 150 | CREATE_SESSION_TOKEN, 151 | [ 152 | tokenId, 153 | sessionToken.data, 154 | sessionToken.uid, 155 | sessionToken.createdAt 156 | ] 157 | ) 158 | } 159 | 160 | var CREATE_KEY_FETCH_TOKEN = 'INSERT INTO keyFetchTokens' + 161 | ' (tokenId, authKey, uid, keyBundle, createdAt)' + 162 | ' VALUES (?, ?, ?, ?, ?)' 163 | 164 | MySql.prototype.createKeyFetchToken = function (tokenId, keyFetchToken) { 165 | return this.write( 166 | CREATE_KEY_FETCH_TOKEN, 167 | [ 168 | tokenId, 169 | keyFetchToken.authKey, 170 | keyFetchToken.uid, 171 | keyFetchToken.keyBundle, 172 | keyFetchToken.createdAt 173 | ] 174 | ) 175 | } 176 | 177 | var CREATE_ACCOUNT_RESET_TOKEN = 'REPLACE INTO accountResetTokens' + 178 | ' (tokenId, tokenData, uid, createdAt)' + 179 | ' VALUES (?, ?, ?, ?)' 180 | 181 | MySql.prototype.createAccountResetToken = function (tokenId, accountResetToken) { 182 | return this.write( 183 | CREATE_ACCOUNT_RESET_TOKEN, 184 | [ 185 | tokenId, 186 | accountResetToken.data, 187 | accountResetToken.uid, 188 | accountResetToken.createdAt 189 | ] 190 | ) 191 | } 192 | 193 | var CREATE_PASSWORD_FORGOT_TOKEN = 'REPLACE INTO passwordForgotTokens' + 194 | ' (tokenId, tokenData, uid, passCode, createdAt, tries)' + 195 | ' VALUES (?, ?, ?, ?, ?, ?)' 196 | 197 | MySql.prototype.createPasswordForgotToken = function (tokenId, passwordForgotToken) { 198 | return this.write( 199 | CREATE_PASSWORD_FORGOT_TOKEN, 200 | [ 201 | tokenId, 202 | passwordForgotToken.data, 203 | passwordForgotToken.uid, 204 | passwordForgotToken.passCode, 205 | passwordForgotToken.createdAt, 206 | passwordForgotToken.tries 207 | ] 208 | ) 209 | } 210 | 211 | var CREATE_PASSWORD_CHANGE_TOKEN = 'REPLACE INTO passwordChangeTokens' + 212 | ' (tokenId, tokenData, uid, createdAt)' + 213 | ' VALUES (?, ?, ?, ?)' 214 | 215 | MySql.prototype.createPasswordChangeToken = function (tokenId, passwordChangeToken) { 216 | return this.write( 217 | CREATE_PASSWORD_CHANGE_TOKEN, 218 | [ 219 | tokenId, 220 | passwordChangeToken.data, 221 | passwordChangeToken.uid, 222 | passwordChangeToken.createdAt 223 | ] 224 | ) 225 | } 226 | 227 | // READ 228 | 229 | var ACCOUNT_EXISTS = 'SELECT uid FROM accounts WHERE normalizedEmail = LOWER(?)' 230 | 231 | MySql.prototype.accountExists = function (email) { 232 | return this.readOne(ACCOUNT_EXISTS, Buffer(email, 'hex').toString('utf8')) 233 | } 234 | 235 | var ACCOUNT_DEVICES = 'SELECT tokenId as id FROM sessionTokens WHERE uid = ?' 236 | 237 | MySql.prototype.accountDevices = function (uid) { 238 | return this.read(ACCOUNT_DEVICES, uid) 239 | } 240 | 241 | var SESSION_TOKEN = 'SELECT t.tokenData, t.uid, t.createdAt,' + 242 | ' a.emailVerified, a.email, a.emailCode, a.verifierSetAt' + 243 | ' FROM sessionTokens t, accounts a' + 244 | ' WHERE t.tokenId = ? AND t.uid = a.uid' 245 | 246 | MySql.prototype.sessionToken = function (id) { 247 | return this.readOne(SESSION_TOKEN, id) 248 | } 249 | 250 | var KEY_FETCH_TOKEN = 'SELECT t.authKey, t.uid, t.keyBundle, t.createdAt,' + 251 | ' a.emailVerified, a.verifierSetAt' + 252 | ' FROM keyFetchTokens t, accounts a' + 253 | ' WHERE t.tokenId = ? AND t.uid = a.uid' 254 | 255 | MySql.prototype.keyFetchToken = function (id) { 256 | return this.readOne(KEY_FETCH_TOKEN, id) 257 | } 258 | 259 | var ACCOUNT_RESET_TOKEN = 'SELECT t.uid, t.tokenData, t.createdAt,' + 260 | ' a.verifierSetAt' + 261 | ' FROM accountResetTokens t, accounts a' + 262 | ' WHERE t.tokenId = ? AND t.uid = a.uid' 263 | 264 | MySql.prototype.accountResetToken = function (id) { 265 | return this.readOne(ACCOUNT_RESET_TOKEN, id) 266 | } 267 | 268 | var PASSWORD_FORGOT_TOKEN = 'SELECT t.tokenData, t.uid, t.createdAt,' + 269 | ' t.passCode, t.tries, a.email, a.verifierSetAt' + 270 | ' FROM passwordForgotTokens t, accounts a' + 271 | ' WHERE t.tokenId = ? AND t.uid = a.uid' 272 | 273 | MySql.prototype.passwordForgotToken = function (id) { 274 | return this.readOne(PASSWORD_FORGOT_TOKEN, id) 275 | } 276 | 277 | var PASSWORD_CHANGE_TOKEN = 'SELECT t.tokenData, t.uid, t.createdAt, a.verifierSetAt' + 278 | ' FROM passwordChangeTokens t, accounts a' + 279 | ' WHERE t.tokenId = ? AND t.uid = a.uid' 280 | 281 | MySql.prototype.passwordChangeToken = function (id) { 282 | return this.readOne(PASSWORD_CHANGE_TOKEN, id) 283 | } 284 | 285 | var EMAIL_RECORD = 'SELECT uid, email, normalizedEmail, emailVerified, emailCode,' + 286 | ' kA, wrapWrapKb, verifierVersion, verifyHash, authSalt, verifierSetAt' + 287 | ' FROM accounts' + 288 | ' WHERE normalizedEmail = LOWER(?)' 289 | 290 | MySql.prototype.emailRecord = function (email) { 291 | return this.readOne(EMAIL_RECORD, Buffer(email, 'hex').toString('utf8')) 292 | } 293 | 294 | var ACCOUNT = 'SELECT uid, email, normalizedEmail, emailCode, emailVerified, kA,' + 295 | ' wrapWrapKb, verifierVersion, verifyHash, authSalt, verifierSetAt, createdAt' + 296 | ' FROM accounts WHERE uid = ?' 297 | 298 | MySql.prototype.account = function (uid) { 299 | return this.readOne(ACCOUNT, uid) 300 | } 301 | 302 | // UPDATE 303 | 304 | var UPDATE_PASSWORD_FORGOT_TOKEN = 'UPDATE passwordForgotTokens' + 305 | ' SET tries = ? WHERE tokenId = ?' 306 | 307 | MySql.prototype.updatePasswordForgotToken = function (tokenId, token) { 308 | return this.write(UPDATE_PASSWORD_FORGOT_TOKEN, [token.tries, tokenId]) 309 | } 310 | 311 | // DELETE 312 | 313 | MySql.prototype.deleteAccount = function (uid) { 314 | return this.transaction( 315 | function (connection) { 316 | var tables = [ 317 | 'sessionTokens', 318 | 'keyFetchTokens', 319 | 'accountResetTokens', 320 | 'passwordChangeTokens', 321 | 'passwordForgotTokens', 322 | 'accounts' 323 | ] 324 | var queries = deleteFromTablesWhereUid(connection, tables, uid) 325 | return P.all(queries) 326 | } 327 | ) 328 | } 329 | 330 | var DELETE_SESSION_TOKEN = 'DELETE FROM sessionTokens WHERE tokenId = ?' 331 | 332 | MySql.prototype.deleteSessionToken = function (tokenId) { 333 | return this.write(DELETE_SESSION_TOKEN, [tokenId]) 334 | } 335 | 336 | var DELETE_KEY_FETCH_TOKEN = 'DELETE FROM keyFetchTokens WHERE tokenId = ?' 337 | 338 | MySql.prototype.deleteKeyFetchToken = function (tokenId) { 339 | return this.write(DELETE_KEY_FETCH_TOKEN, [tokenId]) 340 | } 341 | 342 | var DELETE_ACCOUNT_RESET_TOKEN = 'DELETE FROM accountResetTokens WHERE tokenId = ?' 343 | 344 | MySql.prototype.deleteAccountResetToken = function (tokenId) { 345 | return this.write(DELETE_ACCOUNT_RESET_TOKEN, [tokenId]) 346 | } 347 | 348 | var DELETE_PASSWORD_FORGOT_TOKEN = 'DELETE FROM passwordForgotTokens WHERE tokenId = ?' 349 | 350 | MySql.prototype.deletePasswordForgotToken = function (tokenId) { 351 | return this.write(DELETE_PASSWORD_FORGOT_TOKEN, [tokenId]) 352 | } 353 | 354 | var DELETE_PASSWORD_CHANGE_TOKEN = 'DELETE FROM passwordChangeTokens WHERE tokenId = ?' 355 | 356 | MySql.prototype.deletePasswordChangeToken = function (tokenId) { 357 | return this.write(DELETE_PASSWORD_CHANGE_TOKEN, [tokenId]) 358 | } 359 | 360 | // BATCH 361 | 362 | var RESET_ACCOUNT = 'UPDATE accounts' + 363 | ' SET verifyHash = ?, authSalt = ?, wrapWrapKb = ?, verifierSetAt = ?,' + 364 | ' verifierVersion = ?' + 365 | ' WHERE uid = ?' 366 | 367 | MySql.prototype.resetAccount = function (uid, data) { 368 | return this.transaction( 369 | function (connection) { 370 | var tables = [ 371 | 'sessionTokens', 372 | 'keyFetchTokens', 373 | 'accountResetTokens', 374 | 'passwordChangeTokens', 375 | 'passwordForgotTokens' 376 | ] 377 | var queries = deleteFromTablesWhereUid(connection, tables, uid) 378 | queries.push( 379 | query( 380 | connection, 381 | RESET_ACCOUNT, 382 | [ 383 | data.verifyHash, 384 | data.authSalt, 385 | data.wrapWrapKb, 386 | Date.now(), 387 | data.verifierVersion, 388 | uid 389 | ] 390 | ) 391 | ) 392 | 393 | return P.all(queries) 394 | } 395 | ) 396 | } 397 | 398 | var VERIFY_EMAIL = 'UPDATE accounts SET emailVerified = true WHERE uid = ?' 399 | 400 | MySql.prototype.verifyEmail = function (uid) { 401 | return this.write(VERIFY_EMAIL, [uid]) 402 | } 403 | 404 | MySql.prototype.forgotPasswordVerified = function (tokenId, accountResetToken) { 405 | return this.transaction( 406 | function (connection) { 407 | return P.all([ 408 | query( 409 | connection, 410 | DELETE_PASSWORD_FORGOT_TOKEN, 411 | [tokenId] 412 | ), 413 | query( 414 | connection, 415 | CREATE_ACCOUNT_RESET_TOKEN, 416 | [ 417 | accountResetToken.tokenId, 418 | accountResetToken.data, 419 | accountResetToken.uid, 420 | accountResetToken.createdAt 421 | ] 422 | ), 423 | query( 424 | connection, 425 | VERIFY_EMAIL, 426 | [accountResetToken.uid] 427 | ) 428 | ]) 429 | } 430 | ) 431 | } 432 | 433 | // Internal 434 | 435 | MySql.prototype.singleQuery = function (poolName, sql, params) { 436 | return this.getConnection(poolName) 437 | .then( 438 | function (connection) { 439 | return query(connection, sql, params) 440 | .then( 441 | function (result) { 442 | connection.release() 443 | return result 444 | }, 445 | function (err) { 446 | connection.release() 447 | throw err 448 | } 449 | ) 450 | } 451 | ) 452 | } 453 | 454 | MySql.prototype.transaction = function (fn) { 455 | return retryable( 456 | function () { 457 | return this.getConnection('MASTER') 458 | .then( 459 | function (connection) { 460 | return query(connection, 'BEGIN') 461 | .then( 462 | function () { 463 | return fn(connection) 464 | } 465 | ) 466 | .then( 467 | function (result) { 468 | return query(connection, 'COMMIT') 469 | .then(function () { return result }) 470 | } 471 | ) 472 | .catch( 473 | function (err) { 474 | log.error({ op: 'MySql.transaction', err: err }) 475 | return query(connection, 'ROLLBACK') 476 | .then(function () { throw err }) 477 | } 478 | ) 479 | .then( 480 | function (result) { 481 | connection.release() 482 | return result 483 | }, 484 | function (err) { 485 | connection.release() 486 | throw err 487 | } 488 | ) 489 | } 490 | ) 491 | }.bind(this), 492 | LOCK_ERRNOS 493 | ) 494 | .catch( 495 | function (err) { 496 | throw error.wrap(err) 497 | } 498 | ) 499 | } 500 | 501 | MySql.prototype.readOne = function (sql, param) { 502 | return this.read(sql, param).then(firstResult) 503 | } 504 | 505 | MySql.prototype.read = function (sql, param) { 506 | return this.singleQuery('SLAVE*', sql, [param]) 507 | .catch( 508 | function (err) { 509 | log.error({ op: 'MySql.read', sql: sql, id: param, err: err }) 510 | throw error.wrap(err) 511 | } 512 | ) 513 | } 514 | 515 | MySql.prototype.write = function (sql, params) { 516 | return this.singleQuery('MASTER', sql, params) 517 | .then( 518 | function (result) { 519 | log.trace({ op: 'MySql.write', sql: sql, result: result }) 520 | return {} 521 | }, 522 | function (err) { 523 | log.error({ op: 'MySql.write', sql: sql, err: err }) 524 | if (err.errno === 1062) { 525 | err = error.duplicate() 526 | } 527 | else { 528 | err = error.wrap(err) 529 | } 530 | throw err 531 | } 532 | ) 533 | } 534 | 535 | MySql.prototype.getConnection = function (name) { 536 | return retryable( 537 | this.getClusterConnection, 538 | [1040, 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET'] 539 | ) 540 | } 541 | 542 | function firstResult(results) { 543 | if (!results.length) { throw error.notFound() } 544 | return results[0] 545 | } 546 | 547 | function query(connection, sql, params) { 548 | var d = P.defer() 549 | connection.query( 550 | sql, 551 | params || [], 552 | function (err, results) { 553 | if (err) { return d.reject(err) } 554 | d.resolve(results) 555 | } 556 | ) 557 | return d.promise 558 | } 559 | 560 | function deleteFromTablesWhereUid(connection, tables, uid) { 561 | return tables.map( 562 | function (table) { 563 | return query(connection, 'DELETE FROM ' + table + ' WHERE uid = ?', uid) 564 | } 565 | ) 566 | } 567 | 568 | function retryable(fn, errnos) { 569 | function success(result) { 570 | return result 571 | } 572 | function failure(err) { 573 | var errno = err.cause ? err.cause.errno : err.errno 574 | log.error({ op: 'MySql.retryable', err: err }) 575 | if (errnos.indexOf(errno) === -1) { 576 | throw err 577 | } 578 | return fn() 579 | } 580 | return fn().then(success, failure) 581 | } 582 | 583 | // exposed for testing only 584 | MySql.prototype.retryable_ = retryable 585 | 586 | return MySql 587 | } 588 | -------------------------------------------------------------------------------- /test/local/db_tests.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | require('ass') 6 | var P = require('../../promise') 7 | var test = require('../ptaptest') 8 | var crypto = require('crypto') 9 | var uuid = require('uuid') 10 | var error = require('../../error') 11 | var config = require('../../config') 12 | var log = { trace: console.log, error: console.log } 13 | var DB = require('../../db/mysql')(log, error) 14 | 15 | var zeroBuffer16 = Buffer('00000000000000000000000000000000', 'hex') 16 | var zeroBuffer32 = Buffer('0000000000000000000000000000000000000000000000000000000000000000', 'hex') 17 | 18 | var ACCOUNT = { 19 | uid: uuid.v4('binary'), 20 | email: ('' + Math.random()).substr(2) + '@bar.com', 21 | emailCode: zeroBuffer16, 22 | emailVerified: false, 23 | verifierVersion: 1, 24 | verifyHash: zeroBuffer32, 25 | authSalt: zeroBuffer32, 26 | kA: zeroBuffer32, 27 | wrapWrapKb: zeroBuffer32, 28 | verifierSetAt: Date.now(), 29 | } 30 | 31 | function hex(len) { 32 | return Buffer(crypto.randomBytes(len).toString('hex'), 'hex') 33 | } 34 | function hex16() { return hex(16) } 35 | function hex32() { return hex(32) } 36 | function hex64() { return hex(64) } 37 | function hex96() { return hex(96) } 38 | 39 | var SESSION_TOKEN_ID = hex32() 40 | var SESSION_TOKEN = { 41 | data : hex32(), 42 | uid : ACCOUNT.uid, 43 | createdAt: Date.now(), 44 | } 45 | 46 | var KEY_FETCH_TOKEN_ID = hex32() 47 | var KEY_FETCH_TOKEN = { 48 | authKey : hex32(), 49 | uid : ACCOUNT.uid, 50 | keyBundle : hex96(), 51 | createdAt: Date.now(), 52 | } 53 | 54 | var PASSWORD_FORGOT_TOKEN_ID = hex32() 55 | var PASSWORD_FORGOT_TOKEN = { 56 | data : hex32(), 57 | uid : ACCOUNT.uid, 58 | passCode : hex16(), 59 | tries : 1, 60 | createdAt: Date.now(), 61 | } 62 | 63 | var PASSWORD_CHANGE_TOKEN_ID = hex32() 64 | var PASSWORD_CHANGE_TOKEN = { 65 | data : hex32(), 66 | uid : ACCOUNT.uid, 67 | createdAt: Date.now(), 68 | } 69 | 70 | var ACCOUNT_RESET_TOKEN_ID = hex32() 71 | var ACCOUNT_RESET_TOKEN = { 72 | data : hex32(), 73 | uid : ACCOUNT.uid, 74 | createdAt: Date.now(), 75 | } 76 | 77 | DB.connect(config) 78 | .then( 79 | function (db) { 80 | 81 | test( 82 | 'ping', 83 | function (t) { 84 | t.plan(1); 85 | return db.ping() 86 | .then(function(account) { 87 | t.pass('Got the ping ok') 88 | }, function(err) { 89 | t.fail('Should not have arrived here') 90 | }) 91 | } 92 | ) 93 | 94 | test( 95 | 'account creation', 96 | function (t) { 97 | t.plan(31) 98 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 99 | return db.accountExists(hexEmail) 100 | .then(function(exists) { 101 | t.fail('account should not yet exist for this email address') 102 | }, function(err) { 103 | t.pass('ok, account could not be found') 104 | }) 105 | .then(function() { 106 | return db.createAccount(ACCOUNT.uid, ACCOUNT) 107 | }) 108 | .then(function(account) { 109 | t.deepEqual(account, {}, 'Returned an empty object on account creation') 110 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 111 | return db.accountExists(hexEmail) 112 | }) 113 | .then(function(exists) { 114 | t.ok(exists, 'account exists for this email address') 115 | }) 116 | .then(function() { 117 | t.pass('Retrieving account using uid') 118 | return db.account(ACCOUNT.uid) 119 | }) 120 | .then(function(account) { 121 | t.deepEqual(account.uid, ACCOUNT.uid, 'uid') 122 | t.equal(account.email, ACCOUNT.email, 'email') 123 | t.deepEqual(account.emailCode, ACCOUNT.emailCode, 'emailCode') 124 | t.equal(!!account.emailVerified, ACCOUNT.emailVerified, 'emailVerified') 125 | t.deepEqual(account.kA, ACCOUNT.kA, 'kA') 126 | t.deepEqual(account.wrapWrapKb, ACCOUNT.wrapWrapKb, 'wrapWrapKb') 127 | t.deepEqual(account.verifyHash, ACCOUNT.verifyHash, 'verifyHash') 128 | t.deepEqual(account.authSalt, ACCOUNT.authSalt, 'authSalt') 129 | t.equal(account.verifierVersion, ACCOUNT.verifierVersion, 'verifierVersion') 130 | t.equal(account.verifierSetAt, account.createdAt, 'verifierSetAt has been set to the same as createdAt') 131 | t.ok(account.createdAt) 132 | }) 133 | .then(function() { 134 | t.pass('Retrieving account using email') 135 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 136 | return db.emailRecord(hexEmail) 137 | }) 138 | .then(function(account) { 139 | t.deepEqual(account.uid, ACCOUNT.uid, 'uid') 140 | t.equal(account.email, ACCOUNT.email, 'email') 141 | t.deepEqual(account.emailCode, ACCOUNT.emailCode, 'emailCode') 142 | t.equal(!!account.emailVerified, ACCOUNT.emailVerified, 'emailVerified') 143 | t.deepEqual(account.kA, ACCOUNT.kA, 'kA') 144 | t.deepEqual(account.wrapWrapKb, ACCOUNT.wrapWrapKb, 'wrapWrapKb') 145 | t.deepEqual(account.verifyHash, ACCOUNT.verifyHash, 'verifyHash') 146 | t.deepEqual(account.authSalt, ACCOUNT.authSalt, 'authSalt') 147 | t.equal(account.verifierVersion, ACCOUNT.verifierVersion, 'verifierVersion') 148 | t.ok(account.verifierSetAt, 'verifierSetAt is set to a truthy value') 149 | }) 150 | // and we piggyback some duplicate query error handling here... 151 | .then(function() { 152 | return db.createAccount(ACCOUNT.uid, ACCOUNT) 153 | }) 154 | .then( 155 | function() { 156 | t.fail('this should have resulted in a duplicate account error') 157 | }, 158 | function(err) { 159 | t.ok(err, 'trying to create the same account produces an error') 160 | t.equal(err.code, 409, 'code') 161 | t.equal(err.errno, 101, 'errno') 162 | t.equal(err.message, 'Record already exists', 'message') 163 | t.equal(err.error, 'Conflict', 'error') 164 | } 165 | ) 166 | } 167 | ) 168 | 169 | test( 170 | 'session token handling', 171 | function (t) { 172 | t.plan(10) 173 | return db.createSessionToken(SESSION_TOKEN_ID, SESSION_TOKEN) 174 | .then(function(result) { 175 | t.deepEqual(result, {}, 'Returned an empty object on session token creation') 176 | return db.sessionToken(SESSION_TOKEN_ID) 177 | }) 178 | .then(function(token) { 179 | // tokenId is not returned from db.sessionToken() 180 | t.deepEqual(token.tokenData, SESSION_TOKEN.data, 'token data matches') 181 | t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') 182 | t.ok(token.createdAt, 'Got a createdAt') 183 | t.equal(!!token.emailVerified, ACCOUNT.emailVerified) 184 | t.equal(token.email, ACCOUNT.email) 185 | t.deepEqual(token.emailCode, ACCOUNT.emailCode) 186 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 187 | }) 188 | .then(function() { 189 | return db.deleteSessionToken(SESSION_TOKEN_ID) 190 | }) 191 | .then(function(result) { 192 | t.deepEqual(result, {}, 'Returned an empty object on forgot key fetch token deletion') 193 | return db.sessionToken(SESSION_TOKEN_ID) 194 | }) 195 | .then(function(token) { 196 | t.fail('Session Token should no longer exist') 197 | }, function(err) { 198 | t.pass('Session Token deleted successfully') 199 | }) 200 | } 201 | ) 202 | 203 | test( 204 | 'key fetch token handling', 205 | function (t) { 206 | t.plan(8) 207 | return db.createKeyFetchToken(KEY_FETCH_TOKEN_ID, KEY_FETCH_TOKEN) 208 | .then(function(result) { 209 | t.deepEqual(result, {}, 'Returned an empty object on key fetch token creation') 210 | return db.keyFetchToken(KEY_FETCH_TOKEN_ID) 211 | }) 212 | .then(function(token) { 213 | // tokenId is not returned 214 | t.deepEqual(token.authKey, KEY_FETCH_TOKEN.authKey, 'authKey matches') 215 | t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') 216 | t.ok(token.createdAt, 'Got a createdAt') 217 | t.equal(!!token.emailVerified, ACCOUNT.emailVerified) 218 | // email is not returned 219 | // emailCode is not returned 220 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 221 | }) 222 | .then(function() { 223 | return db.deleteKeyFetchToken(KEY_FETCH_TOKEN_ID) 224 | }) 225 | .then(function(result) { 226 | t.deepEqual(result, {}, 'Returned an empty object on forgot key fetch token deletion') 227 | return db.keyFetchToken(KEY_FETCH_TOKEN_ID) 228 | }) 229 | .then(function(token) { 230 | t.fail('Key Fetch Token should no longer exist') 231 | }, function(err) { 232 | t.pass('Key Fetch Token deleted successfully') 233 | }) 234 | } 235 | ) 236 | 237 | test( 238 | 'forgot password token handling', 239 | function (t) { 240 | t.plan(10) 241 | return db.createPasswordForgotToken(PASSWORD_FORGOT_TOKEN_ID, PASSWORD_FORGOT_TOKEN) 242 | .then(function(result) { 243 | t.deepEqual(result, {}, 'Returned an empty object on forgot password token creation') 244 | return db.passwordForgotToken(PASSWORD_FORGOT_TOKEN_ID) 245 | }) 246 | .then(function(token) { 247 | // tokenId is not returned 248 | t.deepEqual(token.tokenData, PASSWORD_FORGOT_TOKEN.data, 'token data matches') 249 | t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') 250 | t.ok(token.createdAt, 'Got a createdAt') 251 | t.deepEqual(token.passCode, PASSWORD_FORGOT_TOKEN.passCode) 252 | t.equal(token.tries, PASSWORD_FORGOT_TOKEN.tries, 'Tries is correct') 253 | t.equal(token.email, ACCOUNT.email) 254 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 255 | }) 256 | .then(function() { 257 | return db.deletePasswordForgotToken(PASSWORD_FORGOT_TOKEN_ID) 258 | }) 259 | .then(function(result) { 260 | t.deepEqual(result, {}, 'Returned an empty object on forgot password token deletion') 261 | return db.passwordForgotToken(PASSWORD_FORGOT_TOKEN_ID) 262 | }) 263 | .then(function(token) { 264 | t.fail('Password Forgot Token should no longer exist') 265 | }, function(err) { 266 | t.pass('Password Forgot Token deleted successfully') 267 | }) 268 | } 269 | ) 270 | 271 | test( 272 | 'change password token handling', 273 | function (t) { 274 | t.plan(7) 275 | return db.createPasswordChangeToken(PASSWORD_CHANGE_TOKEN_ID, PASSWORD_CHANGE_TOKEN) 276 | .then(function(result) { 277 | t.deepEqual(result, {}, 'Returned an empty object on change password token creation') 278 | return db.passwordChangeToken(PASSWORD_CHANGE_TOKEN_ID) 279 | }) 280 | .then(function(token) { 281 | // tokenId is not returned 282 | t.deepEqual(token.tokenData, PASSWORD_CHANGE_TOKEN.data, 'token data matches') 283 | t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') 284 | t.ok(token.createdAt, 'Got a createdAt') 285 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 286 | }) 287 | .then(function() { 288 | return db.deletePasswordChangeToken(PASSWORD_CHANGE_TOKEN_ID) 289 | }) 290 | .then(function(result) { 291 | t.deepEqual(result, {}, 'Returned an empty object on forgot password change deletion') 292 | return db.passwordChangeToken(PASSWORD_CHANGE_TOKEN_ID) 293 | }) 294 | .then(function(token) { 295 | t.fail('Password Change Token should no longer exist') 296 | }, function(err) { 297 | t.pass('Password Change Token deleted successfully') 298 | }) 299 | } 300 | ) 301 | 302 | test( 303 | 'email verification', 304 | function (t) { 305 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 306 | return db.emailRecord(hexEmail) 307 | .then(function(emailRecord) { 308 | return db.verifyEmail(emailRecord.uid) 309 | }) 310 | .then(function(result) { 311 | t.deepEqual(result, {}, 'Returned an empty object email verification') 312 | return db.account(ACCOUNT.uid) 313 | }) 314 | .then(function(account) { 315 | t.ok(account.emailVerified, 'account should now be emailVerified (truthy)') 316 | t.equal(account.emailVerified, 1, 'account should now be emailVerified (1)') 317 | }) 318 | .then(function() { 319 | // test verifyEmail for a non-existant account 320 | return db.verifyEmail(uuid.v4('binary')) 321 | }) 322 | .then(function(res) { 323 | t.deepEqual(res, {}, 'No matter what happens, we get an empty object back') 324 | }, function(err) { 325 | t.fail('We should not have failed this .verifyEmail() request') 326 | }) 327 | } 328 | ) 329 | 330 | test( 331 | 'account reset token handling', 332 | function (t) { 333 | t.plan(7) 334 | return db.createAccountResetToken(ACCOUNT_RESET_TOKEN_ID, ACCOUNT_RESET_TOKEN) 335 | .then(function(result) { 336 | t.deepEqual(result, {}, 'Returned an empty object on account reset token creation') 337 | return db.accountResetToken(ACCOUNT_RESET_TOKEN_ID) 338 | }) 339 | .then(function(token) { 340 | // tokenId is not returned 341 | t.deepEqual(token.uid, ACCOUNT.uid, 'token belongs to this account') 342 | t.deepEqual(token.tokenData, ACCOUNT_RESET_TOKEN.data, 'token data matches') 343 | t.ok(token.createdAt, 'Got a createdAt') 344 | t.ok(token.verifierSetAt, 'verifierSetAt is set to a truthy value') 345 | }) 346 | .then(function() { 347 | return db.deleteAccountResetToken(ACCOUNT_RESET_TOKEN_ID) 348 | }) 349 | .then(function(result) { 350 | t.deepEqual(result, {}, 'Returned an empty object on account reset deletion') 351 | return db.accountResetToken(ACCOUNT_RESET_TOKEN_ID) 352 | }) 353 | .then(function(token) { 354 | t.fail('Account Reset Token should no longer exist') 355 | }, function(err) { 356 | t.pass('Account Reset Token deleted successfully') 357 | }) 358 | } 359 | ) 360 | 361 | test( 362 | 'db.forgotPasswordVerified', 363 | function (t) { 364 | t.plan(12) 365 | // for this test, we are creating a new account with a different email address 366 | // so that we can check that emailVerified turns from false to true (since 367 | // we already set it to true earlier) 368 | var ACCOUNT = { 369 | uid: uuid.v4('binary'), 370 | email: ('' + Math.random()).substr(2) + '@bar.com', 371 | emailCode: zeroBuffer16, 372 | emailVerified: false, 373 | verifierVersion: 1, 374 | verifyHash: zeroBuffer32, 375 | authSalt: zeroBuffer32, 376 | kA: zeroBuffer32, 377 | wrapWrapKb: zeroBuffer32, 378 | verifierSetAt: Date.now(), 379 | } 380 | var PASSWORD_FORGOT_TOKEN_ID = hex32() 381 | var PASSWORD_FORGOT_TOKEN = { 382 | data : hex32(), 383 | uid : ACCOUNT.uid, 384 | passCode : hex16(), 385 | tries : 1, 386 | createdAt: Date.now(), 387 | } 388 | var ACCOUNT_RESET_TOKEN_ID = hex32() 389 | var ACCOUNT_RESET_TOKEN = { 390 | tokenId : ACCOUNT_RESET_TOKEN_ID, 391 | data : hex32(), 392 | uid : ACCOUNT.uid, 393 | createdAt: Date.now(), 394 | } 395 | 396 | return db.createAccount(ACCOUNT.uid, ACCOUNT) 397 | .then(function() { 398 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 399 | return db.emailRecord(hexEmail) 400 | }) 401 | .then(function(result) { 402 | t.pass('.emailRecord() did not error') 403 | return db.createPasswordForgotToken(PASSWORD_FORGOT_TOKEN_ID, PASSWORD_FORGOT_TOKEN) 404 | }) 405 | .then(function(passwordForgotToken) { 406 | t.pass('.createPasswordForgotToken() did not error') 407 | return db.forgotPasswordVerified(PASSWORD_FORGOT_TOKEN_ID, ACCOUNT_RESET_TOKEN) 408 | }) 409 | .then(function() { 410 | t.pass('.forgotPasswordVerified() did not error') 411 | return db.passwordForgotToken(PASSWORD_FORGOT_TOKEN_ID) 412 | }) 413 | .then(function(token) { 414 | t.fail('Password Forgot Token should no longer exist') 415 | }, function(err) { 416 | t.pass('Password Forgot Token deleted successfully') 417 | }) 418 | .then(function() { 419 | return db.accountResetToken(ACCOUNT_RESET_TOKEN_ID) 420 | }) 421 | .then(function(accountResetToken) { 422 | t.pass('.accountResetToken() did not error') 423 | // tokenId is not returned 424 | t.deepEqual(accountResetToken.uid, ACCOUNT.uid, 'token belongs to this account') 425 | t.deepEqual(accountResetToken.tokenData, ACCOUNT_RESET_TOKEN.data, 'token data matches') 426 | t.ok(accountResetToken.verifierSetAt, 'verifierSetAt is set to a truthy value') 427 | }) 428 | .then(function() { 429 | return db.account(ACCOUNT.uid) 430 | }) 431 | .then(function(account) { 432 | t.ok(account.emailVerified, 'account should now be emailVerified (truthy)') 433 | t.equal(account.emailVerified, 1, 'account should now be emailVerified (1)') 434 | }) 435 | .then(function() { 436 | return db.deleteAccountResetToken(ACCOUNT_RESET_TOKEN_ID) 437 | }) 438 | .then(function(result) { 439 | t.deepEqual(result, {}, 'Returned an empty object on account reset deletion') 440 | return db.accountResetToken(ACCOUNT_RESET_TOKEN_ID) 441 | }) 442 | .then(function(token) { 443 | t.fail('Account Reset Token should no longer exist') 444 | }, function(err) { 445 | t.pass('Account Reset Token deleted successfully') 446 | }) 447 | } 448 | ) 449 | 450 | test( 451 | 'db.accountDevices', 452 | function (t) { 453 | t.plan(3) 454 | var anotherSessionTokenId = hex32() 455 | var anotherSessionToken = { 456 | data : hex32(), 457 | uid : ACCOUNT.uid, 458 | createdAt: Date.now(), 459 | } 460 | db.createSessionToken(SESSION_TOKEN_ID, SESSION_TOKEN) 461 | .then(function(sessionToken) { 462 | return db.createSessionToken(anotherSessionTokenId, anotherSessionToken) 463 | }) 464 | .then(function() { 465 | return db.accountDevices(ACCOUNT.uid) 466 | }) 467 | .then(function(devices) { 468 | t.equal(devices.length, 2, 'Account devices should be two') 469 | return devices[0] 470 | }) 471 | .then(function(sessionToken) { 472 | return db.deleteSessionToken(SESSION_TOKEN_ID) 473 | }) 474 | .then(function(sessionToken) { 475 | return db.accountDevices(ACCOUNT.uid) 476 | }) 477 | .then(function(devices) { 478 | t.equal(devices.length, 1, 'Account devices should be one') 479 | return devices[0] 480 | }) 481 | .then(function(sessionToken) { 482 | return db.deleteSessionToken(anotherSessionTokenId) 483 | }) 484 | .then(function(sessionToken) { 485 | return db.accountDevices(ACCOUNT.uid) 486 | }) 487 | .then(function(devices) { 488 | t.equal(devices.length, 0, 'Account devices should be zero') 489 | }) 490 | } 491 | ) 492 | 493 | test( 494 | 'db.resetAccount', 495 | function (t) { 496 | t.plan(6) 497 | return db.createSessionToken(SESSION_TOKEN_ID, SESSION_TOKEN) 498 | .then(function(sessionToken) { 499 | t.pass('.createSessionToken() did not error') 500 | return db.createAccountResetToken(ACCOUNT_RESET_TOKEN_ID, ACCOUNT_RESET_TOKEN) 501 | }) 502 | .then(function() { 503 | t.pass('.createAccountResetToken() did not error') 504 | return db.resetAccount(ACCOUNT.uid, ACCOUNT) 505 | }) 506 | .then(function(sessionToken) { 507 | t.pass('.resetAccount() did not error') 508 | return db.accountDevices(ACCOUNT.uid) 509 | }) 510 | .then(function(devices) { 511 | t.pass('.accountDevices() did not error') 512 | t.equal(devices.length, 0, 'The devices length should be zero') 513 | }) 514 | .then(function() { 515 | // account should STILL exist for this email address 516 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 517 | return db.accountExists(hexEmail) 518 | }) 519 | .then(function(exists) { 520 | t.ok(exists, 'account still exists ok') 521 | }, function(err) { 522 | t.fail('the account for this email address should still exist') 523 | }) 524 | } 525 | ) 526 | 527 | test( 528 | 'account deletion', 529 | function (t) { 530 | t.plan(1) 531 | // account should no longer exist for this email address 532 | return db.deleteAccount(ACCOUNT.uid) 533 | .then(function() { 534 | var hexEmail = Buffer(ACCOUNT.email).toString('hex') 535 | return db.accountExists(hexEmail) 536 | }) 537 | .then(function(exists) { 538 | t.fail('account should no longer exist for this email address') 539 | }, function(err) { 540 | t.pass('account no longer exists for this email address') 541 | }) 542 | } 543 | ) 544 | 545 | test( 546 | 'teardown', 547 | function (t) { 548 | return db.close() 549 | } 550 | ) 551 | 552 | } 553 | ) 554 | --------------------------------------------------------------------------------