From abe0e52a5e7fb803e99b490a5705d7f6acf7ca6a Mon Sep 17 00:00:00 2001 From: vladikoff Date: Tue, 17 Feb 2015 16:43:32 +1100 Subject: [PATCH] feat(developers): adds support for oauth developers --- docs/api.md | 20 ++ lib/config.js | 4 + lib/db/memory.js | 182 ++++++++++++- lib/db/mysql/index.js | 99 ++++++- lib/db/mysql/patch.js | 2 +- lib/db/mysql/patches/patch-003-004.sql | 19 ++ lib/db/mysql/patches/patch-004-003.sql | 6 + lib/db/mysql/schema.sql | 16 ++ lib/routes/client/delete.js | 19 +- lib/routes/client/list.js | 4 +- lib/routes/client/register.js | 42 ++- lib/routes/client/update.js | 32 ++- lib/routes/developer/activate.js | 37 +++ lib/routing.js | 5 + lib/unique.js | 1 + test/api.js | 347 +++++++++++++++++++++---- test/db.js | 153 +++++++++++ 17 files changed, 897 insertions(+), 91 deletions(-) create mode 100644 lib/db/mysql/patches/patch-003-004.sql create mode 100644 lib/db/mysql/patches/patch-004-003.sql create mode 100644 lib/routes/developer/activate.js diff --git a/docs/api.md b/docs/api.md index 401372bbb..b3b7ad685 100644 --- a/docs/api.md +++ b/docs/api.md @@ -61,6 +61,8 @@ The currently-defined error responses are: - [POST /v1/client][register] - [POST /v1/client/:id][client-update] - [DELETE /v1/client/:id][client-delete] +- Developers + - [POST /v1/developer/activate][developer-activate] - [POST /v1/verify][verify] ### GET /v1/client/:id @@ -249,6 +251,23 @@ curl -v \ A valid response will have a 204 response code and an empty body. +### POST /v1/developer/activate + +Register an oauth developer. + +**Required scope:** `oauth` + +#### Request Parameters + +- None + +#### Response + +A valid response will have a 200 status code and a developer object: +``` +{"developerId":"f5b176ab5be5928d01d4bb0a6c182994","email":"d91c30a8@mozilla.com","createdAt":"2015-03-23T01:22:59.000Z"} +``` + ### GET /v1/authorization This endpoint starts the OAuth flow. A client redirects the user agent @@ -451,3 +470,4 @@ A valid request will return JSON with these properties: [token]: #post-v1token [delete]: #post-v1destroy [verify]: #post-v1verify +[developer-activate]: #post-v1developeractivate \ No newline at end of file diff --git a/lib/config.js b/lib/config.js index 9022e4b21..71abb3936 100644 --- a/lib/config.js +++ b/lib/config.js @@ -175,6 +175,10 @@ const conf = convict({ token: { doc: 'Bytes of generated tokens', default: 32 + }, + developerId: { + doc: 'Bytes of generated developer ids', + default: 16 } } }); diff --git a/lib/db/memory.js b/lib/db/memory.js index 3055d27a5..a3fbfc89b 100644 --- a/lib/db/memory.js +++ b/lib/db/memory.js @@ -34,6 +34,20 @@ const unique = require('../unique'); * createdAt: * } * }, + * developers: { + * : { + * developerId: , + * email: , + * createdAt: + * } + * }, + * clientDevelopers: { + * : { + * developerId: , + * clientId: , + * createdAt: + * } + * }, * tokens: { * : { * token: , @@ -53,6 +67,8 @@ function MemoryStore() { this.clients = {}; this.codes = {}; this.tokens = {}; + this.developers = {}; + this.clientDevelopers = {}; } MemoryStore.connect = function memoryConnect() { @@ -119,18 +135,37 @@ MemoryStore.prototype = { getClient: function getClient(id) { return P.resolve(this.clients[unbuf(id)]); }, - getClients: function getClients() { - return P.resolve(Object.keys(this.clients).map(function(id) { - var client = this.clients[id]; - return { - id: client.id, - name: client.name, - imageUri: client.imageUri, - redirectUri: client.redirectUri, - canGrant: client.canGrant, - whitelisted: client.whitelisted - }; - }, this)); + getClients: function getClients(email) { + var self = this; + + return this.getDeveloper(email) + .then(function (developer) { + if (! developer) { + return []; + } + + var clients = []; + + Object.keys(self.clientDevelopers).forEach(function(key) { + var entry = self.clientDevelopers[key]; + if (entry.developerId === developer.developerId) { + clients.push(unbuf(entry.clientId)); + } + }); + + return clients.map(function(id) { + var client = self.clients[id]; + + return { + id: client.id, + name: client.name, + imageUri: client.imageUri, + redirectUri: client.redirectUri, + canGrant: client.canGrant, + whitelisted: client.whitelisted + }; + }, this); + }); }, removeClient: function removeClient(id) { delete this.clients[unbuf(id)]; @@ -187,7 +222,130 @@ MemoryStore.prototype = { deleteByUserId(this.tokens, userId); deleteByUserId(this.codes, userId); return P.resolve(); + }, + activateDeveloper: function activateDeveloper(email) { + var self = this; + + if (! email) { + return P.reject(new Error('Email is required')); + } + + return this.getDeveloper(email) + .then(function(result) { + if (result) { + return P.reject(new Error('ER_DUP_ENTRY')); + } + + var newId = unique.developerId(); + var developer = { + developerId: newId, + email: email, + createdAt: new Date() + }; + + self.developers[unbuf(newId)] = developer; + return developer; + + }); + }, + getDeveloper: function getDeveloper(email) { + var self = this; + var developer = null; + + if (! email) { + return P.reject(new Error('Email is required')); + } + + Object.keys(self.developers).forEach(function(developerId) { + var devEntry = self.developers[developerId]; + + if (devEntry.email === email) { + developer = devEntry; + } + }); + + return P.resolve(developer); + }, + removeDeveloper: function removeDeveloper(email) { + var self = this; + + if (! email) { + return P.reject(new Error('Email is required')); + } + + Object.keys(self.developers).forEach(function(developerId) { + var devEntry = self.developers[developerId]; + + if (devEntry.email === email) { + delete self.developers[developerId]; + } + }); + + return P.resolve(); + }, + developerOwnsClient: function devOwnsClient(developerEmail, clientId) { + var self = this; + var developerId; + + logger.debug('developerOwnsClient'); + return self.getDeveloper(developerEmail) + .then(function (developer) { + if (! developer) { + return P.reject(); + } + developerId = developer.developerId; + + return self.getClientDevelopers(clientId); + }) + .then(function (developers) { + var result; + + function hasDeveloper(developer) { + result = developer; + return unbuf(developer.developerId) === unbuf(developerId); + } + + if (developers.some(hasDeveloper)) { + return P.resolve(true); + } else { + return P.reject(false); + } + + }); + }, + registerClientDeveloper: function regClientDeveloper(developerId, clientId) { + var entry = { + developerId: buf(developerId), + clientId: buf(clientId), + createdAt: new Date() + }; + var uniqueHexId = unbuf(unique.id()); + + logger.debug('registerClientDeveloper', entry); + this.clientDevelopers[uniqueHexId] = entry; + return P.resolve(entry); + }, + getClientDevelopers: function getClientDevelopers(clientId) { + var self = this; + var developers = []; + + if (! clientId) { + return P.reject(new Error('Client id is required')); + } + + clientId = unbuf(clientId); + + Object.keys(self.clientDevelopers).forEach(function(key) { + var entry = self.clientDevelopers[key]; + + if (unbuf(entry.clientId) === clientId) { + developers.push(self.developers[unbuf(entry.developerId)]); + } + }); + + return P.resolve(developers); } + }; module.exports = MemoryStore; diff --git a/lib/db/mysql/index.js b/lib/db/mysql/index.js index 294ee1f3e..b8c9c4bac 100644 --- a/lib/db/mysql/index.js +++ b/lib/db/mysql/index.js @@ -104,9 +104,30 @@ const QUERY_CLIENT_REGISTER = 'INSERT INTO clients ' + '(id, name, imageUri, secret, redirectUri, whitelisted, canGrant) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?);'; +const QUERY_CLIENT_DEVELOPER_INSERT = + 'INSERT INTO clientDevelopers ' + + '(rowId, developerId, clientId) ' + + 'VALUES (?, ?, ?);'; +const QUERY_CLIENT_DEVELOPER_LIST_BY_CLIENT_ID = + 'SELECT developers.email, developers.createdAt ' + + 'FROM clientDevelopers, developers ' + + 'WHERE clientDevelopers.developerId = developers.developerId ' + + 'AND clientDevelopers.clientId=?;'; +const QUERY_DEVELOPER_OWNS_CLIENT = + 'SELECT clientDevelopers.rowId ' + + 'FROM clientDevelopers, developers ' + + 'WHERE developers.developerId = clientDevelopers.developerId ' + + 'AND developers.email =? AND clientDevelopers.clientId =?;'; +const QUERY_DEVELOPER_INSERT = + 'INSERT INTO developers ' + + '(developerId, email) ' + + 'VALUES (?, ?);'; const QUERY_CLIENT_GET = 'SELECT * FROM clients WHERE id=?'; const QUERY_CLIENT_LIST = 'SELECT id, name, redirectUri, imageUri, canGrant, ' + - 'whitelisted FROM clients'; + 'whitelisted FROM fxa_oauth.clients, clientDevelopers, developers ' + + 'WHERE clients.id = clientDevelopers.clientId AND ' + + 'developers.developerId = clientDevelopers.developerId AND ' + + 'developers.email =?;'; const QUERY_CLIENT_UPDATE = 'UPDATE clients SET ' + 'name=COALESCE(?, name), imageUri=COALESCE(?, imageUri), ' + 'secret=COALESCE(?, secret), redirectUri=COALESCE(?, redirectUri), ' + @@ -125,6 +146,8 @@ const QUERY_CODE_DELETE = 'DELETE FROM codes WHERE code=?'; const QUERY_TOKEN_DELETE = 'DELETE FROM tokens WHERE token=?'; const QUERY_TOKEN_DELETE_USER = 'DELETE FROM tokens WHERE userId=?'; const QUERY_CODE_DELETE_USER = 'DELETE FROM codes WHERE userId=?'; +const QUERY_DEVELOPER = 'SELECT * FROM developers WHERE email=?'; +const QUERY_DEVELOPER_DELETE = 'DELETE FROM developers WHERE email=?'; function firstRow(rows) { return rows[0]; @@ -177,7 +200,77 @@ MysqlStore.prototype = { return client; }); }, + registerClientDeveloper: function regClientDeveloper(developerId, clientId) { + if (!developerId || !clientId) { + var err = new Error('Owner registration requires user and developer id'); + return P.reject(err); + } + + var rowId = unique.id(); + + logger.debug('registerClientDeveloper', { + rowId: rowId, + developerId: developerId, + clientId: clientId + }); + + return this._write(QUERY_CLIENT_DEVELOPER_INSERT, [ + buf(rowId), + buf(developerId), + buf(clientId) + ]); + }, + getClientDevelopers: function getClientDevelopers (clientId) { + if (! clientId) { + return P.reject(new Error('Client id is required')); + } + + return this._read(QUERY_CLIENT_DEVELOPER_LIST_BY_CLIENT_ID, [ + buf(clientId) + ]); + }, + activateDeveloper: function activateDeveloper(email) { + if (! email) { + return P.reject(new Error('Email is required')); + } + var developerId = unique.developerId(); + logger.debug('activateDeveloper', { developerId: developerId }); + return this._write(QUERY_DEVELOPER_INSERT, [ + developerId, email + ]).then(function () { + return this.getDeveloper(email); + }.bind(this)); + }, + getDeveloper: function(email) { + if (! email) { + return P.reject(new Error('Email is required')); + } + + return this._readOne(QUERY_DEVELOPER, [ + email + ]); + }, + removeDeveloper: function(email) { + if (! email) { + return P.reject(new Error('Email is required')); + } + + return this._write(QUERY_DEVELOPER_DELETE, [ + email + ]); + }, + developerOwnsClient: function devOwnsClient(developerEmail, clientId) { + return this._readOne(QUERY_DEVELOPER_OWNS_CLIENT, [ + developerEmail, buf(clientId) + ]).then(function(result) { + if (result) { + return P.resolve(true); + } else { + return P.reject(false); + } + }); + }, updateClient: function updateClient(client) { if (!client.id) { return P.reject(new Error('Update client needs an id')); @@ -203,8 +296,8 @@ MysqlStore.prototype = { getClient: function getClient(id) { return this._readOne(QUERY_CLIENT_GET, [buf(id)]); }, - getClients: function getClients() { - return this._read(QUERY_CLIENT_LIST); + getClients: function getClients(email) { + return this._read(QUERY_CLIENT_LIST, [ email ]); }, removeClient: function removeClient(id) { return this._write(QUERY_CLIENT_DELETE, [buf(id)]); diff --git a/lib/db/mysql/patch.js b/lib/db/mysql/patch.js index f41a90693..717cc6a86 100644 --- a/lib/db/mysql/patch.js +++ b/lib/db/mysql/patch.js @@ -6,5 +6,5 @@ // Update this if you add a new patch, and don't forget to update // the documentation for the current schema in ../schema.sql. -module.exports.level = 3; +module.exports.level = 4; diff --git a/lib/db/mysql/patches/patch-003-004.sql b/lib/db/mysql/patches/patch-003-004.sql new file mode 100644 index 000000000..0cc449502 --- /dev/null +++ b/lib/db/mysql/patches/patch-003-004.sql @@ -0,0 +1,19 @@ +-- Adds support for Client Developers for OAuth clients + +CREATE TABLE developers ( + developerId BINARY(16) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(email) +) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci; + +CREATE TABLE clientDevelopers ( + rowId BINARY(8) NOT NULL PRIMARY KEY, + developerId BINARY(16) NOT NULL, + FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE, + clientId BINARY(8) NOT NULL, + FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE, + createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci; + +UPDATE dbMetadata SET value = '4' WHERE name = 'schema-patch-level'; diff --git a/lib/db/mysql/patches/patch-004-003.sql b/lib/db/mysql/patches/patch-004-003.sql new file mode 100644 index 000000000..8dbde090a --- /dev/null +++ b/lib/db/mysql/patches/patch-004-003.sql @@ -0,0 +1,6 @@ +-- Remove support for Client Developers for OAuth clients + +-- DROP TABLE clientDevelopers; +-- DROP TABLE developers; + +-- UPDATE dbMetadata SET value = '3' WHERE name = 'schema-patch-level'; diff --git a/lib/db/mysql/schema.sql b/lib/db/mysql/schema.sql index f2d8b072b..98d34cc29 100644 --- a/lib/db/mysql/schema.sql +++ b/lib/db/mysql/schema.sql @@ -43,3 +43,19 @@ CREATE TABLE IF NOT EXISTS tokens ( scope VARCHAR(256) NOT NULL, createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci; + +CREATE TABLE IF NOT EXISTS developers ( + developerId BINARY(16) NOT NULL, + FOREIGN KEY (developerId) REFERENCES developers(developerId) ON DELETE CASCADE, + clientId BINARY(8) NOT NULL, + FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE, + createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci; + +CREATE TABLE IF NOT EXISTS clientDevelopers ( + rowId BINARY(8) PRIMARY KEY, + developerId BINARY(16) NOT NULL, + clientId BINARY(8) NOT NULL, + FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE, + createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci; diff --git a/lib/routes/client/delete.js b/lib/routes/client/delete.js index 1375e5cd0..49fcbb35e 100644 --- a/lib/routes/client/delete.js +++ b/lib/routes/client/delete.js @@ -5,7 +5,7 @@ const auth = require('../../auth'); const db = require('../../db'); const validators = require('../../validators'); - +const AppError = require('../../error'); /*jshint camelcase: false*/ module.exports = { @@ -19,8 +19,19 @@ module.exports = { } }, handler: function clientDeleteEndpoint(req, reply) { - db.removeClient(req.params.client_id).done(function() { - reply().code(204); - }, reply); + var email = req.auth.credentials.email; + var clientId = req.params.client_id; + + return db.developerOwnsClient(email, clientId) + .then( + function () { + return db.removeClient(clientId).done(function() { + reply().code(204); + }, reply); + }, + function () { + return reply(AppError.unauthorized('Illegal Developer')); + } + ); } }; diff --git a/lib/routes/client/list.js b/lib/routes/client/list.js index d3c557099..49a2888d0 100644 --- a/lib/routes/client/list.js +++ b/lib/routes/client/list.js @@ -42,7 +42,9 @@ module.exports = { } }, handler: function listEndpoint(req, reply) { - db.getClients().done(function(clients) { + var developerEmail = req.auth.credentials.email; + + db.getClients(developerEmail).done(function(clients) { reply({ clients: clients.map(serialize) }); diff --git a/lib/routes/client/register.js b/lib/routes/client/register.js index 8ab4fc7f0..a44d67be6 100644 --- a/lib/routes/client/register.js +++ b/lib/routes/client/register.js @@ -10,7 +10,7 @@ const encrypt = require('../../encrypt'); const hex = require('buf').to.hex; const unique = require('../../unique'); const validators = require('../../validators'); - +const AppError = require('../../error'); /*jshint camelcase: false*/ module.exports = { @@ -50,16 +50,34 @@ module.exports = { canGrant: !!payload.can_grant, whitelisted: !!payload.whitelisted }; - db.registerClient(client).then(function() { - reply({ - id: hex(client.id), - secret: hex(secret), - name: client.name, - redirect_uri: client.redirectUri, - image_uri: client.imageUri, - can_grant: client.canGrant, - whitelisted: client.whitelisted - }).code(201); - }, reply); + var developerEmail = req.auth.credentials.email; + var developerId = null; + + return db.getDeveloper(developerEmail) + .then(function (developer) { + + // must be a developer to register clients + if (! developer) { + throw AppError.unauthorized('Illegal Developer'); + } + + developerId = developer.developerId; + + return db.registerClient(client); + }) + .then(function() { + return db.registerClientDeveloper(developerId, hex(client.id)); + }) + .then(function() { + reply({ + id: hex(client.id), + secret: hex(secret), + name: client.name, + redirect_uri: client.redirectUri, + image_uri: client.imageUri, + can_grant: client.canGrant, + whitelisted: client.whitelisted + }).code(201); + }, reply); } }; diff --git a/lib/routes/client/update.js b/lib/routes/client/update.js index 830530955..0f999e1ae 100644 --- a/lib/routes/client/update.js +++ b/lib/routes/client/update.js @@ -8,7 +8,7 @@ const Joi = require('joi'); const auth = require('../../auth'); const db = require('../../db'); const validators = require('../../validators'); - +const AppError = require('../../error'); /*jshint camelcase: false*/ module.exports = { @@ -28,16 +28,26 @@ module.exports = { } }, handler: function updateClientEndpoint(req, reply) { - var id = req.params.client_id; + var clientId = req.params.client_id; var payload = req.payload; - db.updateClient({ - id: buf(id), - name: payload.name, - redirectUri: payload.redirect_uri, - imageUri: payload.image_uri, - canGrant: payload.can_grant - }).done(function() { - reply({}); - }, reply); + var email = req.auth.credentials.email; + + return db.developerOwnsClient(email, clientId) + .then( + function () { + return db.updateClient({ + id: buf(clientId), + name: payload.name, + redirectUri: payload.redirect_uri, + imageUri: payload.image_uri, + canGrant: payload.can_grant + }).done(function() { + reply({}); + }, reply); + }, + function () { + return reply(AppError.unauthorized('Illegal Developer')); + } + ); } }; diff --git a/lib/routes/developer/activate.js b/lib/routes/developer/activate.js new file mode 100644 index 000000000..353a49429 --- /dev/null +++ b/lib/routes/developer/activate.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const auth = require('../../auth'); +const db = require('../../db'); +const hex = require('buf').to.hex; + +function developerResponse(developer) { + return { + developerId: hex(developer.developerId), + email: developer.email, + createdAt: developer.createdAt + }; +} + +/*jshint camelcase: false*/ +module.exports = { + auth: { + strategy: auth.AUTH_STRATEGY, + scope: [auth.SCOPE_CLIENT_MANAGEMENT] + }, + handler: function activateRegistration(req, reply) { + var email = req.auth.credentials.email; + + return db.getDeveloper(email) + .then(function(developer) { + if (developer) { + return developer; + } else { + return db.activateDeveloper(email); + } + }) + .then(developerResponse) + .done(reply, reply); + } +}; diff --git a/lib/routing.js b/lib/routing.js index ac4e6652f..f5e9ad243 100644 --- a/lib/routing.js +++ b/lib/routing.js @@ -84,5 +84,10 @@ exports.clients = [ method: 'DELETE', path: v('/client/{client_id}'), config: require('./routes/client/delete') + }, + { + method: 'POST', + path: v('/developer/activate'), + config: require('./routes/developer/activate') } ]; diff --git a/lib/unique.js b/lib/unique.js index 29b8567d4..382323db7 100644 --- a/lib/unique.js +++ b/lib/unique.js @@ -17,6 +17,7 @@ function fn(configName) { } unique.id = fn('id'); +unique.developerId = fn('developerId'); unique.secret = fn('clientSecret'); unique.code = fn('code'); unique.token = fn('token'); diff --git a/test/api.js b/test/api.js index d170c40b4..d6828cf15 100644 --- a/test/api.js +++ b/test/api.js @@ -130,6 +130,33 @@ function assertRequestParam(result, param) { assert.equal(result.validation.keys[0], param); } +// helper function to create a new user, email and token for some client +/** + * + * @param {String} cId - hex client id + */ +function getUniqueUserAndToken(cId) { + if (! cId) { + throw new Error('No client id set'); + } + + var uid = unique(16).toString('hex'); + var email = unique(4).toString('hex') + '@mozilla.com'; + + return db.generateToken({ + clientId: buf(cId), + userId: buf(uid), + email: email, + scope: [auth.SCOPE_CLIENT_MANAGEMENT] + }).then(function (token) { + return { + uid: uid, + email: email, + token: token.token.toString('hex') + }; + }); +} + describe('/v1', function() { before(function(done) { @@ -804,7 +831,10 @@ describe('/v1', function() { }); }); - it('should return a list of clients', function() { + it('should return an empty list of clients', function() { + // this developer has no clients associated, it returns 0 + // value is the same as the API endpoint and a DB call + return Server.internal.api.get({ url: '/clients', headers: { @@ -812,14 +842,60 @@ describe('/v1', function() { } }).then(function(res) { assert.equal(res.statusCode, 200); - return db.getClients().then(function(clients) { + + return db.getClients(VEMAIL).then(function(clients) { assert.equal(res.result.clients.length, clients.length); + assert.equal(res.result.clients.length, 0); }); }); }); + + it('should return a list of clients for a developer', function() { + var uid, vemail, tok; + + return getUniqueUserAndToken(clientId) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + // make this user a developer + return db.activateDeveloper(vemail); + }).then(function() { + return db.getDeveloper(vemail); + }).then(function(developer) { + var devId = developer.developerId; + return db.registerClientDeveloper(devId, clientId); + }).then(function () { + return Server.internal.api.get({ + url: '/clients', + headers: { + authorization: 'Bearer ' + tok + } + }); + }).then(function(res) { + assert.equal(res.statusCode, 200); + return db.getClients(vemail).then(function(clients) { + assert.equal(res.result.clients.length, clients.length); + assert.equal(res.result.clients.length, 1); + }); + }); + }); }); describe('POST', function() { + var developer; + + before(function() { + return Server.internal.api.post({ + url: '/developer/activate', + headers: { + authorization: 'Bearer ' + tok + } + }).then(function(res) { + developer = res.result; + }); + }); + it('should register a client', function() { return Server.internal.api.post({ url: '/client', @@ -899,42 +975,105 @@ describe('/v1', function() { describe('POST /:id', function() { var id = unique.id(); - it('should update the client', function() { - var secret = unique.secret(); - var imageUri = 'https://example.foo.domain/logo.png'; + + it('should forbid update to unknown developers', function() { + var uid, vemail, tok, devId; + var id = unique.id(); var client = { name: 'test/api/update', id: id, - hashedSecret: encrypt.hash(secret), + hashedSecret: encrypt.hash(unique.secret()), redirectUri: 'https://example.domain', - imageUri: imageUri, + imageUri: 'https://example.com/logo.png', whitelisted: true }; - return db.registerClient(client).then(function() { - return Server.internal.api.post({ - url: '/client/' + id.toString('hex'), - headers: { - authorization: 'Bearer ' + tok, - }, - payload: { - name: 'updated', - redirect_uri: clientUri - } + + return db.registerClient(client) + .then(function () { + return getUniqueUserAndToken(id.toString('hex')); + }) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + + return db.activateDeveloper(vemail); + }).then(function () { + return db.getDeveloper(vemail); + }).then(function (developer) { + devId = developer.developerId; + }).then(function () { + return Server.internal.api.post({ + url: '/client/' + id.toString('hex'), + headers: { + authorization: 'Bearer ' + tok, + }, + payload: { + name: 'updated', + redirect_uri: clientUri + } + }); + }).then(function (res) { + assert.equal(res.statusCode, 401); }); - }).then(function(res) { - assert.equal(res.statusCode, 200); - assert.equal(res.payload, '{}'); - return db.getClient(id); - }).then(function(klient) { - assert.equal(klient.name, 'updated'); - assert.equal(klient.redirectUri, clientUri); - assert.equal(klient.imageUri, imageUri); - assert.equal(klient.whitelisted, true); - assert.equal(klient.canGrant, false); - }); }); - it('should forbid unknown properties', function() { + it('should allow client update', function() { + var uid, vemail, tok, devId; + var id = unique.id(); + var client = { + name: 'test/api/update2', + id: id, + hashedSecret: encrypt.hash(unique.secret()), + redirectUri: 'https://example.domain', + imageUri: 'https://example.com/logo.png', + whitelisted: true + }; + + return db.registerClient(client) + .then(function () { + return getUniqueUserAndToken(id.toString('hex')); + }) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + + return db.activateDeveloper(vemail); + }).then(function () { + return db.getDeveloper(vemail); + }).then(function (developer) { + devId = developer.developerId; + }).then(function () { + return db.registerClientDeveloper( + devId.toString('hex'), + id.toString('hex') + ); + }).then(function () { + return Server.internal.api.post({ + url: '/client/' + id.toString('hex'), + headers: { + authorization: 'Bearer ' + tok, + }, + payload: { + name: 'updated', + redirect_uri: clientUri + } + }); + }).then(function (res) { + assert.equal(res.statusCode, 200); + assert.equal(res.payload, '{}'); + return db.getClient(client.id); + }).then(function (klient) { + assert.equal(klient.name, 'updated'); + assert.equal(klient.redirectUri, clientUri); + assert.equal(klient.imageUri, client.imageUri); + assert.equal(klient.whitelisted, true); + assert.equal(klient.canGrant, false); + }); + }); + + it('should forbid unknown properties', function () { return Server.internal.api.post({ url: '/client/' + id.toString('hex'), headers: { @@ -972,33 +1111,97 @@ describe('/v1', function() { }); describe('DELETE /:id', function() { - var id = unique.id(); + it('should delete the client', function() { - var secret = unique.secret(); + var uid, vemail, tok, devId; + var id = unique.id(); var client = { - name: 'test/api/delete', + name: 'test/api/deleteOwner', id: id, - hashedSecret: encrypt.hash(secret), - redirectUri: clientUri, - imageUri: clientUri, + hashedSecret: encrypt.hash(unique.secret()), + redirectUri: 'https://example.domain', + imageUri: 'https://example.com/logo.png', whitelisted: true }; - return db.registerClient(client).then(function() { - return Server.internal.api.delete({ - url: '/client/' + id.toString('hex'), - headers: { - authorization: 'Bearer ' + tok, - } + + return db.registerClient(client) + .then(function () { + return getUniqueUserAndToken(id.toString('hex')); + }) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + + return db.activateDeveloper(vemail); + }).then(function () { + return db.getDeveloper(vemail); + }).then(function (developer) { + devId = developer.developerId; + }).then(function () { + return db.registerClientDeveloper( + devId.toString('hex'), + id.toString('hex') + ); + }).then(function () { + return Server.internal.api.delete({ + url: '/client/' + id.toString('hex'), + headers: { + authorization: 'Bearer ' + tok, + } + }); + }).then(function(res) { + assert.equal(res.statusCode, 204); + return db.getClient(id); + }).then(function(client) { + assert.equal(client, undefined); + }); + }); + + it('should not delete the client if not owner', function() { + var uid, vemail, tok, devId; + var id = unique.id(); + var client = { + name: 'test/api/deleteOwner', + id: id, + hashedSecret: encrypt.hash(unique.secret()), + redirectUri: 'https://example.domain', + imageUri: 'https://example.com/logo.png', + whitelisted: true + }; + + return db.registerClient(client) + .then(function () { + return getUniqueUserAndToken(id.toString('hex')); + }) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + + return db.activateDeveloper(vemail); + }).then(function () { + return db.getDeveloper(vemail); + }).then(function (developer) { + devId = developer.developerId; + }).then(function () { + return Server.internal.api.delete({ + url: '/client/' + id.toString('hex'), + headers: { + authorization: 'Bearer ' + tok, + } + }); + }).then(function(res) { + assert.equal(res.statusCode, 401); + return db.getClient(id); + }).then(function(klient) { + assert.equal(klient.id.toString('hex'), id.toString('hex')); }); - }).then(function(res) { - assert.equal(res.statusCode, 204); - return db.getClient(id); - }).then(function(client) { - assert.equal(client, undefined); - }); }); it('should require authorization', function() { + var id = unique.id(); + return Server.internal.api.delete({ url: '/client/' + id.toString('hex'), payload: { @@ -1010,6 +1213,8 @@ describe('/v1', function() { }); it('should check the whitelist', function() { + var id = unique.id(); + return Server.internal.api.delete({ url: '/client/' + id.toString('hex'), headers: { @@ -1023,6 +1228,54 @@ describe('/v1', function() { }); }); + + describe('/developer', function() { + describe('POST /developer/activate', function() { + it('should create a developer', function(done) { + var uid, vemail, tok; + + return getUniqueUserAndToken(clientId) + .then(function(data) { + tok = data.token; + uid = data.uid; + vemail = data.email; + + return db.getDeveloper(vemail); + }).then(function(developer) { + assert.equal(developer, null); + + return Server.internal.api.post({ + url: '/developer/activate', + headers: { + authorization: 'Bearer ' + tok + } + }); + + }).then(function(res) { + assert.equal(res.statusCode, 200); + assert.equal(res.result.email, vemail); + assert(res.result.developerId); + assert(res.result.createdAt); + + return db.getDeveloper(vemail); + }).then(function(developer) { + + assert.equal(developer.email, vemail); + }).done(done, done); + }); + }); + + describe('GET /developer', function() { + it('should not exist', function(done) { + Server.internal.api.get('/developer') + .then(function(res) { + assert.equal(res.statusCode, 404); + }).done(done, done); + }); + }); + + }); + describe('/verify', function() { describe('unknown token', function() { diff --git a/test/db.js b/test/db.js index 42a542590..fbae0d2f4 100644 --- a/test/db.js +++ b/test/db.js @@ -142,4 +142,157 @@ describe('db', function() { }); }); + describe('developers', function () { + + describe('removeDeveloper', function() { + it('should not fail on non-existent developers', function() { + return db.removeDeveloper('unknown@developer.com'); + }); + + it('should delete developers', function() { + var email = 'email' + randomString(10) + '@mozilla.com'; + + return db.activateDeveloper(email) + .then(function(developer) { + assert.equal(developer.email, email); + + return db.removeDeveloper(email); + }) + .then(function() { + return db.getDeveloper(email); + }) + .done(function(developer) { + assert.equal(developer, null); + }); + }); + }); + + describe('getDeveloper', function() { + it('should return null if developer does not exit', function() { + return db.getDeveloper('unknown@developer.com') + .then(function(developer) { + assert.equal(developer, null); + }); + }); + + it('should throw on empty email', function() { + return db.getDeveloper() + .done( + assert.fail, + function(err) { + assert.equal(err.message, 'Email is required'); + } + ); + }); + + }); + + describe('activateDeveloper and getDeveloper', function() { + it('should create developers', function() { + var email = 'email' + randomString(10) + '@mozilla.com'; + + return db.activateDeveloper(email) + .done(function(developer) { + assert.equal(developer.email, email); + }); + }); + + it('should not allow duplicates', function() { + var email = 'email' + randomString(10) + '@mozilla.com'; + + return db.activateDeveloper(email) + .then(function() { + return db.activateDeveloper(email); + }) + .done( + function() { + assert.fail(); + }, + function(err) { + assert.equal(err.message.indexOf('ER_DUP_ENTRY') >= 0, true); + } + ); + }); + + it('should throw on empty email', function() { + return db.activateDeveloper() + .done( + assert.fail, + function(err) { + assert.equal(err.message, 'Email is required'); + } + ); + }); + + }); + + describe('registerClientDeveloper and developerOwnsClient', function() { + var clientId = buf(randomString(8)); + var userId = buf(randomString(16)); + var email = 'a@b.c'; + var scope = ['no-scope']; + var code = null; + var token = null; + + before(function() { + return db.registerClient({ + id: clientId, + name: 'registerClientDeveloper', + hashedSecret: randomString(32), + imageUri: 'https://example.domain/logo', + redirectUri: 'https://example.domain/return?foo=bar', + whitelisted: true + }).then(function() { + return db.generateCode(clientId, userId, email, scope, 0); + }).then(function(c) { + code = c; + return db.getCode(code); + }).then(function(code) { + assert.equal(hex(code.userId), hex(userId)); + return db.generateToken({ + clientId: clientId, + userId: userId, + email: email, + scope: scope + }); + }).then(function(t) { + token = t.token; + assert.equal(hex(t.userId), hex(userId), 'token userId'); + }); + }); + + it('should attach a developer to a client', function(done) { + var email = 'email' + randomString(10) + '@mozilla.com'; + + return db.activateDeveloper(email) + .then(function(developer) { + return db.registerClientDeveloper( + hex(developer.developerId), + hex(clientId) + ); + }) + .then(function() { + return db.getClientDevelopers(hex(clientId)); + }) + .done(function(developers) { + if (developers) { + var found = false; + + developers.forEach(function(developer) { + if (developer.email === email) { + found = true; + } + }); + + assert.equal(found, true); + return done(); + } + }, done); + + }); + + }); + + }); + });