X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=test%2Fsrc%2Fmanager.js;fp=test%2Fsrc%2Fmanager.js;h=d8c09219c2cf323ecea93917013f597573588128;hb=9696c012e6b9a6c58904baa397ca0ebf78112316;hp=0000000000000000000000000000000000000000;hpb=f59e918f3aba3a218c94a252072801fc40527647;p=websub-hub diff --git a/test/src/manager.js b/test/src/manager.js new file mode 100644 index 0000000..d8c0921 --- /dev/null +++ b/test/src/manager.js @@ -0,0 +1,759 @@ +/* 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 common = require('../../src/common'); +const Errors = require('../../src/errors'); +const DBErrors = require('../../src/db/errors'); + +const stubDb = require('../stub-db'); +const stubLogger = require('../stub-logger'); +const testData = require('../test-data/manager'); + +const noExpectedException = 'did not get expected exception'; + +describe('Manager', function () { + let manager, options; + let req, res, ctx; + + beforeEach(function () { + options = new Config('test'); + req = { + getHeader : sinon.stub(), + }; + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + }; + ctx = { + params: {}, + }; + manager = new Manager(stubLogger, stubDb, options); + sinon.stub(manager.communication, 'verificationProcess'); + sinon.stub(manager.communication, 'topicFetchProcess'); + stubDb._reset(); + stubLogger._reset(); + }); + afterEach(function () { + sinon.restore(); + }); + + it('instantiates', function () { + assert(manager); + }); + + describe('getRoot', function () { + beforeEach(function () { + sinon.stub(common, 'isClientCached'); + req = {}; + }); + it('normal response', async function () { + common.isClientCached.returns(false); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('repeat response', async function () { + manager.startTime = (new Date()).toGMTString(); + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('cached response', async function () { + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 304); + }); + }); // getRoot + + describe('getHealthcheck', function () { + it('normal response', async function () { + await manager.getHealthcheck(res, ctx); + assert(res.end.called); + }); + }); // getById + + describe('getInfo', function () { + it('requires query param', async function() { + ctx.queryParams = {}; + try { + await manager.getInfo(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.statusCode, 400); + } + }); + it('requires parsable query param', async function() { + ctx.queryParams = { topic: 'not a url' }; + try { + await manager.getInfo(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.statusCode, 400); + } + }); + it('does not find unhandled topic', async function() { + ctx.queryParams = { topic: 'https://example.com/blog/' }; + try { + await manager.getInfo(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.statusCode, 404); + } + }); + it('returns a count', async function() { + manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 }); + ctx.queryParams = { + topic: 'https://example.com/blog/', + }; + await manager.getInfo(res, ctx); + assert(res.end.called); + }); + it('returns a count as json', async function() { + manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 }); + ctx.responseType = 'application/json'; + ctx.queryParams = { + topic: 'https://example.com/blog/', + }; + await manager.getInfo(res, ctx); + assert(res.end.called); + }); + it('returns a count as json as override format', async function() { + manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 }); + ctx.responseType = 'text/html'; + ctx.queryParams = { + topic: 'https://example.com/blog/', + format: 'json', + }; + await manager.getInfo(res, ctx); + assert(res.end.called); + assert(res.setHeader.called); + }); + it('returns an svg badge as override format', async function() { + manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 }); + ctx.responseType = 'text/html'; + ctx.queryParams = { + topic: 'https://example.com/blog/', + format: 'svg', + }; + await manager.getInfo(res, ctx); + assert(res.end.called); + assert(res.setHeader.called); + }); + }); // getInfo + + describe('getAdminOverview', function () { + it('covers', async function () { + manager.db.topicGetAll.resolves([ + { + id: '56c557ce-e667-11eb-bd80-0025905f714a', + created: new Date(), + url: 'https://example.com/', + leaseSecondsPreferred: 123, + leaseSecondsMin: 12, + leaseSecondsMax: 123456789, + publisherValidationUrl: null, + contentHashAlgorithm: 'hashy', + isActive: true, + isDeleted: false, + lastPublish: new Date(-Infinity), + contentFetchNextAttempt: undefined, + contentFetchAttemptsSinceSuccess: 3, + contentUpdated: new Date(0), + contentHash: 'abc', + contentType: 'foo', + subscribers: 12, + }, + ]); + await manager.getAdminOverview(res, ctx); + assert(res.end.called); + }); + }); + + describe('getTopicDetails', function () { + it('covers', async function() { + ctx.params.topicId = '56c557ce-e667-11eb-bd80-0025905f714a'; + manager.db.topicGetById.resolves({ + id: '56c557ce-e667-11eb-bd80-0025905f714a', + created: new Date(), + url: 'https://example.com/', + leaseSecondsPreferred: 123, + leaseSecondsMin: 12, + leaseSecondsMax: 123456789, + publisherValidationUrl: null, + contentHashAlgorithm: 'hashy', + isActive: true, + isDeleted: false, + lastPublish: new Date(-Infinity), + contentFetchNextAttempt: undefined, + contentFetchAttemptsSinceSuccess: 3, + contentUpdated: new Date(0), + contentHash: 'abc', + contentType: 'foo', + subscribers: 12, + }); + manager.db.subscriptionsByTopicId.resolves([{ + id: '', + created: new Date(), + topicId: '56c557ce-e667-11eb-bd80-0025905f714a', + callback: '', + verified: new Date(), + expires: new Date(), + secret: '', + signatureAlgorithm: 'hmacy', + httpRemoteAddr: '', + httpFrom: '', + contentDelivered: new Date(), + deliveryAttemptsSinceSuccess: 0, + deliveryNextAttempt: new Date(-Infinity), + }]); + await manager.getTopicDetails(res, ctx); + assert(res.end.called); + }); + }); // getTopicDetails + + describe('postRoot', function () { + let origProcessImmediately; + beforeEach(function () { + origProcessImmediately = manager.options.manager.processImmediately; + ctx.parsedBody = {}; + }); + this.afterEach(function () { + manager.options.manager.processImmediately = origProcessImmediately; + }); + it('requires parameters', async function () { + try { + await manager.postRoot(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Bad Request'); + } + }); + it('accepts valid subscription', async function () { + ctx = Object.assign({}, testData.validSubscribeCtx); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.verificationInsert.resolves({ + changes: 1, + lastInsertRowid: undefined, + duration: 12, + }); + await manager.postRoot(req, res, ctx); + assert(manager.db.verificationInsert.called); + assert(res.end.called); + }); + it('accepts valid subscription without claiming work', async function () { + manager.options.manager.processImmediately = false; + ctx = Object.assign({}, testData.validSubscribeCtx); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.verificationInsert.resolves({ + changes: 1, + lastInsertRowid: undefined, + duration: 12, + }); + await manager.postRoot(req, res, ctx); + assert(manager.db.verificationInsert.called); + assert(!manager.communication.verificationProcess.called); + assert(res.end.called); + }); + it('accepts valid subscription, covers processVerification failure', async function () { + ctx = Object.assign({}, testData.validSubscribeCtx); + manager.communication.verificationProcess.rejects('failed'); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.verificationInsert.resolves({ + changes: 1, + lastInsertRowid: undefined, + duration: 12, + }); + await manager.postRoot(req, res, ctx); + assert(manager.db.verificationInsert.called); + assert(res.end.called); + assert(manager.logger.error.called); + }); + it('covers db.verificationInsert failure', async function () { + const expectedException = new Error('failure'); + ctx = Object.assign({}, testData.validSubscribeCtx); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.verificationInsert.rejects(expectedException); + assert.rejects(async () => { + await manager.postRoot(req, res, ctx); + }, expectedException); + }); + it('accepts valid unsubscription', async function () { + ctx = Object.assign({}, testData.validUnsubscribeCtx); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.subscriptionGet.resolves({ + id: 22, + }); + manager.db.verificationInsert.resolves({ + changes: 1, + lastInsertRowid: undefined, + duration: 12, + }); + await manager.postRoot(req, res, ctx); + assert(res.end.called); + }); + it('accepts valid publish', async function () { + ctx = Object.assign({}, testData.validPublishCtx); + manager.db.topicGetByUrl.resolves({ + id: 111, + }); + manager.db.topicFetchRequested.resolves({ + changes: 1, + lastInsertRowid: undefined, + duration: 12, + }); + await manager.postRoot(req, res, ctx); + assert(res.end.called); + }); + }); // postRoot + + describe('_getRootData', function () { + it('extracts expected values', function () { + req.getHeader.returns('user@example.com'); + ctx = Object.assign({}, testData.validSubscribeCtx) + const result = Manager._getRootData(req, ctx); + assert.deepStrictEqual(result, testData.validRootData); + }); + }); // _getRootData + + describe('_validateRootData', function () { + // This only wraps the other _check functions, not bothering with coverage. + }); // _validateRootData + + describe('_checkTopic', function () { + let dbCtx, data, warn, err; + const topic = { + id: 111, + leaseSecondsPreferred: 86400 * 10, + leaseSecondsMax: 86400 * 20, + leaseSecondsMin: 86400, + }; + beforeEach(function () { + dbCtx = {}; + data = {}; + warn = []; + err = []; + }); + it('succeeds', async function () { + data = { + topic: 'http://example.com/blog', + }; + manager.db.topicGetByUrl.resolves(topic); + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 0, err); + assert.strictEqual(data.topicId, 111); + assert.strictEqual(data.leaseSeconds, 864000); + }); + it('errors on unknown topic', async function () { + manager.db.topicGetByUrl.resolves(); + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 1, err); + }); + it('warns on lease under min range', async function () { + data = { + topic: 'http://example.com/blog', + leaseSeconds: 97, + }; + manager.db.topicGetByUrl.resolves(topic); + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 1, warn); + assert.strictEqual(err.length, 0, err); + assert.strictEqual(data.topicId, 111); + assert.strictEqual(data.leaseSeconds, 86400); + }); + it('warns on lease over max range', async function () { + data = { + topic: 'http://example.com/blog', + leaseSeconds: 86400 * 100, + }; + manager.db.topicGetByUrl.resolves(topic); + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 1, warn); + assert.strictEqual(err.length, 0, err); + assert.strictEqual(data.topicId, 111); + assert.strictEqual(data.leaseSeconds, 86400 * 20); + }); + it('sets publisher validation state when available', async function () { + data = { + topic: 'http://example.com/blog', + }; + manager.db.topicGetByUrl.resolves(Object.assign({}, topic, { + publisherValidationUrl: 'http://example.com/validate', + })); + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 0, err); + assert.strictEqual(data.topicId, 111); + assert.strictEqual(data.leaseSeconds, 864000); + assert.strictEqual(data.isPublisherValidated, false); + }); + it('accepts new public subscribe topic', async function () { + manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic); + data = { + topic: 'http://example.com/blog', + }; + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, 'unexpected warnings length'); + assert.strictEqual(err.length, 0, 'unexpected errors length'); + assert.strictEqual(data.topicId, 111, 'unexpected topic id'); + }); + it('does not accept new public subscribe for invalid topic', async function () { + manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic); + data = { + topic: 'not a topic', + }; + await manager._checkTopic(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, 'unexpected warnings length'); + assert.strictEqual(err.length, 1, 'unexpected errors length'); + }); + }); // _checkTopic + + describe('_checkCallbackAndSecrets', function () { + let data, warn, err; + let origStrictSecrets; + before(function () { + origStrictSecrets = manager.options.manager.strictSecrets; + }); + beforeEach(function () { + data = {}; + warn = []; + err = []; + }); + afterEach(function () { + manager.options.manager.strictSecrets = origStrictSecrets; + }); + it('succeeds', function () { + data = { + callback: 'https://example.com/callback', + secret: 'so safe', + isSecure: true, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 0, err); + }); + it('errors with invalid callback', function () { + data = { + callback: 'not a url', + secret: 'so safe', + isSecure: true, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 1, err); + }); + it('errors when secret too large', function () { + data = { + callback: 'https://example.com/callback', + secret: 'x'.repeat(256), + isSecure: true, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 1, err); + }); + it('warns when callback is insecure', function () { + data = { + callback: 'http://example.com/callback', + isSecure: true, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 1, warn); + assert.strictEqual(err.length, 0, err); + }); + it('warns when hub is insecure with secret', function () { + data = { + callback: 'https://example.com/callback', + secret: 'so safe', + isSecure: false, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 1, warn); + assert.strictEqual(err.length, 0, err); + }); + it('errors when callback is insecure with secret and strict', function () { + manager.options.manager.strictSecrets = true; + data = { + callback: 'http://example.com/callback', + secret: 'so safe', + isSecure: true, + }; + manager._checkCallbackAndSecrets(data, warn, err); + assert.strictEqual(warn.length, 1, warn); + assert.strictEqual(err.length, 1, err); + }); + }); // _checkCallbackAndSecrets + + describe('_checkMode', function () { + let dbCtx, data, warn, err; + beforeEach(function () { + dbCtx = {}; + data = {}; + warn = []; + err = []; + }); + it('subscribe succeeds', async function () { + data = { + mode: 'subscribe', + }; + await manager._checkMode(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0); + assert.strictEqual(err.length, 0); + }); + it('unsubscribe succeeds', async function () { + data = { + mode: 'unsubscribe', + callback: 'http://example.com', + topicId: 123, + }; + manager.db.subscriptionGet.resolves({ + expires: (Date.now() / 1000) + 60, + }); + await manager._checkMode(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 0, err); + }); + it('unsubscribe requires valid data', async function () { + data = { + mode: 'unsubscribe', + callback: 'http://example.com', + topicId: undefined, + }; + manager.db.subscriptionGet.resolves({ + expires: (Date.now() / 1000) - 60, + }); + await manager._checkMode(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 1, err); + }); + it('unsubscribe ignores expired subscription', async function () { + data = { + mode: 'unsubscribe', + callback: 'http://example.com', + topicId: 123, + }; + manager.db.subscriptionGet.resolves({ + expires: (Date.now() / 1000) - 60, + }); + await manager._checkMode(dbCtx, data, warn, err); + assert.strictEqual(warn.length, 0, warn); + assert.strictEqual(err.length, 1, err); + }); + }); // _checkMode + + describe('_checkPublish', function () { + let dbCtx, data, warn, err, requestId; + beforeEach(function () { + dbCtx = {}; + data = {}; + warn = []; + err = []; + requestId = 'blah'; + }); + it('succeeds', async function () { + manager.db.topicGetByUrl.resolves({ + id: 222, + }); + Object.assign(data, testData.validPublishRootData); + await manager._checkPublish(dbCtx, data, warn, err, requestId); + assert.strictEqual(warn.length, 0, 'unexpected warnings length'); + assert.strictEqual(err.length, 0, 'unexpected errors length'); + assert.strictEqual(data.topicId, 222, 'unexpected topic id'); + }); + it('fails bad url', async function () { + Object.assign(data, testData.validPublishRootData, { topic: 'not_a_url' }); + await manager._checkPublish(dbCtx, data, warn, err, requestId); + assert.strictEqual(err.length, 1, 'unexpected errors length'); + assert.strictEqual(warn.length, 0); + }); + it('accepts new public publish topic', async function () { + manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves({ + id: 222, + }); + Object.assign(data, testData.validPublishRootData); + await manager._checkPublish(dbCtx, data, warn, err, requestId); + assert.strictEqual(warn.length, 0, 'unexpected warnings length'); + assert.strictEqual(err.length, 0, 'unexpected errors length'); + assert.strictEqual(data.topicId, 222, 'unexpected topic id'); + }); + it('does not publish deleted topic', async function () { + manager.db.topicGetByUrl.resolves({ + id: 222, + isDeleted: true, + }); + Object.assign(data, testData.validPublishRootData); + await manager._checkPublish(dbCtx, data, warn, err, requestId); + assert.strictEqual(warn.length, 0, 'unexpected warnings length'); + assert.strictEqual(err.length, 1, 'unexpected errors length'); + assert.strictEqual(data.topicId, undefined, 'unexpected topic id'); + }); + }); // _checkPublish + + describe('processTasks', function () { + it('covers', async function () { + sinon.stub(manager.communication.worker, 'process'); + await manager.processTasks(res, ctx); + assert(manager.communication.worker.process.called); + assert(res.end.called); + }); + }); // processTasks + + describe('updateTopic', function () { + it('fails if no topic exists', async function () { + try { + await manager.updateTopic(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + } + }); + it('deletes', async function () { + ctx.method = 'DELETE'; + manager.db.topicGetById.resolves({}); + await manager.updateTopic(res, ctx); + assert(manager.db.topicDeleted.called); + }); + it('does not patch without data', async function () { + manager.db.topicGetById.resolves({}); + await manager.updateTopic(res, ctx); + assert(!manager.db.topicUpdate.called); + assert.strictEqual(res.statusCode, 204); + }); + it('does not patch with same data', async function () { + manager.db.topicGetById.resolves({ + leaseSecondsPreferred: '86400', + }); + ctx.parsedBody = { + leaseSecondsPreferred: '86400', + }; + await manager.updateTopic(res, ctx); + assert(!manager.db.topicUpdate.called); + assert.strictEqual(res.statusCode, 204); + }); + it('patches', async function () { + ctx.queryParams = { + leaseSecondsPreferred: '86400', + }; + manager.db.topicGetById.resolves({}); + await manager.updateTopic(res, ctx); + assert(manager.db.topicUpdate.called); + }); + it('handles validation error', async function () { + ctx.queryParams = { + leaseSecondsPreferred: 'blorp', + }; + manager.db.topicGetById.resolves({}); + manager.db.topicUpdate.rejects(new DBErrors.DataValidation('something')); + try { + await manager.updateTopic(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + assert.strictEqual(e.statusCode, 400); + } + }); + it('handles generic error', async function () { + const expected = new Error('blah'); + ctx.queryParams = { + leaseSecondsPreferred: '123', + }; + manager.db.topicGetById.resolves({}); + manager.db.topicUpdate.rejects(expected); + try { + await manager.updateTopic(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expected); + } + }); + }); // updateTopic + + describe('updateSubscription', function () { + it('fails if no subscription exists', async function () { + try { + await manager.updateSubscription(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + } + }); + it('deletes', async function () { + ctx.method = 'DELETE'; + manager.db.subscriptionGetById.resolves({}); + await manager.updateSubscription(res, ctx); + assert(manager.db.verificationInsert.called); + }); + it('does not patch without data', async function () { + manager.db.subscriptionGetById.resolves({}); + await manager.updateSubscription(res, ctx); + assert(!manager.db.subscriptionUpdate.called); + assert.strictEqual(res.statusCode, 204); + }); + it('does not patch with same data', async function () { + manager.db.subscriptionGetById.resolves({ + signatureAlgorithm: 'sha256', + }); + ctx.parsedBody = { + signatureAlgorithm: 'sha256', + }; + await manager.updateSubscription(res, ctx); + assert(!manager.db.subscriptionUpdate.called); + assert.strictEqual(res.statusCode, 204); + }); + it('patches', async function () { + ctx.queryParams = { + signatureAlgorithm: 'sha256', + }; + manager.db.subscriptionGetById.resolves({}); + await manager.updateSubscription(res, ctx); + assert(manager.db.subscriptionUpdate.called); + }); + it('handles validation error', async function () { + ctx.queryParams = { + signatureAlgorithm: 123, + }; + manager.db.subscriptionGetById.resolves({}); + manager.db.subscriptionUpdate.rejects(new DBErrors.DataValidation('something')); + try { + await manager.updateSubscription(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + assert.strictEqual(e.statusCode, 400); + } + }); + it('handles generic error', async function () { + const expected = new Error('blah'); + ctx.queryParams = { + signatureAlgorithm: 'blorp', + }; + manager.db.subscriptionGetById.resolves({}); + manager.db.subscriptionUpdate.rejects(expected); + try { + await manager.updateSubscription(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expected); + } + }); + }); // updateSubscription + +}); // Manager \ No newline at end of file