X-Git-Url: http://git.squeep.com/?p=squeep-indie-auther;a=blobdiff_plain;f=test%2Fsrc%2Fmanager.js;fp=test%2Fsrc%2Fmanager.js;h=7336e1fb7989ad716543ead8daed5017ce93bc44;hp=0000000000000000000000000000000000000000;hb=b0103b0d496262c438b40bc20304081dbfe41e73;hpb=8ed81748bce7cea7904cac7225b20a60cafdfc16 diff --git a/test/src/manager.js b/test/src/manager.js new file mode 100644 index 0000000..7336e1f --- /dev/null +++ b/test/src/manager.js @@ -0,0 +1,2323 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const Manager = require('../../src/manager'); +const Config = require('../../config'); +const Enum = require('../../src/enum'); +const { ResponseError } = require('../../src/errors'); +const { UnexpectedResult } = require('../../src/db/errors'); +const dns = require('dns'); + +const StubDatabase = require('../stub-db'); +const StubLogger = require('../stub-logger'); + +const expectedException = new Error('oh no'); +const noExpectedException = 'did not get expected exception'; + +describe('Manager', function () { + let manager, options, stubDb, logger; + let req, res, ctx; + + beforeEach(function () { + logger = new StubLogger(); + logger._reset(); + stubDb = new StubDatabase(); + stubDb._reset(); + options = new Config('test'); + req = { + getHeader : sinon.stub(), + }; + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + statusCode: 200, + }; + ctx = { + params: {}, + parsedBody: {}, + queryParams: {}, + session: {}, + errors: [], + notifications: [], + }; + manager = new Manager(logger, stubDb, options); + sinon.stub(manager.communication, 'fetchProfile'); + sinon.stub(manager.communication, 'fetchClientIdentifier'); + sinon.stub(manager.communication, 'deliverTicket'); + sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.13' }]); + sinon.stub(manager.queuePublisher, 'connect'); + sinon.stub(manager.queuePublisher, 'establishAMQPPlumbing'); + sinon.stub(manager.queuePublisher, 'publish'); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('constructor', function () { + it('instantiates', function () { + assert(manager); + }); + it('covers no queuing', function () { + options.queues.amqp.url = undefined; + manager = new Manager(logger, stubDb, options); + assert(manager); + }); + }); // constructor + + describe('initialize', function () { + let spy; + beforeEach(function () { + spy = sinon.spy(manager, '_connectQueues'); + }); + it('covers', async function () { + await manager.initialize(); + assert(spy.called); + }); + it('covers no queue', async function () { + delete options.queues.amqp.url; + manager = new Manager(logger, stubDb, options); + await manager.initialize(); + assert(spy.notCalled); + }); + }); // initialize + + describe('getRoot', function () { + it('normal response', async function () { + await manager.getRoot(res, ctx); + assert(res.end.called); + }); + }); // getRoot + + describe('getMeta', function () { + it('normal response', async function () { + await manager.getMeta(res, ctx); + assert(res.end.called); + JSON.parse(res.end.args[0][0]); + }); + it('covers no ticket queue', async function () { + delete options.queues.amqp.url; + manager = new Manager(logger, stubDb, options); + await manager.getMeta(res, ctx); + assert(res.end.called); + }); + }); // getMeta + + describe('getHealthcheck', function () { + it('normal response', async function () { + await manager.getHealthcheck(res, ctx); + assert(res.end.called); + }); + }); // getHealthcheck + + describe('getAuthorization', function () { + it('covers missing redirect fields', async function () { + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 400); + }); + it('requires a configured profile', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + }, + profiles: [], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'state': '123456', + 'code_challenge_method': 'S256', + 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog', + 'scope': 'profile email', + }); + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert(ctx.session.error); + assert(res.setHeader.called); + }); + it('covers valid', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'state': '123456', + 'code_challenge_method': 'S256', + 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog', + 'scope': 'profile email', + 'me': 'https://profile.example.com/', + }); + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(ctx.session.error, undefined); + assert.strictEqual(ctx.session.errorDescriptions.length, 0); + assert.strictEqual(ctx.notifications.length, 0); + }); + it('succeeds with mismatched profile hint', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'state': '123456', + 'code_challenge_method': 'S256', + 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog', + 'scope': 'profile email', + 'me': 'https://somethingelse.example.com/', + }); + await manager.getAuthorization(res, ctx); + assert(!('me' in ctx.session)); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(ctx.session.error, undefined); + assert.strictEqual(ctx.session.errorDescriptions.length, 0); + }); + it('covers invalid redirect', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'blargl', + 'state': '', + 'code_challenge_method': 'S256', + 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog', + }); + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert.strictEqual(ctx.session.error, 'invalid_request'); + assert.strictEqual(ctx.session.errorDescriptions.length, 2); + }); + it('covers legacy non-PKCE missing fields', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'state': '123456', + 'scope': 'profile email', + 'me': 'https://profile.example.com/', + }); + manager.options.manager.allowLegacyNonPKCE = true; + + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(ctx.session.error, undefined); + assert.strictEqual(ctx.session.errorDescriptions.length, 0); + }); + it('rejects legacy non-PKCE not missing all fields', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'code_challenge_method': 'S256', + 'state': '123456', + 'scope': 'profile email', + 'me': 'https://profile.example.com/', + }); + manager.options.manager.allowLegacyNonPKCE = true; + + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert.strictEqual(ctx.session.error, 'invalid_request'); + assert.strictEqual(ctx.session.errorDescriptions.length, 1); + }); + it('rejects legacy non-PKCE not missing all fields', async function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'create': { + description: '', + profiles: ['https://profile.example.com'], + }, + }, + }, + scopeIndex: { + 'profile': { + description: '', + profiles: [], + }, + 'email': { + description: '', + profiles: [], + }, + 'create': { + description: '', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.communication.fetchClientIdentifier.resolves({ + items: [], + }); + ctx.authenticationId = 'username'; + Object.assign(ctx.queryParams, { + 'client_id': 'https://client.example.com/', + 'redirect_uri': 'https://client.example.com/action', + 'response_type': 'code', + 'code_challenge': 'xxx', + 'state': '123456', + 'scope': 'profile email', + 'me': 'https://profile.example.com/', + }); + manager.options.manager.allowLegacyNonPKCE = true; + + await manager.getAuthorization(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert.strictEqual(ctx.session.error, 'invalid_request'); + assert.strictEqual(ctx.session.errorDescriptions.length, 1); + }); }); // getAuthorization + + describe('_setError', function () { + it('covers', function () { + const err = 'invalid_request'; + const errDesc = 'something went wrong'; + Manager._setError(ctx, err, errDesc); + }); + it('covers bad error', function () { + const err = 'floopy'; + const errDesc = 'something went wrong'; + try { + Manager._setError(ctx, err, errDesc); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof RangeError); + } + }); + it('covers invalid error description', function () { + const err = 'invalid_scope'; + const errDesc = 'something "went wrong"!'; + try { + Manager._setError(ctx, err, errDesc); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof RangeError); + } + }); + }); // _setError + + describe('_clientIdRequired', function () { + let clientIdentifier; + beforeEach(function () { + clientIdentifier = { + // h-card here + }; + manager.communication.fetchClientIdentifier.resolves(clientIdentifier); + }); + it('covers valid', async function () { + ctx.queryParams['client_id'] = 'https://client.example.com/'; + + await manager._clientIdRequired(ctx); + + assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires client_id', async function () { + ctx.queryParams['client_id'] = undefined; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('requires valid client_id', async function () { + ctx.queryParams['client_id'] = 'not a url'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects strange schema', async function () { + ctx.queryParams['client_id'] = 'file:///etc/shadow'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects un-allowed parts', async function () { + ctx.queryParams['client_id'] = 'https://user:pass@client.example.com/#here'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects relative paths', async function () { + ctx.queryParams['client_id'] = 'https://client.example.com/x/../y/'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects ipv6 hostname', async function () { + ctx.queryParams['client_id'] = 'https://[fd12:3456:789a:1::1]/'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('allows ipv6 loopback hostname', async function () { + ctx.queryParams['client_id'] = 'https://[::1]/'; + + await manager._clientIdRequired(ctx); + + assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('rejects ipv4 hostname', async function () { + ctx.queryParams['client_id'] = 'https://10.9.8.7/'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('allows ipv4 loopback hostname', async function () { + ctx.queryParams['client_id'] = 'https:/127.0.10.100/'; + + await manager._clientIdRequired(ctx); + + assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires response', async function () { + manager.communication.fetchClientIdentifier.restore(); + sinon.stub(manager.communication, 'fetchClientIdentifier').resolves(); + ctx.queryParams['client_id'] = 'https://client.example.com/'; + + await manager._clientIdRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + }); // _clientIdRequired + + describe('_redirectURIRequired', function () { + beforeEach(function () { + ctx.session.clientId = new URL('https://client.example.com/'); + ctx.session.clientIdentifier = { + rels: { + 'redirect_uri': ['https://alternate.example.com/', 'https://other.example.com/'], + }, + }; + }); + it('covers valid', function () { + ctx.queryParams['redirect_uri'] = 'https://client.example.com/return'; + + Manager._redirectURIRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires redirect_uri', function () { + ctx.queryParams['redirect_uri'] = undefined; + + Manager._redirectURIRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('requires valid redirect_uri', function () { + ctx.queryParams['redirect_uri'] = 'not a url'; + + Manager._redirectURIRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects no matching alternate redirect_uri from client_id', function () { + ctx.queryParams['redirect_uri'] = 'https://unlisted.example.com/'; + + Manager._redirectURIRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('allows alternate redirect_uri from client_id', function () { + ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/'; + + Manager._redirectURIRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + }); // _redirectURIRequired + + describe('_responseTypeRequired', function () { + it('covers valid', function () { + ctx.queryParams['response_type'] = 'code'; + + Manager._responseTypeRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires response_type', function () { + ctx.queryParams['response_type'] = undefined; + + Manager._responseTypeRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects invalid', function () { + ctx.queryParams['response_type'] = 'flarp'; + + Manager._responseTypeRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + }); // _responseTypeRequired + + describe('_stateRequired', function () { + it('covers valid', function () { + ctx.queryParams['state'] = 'StateStateState'; + + Manager._stateRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires state', function () { + ctx.queryParams['state'] = undefined; + + Manager._stateRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + }); // _stateRequired + + describe('_codeChallengeMethodRequired', function () { + it('covers valid', function () { + ctx.queryParams['code_challenge_method'] = 'S256'; + + manager._codeChallengeMethodRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires code_challenge_method', function () { + ctx.queryParams['code_challenge_method'] = undefined; + + manager._codeChallengeMethodRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects invalid', function () { + ctx.queryParams['code_challenge_method'] = 'MD5'; + + manager._codeChallengeMethodRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('covers legacy non-PKCE', function () { + ctx.queryParams['code_challenge_method'] = undefined; + manager.options.manager.allowLegacyNonPKCE = true; + + manager._codeChallengeMethodRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + }); // _codeChallengeMethodRequired + + describe('_codeChallengeRequired', function () { + it('covers valid', function () { + ctx.queryParams['code_challenge'] = 'NBKNqs1TfjQFqpewPNOstmQ5MJnLoeTTbjqtQ9JbZOo'; + + manager._codeChallengeRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + }); + it('requires code_challenge', function () { + ctx.queryParams['code_challenge'] = undefined; + + manager._codeChallengeRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('rejects invalid', function () { + ctx.queryParams['code_challenge'] = 'not base64/url encoded'; + + manager._codeChallengeRequired(ctx); + + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('covers legacy non-PKCE', function () { + ctx.queryParams['code_challenge'] = undefined; + manager.options.manager.allowLegacyNonPKCE = true; + + manager._codeChallengeRequired(ctx); + + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + + }); + }); // _codeChallengeRequired + + describe('_redirectURIRequired', function () { + beforeEach(function () { + sinon.stub(Manager, '_setError'); + ctx.queryParams['redirect_uri'] = 'https://example.com/redirect'; + ctx.session.clientId = new URL('https://example.com/'); + }); + it('requires redirect_uri', function () { + delete ctx.queryParams['redirect_uri']; + Manager._redirectURIRequired(ctx); + assert(Manager._setError.called); + }); + it('requires valid redirect_uri', function () { + ctx.queryParams['redirect_uri'] = 'not a uri'; + Manager._redirectURIRequired(ctx); + assert(Manager._setError.called); + }); + it('sets redirectUri if no clientId', function () { + delete ctx.session.clientId; + Manager._redirectURIRequired(ctx); + assert(Manager._setError.notCalled); + assert(ctx.session.redirectUri instanceof URL); + }); + it('sets redirectUri if clientId matches', function () { + Manager._redirectURIRequired(ctx); + assert(Manager._setError.notCalled); + assert(ctx.session.redirectUri instanceof URL); + }); + it('rejects mis-matched', function () { + ctx.queryParams['redirect_uri'] = 'https://example.com:8080/redirect'; + Manager._redirectURIRequired(ctx); + assert(Manager._setError.called); + assert.strictEqual(ctx.session.redirectUri, undefined); + }); + it('allows client-specified alternate redirect uri', function () { + ctx.session.clientIdentifier = { + rels: { + 'redirect_uri': ['https://alternate.example.com/redirect'], + }, + }; + ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/redirect'; + Manager._redirectURIRequired(ctx); + assert(Manager._setError.notCalled); + assert(ctx.session.redirectUri instanceof URL); + }); + }); // _redirectURIRequired + + describe('_scopeOptional', function () { + it('covers valid', function () { + ctx.queryParams['scope'] = 'profile email'; + manager._scopeOptional(ctx); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + assert.strictEqual(ctx.session.scope.length, 2); + }); + it('allows empty', function () { + ctx.queryParams['scope'] = undefined; + manager._scopeOptional(ctx); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + assert.strictEqual(ctx.session.scope.length, 0); + }); + it('rejects invalid scope combination', function () { + ctx.queryParams['scope'] = 'email'; + manager._scopeOptional(ctx); + assert(ctx.session.error); + assert(ctx.session.errorDescriptions.length); + }); + it('ignores invalid scope', function () { + ctx.queryParams['scope'] = 'profile email "funny_business"'; + manager._scopeOptional(ctx); + assert.strictEqual(ctx.session.error, undefined); + assert.deepStrictEqual(ctx.session.errorDescriptions, undefined); + assert.strictEqual(ctx.session.scope.length, 2); + }); + }); // _scopeOptional + + describe('_meOptional', function () { + this.beforeEach(function () { + ctx.queryParams['me'] = 'https://profile.example.com/'; + }); + it('covers valid', async function () { + await manager._meOptional(ctx); + + assert.strictEqual(ctx.session.me.href, ctx.queryParams['me']); + }); + it('ignore invalid', async function () { + ctx.queryParams['me'] = 'not a url'; + + await manager._meOptional(ctx); + + assert.strictEqual(ctx.session.me, undefined); + }); + it('allows empty', async function () { + ctx.queryParams['me'] = undefined; + + await manager._meOptional(ctx); + + assert.strictEqual(ctx.session.me, undefined); + }); + }); // _meOptional + + describe('_profileValidForIdentifier', function () { + beforeEach(function () { + ctx.session = { + profiles: ['https://profile.example.com/', 'https://example.com/profile'], + me: new URL('https://example.com/profile'), + }; + }); + it('covers valid', async function () { + + const result = await manager._profileValidForIdentifier(ctx); + + assert.strictEqual(result, true); + }); + it('covers missing me', async function () { + delete ctx.session.me; + + const result = await manager._profileValidForIdentifier(ctx); + + assert.strictEqual(result, false); + }); + }); // _profileValidForIdentifier + + describe('_parseLifespan', function () { + let field, customField; + beforeEach(function () { + field = 'lifespan'; + customField = 'lifespan-seconds'; + ctx.parsedBody['lifespan'] = undefined; + ctx.parsedBody['lifespan-seconds'] = undefined; + }); + it('returns nothing without fields', function () { + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, undefined); + }); + it('returns nothing for unrecognized field', function () { + ctx.parsedBody['lifespan'] = 'a while'; + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, undefined); + }); + it('returns recognized preset value', function () { + ctx.parsedBody['lifespan'] = '1d'; + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, 86400); + }); + it('returns valid custom value', function () { + ctx.parsedBody['lifespan'] = 'custom'; + ctx.parsedBody['lifespan-seconds'] = '123'; + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, 123); + }); + it('returns nothing for invalid custom value', function () { + ctx.parsedBody['lifespan'] = 'custom'; + ctx.parsedBody['lifespan-seconds'] = 'Not a number'; + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, undefined); + }); + it('returns nothing for invalid custom value', function () { + ctx.parsedBody['lifespan'] = 'custom'; + ctx.parsedBody['lifespan-seconds'] = '-50'; + const result = manager._parseLifespan(ctx, field, customField); + assert.strictEqual(result, undefined); + }); + }); // _parseLifespan + + describe('_parseConsentScopes', function () { + it('covers no scopes', function () { + const result = manager._parseConsentScopes(ctx); + assert.deepStrictEqual(result, []); + }); + it('filters invalid scopes', function () { + ctx.parsedBody['accepted_scopes'] = ['read', 'email']; + ctx.parsedBody['ad_hoc_scopes'] = 'bad"scope create '; + const result = manager._parseConsentScopes(ctx); + assert.deepStrictEqual(result, ['read', 'create']); + }); + }); // _parseConsentScopes + + describe('_parseConsentMe', function () { + beforeEach(function () { + ctx.session.profiles = ['https://me.example.com/']; + }); + it('covers valid', function () { + const expected = 'https://me.example.com/'; + ctx.parsedBody['me'] = expected; + const result = manager._parseConsentMe(ctx); + assert(result); + assert.strictEqual(result.href, expected); + }); + it('rejects unsupported', function () { + ctx.parsedBody['me'] = 'https://notme.example.com/'; + const result = manager._parseConsentMe(ctx); + assert(!result); + assert(ctx.session.error); + }); + it('rejects invalid', function () { + ctx.parsedBody['me'] = 'bagel'; + const result = manager._parseConsentMe(ctx); + assert(!result); + assert(ctx.session.error); + }); + }); // _parseConsentMe + + describe('_fetchConsentProfileData', function () { + let profileResponse; + beforeEach(function () { + profileResponse = { + url: 'https://profile.example.com/', + }; + manager.communication.fetchProfile.resolves(profileResponse); + }); + it('covers success', async function () { + const expected = profileResponse; + const result = await manager._fetchConsentProfileData(ctx); + assert.deepStrictEqual(result, expected); + assert(!ctx.session.error); + }); + it('covers empty response', async function () { + manager.communication.fetchProfile.resolves(); + const result = await manager._fetchConsentProfileData(ctx); + assert.deepStrictEqual(result, undefined); + assert(ctx.session.error); + }); + it('covers failure', async function () { + manager.communication.fetchProfile.rejects(); + const result = await manager._fetchConsentProfileData(ctx); + assert.deepStrictEqual(result, undefined); + assert(ctx.session.error); + }); + }); // _fetchConsentProfileData + + describe('postConsent', function () { + let oldSession; + beforeEach(function () { + sinon.stub(manager.mysteryBox, 'unpack'); + sinon.stub(manager.mysteryBox, 'pack'); + manager.communication.fetchProfile.resolves({ + url: 'https://profile.example.com/', + }); + oldSession = { + clientId: 'https://example.com/', + redirectUri: 'https://example.com/_redirect', + profiles: ['https://profile.example.com/'], + }; + manager.mysteryBox.unpack.resolves(oldSession); + ctx.parsedBody['me'] = 'https://profile.example.com/'; + ctx.parsedBody['accept'] = 'true'; + }); + it('covers valid', async function () { + await manager.postConsent(res, ctx); + assert(!ctx.session.error, ctx.session.error); + assert.strictEqual(res.statusCode, 302); + }); + it('covers valid with expiration and refresh', async function () { + ctx.parsedBody['expires'] = '1d'; + ctx.parsedBody['refresh'] = '1w'; + await manager.postConsent(res, ctx); + assert(!ctx.session.error, ctx.session.error); + assert.strictEqual(res.statusCode, 302); + }); + it('covers denial', async function () { + ctx.parsedBody['accept'] = 'false'; + await manager.postConsent(res, ctx); + assert(ctx.session.error); + assert.strictEqual(ctx.session.error, 'access_denied'); + assert.strictEqual(res.statusCode, 302); + }); + it('covers profile fetch failure', async function () { + manager.communication.fetchProfile.resolves(); + await manager.postConsent(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert(ctx.session.error); + }); + it('covers bad code', async function () { + manager.mysteryBox.unpack.rejects(); + await manager.postConsent(res, ctx); + assert.strictEqual(res.statusCode, 400); + assert(ctx.session.error); + }); + it('removes email scope without profile', async function () { + ctx.parsedBody['accepted_scopes'] = ['email', 'create']; + await manager.postConsent(res, ctx); + assert(!ctx.session.acceptedScopes.includes('email')); + }); + it('merges valid ad-hoc scopes', async function () { + ctx.parsedBody['accepted_scopes'] = ['email', 'create']; + ctx.parsedBody['ad_hoc_scopes'] = ' my:scope "badScope'; + await manager.postConsent(res, ctx); + assert(ctx.session.acceptedScopes.includes('my:scope')); + }); + it('covers invalid selected me profile', async function () { + ctx.parsedBody['me'] = 'https://different.example.com/'; + await manager.postConsent(res, ctx); + assert(ctx.session.error); + }); + it('covers invalid me url', async function () { + ctx.parsedBody['me'] = 'bagel'; + await manager.postConsent(res, ctx); + assert(ctx.session.error); + }); + it('covers profile fetch error', async function () { + manager.communication.fetchProfile.rejects(expectedException); + await manager.postConsent(res, ctx); + assert.strictEqual(res.statusCode, 302); + assert(ctx.session.error); + }); + }); // postConsent + + describe('postAuthorization', function () { + let code, parsedBody; + beforeEach(function () { + sinon.stub(manager.mysteryBox, 'unpack'); + code = { + codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a', + codeChallengeMethod: 'S256', + codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ', + clientId: 'https://app.example.com/', + redirectUri: 'https://app.example.com/_redirect', + acceptedScopes: ['profile'], + minted: Date.now(), + me: 'https://client.example.com/', + identifier: 'username', + profile: { + name: 'Firsty McLastname', + email: 'f.mclastname@example.com', + }, + }; + parsedBody = { + code: 'codeCodeCode', + 'client_id': 'https://app.example.com/', + 'redirect_uri': 'https://app.example.com/_redirect', + 'grant_type': 'authorization_code', + 'code_verifier': 'verifier', + }; + }); + it('covers valid', async function () { + manager.db.redeemCode.resolves(true); + manager.mysteryBox.unpack.resolves(code); + Object.assign(ctx.parsedBody, parsedBody); + + await manager.postAuthorization(res, ctx); + assert(!ctx.session.error, ctx.session.error); + assert(!res.end.firstCall.args[0].includes('email')); + }); + it('includes email if accepted in scope', async function () { + code.acceptedScopes = ['profile', 'email']; + manager.db.redeemCode.resolves(true); + manager.mysteryBox.unpack.resolves(code); + Object.assign(ctx.parsedBody, parsedBody); + + await manager.postAuthorization(res, ctx); + assert(!ctx.session.error); + assert(res.end.firstCall.args[0].includes('email')); + }); + it('fails if already redeemed', async function () { + manager.db.redeemCode.resolves(false); + manager.mysteryBox.unpack.resolves(code); + Object.assign(ctx.parsedBody, parsedBody); + + await manager.postAuthorization(res, ctx); + assert(ctx.session.error); + }); + it('covers bad request', async function () { + manager.mysteryBox.unpack.rejects(expectedException); + Object.assign(ctx.parsedBody, parsedBody); + + await manager.postAuthorization(res, ctx); + assert(ctx.session.error); + }); + }); // postAuthorization + + describe('_ingestPostAuthorizationRequest', function () { + beforeEach(function () { + sinon.stub(manager, '_restoreSessionFromCode'); + sinon.stub(manager, '_checkSessionMatchingClientId'); + sinon.stub(manager, '_checkSessionMatchingRedirectUri'); + sinon.stub(manager, '_checkGrantType'); + sinon.stub(manager, '_checkSessionMatchingCodeVerifier'); + }); + it('covers valid', async function () { + manager._restoreSessionFromCode.callsFake((ctx) => { + ctx.session = { + me: 'https://profile.example.com/', + minted: Date.now(), + }; + }); + + await manager._ingestPostAuthorizationRequest(ctx); + assert(!ctx.session.error); + }); + it('requires data', async function () { + delete ctx.parsedBody; + await manager._ingestPostAuthorizationRequest(ctx); + assert(ctx.session.error); + }); + it('requires me field', async function () { + manager._restoreSessionFromCode.callsFake((ctx) => { + ctx.session = { + minted: Date.now(), + }; + }); + await manager._ingestPostAuthorizationRequest(ctx); + assert(ctx.session.error); + }); + it('requires minted field', async function () { + manager._restoreSessionFromCode.callsFake((ctx) => { + ctx.session = { + me: 'https://profile.example.com/', + }; + }); + await manager._ingestPostAuthorizationRequest(ctx); + assert(ctx.session.error); + }); + it('rejects expired code', async function () { + manager._restoreSessionFromCode.callsFake((ctx) => { + ctx.session = { + me: 'https://profile.example.com/', + minted: Date.now() - 86400000, + }; + }); + + await manager._ingestPostAuthorizationRequest(ctx); + assert(ctx.session.error); + }); + }); // _ingestPostAuthorizationRequest + + describe('_restoreSessionFromCode', function () { + let unpackedCode; + beforeEach(function () { + sinon.stub(manager.mysteryBox, 'unpack'); + unpackedCode = { + codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a', + codeChallengeMethod: 'S256', + codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ', + clientId: 'https://app.example.com/', + redirectUri: 'https://app.example.com/_redirect', + acceptedScopes: ['profile'], + minted: Date.now(), + me: 'https://client.example.com/', + identifier: 'username', + profile: { + name: 'Firsty McLastname', + email: 'f.mclastname@example.com', + }, + }; + }); + it('covers valid', async function () { + ctx.parsedBody['code'] = 'codeCodeCode'; + manager.mysteryBox.unpack.resolves(unpackedCode); + const expected = Object.assign({}, ctx, { + session: unpackedCode, + }); + await manager._restoreSessionFromCode(ctx); + assert.deepStrictEqual(ctx, expected); + assert(!ctx.session.error); + }); + it('requires code', async function () { + ctx.parsedBody['code'] = ''; + manager.mysteryBox.unpack.resolves({ + me: 'https://example.com/me', + }); + await manager._restoreSessionFromCode(ctx); + assert(ctx.session.error); + }); + it('covers invalid code', async function () { + ctx.parsedBody['code'] = 'codeCodeCode'; + manager.mysteryBox.unpack.rejects(); + await manager._restoreSessionFromCode(ctx); + assert(ctx.session.error); + }); + it('covers missing code fields', async function () { + ctx.parsedBody['code'] = 'codeCodeCode'; + delete unpackedCode.clientId; + manager.mysteryBox.unpack.resolves(unpackedCode); + await manager._restoreSessionFromCode(ctx); + assert(ctx.session.error); + }); + it('covers legacy non-PKCE missing fields', async function () { + ctx.parsedBody['code'] = 'codeCodeCode'; + delete unpackedCode.codeChallengeMethod; + delete unpackedCode.codeChallenge; + manager.mysteryBox.unpack.resolves(unpackedCode); + manager.options.manager.allowLegacyNonPKCE = true; + const expected = Object.assign({}, ctx, { + session: unpackedCode, + }); + await manager._restoreSessionFromCode(ctx); + assert.deepStrictEqual(ctx, expected); + assert(!ctx.session.error); + }); + }); // _restoreSessionFromCode + + describe('_checkSessionMatchingClientId', function () { + it('covers valid', async function () { + ctx.session = { + clientId: 'https://client.example.com/', + }; + ctx.parsedBody['client_id'] = 'https://client.example.com/'; + + manager._checkSessionMatchingClientId(ctx); + assert(!ctx.session.error); + }); + it('covers missing', async function () { + ctx.session = { + clientId: 'https://client.example.com/', + }; + ctx.parsedBody['client_id'] = undefined; + + manager._checkSessionMatchingClientId(ctx); + assert(ctx.session.error); + }); + it('covers un-parsable', async function () { + ctx.session = { + clientId: 'https://client.example.com/', + }; + ctx.parsedBody['client_id'] = 'not a url'; + + manager._checkSessionMatchingClientId(ctx); + assert(ctx.session.error); + }); + it('covers mismatch', async function () { + ctx.session = { + clientId: 'https://client.example.com/', + }; + ctx.parsedBody['client_id'] = 'https://otherclient.example.com/'; + + manager._checkSessionMatchingClientId(ctx); + assert(ctx.session.error); + }); + }); // _checkSessionMatchingClientId + + describe('_checkSessionMatchingRedirectUri', function () { + it('covers valid', async function () { + ctx.parsedBody['redirect_uri'] = 'https://client.example.com/_redirect'; + ctx.session.redirectUri = 'https://client.example.com/_redirect'; + + manager._checkSessionMatchingRedirectUri(ctx); + assert(!ctx.session.error); + }); + it('requires field', async function () { + ctx.parsedBody['redirect_uri'] = undefined; + ctx.session.redirectUri = 'https://client.example.com/_redirect'; + + manager._checkSessionMatchingRedirectUri(ctx); + assert(ctx.session.error); + }); + it('requires valid field', async function () { + ctx.parsedBody['redirect_uri'] = 'not a url'; + ctx.session.redirectUri = 'https://client.example.com/_redirect'; + + manager._checkSessionMatchingRedirectUri(ctx); + assert(ctx.session.error); + }); + it('requires match', async function () { + ctx.parsedBody['redirect_uri'] = 'https://client.example.com/other'; + ctx.session.redirectUri = 'https://client.example.com/_redirect'; + + manager._checkSessionMatchingRedirectUri(ctx); + assert(ctx.session.error); + }); + }); // _checkSessionMatchingRedirectUri + + describe('_checkGrantType', function () { + it('covers valid', async function () { + ctx.parsedBody['grant_type'] = 'authorization_code'; + + manager._checkGrantType(ctx); + assert(!ctx.session.error); + }); + it('allows missing, because of one client', async function () { + ctx.parsedBody['grant_type'] = undefined; + + manager._checkGrantType(ctx); + assert(!ctx.session.error); + }); + it('rejects invalid', async function () { + ctx.parsedBody['grant_type'] = 'pigeon_dance'; + + manager._checkGrantType(ctx); + assert(ctx.session.error); + }); + }); // _checkGrantType + + describe('_checkSessionMatchingCodeVerifier', function () { + it('covers valid', async function () { + ctx.parsedBody['code_verifier'] = 'verifier'; + ctx.session.codeChallengeMethod = 'S256'; + ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ'; + + manager._checkSessionMatchingCodeVerifier(ctx); + assert(!ctx.session.error); + }); + it('requires field', async function () { + ctx.parsedBody['code_verifier'] = undefined; + ctx.session.codeChallengeMethod = 'S256'; + ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ'; + + manager._checkSessionMatchingCodeVerifier(ctx); + assert(ctx.session.error); + }); + it('requires match', async function () { + ctx.parsedBody['code_verifier'] = 'wrongverifier'; + ctx.session.codeChallengeMethod = 'S256'; + ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ'; + + manager._checkSessionMatchingCodeVerifier(ctx); + assert(ctx.session.error); + }); + it('covers legacy non-PKCE missing fields', async function () { + ctx.parsedBody['code_verifier'] = undefined; + ctx.session.codeChallengeMethod = undefined; + ctx.session.codeChallenge = undefined; + manager.options.manager.allowLegacyNonPKCE = true; + + manager._checkSessionMatchingCodeVerifier(ctx); + assert(!ctx.session.error); + }); + }); // _checkSessionMatchingCodeVerifier + + describe('postToken', function () { + let unpackedCode; + beforeEach(function () { + ctx.session.acceptedScopes = []; + unpackedCode = { + codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a', + codeChallengeMethod: 'S256', + codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ', + clientId: 'https://app.example.com/', + redirectUri: 'https://app.example.com/return', + acceptedScopes: ['profile', 'email', 'tricks'], + minted: Date.now(), + me: 'https://client.example.com/', + identifier: 'username', + profile: { + name: 'Firsty McLastname', + email: 'f.mclastname@example.com', + url: 'https://example.com/', + }, + }; + }); + describe('Revocation (legacy)', function () { + beforeEach(function () { + sinon.stub(manager, '_revokeToken'); + }); + it('covers revocation', async function () { + manager._revokeToken.resolves(); + ctx.parsedBody = { + action: 'revoke', + token: 'XXX', + }; + await manager.postToken(req, res, ctx); + assert(manager._revokeToken.called); + }); + }); // Revocation + describe('Validation (legacy)', function () { + beforeEach(function () { + sinon.stub(manager, '_validateToken'); + req.getHeader.returns({ Authorization: 'Bearer XXX' }); + }); + it('covers validation', async function () { + ctx.bearer = { isValid: true }; + await manager.postToken(req, res, ctx); + assert(manager._validateToken.called); + }); + }); // Validation + describe('Refresh', function () { + beforeEach(function () { + sinon.stub(manager, '_refreshToken'); + }); + it('covers refresh', async function () { + ctx.parsedBody['grant_type'] = 'refresh_token'; + await manager.postToken(req, res, ctx); + assert(manager._refreshToken.called); + }); + }); // Refresh + describe('Ticket Redemption', function () { + beforeEach(function () { + sinon.stub(manager, '_ticketAuthToken'); + }); + it('covers ticket', async function () { + ctx.parsedBody['grant_type'] = 'ticket'; + await manager.postToken(req, res, ctx); + assert(manager._ticketAuthToken.called); + }); + it('covers no ticket queue', async function () { + delete options.queues.amqp.url; + manager = new Manager(logger, stubDb, options); + sinon.stub(manager.communication, 'fetchProfile'); + sinon.stub(manager.communication, 'fetchClientIdentifier'); + sinon.stub(manager.communication, 'deliverTicket'); + + ctx.parsedBody['grant_type'] = 'ticket'; + await assert.rejects(() => manager.postToken(req, res, ctx), ResponseError); + }); + }); // Ticket Redemption + describe('Code Redemption', function () { + beforeEach(function () { + sinon.stub(manager.mysteryBox, 'unpack'); + sinon.spy(manager.mysteryBox, 'pack'); + manager.mysteryBox.unpack.resolves(unpackedCode); + ctx.parsedBody = { + 'redirect_uri': 'https://app.example.com/return', + 'code': 'xxx', + }; + }); + it('covers invalid code', async function () { + manager.mysteryBox.unpack.rejects(expectedException); + try { + await manager.postToken(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + } + }); + it('covers mismatched redirect', async function () { + ctx.parsedBody['redirect_uri'] = 'https://elsewhere.example.com/'; + try { + await manager.postToken(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + } + }); + it('covers success', async function () { + manager.db.redeemCode.resolves(true); + await manager.postToken(req, res, ctx); + assert(res.end.called); + assert.strictEqual(manager.mysteryBox.pack.callCount, 1); + }); + it('covers success with refresh', async function () { + manager.db.redeemCode.resolves(true); + unpackedCode.refreshLifespan = 86400; + unpackedCode.tokenLifespan = 86400; + manager.mysteryBox.unpack.resolves(unpackedCode); + await manager.postToken(req, res, ctx); + assert(res.end.called); + assert.strictEqual(manager.mysteryBox.pack.callCount, 2); + }); + it('covers redemption failure', async function () { + manager.db.redeemCode.resolves(false); + try { + await manager.postToken(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + } + }); + it('removes email from profile if not in scope', async function () { + manager.db.redeemCode.resolves(true); + unpackedCode.acceptedScopes = ['profile', 'tricks']; + manager.mysteryBox.unpack.resolves(unpackedCode); + await manager.postToken(req, res, ctx); + assert(res.end.called); + const response = JSON.parse(res.end.args[0][0]); + assert(!('email' in response.profile)); + }); + + }); // Code Redemption + describe('Invalid grant_type', function () { + it('throws response error', async function () { + ctx.parsedBody['grant_type'] = 'bad'; + try { + await manager.postToken(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + } + }); + }); // Invalid grant_type + }); // postToken + + describe('_validateToken', function () { + let dbCtx; + beforeEach(function () { + dbCtx = {}; + sinon.stub(manager, '_checkTokenValidationRequest'); + }); + it('covers valid token', async function () { + ctx.bearer = { + isValid: true, + }; + ctx.token = { + }; + await manager._validateToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('covers invalid token', async function () { + ctx.bearer = { + isValid: false, + }; + await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError); + }); + it('covers errors', async function () { + ctx.bearer = { + isValid: false, + }; + ctx.session.error = 'error'; + ctx.session.errorDescriptions = ['error_description']; + await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError); + }); + }); // _validateToken + + describe('_checkTokenValidationRequest', function () { + let dbCtx; + beforeEach(function () { + dbCtx = {}; + sinon.stub(manager.mysteryBox, 'unpack'); + }); + it('does nothing with no auth header', async function () { + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + }); + it('does nothing with unknown auth header', async function () { + req.getHeader.returns('flarp authy woo'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + }); + it('requires a valid auth token', async function () { + manager.mysteryBox.unpack.rejects(expectedException); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert(ctx.session.error); + }); + it('requires valid auth token fields', async function () { + manager.mysteryBox.unpack.resolves({}); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert(ctx.session.error) + }); + it('covers no token', async function () { + manager.mysteryBox.unpack.resolves({ c: 'xxx' }); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert(ctx.session.error) + }); + it('covers db error', async function () { + manager.mysteryBox.unpack.resolves({ c: 'xxx' }); + manager.db.tokenGetByCodeId.rejects(expectedException); + req.getHeader.returns('Bearer XXX'); + await assert.rejects(manager._checkTokenValidationRequest(dbCtx, req, ctx), expectedException); + }); + it('valid token', async function () { + manager.mysteryBox.unpack.resolves({ c: 'xxx' }); + manager.db.tokenGetByCodeId.resolves({ + isRevoked: false, + expires: new Date(Date.now() + 86400000), + }); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert.strictEqual(ctx.bearer.isValid, true); + }); + it('revoked token', async function () { + manager.mysteryBox.unpack.resolves({ c: 'xxx' }); + manager.db.tokenGetByCodeId.resolves({ + isRevoked: true, + expires: new Date(Date.now() + 86400000), + }); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert.strictEqual(ctx.bearer.isValid, false); + }); + it('expired token', async function () { + manager.mysteryBox.unpack.resolves({ c: 'xxx' }); + manager.db.tokenGetByCodeId.resolves({ + isRevoked: false, + expires: new Date(Date.now() - 86400000), + }); + req.getHeader.returns('Bearer XXX'); + await manager._checkTokenValidationRequest(dbCtx, req, ctx); + assert.strictEqual(ctx.bearer.isValid, false); + }); + }); // _checkTokenValidationRequest + + describe('postIntrospection', function () { + let inactiveToken, activeToken, dbResponse; + beforeEach(function () { + dbResponse = { + profile: 'https://profile.example.com/', + clientId: 'https://client.example.com/', + scopes: ['scope1', 'scope2'], + created: new Date(), + expires: Infinity, + }; + inactiveToken = JSON.stringify({ + active: false, + }); + activeToken = JSON.stringify({ + active: true, + me: dbResponse.profile, + 'client_id': dbResponse.clientId, + scope: dbResponse.scopes.join(' '), + iat: Math.ceil(dbResponse.created.getTime() / 1000), + }); + sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '7e9991dc-9cd5-11ec-85c4-0025905f714a' }); + manager.db.tokenGetByCodeId.resolves(dbResponse); + }); + it('covers bad token', async function () { + manager.mysteryBox.unpack.rejects(); + await manager.postIntrospection(res, ctx); + assert(res.end.called); + assert.strictEqual(res.end.args[0][0], inactiveToken); + }); + it('covers token not in db', async function () { + manager.db.tokenGetByCodeId.resolves(); + await manager.postIntrospection(res, ctx); + assert(res.end.called); + assert.strictEqual(res.end.args[0][0], inactiveToken); + }); + it('covers valid token', async function () { + await manager.postIntrospection(res, ctx); + assert(res.end.called); + assert.strictEqual(res.end.args[0][0], activeToken); + }); + it('covers expired token', async function () { + dbResponse.expires = new Date((new Date()).getTime() - 86400000); + await manager.postIntrospection(res, ctx); + assert(res.end.called); + assert.strictEqual(res.end.args[0][0], inactiveToken); + }); + it('covers expiring token', async function () { + dbResponse.expires = new Date((new Date()).getTime() + 86400000); + activeToken = JSON.stringify({ + active: true, + me: dbResponse.profile, + 'client_id': dbResponse.clientId, + scope: dbResponse.scopes.join(' '), + iat: Math.ceil(dbResponse.created.getTime() / 1000), + exp: Math.ceil(dbResponse.expires / 1000), + }); + await manager.postIntrospection(res, ctx); + assert(res.end.called); + assert.strictEqual(res.end.args[0][0], activeToken); + }); + it('covers ticket', async function () { + ctx.parsedBody['token_hint_type'] = 'ticket'; + const nowEpoch = Math.ceil(Date.now() / 1000); + manager.mysteryBox.unpack.resolves({ + c: '515172ae-5b0b-11ed-a6af-0025905f714a', + iss: nowEpoch - 86400, + exp: nowEpoch + 86400, + sub: 'https://subject.exmaple.com/', + res: 'https://profile.example.com/feed', + scope: ['read', 'role:private'], + ident: 'username', + profile: 'https://profile.example.com/', + }); + await manager.postIntrospection(res, ctx); + assert(res.end.called); + }); + }); // postIntrospection + + describe('_revokeToken', function () { + let dbCtx; + beforeEach(function () { + dbCtx = {}; + }); + it('requires token field', async function () { + await manager._revokeToken(dbCtx, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 400); + }); + it('requires parsable token', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves({ notC: 'foop' }); + ctx.parsedBody['token'] = 'invalid token'; + ctx.parsedBody['token_type_hint'] = 'access_token'; + await manager._revokeToken(dbCtx, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 400); + }); + it('requires parsable token', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves(); + ctx.parsedBody['token'] = 'invalid token'; + ctx.parsedBody['token_type_hint'] = 'refresh_token'; + await manager._revokeToken(dbCtx, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 400); + }); + it('succeeds', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' }); + ctx.parsedBody['token'] = 'valid token'; + await manager._revokeToken(dbCtx, res, ctx); + assert(manager.db.tokenRevokeByCodeId.called); + assert(res.end.called); + }); + it('succeeds for refresh token', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves({ rc: '8e4aed9e-fa3e-11ec-992e-0025905f714a' }); + ctx.parsedBody['token'] = 'valid token'; + await manager._revokeToken(dbCtx, res, ctx); + assert(manager.db.tokenRefreshRevokeByCodeId.called); + assert(res.end.called); + }); + it('covers non-revokable token', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' }); + manager.db.tokenRevokeByCodeId.rejects(new UnexpectedResult()); + ctx.parsedBody['token'] = 'valid token'; + await manager._revokeToken(dbCtx, res, ctx); + assert.strictEqual(res.statusCode, 404); + }); + it('covers failure', async function () { + sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' }); + manager.db.tokenRevokeByCodeId.rejects(expectedException); + ctx.parsedBody['token'] = 'valid token'; + ctx.parsedBody['token_type_hint'] = 'ignores_bad_hint'; + await assert.rejects(manager._revokeToken(dbCtx, res, ctx), expectedException, noExpectedException); + }); + }); // _revokeToken + + describe('_scopeDifference', function () { + let previousScopes, requestedScopes; + beforeEach(function () { + previousScopes = ['a', 'b', 'c']; + requestedScopes = ['b', 'c', 'd']; + }); + it('covers', function () { + const expected = ['a']; + const result = Manager._scopeDifference(previousScopes, requestedScopes); + assert.deepStrictEqual(result, expected); + }); + }); // _scopeDifference + + describe('_refreshToken', function () { + let dbCtx; + beforeEach(function () { + dbCtx = {}; + ctx.parsedBody['client_id'] = 'https://client.example.com/'; + const nowEpoch = Math.ceil(Date.now() / 1000); + sinon.stub(manager.mysteryBox, 'unpack').resolves({ + rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a', + ts: nowEpoch - 86400, + exp: nowEpoch + 86400, + }); + sinon.stub(manager.mysteryBox, 'pack').resolves('newToken'); + const futureDate = new Date(Date.now() + 86400000); + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: futureDate, + duration: 86400, + clientId: 'https://client.example.com/', + scopes: ['profile', 'create'], + }); + manager.db.refreshCode.resolves({ + expires: futureDate, + refreshExpires: futureDate, + }); + }); + it('requires a token', async function () { + manager.mysteryBox.unpack.rejects(); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires token to have refresh field', async function () { + manager.mysteryBox.unpack.resolves(); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires token to exist in db', async function () { + manager.db.tokenGetByCodeId.resolves(); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires token be refreshable', async function () { + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: undefined, + }); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires refresh of token not be expired', async function () { + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: 1000, + }); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires token not to have been already refreshed', async function () { + const nowEpoch = Math.ceil(Date.now() / 1000); + manager.mysteryBox.unpack.resolves({ + rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a', + ts: nowEpoch - 864000, + exp: nowEpoch - 86400, + }); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('requires client_id requesting refresh match', async function () { + ctx.parsedBody['client_id'] = 'https://wrong.example.com/'; + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + it('succeeds', async function () { + await manager._refreshToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('covers non-expiring', async function () { + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: new Date(Date.now() + 86400000), + duration: 86400, + clientId: 'https://client.example.com/', + scopes: ['profile', 'create'], + }); + await manager._refreshToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('covers profile and email', async function () { + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: new Date(Date.now() + 86400000), + duration: 86400, + clientId: 'https://client.example.com/', + scopes: ['profile', 'email', 'create'], + }); + await manager._refreshToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('succeeds with scope reduction', async function () { + ctx.parsedBody['scope'] = 'profile fancy'; + manager.db.tokenGetByCodeId.resolves({ + refreshExpires: new Date(Date.now() + 86400000), + clientId: 'https://client.example.com/', + scopes: ['profile', 'create'], + }); + await manager._refreshToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('covers refresh failed', async function () { + manager.db.refreshCode.resolves(); + await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError); + }); + }); // _refreshToken + + describe('_mintTicket', function () { + let dbCtx, payload; + beforeEach(function () { + dbCtx = {}; + payload = { + subject: 'https://third-party.example.com/', + resource: 'https://private.example.com/feed', + scopes: ['read'], + identifier: 'account', + profile: 'https://profile.example.com/', + ticketLifespanSeconds: 86400, + }; + }); + it('covers', async function () { + const expected = 'xxx'; + sinon.stub(manager.mysteryBox, 'pack').resolves(expected); + const result = await manager._mintTicket(dbCtx, payload); + assert.strictEqual(result, expected); + }); + }); // _mintTicket + + describe('_ticketAuthToken', function () { + let dbCtx, ticketPayload, nowEpoch; + beforeEach(function () { + dbCtx = {}; + nowEpoch = Math.ceil(Date.now() / 1000); + ticketPayload = { + c: '5ec17f78-5576-11ed-b444-0025905f714a', + iss: nowEpoch - 86400, + exp: nowEpoch + 86400, + sub: 'https://third-party.example.com/', + res: 'https://private.example.com/feed', + scope: ['read', 'flap'], + ident: 'account', + profile: 'https://profile.example.com/', + }; + sinon.stub(manager.mysteryBox, 'unpack').resolves(ticketPayload); + sinon.stub(manager.mysteryBox, 'pack').resolves('ticket'); + }); + it('covers invalid ticket', async function () { + manager.mysteryBox.unpack.resolves(); + await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError); + }); + it('covers expired ticket', async function () { + manager.mysteryBox.unpack.resolves({ + c: '5ec17f78-5576-11ed-b444-0025905f714a', + iss: nowEpoch - 172800, + exp: nowEpoch - 86400, + sub: 'https://third-party.example.com/', + res: 'https://private.example.com/feed', + scope: ['read', 'flap'], + ident: 'account', + profile: 'https://profile.example.com/', + }); + await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError); + }); + it('covers success', async function () { + manager.db.redeemCode.resolves(true); + await manager._ticketAuthToken(dbCtx, req, res, ctx); + assert(res.end.called); + }); + it('covers invalid redeem', async function () { + manager.db.redeemCode.resolves(false); + await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError); + }); + }); // _ticketAuthToken + + describe('postRevocation', function () { + beforeEach(function () { + sinon.stub(manager, '_revokeToken'); + }); + it('covers success', async function () { + manager._revokeToken.resolves(); + await manager.postRevocation(res, ctx); + assert(manager._revokeToken.called); + }); + it('covers failure', async function () { + manager._revokeToken.rejects(expectedException); + await assert.rejects(manager.postRevocation(res, ctx)); + }); + }); // postRevocation + + describe('postUserInfo', function () { + beforeEach(function () { + ctx.parsedBody['token'] = 'XXX'; + sinon.stub(manager.mysteryBox, 'unpack'); + }); + it('requires a token', async function () { + delete ctx.parsedBody.token; + await manager.postUserInfo(res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 400); + }); + it('requires a valid token', async function () { + manager.mysteryBox.unpack.rejects(expectedException); + await manager.postUserInfo(res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 401); + }); + it('requires token to have profile scope', async function () { + manager.mysteryBox.unpack.resolves({}); + manager.db.tokenGetByCodeId.resolves({ + scopes: [], + }); + await manager.postUserInfo(res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 403); + }); + it('succeeds', async function () { + manager.mysteryBox.unpack.resolves({}); + manager.db.tokenGetByCodeId.resolves({ + scopes: ['profile', 'email'], + profile: { + url: 'https://example.com/', + email: 'user@example.com', + }, + }); + await manager.postUserInfo(res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 200); + }); + it('succeeds, and does not include email without scope', async function () { + manager.mysteryBox.unpack.resolves({}); + manager.db.tokenGetByCodeId.resolves({ + scopes: ['profile'], + profile: { + url: 'https://example.com/', + email: 'user@example.com', + }, + }); + await manager.postUserInfo(res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 200); + const response = JSON.parse(res.end.args[0][0]); + assert(!('email' in response)); + }); + }); // postUserInfo + + describe('getAdmin', function () { + beforeEach(function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'scope': { + 'scope1': { + description: 'a scope', + profiles: ['https://profile.example.com/'], + }, + }, + }, + }, + scopeIndex: { + 'scope1': { + description: 'a scope', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.db.tokensGetByIdentifier.resolves(); + }); + it('covers', async function () { + await manager.getAdmin(res, ctx); + }); + }); // getAdmin + + describe('postAdmin', function () { + beforeEach(function () { + manager.db.profilesScopesByIdentifier.resolves({ + profileScopes: { + 'https://profile.example.com/': { + 'scope': { + 'scope1': { + description: 'a scope', + profiles: ['https://profile.example.com/'], + }, + }, + }, + }, + scopeIndex: { + 'scope1': { + description: 'a scope', + profiles: ['https://profile.example.com/'], + }, + }, + profiles: ['https://profile.example.com/'], + }); + manager.db.tokensGetByIdentifier.resolves([]); + manager.db.tokenRevokeByCodeId.resolves(); + manager.db.profileIdentifierInsert.resolves(); + manager.db.profileScopesSetAll.resolves(); + manager.communication.fetchProfile.resolves({ + metadata: { + authorizationEndpoint: manager.selfAuthorizationEndpoint, + }, + }); + }); + describe('save-scopes action', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'save-scopes'; + ctx.parsedBody['scopes-https://profile/example.com/'] = ['scope1', 'scope2']; + }); + it('covers saving scopes', async function () { + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.profileScopesSetAll.called); + }); + it('covers saving scopes error', async function () { + manager.db.profileScopesSetAll.rejects(); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + }); // save-scopes action + describe('new-profile action', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'new-profile'; + }); + it('covers new profile', async function () { + ctx.parsedBody['profile'] = 'https://profile.example.com/'; + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.profileIdentifierInsert.called); + assert(manager.db.profileScopesSetAll.called); + }); + it('covers invalid profile', async function () { + ctx.parsedBody['action'] = 'new-profile'; + ctx.parsedBody['profile'] = 'not a url'; + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers other validation failure', async function () { + sinon.stub(manager.communication, 'validateProfile').rejects(expectedException); + ctx.parsedBody['action'] = 'new-profile'; + ctx.parsedBody['profile'] = 'not a url'; + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers mismatched profile', async function () { + ctx.parsedBody['action'] = 'new-profile'; + ctx.parsedBody['profile'] = 'https://profile.example.com/'; + manager.communication.fetchProfile.resolves({ + metadata: { + authorizationEndpoint: 'https://other.example.com/auth', + }, + }); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers new profile error', async function () { + ctx.parsedBody['action'] = 'new-profile'; + ctx.parsedBody['profile'] = 'https://profile.example.com/'; + manager.db.profileIdentifierInsert.rejects(); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + }); // new-profile action + describe('new-scope action', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'new-scope'; + }); + it('covers new scope', async function () { + ctx.parsedBody['scope'] = 'newscope'; + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.scopeUpsert.called); + }); + it('covers bad scope', async function () { + ctx.parsedBody['scope'] = 'bad scope'; + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers new scope error', async function () { + ctx.parsedBody['scope'] = 'newscope'; + manager.db.scopeUpsert.rejects(); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers empty scope', async function () { + delete ctx.parsedBody.scope; + await manager.postAdmin(res, ctx); + assert(!ctx.errors.length); + }); + }); // new-scope action + describe('delete-scope-* action', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'delete-scope-food%3Ayum'; + }); + it('covers delete', async function () { + manager.db.scopeDelete.resolves(true); + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.scopeDelete.called); + }); + it('covers no delete', async function () { + manager.db.scopeDelete.resolves(false); + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.scopeDelete.called); + }); + it('covers delete error', async function () { + manager.db.scopeDelete.rejects(); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + assert(manager.db.scopeDelete.called); + }); + it('ignores empty scope', async function () { + ctx.parsedBody['action'] = 'delete-scope-'; + await manager.postAdmin(res, ctx); + assert(manager.db.scopeDelete.notCalled); + assert(!ctx.notifications.length); + assert(!ctx.errors.length); + }); + }); // delete-scope-* action + describe('revoke-* action', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'revoke-b1591c00-9cb7-11ec-a05c-0025905f714a'; + }); + it('covers revocation', async function () { + await manager.postAdmin(res, ctx); + assert(ctx.notifications.length); + assert(manager.db.tokenRevokeByCodeId.called); + }); + it('covers revocation error', async function () { + manager.db.tokenRevokeByCodeId.rejects(); + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + it('covers no code', async function () { + ctx.parsedBody['action'] = 'revoke-'; + await manager.postAdmin(res, ctx); + assert(!ctx.notifications.length); + assert(!ctx.errors.length); + assert(manager.db.tokenRevokeByCodeId.notCalled); + }); + }); // revoke-* action + it('covers empty action', async function () { + delete ctx.parsedBody.action; + await manager.postAdmin(res, ctx); + assert(!ctx.errors.length); + }); + it('covers unknown action', async function () { + ctx.parsedBody['action'] = 'unsupported-action'; + await manager.postAdmin(res, ctx); + assert(ctx.errors.length); + }); + }); // postAdmin + + describe('getAdminTicket', function () { + it('covers', async function () { + manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} }); + await manager.getAdminTicket(res, ctx); + assert(res.end.called); + }); + }); // getAdminTicket + + describe('postAdminTicket', function () { + beforeEach(function () { + ctx.parsedBody['action'] = 'proffer-ticket'; + ctx.parsedBody['scopes'] = ['read', 'role:private']; + ctx.parsedBody['adhoc'] = 'adhoc_scope'; + ctx.parsedBody['profile'] = 'https://profile.example.com/'; + ctx.parsedBody['resource'] = 'https://profile.example.com/feed'; + ctx.parsedBody['subject'] = 'https://subject.example.com/'; + manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} }); + sinon.stub(manager.mysteryBox, 'pack').resolves('ticket'); + manager.communication.fetchProfile.resolves({ + metadata: { + ticketEndpoint: 'https://example.com/ticket', + }, + }); + }); + it('covers success', async function () { + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + assert.strictEqual(ctx.errors.length, 0); + assert.strictEqual(ctx.notifications.length, 1); + }); + it('requires params', async function () { + delete ctx.parsedBody['adhoc']; + ctx.parsedBody['profile'] = 'bad url'; + ctx.parsedBody['resource'] = 'bad url'; + ctx.parsedBody['subject'] = 'bad url'; + ctx.parsedBody['scopes'] = ['fl"hrgl', 'email']; + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + assert.strictEqual(ctx.errors.length, 5); + assert.strictEqual(ctx.notifications.length, 0); + }); + it('ignores unknown action', async function () { + ctx.parsedBody['action'] = 'prove-dough'; + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + }); + it('covers delivery failure', async function () { + manager.communication.deliverTicket.rejects(expectedException); + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + assert.strictEqual(ctx.errors.length, 1); + assert.strictEqual(ctx.notifications.length, 0); + }); + it('covers no ticket endpoint', async function () { + manager.communication.fetchProfile.resolves({ + metadata: { + }, + }); + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + assert.strictEqual(ctx.errors.length, 1); + assert.strictEqual(ctx.notifications.length, 0); + }); + it('covers bad ticket endpoint', async function () { + manager.communication.fetchProfile.resolves({ + metadata: { + ticketEndpoint: 'not a url', + }, + }); + await manager.postAdminTicket(res, ctx); + assert(res.end.called); + assert.strictEqual(ctx.errors.length, 1); + assert.strictEqual(ctx.notifications.length, 0); + }); + }); // postAdminTicket + + describe('postTicket', function () { + beforeEach(function () { + ctx.parsedBody = { + ticket: 'ticket123', + resource: 'https://blog.example.com/', + subject: 'https://otheruser.example.com/', + }; + }); + it('accepts a ticket for a known profile', async function () { + manager.db.profileIsValid.resolves(true); + await manager.postTicket(req, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 202); + }); + it('rejects invalid resource', async function () { + ctx.parsedBody.resource = 'invalid url'; + await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError); + }); + it('rejects invalid subject', async function () { + manager.db.profileIsValid(false); + await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError); + }); + it('covers queue publish failure', async function () { + manager.db.profileIsValid.resolves(true); + manager.queuePublisher.publish.rejects(expectedException); + await assert.rejects(() => manager.postTicket(req, res, ctx), expectedException); + }); + it('covers no ticket queue', async function () { + delete options.queues.amqp.url; + manager = new Manager(logger, stubDb, options); + + await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError); + }); + + }); // postTicket + + describe('getAdminMaintenance', function () { + it('covers information', async function () { + await manager.getAdminMaintenance(res, ctx); + assert(res.end.called); + }); + it('covers tasks', async function () { + ctx.queryParams = { + [Enum.Chore.CleanTokens]: '', + }; + await manager.getAdminMaintenance(res, ctx); + assert(res.end.called); + }); + }); // getAdminMaintenance + +}); // Manager \ No newline at end of file