2 /* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */
6 const assert
= require('assert');
7 const sinon
= require('sinon'); // eslint-disable-line node/no-unpublished-require
9 const Manager
= require('../../src/manager');
10 const Config
= require('../../config');
11 const common
= require('../../src/common');
12 const Errors
= require('../../src/errors');
13 const DBErrors
= require('../../src/db/errors');
15 const stubDb
= require('../stub-db');
16 const stubLogger
= require('../stub-logger');
17 const testData
= require('../test-data/manager');
19 const noExpectedException
= 'did not get expected exception';
21 describe('Manager', function () {
25 beforeEach(function () {
26 options
= new Config('test');
28 getHeader : sinon
.stub(),
32 setHeader: sinon
.stub(),
37 manager
= new Manager(stubLogger
, stubDb
, options
);
38 sinon
.stub(manager
.communication
, 'verificationProcess');
39 sinon
.stub(manager
.communication
, 'topicFetchProcess');
43 afterEach(function () {
47 it('instantiates', function () {
51 describe('getRoot', function () {
52 beforeEach(function () {
53 sinon
.stub(common
, 'isClientCached');
56 it('normal response', async
function () {
57 common
.isClientCached
.returns(false);
58 await manager
.getRoot(req
, res
, ctx
);
59 assert(res
.end
.called
);
61 it('repeat response', async
function () {
62 manager
.startTime
= (new Date()).toGMTString();
63 common
.isClientCached
.returns(true);
64 await manager
.getRoot(req
, res
, ctx
);
65 assert(res
.end
.called
);
67 it('cached response', async
function () {
68 common
.isClientCached
.returns(true);
69 await manager
.getRoot(req
, res
, ctx
);
70 assert(res
.end
.called
);
71 assert
.strictEqual(res
.statusCode
, 304);
75 describe('getHealthcheck', function () {
76 it('normal response', async
function () {
77 await manager
.getHealthcheck(res
, ctx
);
78 assert(res
.end
.called
);
82 describe('getInfo', function () {
83 it('requires query param', async
function() {
86 await manager
.getInfo(res
, ctx
);
87 assert
.fail(noExpectedException
);
89 assert
.strictEqual(e
.statusCode
, 400);
92 it('requires parsable query param', async
function() {
93 ctx
.queryParams
= { topic: 'not a url' };
95 await manager
.getInfo(res
, ctx
);
96 assert
.fail(noExpectedException
);
98 assert
.strictEqual(e
.statusCode
, 400);
101 it('does not find unhandled topic', async
function() {
102 ctx
.queryParams
= { topic: 'https://example.com/blog/' };
104 await manager
.getInfo(res
, ctx
);
105 assert
.fail(noExpectedException
);
107 assert
.strictEqual(e
.statusCode
, 404);
110 it('returns a count', async
function() {
111 manager
.db
.subscriptionCountByTopicUrl
.resolves({ count: 4 });
113 topic: 'https://example.com/blog/',
115 await manager
.getInfo(res
, ctx
);
116 assert(res
.end
.called
);
118 it('returns a count as json', async
function() {
119 manager
.db
.subscriptionCountByTopicUrl
.resolves({ count: 4 });
120 ctx
.responseType
= 'application/json';
122 topic: 'https://example.com/blog/',
124 await manager
.getInfo(res
, ctx
);
125 assert(res
.end
.called
);
127 it('returns a count as json as override format', async
function() {
128 manager
.db
.subscriptionCountByTopicUrl
.resolves({ count: 4 });
129 ctx
.responseType
= 'text/html';
131 topic: 'https://example.com/blog/',
134 await manager
.getInfo(res
, ctx
);
135 assert(res
.end
.called
);
136 assert(res
.setHeader
.called
);
138 it('returns an svg badge as override format', async
function() {
139 manager
.db
.subscriptionCountByTopicUrl
.resolves({ count: 4 });
140 ctx
.responseType
= 'text/html';
142 topic: 'https://example.com/blog/',
145 await manager
.getInfo(res
, ctx
);
146 assert(res
.end
.called
);
147 assert(res
.setHeader
.called
);
151 describe('getAdminOverview', function () {
152 it('covers', async
function () {
153 manager
.db
.topicGetAll
.resolves([
155 id: '56c557ce-e667-11eb-bd80-0025905f714a',
157 url: 'https://example.com/',
158 leaseSecondsPreferred: 123,
160 leaseSecondsMax: 123456789,
161 publisherValidationUrl: null,
162 contentHashAlgorithm: 'hashy',
165 lastPublish: new Date(-Infinity
),
166 contentFetchNextAttempt: undefined,
167 contentFetchAttemptsSinceSuccess: 3,
168 contentUpdated: new Date(0),
174 await manager
.getAdminOverview(res
, ctx
);
175 assert(res
.end
.called
);
179 describe('getTopicDetails', function () {
180 it('covers', async
function() {
181 ctx
.params
.topicId
= '56c557ce-e667-11eb-bd80-0025905f714a';
182 manager
.db
.topicGetById
.resolves({
183 id: '56c557ce-e667-11eb-bd80-0025905f714a',
185 url: 'https://example.com/',
186 leaseSecondsPreferred: 123,
188 leaseSecondsMax: 123456789,
189 publisherValidationUrl: null,
190 contentHashAlgorithm: 'hashy',
193 lastPublish: new Date(-Infinity
),
194 contentFetchNextAttempt: undefined,
195 contentFetchAttemptsSinceSuccess: 3,
196 contentUpdated: new Date(0),
201 manager
.db
.subscriptionsByTopicId
.resolves([{
204 topicId: '56c557ce-e667-11eb-bd80-0025905f714a',
206 verified: new Date(),
209 signatureAlgorithm: 'hmacy',
212 contentDelivered: new Date(),
213 deliveryAttemptsSinceSuccess: 0,
214 deliveryNextAttempt: new Date(-Infinity
),
216 await manager
.getTopicDetails(res
, ctx
);
217 assert(res
.end
.called
);
219 }); // getTopicDetails
221 describe('postRoot', function () {
222 let origProcessImmediately
;
223 beforeEach(function () {
224 origProcessImmediately
= manager
.options
.manager
.processImmediately
;
227 this.afterEach(function () {
228 manager
.options
.manager
.processImmediately
= origProcessImmediately
;
230 it('requires parameters', async
function () {
232 await manager
.postRoot(req
, res
, ctx
);
233 assert
.fail(noExpectedException
);
235 assert
.strictEqual(e
.message
, 'Bad Request');
238 it('accepts valid subscription', async
function () {
239 ctx
= Object
.assign({}, testData
.validSubscribeCtx
);
240 manager
.db
.topicGetByUrl
.resolves({
243 manager
.db
.verificationInsert
.resolves({
245 lastInsertRowid: undefined,
248 await manager
.postRoot(req
, res
, ctx
);
249 assert(manager
.db
.verificationInsert
.called
);
250 assert(res
.end
.called
);
252 it('accepts valid subscription without claiming work', async
function () {
253 manager
.options
.manager
.processImmediately
= false;
254 ctx
= Object
.assign({}, testData
.validSubscribeCtx
);
255 manager
.db
.topicGetByUrl
.resolves({
258 manager
.db
.verificationInsert
.resolves({
260 lastInsertRowid: undefined,
263 await manager
.postRoot(req
, res
, ctx
);
264 assert(manager
.db
.verificationInsert
.called
);
265 assert(!manager
.communication
.verificationProcess
.called
);
266 assert(res
.end
.called
);
268 it('accepts valid subscription, covers processVerification failure', async
function () {
269 ctx
= Object
.assign({}, testData
.validSubscribeCtx
);
270 manager
.communication
.verificationProcess
.rejects('failed');
271 manager
.db
.topicGetByUrl
.resolves({
274 manager
.db
.verificationInsert
.resolves({
276 lastInsertRowid: undefined,
279 await manager
.postRoot(req
, res
, ctx
);
280 assert(manager
.db
.verificationInsert
.called
);
281 assert(res
.end
.called
);
282 assert(manager
.logger
.error
.called
);
284 it('covers db.verificationInsert failure', async
function () {
285 const expectedException
= new Error('failure');
286 ctx
= Object
.assign({}, testData
.validSubscribeCtx
);
287 manager
.db
.topicGetByUrl
.resolves({
290 manager
.db
.verificationInsert
.rejects(expectedException
);
291 assert
.rejects(async () => {
292 await manager
.postRoot(req
, res
, ctx
);
293 }, expectedException
);
295 it('accepts valid unsubscription', async
function () {
296 ctx
= Object
.assign({}, testData
.validUnsubscribeCtx
);
297 manager
.db
.topicGetByUrl
.resolves({
300 manager
.db
.subscriptionGet
.resolves({
303 manager
.db
.verificationInsert
.resolves({
305 lastInsertRowid: undefined,
308 await manager
.postRoot(req
, res
, ctx
);
309 assert(res
.end
.called
);
311 it('accepts valid publish', async
function () {
312 ctx
= Object
.assign({}, testData
.validPublishCtx
);
313 manager
.db
.topicGetByUrl
.resolves({
316 manager
.db
.topicFetchRequested
.resolves({
318 lastInsertRowid: undefined,
321 await manager
.postRoot(req
, res
, ctx
);
322 assert(res
.end
.called
);
326 describe('_getRootData', function () {
327 it('extracts expected values', function () {
328 req
.getHeader
.returns('user@example.com');
329 ctx
= Object
.assign({}, testData
.validSubscribeCtx
)
330 const result
= Manager
._getRootData(req
, ctx
);
331 assert
.deepStrictEqual(result
, testData
.validRootData
);
335 describe('_validateRootData', function () {
336 // This only wraps the other _check functions, not bothering with coverage.
337 }); // _validateRootData
339 describe('_checkTopic', function () {
340 let dbCtx
, data
, warn
, err
;
343 leaseSecondsPreferred: 86400 * 10,
344 leaseSecondsMax: 86400 * 20,
345 leaseSecondsMin: 86400,
347 beforeEach(function () {
353 it('succeeds', async
function () {
355 topic: 'http://example.com/blog',
357 manager
.db
.topicGetByUrl
.resolves(topic
);
358 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
359 assert
.strictEqual(warn
.length
, 0, warn
);
360 assert
.strictEqual(err
.length
, 0, err
);
361 assert
.strictEqual(data
.topicId
, 111);
362 assert
.strictEqual(data
.leaseSeconds
, 864000);
364 it('errors on unknown topic', async
function () {
365 manager
.db
.topicGetByUrl
.resolves();
366 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
367 assert
.strictEqual(warn
.length
, 0, warn
);
368 assert
.strictEqual(err
.length
, 1, err
);
370 it('warns on lease under min range', async
function () {
372 topic: 'http://example.com/blog',
375 manager
.db
.topicGetByUrl
.resolves(topic
);
376 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
377 assert
.strictEqual(warn
.length
, 1, warn
);
378 assert
.strictEqual(err
.length
, 0, err
);
379 assert
.strictEqual(data
.topicId
, 111);
380 assert
.strictEqual(data
.leaseSeconds
, 86400);
382 it('warns on lease over max range', async
function () {
384 topic: 'http://example.com/blog',
385 leaseSeconds: 86400 * 100,
387 manager
.db
.topicGetByUrl
.resolves(topic
);
388 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
389 assert
.strictEqual(warn
.length
, 1, warn
);
390 assert
.strictEqual(err
.length
, 0, err
);
391 assert
.strictEqual(data
.topicId
, 111);
392 assert
.strictEqual(data
.leaseSeconds
, 86400 * 20);
394 it('sets publisher validation state when available', async
function () {
396 topic: 'http://example.com/blog',
398 manager
.db
.topicGetByUrl
.resolves(Object
.assign({}, topic
, {
399 publisherValidationUrl: 'http://example.com/validate',
401 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
402 assert
.strictEqual(warn
.length
, 0, warn
);
403 assert
.strictEqual(err
.length
, 0, err
);
404 assert
.strictEqual(data
.topicId
, 111);
405 assert
.strictEqual(data
.leaseSeconds
, 864000);
406 assert
.strictEqual(data
.isPublisherValidated
, false);
408 it('accepts new public subscribe topic', async
function () {
409 manager
.db
.topicGetByUrl
.onCall(0).resolves().onCall(1).resolves(topic
);
411 topic: 'http://example.com/blog',
413 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
414 assert
.strictEqual(warn
.length
, 0, 'unexpected warnings length');
415 assert
.strictEqual(err
.length
, 0, 'unexpected errors length');
416 assert
.strictEqual(data
.topicId
, 111, 'unexpected topic id');
418 it('does not accept new public subscribe for invalid topic', async
function () {
419 manager
.db
.topicGetByUrl
.onCall(0).resolves().onCall(1).resolves(topic
);
421 topic: 'not a topic',
423 await manager
._checkTopic(dbCtx
, data
, warn
, err
);
424 assert
.strictEqual(warn
.length
, 0, 'unexpected warnings length');
425 assert
.strictEqual(err
.length
, 1, 'unexpected errors length');
429 describe('_checkCallbackAndSecrets', function () {
431 let origStrictSecrets
;
433 origStrictSecrets
= manager
.options
.manager
.strictSecrets
;
435 beforeEach(function () {
440 afterEach(function () {
441 manager
.options
.manager
.strictSecrets
= origStrictSecrets
;
443 it('succeeds', function () {
445 callback: 'https://example.com/callback',
449 manager
._checkCallbackAndSecrets(data
, warn
, err
);
450 assert
.strictEqual(warn
.length
, 0, warn
);
451 assert
.strictEqual(err
.length
, 0, err
);
453 it('errors with invalid callback', function () {
455 callback: 'not a url',
459 manager
._checkCallbackAndSecrets(data
, warn
, err
);
460 assert
.strictEqual(warn
.length
, 0, warn
);
461 assert
.strictEqual(err
.length
, 1, err
);
463 it('errors when secret too large', function () {
465 callback: 'https://example.com/callback',
466 secret: 'x'.repeat(256),
469 manager
._checkCallbackAndSecrets(data
, warn
, err
);
470 assert
.strictEqual(warn
.length
, 0, warn
);
471 assert
.strictEqual(err
.length
, 1, err
);
473 it('warns when callback is insecure', function () {
475 callback: 'http://example.com/callback',
478 manager
._checkCallbackAndSecrets(data
, warn
, err
);
479 assert
.strictEqual(warn
.length
, 1, warn
);
480 assert
.strictEqual(err
.length
, 0, err
);
482 it('warns when hub is insecure with secret', function () {
484 callback: 'https://example.com/callback',
488 manager
._checkCallbackAndSecrets(data
, warn
, err
);
489 assert
.strictEqual(warn
.length
, 1, warn
);
490 assert
.strictEqual(err
.length
, 0, err
);
492 it('errors when callback is insecure with secret and strict', function () {
493 manager
.options
.manager
.strictSecrets
= true;
495 callback: 'http://example.com/callback',
499 manager
._checkCallbackAndSecrets(data
, warn
, err
);
500 assert
.strictEqual(warn
.length
, 1, warn
);
501 assert
.strictEqual(err
.length
, 1, err
);
503 }); // _checkCallbackAndSecrets
505 describe('_checkMode', function () {
506 let dbCtx
, data
, warn
, err
;
507 beforeEach(function () {
513 it('subscribe succeeds', async
function () {
517 await manager
._checkMode(dbCtx
, data
, warn
, err
);
518 assert
.strictEqual(warn
.length
, 0);
519 assert
.strictEqual(err
.length
, 0);
521 it('unsubscribe succeeds', async
function () {
524 callback: 'http://example.com',
527 manager
.db
.subscriptionGet
.resolves({
528 expires: (Date
.now() / 1000) + 60,
530 await manager
._checkMode(dbCtx
, data
, warn
, err
);
531 assert
.strictEqual(warn
.length
, 0, warn
);
532 assert
.strictEqual(err
.length
, 0, err
);
534 it('unsubscribe requires valid data', async
function () {
537 callback: 'http://example.com',
540 manager
.db
.subscriptionGet
.resolves({
541 expires: (Date
.now() / 1000) - 60,
543 await manager
._checkMode(dbCtx
, data
, warn
, err
);
544 assert
.strictEqual(warn
.length
, 0, warn
);
545 assert
.strictEqual(err
.length
, 1, err
);
547 it('unsubscribe ignores expired subscription', async
function () {
550 callback: 'http://example.com',
553 manager
.db
.subscriptionGet
.resolves({
554 expires: (Date
.now() / 1000) - 60,
556 await manager
._checkMode(dbCtx
, data
, warn
, err
);
557 assert
.strictEqual(warn
.length
, 0, warn
);
558 assert
.strictEqual(err
.length
, 1, err
);
562 describe('_checkPublish', function () {
563 let dbCtx
, data
, warn
, err
, requestId
;
564 beforeEach(function () {
571 it('succeeds', async
function () {
572 manager
.db
.topicGetByUrl
.resolves({
575 Object
.assign(data
, testData
.validPublishRootData
);
576 await manager
._checkPublish(dbCtx
, data
, warn
, err
, requestId
);
577 assert
.strictEqual(warn
.length
, 0, 'unexpected warnings length');
578 assert
.strictEqual(err
.length
, 0, 'unexpected errors length');
579 assert
.strictEqual(data
.topicId
, 222, 'unexpected topic id');
581 it('fails bad url', async
function () {
582 Object
.assign(data
, testData
.validPublishRootData
, { topic: 'not_a_url' });
583 await manager
._checkPublish(dbCtx
, data
, warn
, err
, requestId
);
584 assert
.strictEqual(err
.length
, 1, 'unexpected errors length');
585 assert
.strictEqual(warn
.length
, 0);
587 it('accepts new public publish topic', async
function () {
588 manager
.db
.topicGetByUrl
.onCall(0).resolves().onCall(1).resolves({
591 Object
.assign(data
, testData
.validPublishRootData
);
592 await manager
._checkPublish(dbCtx
, data
, warn
, err
, requestId
);
593 assert
.strictEqual(warn
.length
, 0, 'unexpected warnings length');
594 assert
.strictEqual(err
.length
, 0, 'unexpected errors length');
595 assert
.strictEqual(data
.topicId
, 222, 'unexpected topic id');
597 it('does not publish deleted topic', async
function () {
598 manager
.db
.topicGetByUrl
.resolves({
602 Object
.assign(data
, testData
.validPublishRootData
);
603 await manager
._checkPublish(dbCtx
, data
, warn
, err
, requestId
);
604 assert
.strictEqual(warn
.length
, 0, 'unexpected warnings length');
605 assert
.strictEqual(err
.length
, 1, 'unexpected errors length');
606 assert
.strictEqual(data
.topicId
, undefined, 'unexpected topic id');
610 describe('processTasks', function () {
611 it('covers', async
function () {
612 sinon
.stub(manager
.communication
.worker
, 'process');
613 await manager
.processTasks(res
, ctx
);
614 assert(manager
.communication
.worker
.process
.called
);
615 assert(res
.end
.called
);
619 describe('updateTopic', function () {
620 it('fails if no topic exists', async
function () {
622 await manager
.updateTopic(res
, ctx
);
623 assert
.fail(noExpectedException
);
625 assert(e
instanceof Errors
.ResponseError
);
628 it('deletes', async
function () {
629 ctx
.method
= 'DELETE';
630 manager
.db
.topicGetById
.resolves({});
631 await manager
.updateTopic(res
, ctx
);
632 assert(manager
.db
.topicDeleted
.called
);
634 it('does not patch without data', async
function () {
635 manager
.db
.topicGetById
.resolves({});
636 await manager
.updateTopic(res
, ctx
);
637 assert(!manager
.db
.topicUpdate
.called
);
638 assert
.strictEqual(res
.statusCode
, 204);
640 it('does not patch with same data', async
function () {
641 manager
.db
.topicGetById
.resolves({
642 leaseSecondsPreferred: '86400',
645 leaseSecondsPreferred: '86400',
647 await manager
.updateTopic(res
, ctx
);
648 assert(!manager
.db
.topicUpdate
.called
);
649 assert
.strictEqual(res
.statusCode
, 204);
651 it('patches', async
function () {
653 leaseSecondsPreferred: '86400',
655 manager
.db
.topicGetById
.resolves({});
656 await manager
.updateTopic(res
, ctx
);
657 assert(manager
.db
.topicUpdate
.called
);
659 it('handles validation error', async
function () {
661 leaseSecondsPreferred: 'blorp',
663 manager
.db
.topicGetById
.resolves({});
664 manager
.db
.topicUpdate
.rejects(new DBErrors
.DataValidation('something'));
666 await manager
.updateTopic(res
, ctx
);
667 assert
.fail(noExpectedException
);
669 assert(e
instanceof Errors
.ResponseError
);
670 assert
.strictEqual(e
.statusCode
, 400);
673 it('handles generic error', async
function () {
674 const expected
= new Error('blah');
676 leaseSecondsPreferred: '123',
678 manager
.db
.topicGetById
.resolves({});
679 manager
.db
.topicUpdate
.rejects(expected
);
681 await manager
.updateTopic(res
, ctx
);
682 assert
.fail(noExpectedException
);
684 assert
.deepStrictEqual(e
, expected
);
689 describe('updateSubscription', function () {
690 it('fails if no subscription exists', async
function () {
692 await manager
.updateSubscription(res
, ctx
);
693 assert
.fail(noExpectedException
);
695 assert(e
instanceof Errors
.ResponseError
);
698 it('deletes', async
function () {
699 ctx
.method
= 'DELETE';
700 manager
.db
.subscriptionGetById
.resolves({});
701 await manager
.updateSubscription(res
, ctx
);
702 assert(manager
.db
.verificationInsert
.called
);
704 it('does not patch without data', async
function () {
705 manager
.db
.subscriptionGetById
.resolves({});
706 await manager
.updateSubscription(res
, ctx
);
707 assert(!manager
.db
.subscriptionUpdate
.called
);
708 assert
.strictEqual(res
.statusCode
, 204);
710 it('does not patch with same data', async
function () {
711 manager
.db
.subscriptionGetById
.resolves({
712 signatureAlgorithm: 'sha256',
715 signatureAlgorithm: 'sha256',
717 await manager
.updateSubscription(res
, ctx
);
718 assert(!manager
.db
.subscriptionUpdate
.called
);
719 assert
.strictEqual(res
.statusCode
, 204);
721 it('patches', async
function () {
723 signatureAlgorithm: 'sha256',
725 manager
.db
.subscriptionGetById
.resolves({});
726 await manager
.updateSubscription(res
, ctx
);
727 assert(manager
.db
.subscriptionUpdate
.called
);
729 it('handles validation error', async
function () {
731 signatureAlgorithm: 123,
733 manager
.db
.subscriptionGetById
.resolves({});
734 manager
.db
.subscriptionUpdate
.rejects(new DBErrors
.DataValidation('something'));
736 await manager
.updateSubscription(res
, ctx
);
737 assert
.fail(noExpectedException
);
739 assert(e
instanceof Errors
.ResponseError
);
740 assert
.strictEqual(e
.statusCode
, 400);
743 it('handles generic error', async
function () {
744 const expected
= new Error('blah');
746 signatureAlgorithm: 'blorp',
748 manager
.db
.subscriptionGetById
.resolves({});
749 manager
.db
.subscriptionUpdate
.rejects(expected
);
751 await manager
.updateSubscription(res
, ctx
);
752 assert
.fail(noExpectedException
);
754 assert
.deepStrictEqual(e
, expected
);
757 }); // updateSubscription