consider path when determining if an IA profile user can view a topic
[websub-hub] / test / src / manager.js
1 /* eslint-env mocha */
2 /* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */
3
4 'use strict';
5
6 const assert = require('assert');
7 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
8
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');
14
15 const stubDb = require('../stub-db');
16 const stubLogger = require('../stub-logger');
17 const testData = require('../test-data/manager');
18
19 const noExpectedException = 'did not get expected exception';
20
21 describe('Manager', function () {
22 let manager, options;
23 let req, res, ctx;
24
25 beforeEach(function () {
26 options = new Config('test');
27 req = {
28 getHeader : sinon.stub(),
29 };
30 res = {
31 end: sinon.stub(),
32 setHeader: sinon.stub(),
33 };
34 ctx = {
35 params: {},
36 queryParams: {},
37 };
38 manager = new Manager(stubLogger, stubDb, options);
39 sinon.stub(manager.communication, 'verificationProcess');
40 sinon.stub(manager.communication, 'topicFetchProcess');
41 sinon.stub(manager.communication, 'topicFetchClaimAndProcessById');
42 stubDb._reset();
43 stubLogger._reset();
44 });
45 afterEach(function () {
46 sinon.restore();
47 });
48
49 it('instantiates', function () {
50 assert(manager);
51 });
52
53 describe('getRoot', function () {
54 beforeEach(function () {
55 sinon.stub(common, 'isClientCached');
56 req = {};
57 });
58 it('normal response', async function () {
59 common.isClientCached.returns(false);
60 await manager.getRoot(req, res, ctx);
61 assert(res.end.called);
62 });
63 }); // getRoot
64
65 describe('getHealthcheck', function () {
66 it('normal response', async function () {
67 await manager.getHealthcheck(res, ctx);
68 assert(res.end.called);
69 });
70 }); // getById
71
72 describe('getInfo', function () {
73 it('requires query param', async function() {
74 ctx.queryParams = {};
75 try {
76 await manager.getInfo(res, ctx);
77 assert.fail(noExpectedException);
78 } catch (e) {
79 assert.strictEqual(e.statusCode, 400);
80 }
81 });
82 it('requires parsable query param', async function() {
83 ctx.queryParams = { topic: 'not a url' };
84 try {
85 await manager.getInfo(res, ctx);
86 assert.fail(noExpectedException);
87 } catch (e) {
88 assert.strictEqual(e.statusCode, 400);
89 }
90 });
91 it('does not find unhandled topic', async function() {
92 ctx.queryParams = { topic: 'https://example.com/blog/' };
93 try {
94 await manager.getInfo(res, ctx);
95 assert.fail(noExpectedException);
96 } catch (e) {
97 assert.strictEqual(e.statusCode, 404);
98 }
99 });
100 it('returns a count', async function() {
101 manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
102 ctx.queryParams = {
103 topic: 'https://example.com/blog/',
104 };
105 await manager.getInfo(res, ctx);
106 assert(res.end.called);
107 });
108 it('returns a count as json', async function() {
109 manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
110 ctx.responseType = 'application/json';
111 ctx.queryParams = {
112 topic: 'https://example.com/blog/',
113 };
114 await manager.getInfo(res, ctx);
115 assert(res.end.called);
116 });
117 it('returns a count as json as override format', async function() {
118 manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
119 ctx.responseType = 'text/html';
120 ctx.queryParams = {
121 topic: 'https://example.com/blog/',
122 format: 'json',
123 };
124 await manager.getInfo(res, ctx);
125 assert(res.end.called);
126 assert(res.setHeader.called);
127 });
128 it('returns an svg badge as override format', async function() {
129 manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
130 ctx.responseType = 'text/html';
131 ctx.queryParams = {
132 topic: 'https://example.com/blog/',
133 format: 'svg',
134 };
135 await manager.getInfo(res, ctx);
136 assert(res.end.called);
137 assert(res.setHeader.called);
138 });
139 }); // getInfo
140
141 describe('_historyBarCaption', function () {
142 it('covers today, none', function () {
143 const result = Manager._historyBarCaption(0, 0);
144 assert.strictEqual(result, 'today, no updates');
145 });
146 it('covers yesterday, singular', function () {
147 const result = Manager._historyBarCaption(1, 1);
148 assert.strictEqual(result, 'yesterday, 1 update');
149 });
150 it('covers older, plural', function () {
151 const result = Manager._historyBarCaption(7, 3);
152 assert.strictEqual(result, '7 days ago, 3 updates');
153 });
154 }); // _historyBarCaption
155
156 describe('getHistorySVG', function () {
157 beforeEach(function () {
158 manager.db.topicPublishHistory.resolves([0, 1, 2, 1, 0, 1, 2, 0, 1]);
159 });
160 it('covers', async function () {
161 await manager.getHistorySVG(res, ctx);
162 assert(res.end.called);
163 });
164 }); // getHistorySVG
165
166 describe('getAdminOverview', function () {
167 beforeEach(function () {
168 manager.db.topicGetAll.resolves([
169 {
170 id: '56c557ce-e667-11eb-bd80-0025905f714a',
171 created: new Date(),
172 url: 'https://example.com/',
173 leaseSecondsPreferred: 123,
174 leaseSecondsMin: 12,
175 leaseSecondsMax: 123456789,
176 publisherValidationUrl: null,
177 contentHashAlgorithm: 'hashy',
178 isActive: true,
179 isDeleted: false,
180 lastPublish: new Date(-Infinity),
181 contentFetchNextAttempt: undefined,
182 contentFetchAttemptsSinceSuccess: 3,
183 contentUpdated: new Date(0),
184 contentHash: 'abc',
185 contentType: 'foo',
186 subscribers: 12,
187 },
188 ]);
189 });
190 it('covers', async function () {
191 await manager.getAdminOverview(res, ctx);
192 assert(res.end.called);
193 });
194 it('covers non-matching profile', async function () {
195 ctx.session = {
196 authenticatedProfile: 'https://different.example.com/profile',
197 };
198 await manager.getAdminOverview(res, ctx);
199 assert.deepStrictEqual(ctx.topics, []);
200 assert(res.end.called);
201 });
202 }); // getAdminOverview
203
204 describe('getTopicDetails', function () {
205 beforeEach(function () {
206 ctx.params.topicId = '56c557ce-e667-11eb-bd80-0025905f714a';
207 manager.db.topicGetById.resolves({
208 id: '56c557ce-e667-11eb-bd80-0025905f714a',
209 created: new Date(),
210 url: 'https://example.com/topic',
211 leaseSecondsPreferred: 123,
212 leaseSecondsMin: 12,
213 leaseSecondsMax: 123456789,
214 publisherValidationUrl: null,
215 contentHashAlgorithm: 'hashy',
216 isActive: true,
217 isDeleted: false,
218 lastPublish: new Date(-Infinity),
219 contentFetchNextAttempt: undefined,
220 contentFetchAttemptsSinceSuccess: 3,
221 contentUpdated: new Date(0),
222 contentHash: 'abc',
223 contentType: 'foo',
224 subscribers: 12,
225 });
226 manager.db.subscriptionsByTopicId.resolves([{
227 id: '',
228 created: new Date(),
229 topicId: '56c557ce-e667-11eb-bd80-0025905f714a',
230 callback: '',
231 verified: new Date(),
232 expires: new Date(),
233 secret: '',
234 signatureAlgorithm: 'hmacy',
235 httpRemoteAddr: '',
236 httpFrom: '',
237 contentDelivered: new Date(),
238 deliveryAttemptsSinceSuccess: 0,
239 deliveryNextAttempt: new Date(-Infinity),
240 }]);
241 manager.db.topicPublishHistory.resolves([0, 1, 0, 1, 0]);
242 });
243 it('covers', async function() {
244 await manager.getTopicDetails(res, ctx);
245 assert(res.end.called);
246 });
247 it('covers non-matching profile', async function () {
248 ctx.session = {
249 authenticatedProfile: 'https://different.example.com/profile',
250 };
251 await manager.getTopicDetails(res, ctx);
252 assert.strictEqual(ctx.topic, null);
253 assert(res.end.called);
254 });
255 it('covers matching profile', async function () {
256 ctx.session = {
257 authenticatedProfile: 'https://example.com/',
258 };
259 await manager.getTopicDetails(res, ctx);
260 assert(ctx.topic);
261 assert(res.end.called);
262 });
263 }); // getTopicDetails
264
265 describe('postRoot', function () {
266 let origProcessImmediately;
267 beforeEach(function () {
268 origProcessImmediately = manager.options.manager.processImmediately;
269 ctx.parsedBody = {};
270 });
271 this.afterEach(function () {
272 manager.options.manager.processImmediately = origProcessImmediately;
273 });
274 it('requires parameters', async function () {
275 try {
276 await manager.postRoot(req, res, ctx);
277 assert.fail(noExpectedException);
278 } catch (e) {
279 assert.strictEqual(e.message, 'Bad Request');
280 }
281 });
282 it('accepts valid subscription', async function () {
283 ctx = Object.assign({}, testData.validSubscribeCtx);
284 manager.db.topicGetByUrl.resolves({
285 id: 111,
286 });
287 manager.db.verificationInsert.resolves({
288 changes: 1,
289 lastInsertRowid: undefined,
290 duration: 12,
291 });
292 await manager.postRoot(req, res, ctx);
293 assert(manager.db.verificationInsert.called);
294 assert(res.end.called);
295 });
296 it('accepts valid subscription without claiming work', async function () {
297 manager.options.manager.processImmediately = false;
298 ctx = Object.assign({}, testData.validSubscribeCtx);
299 manager.db.topicGetByUrl.resolves({
300 id: 111,
301 });
302 manager.db.verificationInsert.resolves({
303 changes: 1,
304 lastInsertRowid: undefined,
305 duration: 12,
306 });
307 await manager.postRoot(req, res, ctx);
308 assert(manager.db.verificationInsert.called);
309 assert(!manager.communication.verificationProcess.called);
310 assert(res.end.called);
311 });
312 it('accepts valid subscription, covers processVerification failure', async function () {
313 ctx = Object.assign({}, testData.validSubscribeCtx);
314 manager.communication.verificationProcess.rejects('failed');
315 manager.db.topicGetByUrl.resolves({
316 id: 111,
317 });
318 manager.db.verificationInsert.resolves({
319 changes: 1,
320 lastInsertRowid: undefined,
321 duration: 12,
322 });
323 await manager.postRoot(req, res, ctx);
324 assert(manager.db.verificationInsert.called);
325 assert(res.end.called);
326 assert(manager.logger.error.called);
327 });
328 it('covers db.verificationInsert failure', async function () {
329 const expectedException = new Error('failure');
330 ctx = Object.assign({}, testData.validSubscribeCtx);
331 manager.db.topicGetByUrl.resolves({
332 id: 111,
333 });
334 manager.db.verificationInsert.rejects(expectedException);
335 assert.rejects(async () => {
336 await manager.postRoot(req, res, ctx);
337 }, expectedException);
338 });
339 it('accepts valid unsubscription', async function () {
340 ctx = Object.assign({}, testData.validUnsubscribeCtx);
341 manager.db.topicGetByUrl.resolves({
342 id: 111,
343 });
344 manager.db.subscriptionGet.resolves({
345 id: 22,
346 });
347 manager.db.verificationInsert.resolves({
348 changes: 1,
349 lastInsertRowid: undefined,
350 duration: 12,
351 });
352 await manager.postRoot(req, res, ctx);
353 assert(res.end.called);
354 });
355 it('accepts valid publish', async function () {
356 ctx = Object.assign({}, testData.validPublishCtx);
357 manager.db.topicGetByUrl.resolves({
358 id: 111,
359 });
360 manager.db.topicFetchRequested.resolves({
361 changes: 1,
362 lastInsertRowid: undefined,
363 duration: 12,
364 });
365 await manager.postRoot(req, res, ctx);
366 assert(res.end.called);
367 });
368 }); // postRoot
369
370 describe('_profileControlsTopic', function () {
371 let profileUrlObj, topicUrlObj;
372 it('allows exact match', function () {
373 profileUrlObj = new URL('https://profile.example.com/');
374 topicUrlObj = new URL('https://profile.example.com/');
375 const result = Manager._profileControlsTopic(profileUrlObj, topicUrlObj);
376 assert.strictEqual(result, true);
377 });
378 it('allows descendent-path match', function () {
379 profileUrlObj = new URL('https://profile.example.com/');
380 topicUrlObj = new URL('https://profile.example.com/feed/atom');
381 const result = Manager._profileControlsTopic(profileUrlObj, topicUrlObj);
382 assert.strictEqual(result, true);
383 });
384 it('disallows non-descendent-path', function () {
385 profileUrlObj = new URL('https://profile.example.com/itsame');
386 topicUrlObj = new URL('https://profile.example.com/');
387 const result = Manager._profileControlsTopic(profileUrlObj, topicUrlObj);
388 assert.strictEqual(result, false);
389 });
390 it('disallows non-matched host', function () {
391 profileUrlObj = new URL('https://profile.example.com/itsame');
392 topicUrlObj = new URL('https://elsewhere.example.com/itsame/feed');
393 const result = Manager._profileControlsTopic(profileUrlObj, topicUrlObj);
394 assert.strictEqual(result, false);
395 });
396 }); // _profileControlsTopic
397
398 describe('_getRootData', function () {
399 it('extracts expected values', function () {
400 req.getHeader.returns('user@example.com');
401 ctx = Object.assign({}, testData.validSubscribeCtx)
402 const result = Manager._getRootData(req, ctx);
403 assert.deepStrictEqual(result, testData.validRootData);
404 });
405 }); // _getRootData
406
407 describe('_validateRootData', function () {
408 // This only wraps the other _check functions, not bothering with coverage.
409 }); // _validateRootData
410
411 describe('_checkTopic', function () {
412 let dbCtx, data, warn, err;
413 const topic = {
414 id: 111,
415 leaseSecondsPreferred: 86400 * 10,
416 leaseSecondsMax: 86400 * 20,
417 leaseSecondsMin: 86400,
418 };
419 beforeEach(function () {
420 dbCtx = {};
421 data = {};
422 warn = [];
423 err = [];
424 });
425 it('succeeds', async function () {
426 data = {
427 topic: 'http://example.com/blog',
428 };
429 manager.db.topicGetByUrl.resolves(topic);
430 await manager._checkTopic(dbCtx, data, warn, err);
431 assert.strictEqual(warn.length, 0, warn);
432 assert.strictEqual(err.length, 0, err);
433 assert.strictEqual(data.topicId, 111);
434 assert.strictEqual(data.leaseSeconds, 864000);
435 });
436 it('errors on unknown topic', async function () {
437 manager.db.topicGetByUrl.resolves();
438 await manager._checkTopic(dbCtx, data, warn, err);
439 assert.strictEqual(warn.length, 0, warn);
440 assert.strictEqual(err.length, 1, err);
441 });
442 it('warns on lease under min range', async function () {
443 data = {
444 topic: 'http://example.com/blog',
445 leaseSeconds: 97,
446 };
447 manager.db.topicGetByUrl.resolves(topic);
448 await manager._checkTopic(dbCtx, data, warn, err);
449 assert.strictEqual(warn.length, 1, warn);
450 assert.strictEqual(err.length, 0, err);
451 assert.strictEqual(data.topicId, 111);
452 assert.strictEqual(data.leaseSeconds, 86400);
453 });
454 it('warns on lease over max range', async function () {
455 data = {
456 topic: 'http://example.com/blog',
457 leaseSeconds: 86400 * 100,
458 };
459 manager.db.topicGetByUrl.resolves(topic);
460 await manager._checkTopic(dbCtx, data, warn, err);
461 assert.strictEqual(warn.length, 1, warn);
462 assert.strictEqual(err.length, 0, err);
463 assert.strictEqual(data.topicId, 111);
464 assert.strictEqual(data.leaseSeconds, 86400 * 20);
465 });
466 it('sets publisher validation state when available', async function () {
467 data = {
468 topic: 'http://example.com/blog',
469 };
470 manager.db.topicGetByUrl.resolves(Object.assign({}, topic, {
471 publisherValidationUrl: 'http://example.com/validate',
472 }));
473 await manager._checkTopic(dbCtx, data, warn, err);
474 assert.strictEqual(warn.length, 0, warn);
475 assert.strictEqual(err.length, 0, err);
476 assert.strictEqual(data.topicId, 111);
477 assert.strictEqual(data.leaseSeconds, 864000);
478 assert.strictEqual(data.isPublisherValidated, false);
479 });
480 it('accepts new public subscribe topic', async function () {
481 manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic);
482 data = {
483 topic: 'http://example.com/blog',
484 };
485 await manager._checkTopic(dbCtx, data, warn, err);
486 assert.strictEqual(warn.length, 0, 'unexpected warnings length');
487 assert.strictEqual(err.length, 0, 'unexpected errors length');
488 assert.strictEqual(data.topicId, 111, 'unexpected topic id');
489 });
490 it('does not accept new public subscribe for invalid topic', async function () {
491 manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic);
492 data = {
493 topic: 'not a topic',
494 };
495 await manager._checkTopic(dbCtx, data, warn, err);
496 assert.strictEqual(warn.length, 0, 'unexpected warnings length');
497 assert.strictEqual(err.length, 1, 'unexpected errors length');
498 });
499 }); // _checkTopic
500
501 describe('_checkCallbackAndSecrets', function () {
502 let data, warn, err;
503 let origStrictSecrets;
504 before(function () {
505 origStrictSecrets = manager.options.manager.strictSecrets;
506 });
507 beforeEach(function () {
508 data = {};
509 warn = [];
510 err = [];
511 });
512 afterEach(function () {
513 manager.options.manager.strictSecrets = origStrictSecrets;
514 });
515 it('succeeds', function () {
516 data = {
517 callback: 'https://example.com/callback',
518 secret: 'so safe',
519 isSecure: true,
520 };
521 manager._checkCallbackAndSecrets(data, warn, err);
522 assert.strictEqual(warn.length, 0, warn);
523 assert.strictEqual(err.length, 0, err);
524 });
525 it('errors with invalid callback', function () {
526 data = {
527 callback: 'not a url',
528 secret: 'so safe',
529 isSecure: true,
530 };
531 manager._checkCallbackAndSecrets(data, warn, err);
532 assert.strictEqual(warn.length, 0, warn);
533 assert.strictEqual(err.length, 1, err);
534 });
535 it('errors when secret too large', function () {
536 data = {
537 callback: 'https://example.com/callback',
538 secret: 'x'.repeat(256),
539 isSecure: true,
540 };
541 manager._checkCallbackAndSecrets(data, warn, err);
542 assert.strictEqual(warn.length, 0, warn);
543 assert.strictEqual(err.length, 1, err);
544 });
545 it('warns when callback is insecure', function () {
546 data = {
547 callback: 'http://example.com/callback',
548 isSecure: true,
549 };
550 manager._checkCallbackAndSecrets(data, warn, err);
551 assert.strictEqual(warn.length, 1, warn);
552 assert.strictEqual(err.length, 0, err);
553 });
554 it('warns when hub is insecure with secret', function () {
555 data = {
556 callback: 'https://example.com/callback',
557 secret: 'so safe',
558 isSecure: false,
559 };
560 manager._checkCallbackAndSecrets(data, warn, err);
561 assert.strictEqual(warn.length, 1, warn);
562 assert.strictEqual(err.length, 0, err);
563 });
564 it('errors when callback is insecure with secret and strict', function () {
565 manager.options.manager.strictSecrets = true;
566 data = {
567 callback: 'http://example.com/callback',
568 secret: 'so safe',
569 isSecure: true,
570 };
571 manager._checkCallbackAndSecrets(data, warn, err);
572 assert.strictEqual(warn.length, 1, warn);
573 assert.strictEqual(err.length, 1, err);
574 });
575 }); // _checkCallbackAndSecrets
576
577 describe('_checkMode', function () {
578 let dbCtx, data, warn, err;
579 beforeEach(function () {
580 dbCtx = {};
581 data = {};
582 warn = [];
583 err = [];
584 });
585 it('subscribe succeeds', async function () {
586 data = {
587 mode: 'subscribe',
588 };
589 await manager._checkMode(dbCtx, data, warn, err);
590 assert.strictEqual(warn.length, 0);
591 assert.strictEqual(err.length, 0);
592 });
593 it('unsubscribe succeeds', async function () {
594 data = {
595 mode: 'unsubscribe',
596 callback: 'http://example.com',
597 topicId: 123,
598 };
599 manager.db.subscriptionGet.resolves({
600 expires: (Date.now() / 1000) + 60,
601 });
602 await manager._checkMode(dbCtx, data, warn, err);
603 assert.strictEqual(warn.length, 0, warn);
604 assert.strictEqual(err.length, 0, err);
605 });
606 it('unsubscribe requires valid data', async function () {
607 data = {
608 mode: 'unsubscribe',
609 callback: 'http://example.com',
610 topicId: undefined,
611 };
612 manager.db.subscriptionGet.resolves({
613 expires: (Date.now() / 1000) - 60,
614 });
615 await manager._checkMode(dbCtx, data, warn, err);
616 assert.strictEqual(warn.length, 0, warn);
617 assert.strictEqual(err.length, 1, err);
618 });
619 it('unsubscribe ignores expired subscription', async function () {
620 data = {
621 mode: 'unsubscribe',
622 callback: 'http://example.com',
623 topicId: 123,
624 };
625 manager.db.subscriptionGet.resolves({
626 expires: (Date.now() / 1000) - 60,
627 });
628 await manager._checkMode(dbCtx, data, warn, err);
629 assert.strictEqual(warn.length, 0, warn);
630 assert.strictEqual(err.length, 1, err);
631 });
632 }); // _checkMode
633
634 describe('_publishTopics', function () {
635 let dbCtx, data, requestId;
636 beforeEach(function () {
637 dbCtx = {};
638 data = {};
639 requestId = 'blah';
640 });
641 it('succeeds', async function () {
642 manager.db.topicGetByUrl.resolves({
643 id: 222,
644 });
645 Object.assign(data, testData.validPublishRootData);
646 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
647 assert.strictEqual(topicResults.length, 1);
648 assert.strictEqual(topicResults[0].warn.length, 0, 'unexpected warnings length');
649 assert.strictEqual(topicResults[0].err.length, 0, 'unexpected errors length');
650 assert.strictEqual(topicResults[0].topicId, 222, 'unexpected topic id');
651 });
652 it('fails bad url', async function () {
653 Object.assign(data, testData.validPublishRootData, { topic: 'not_a_url' });
654 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
655 assert.strictEqual(topicResults.length, 1);
656 assert.strictEqual(topicResults[0].err.length, 1, 'unexpected errors length');
657 assert.strictEqual(topicResults[0].warn.length, 0);
658 });
659 it('accepts new public publish topic', async function () {
660 manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves({
661 id: 222,
662 });
663 Object.assign(data, testData.validPublishRootData);
664 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
665 assert.strictEqual(topicResults.length, 1);
666 assert.strictEqual(topicResults[0].warn.length, 0, 'unexpected warnings length');
667 assert.strictEqual(topicResults[0].err.length, 0, 'unexpected errors length');
668 assert.strictEqual(topicResults[0].topicId, 222, 'unexpected topic id');
669 });
670 it('does not publish deleted topic', async function () {
671 manager.db.topicGetByUrl.resolves({
672 id: 222,
673 isDeleted: true,
674 });
675 Object.assign(data, testData.validPublishRootData);
676 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
677 assert.strictEqual(topicResults.length, 1);
678 assert.strictEqual(topicResults[0].warn.length, 0, 'unexpected warnings length');
679 assert.strictEqual(topicResults[0].err.length, 1, 'unexpected errors length');
680 assert.strictEqual(topicResults[0].topicId, undefined, 'unexpected topic id');
681 });
682 it('no topics', async function() {
683 Object.assign(data, testData.validPublishRootData);
684 delete data.topic;
685 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
686 assert.strictEqual(topicResults.length, 0);
687 });
688 it('multiple valid topics', async function () {
689 manager.db.topicGetByUrl.resolves({
690 id: 222,
691 });
692 Object.assign(data, testData.validPublishRootData);
693 data.url = ['https://example.com/first', 'https://example.com/second'];
694 data.topic = ['https://example.com/third'];
695 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
696 assert.strictEqual(topicResults.length, 3);
697 assert.strictEqual(topicResults[0].warn.length, 0, 'unexpected warnings length');
698 assert.strictEqual(topicResults[0].err.length, 0, 'unexpected errors length');
699 assert.strictEqual(topicResults[0].topicId, 222, 'unexpected topic id');
700 assert.strictEqual(topicResults[1].warn.length, 0, 'unexpected warnings length');
701 assert.strictEqual(topicResults[1].err.length, 0, 'unexpected errors length');
702 assert.strictEqual(topicResults[1].topicId, 222, 'unexpected topic id');
703 assert.strictEqual(topicResults[2].warn.length, 0, 'unexpected warnings length');
704 assert.strictEqual(topicResults[2].err.length, 0, 'unexpected errors length');
705 assert.strictEqual(topicResults[2].topicId, 222, 'unexpected topic id');
706 });
707 it('mix of valid and invalid topics', async function () {
708 manager.db.topicGetByUrl.onCall(1).resolves().resolves({
709 id: 222,
710 });
711 Object.assign(data, testData.validPublishRootData);
712 data.url = ['https://example.com/first', 'not a url'];
713 data.topic = ['https://example.com/third'];
714 const topicResults = await manager._publishTopics(dbCtx, data, requestId);
715 assert.strictEqual(topicResults.length, 3);
716 assert.strictEqual(topicResults[0].warn.length, 0, 'unexpected warnings length');
717 assert.strictEqual(topicResults[0].err.length, 0, 'unexpected errors length');
718 assert.strictEqual(topicResults[0].topicId, 222, 'unexpected topic id');
719 assert.strictEqual(topicResults[1].warn.length, 0, 'unexpected warnings length');
720 assert.strictEqual(topicResults[1].err.length, 1, 'unexpected errors length');
721 assert.strictEqual(topicResults[1].topicId, undefined, 'unexpected topic id');
722 assert.strictEqual(topicResults[2].warn.length, 0, 'unexpected warnings length');
723 assert.strictEqual(topicResults[2].err.length, 0, 'unexpected errors length');
724 assert.strictEqual(topicResults[2].topicId, 222, 'unexpected topic id');
725 });
726 }); // _publishTopics
727
728 describe('_publishRequest', function () {
729 let dbCtx, data, res, ctx;
730 beforeEach(function () {
731 dbCtx = {};
732 data = {};
733 res = {
734 end: sinon.stub(),
735 };
736 ctx = {};
737 });
738 it('requires a topic', async function () {
739 try {
740 await manager._publishRequest(dbCtx, data, res, ctx);
741 assert.fail(noExpectedException);
742 } catch (e) {
743 assert(e instanceof Errors.ResponseError);
744 }
745 });
746 it('processes one topic', async function() {
747 manager.db.topicGetByUrl.resolves({
748 id: 222,
749 });
750 Object.assign(data, testData.validPublishRootData);
751 manager.db.topicFetchRequested.resolves();
752 await manager._publishRequest(dbCtx, data, res, ctx);
753 assert(manager.db.topicFetchRequested.called);
754 assert.strictEqual(res.statusCode, 202);
755 assert(res.end.called);
756 });
757 it('processes mix of valid and invalid topics', async function () {
758 ctx.responseType = 'application/json';
759 manager.db.topicGetByUrl.onCall(1).resolves().resolves({
760 id: 222,
761 });
762 Object.assign(data, testData.validPublishRootData);
763 data.url = ['https://example.com/first', 'not a url'];
764 data.topic = ['https://example.com/third'];
765 await manager._publishRequest(dbCtx, data, res, ctx);
766 assert.strictEqual(res.statusCode, 207);
767 assert(res.end.called);
768 });
769 it('covers topicFetchRequest failure', async function () {
770 manager.db.topicGetByUrl.resolves({
771 id: 222,
772 });
773 Object.assign(data, testData.validPublishRootData);
774 const expected = new Error('boo');
775 manager.db.topicFetchRequested.rejects(expected);
776 try {
777 await manager._publishRequest(dbCtx, data, res, ctx);
778 assert.fail(noExpectedException);
779 } catch (e) {
780 assert.deepStrictEqual(e, expected);
781 }
782 });
783 it('covers immediate processing error', async function() {
784 manager.options.manager.processImmediately = true;
785 manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves({
786 id: 222,
787 });
788 manager.communication.topicFetchClaimAndProcessById.rejects();
789 Object.assign(data, testData.validPublishRootData);
790 await manager._publishRequest(dbCtx, data, res, ctx);
791 assert(manager.db.topicFetchRequested.called);
792 assert.strictEqual(res.statusCode, 202);
793 assert(res.end.called);
794 assert(manager.communication.topicFetchClaimAndProcessById.called)
795 });
796 it('covers no immediate processing', async function() {
797 manager.options.manager.processImmediately = false;
798 manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves({
799 id: 222,
800 });
801 Object.assign(data, testData.validPublishRootData);
802 await manager._publishRequest(dbCtx, data, res, ctx);
803 assert(manager.db.topicFetchRequested.called);
804 assert.strictEqual(res.statusCode, 202);
805 assert(res.end.called);
806 assert(!manager.communication.topicFetchClaimAndProcessById.called)
807 });
808 }); // _publishRequest
809
810 describe('multiPublishContent', function () {
811 let publishTopics;
812 beforeEach(function () {
813 publishTopics = [{
814 url: 'https://example.com/first',
815 warn: [],
816 err: [],
817 topicId: 222,
818 status: 202,
819 statusMessage: 'Accepted',
820 },
821 {
822 url: 'not a url',
823 warn: [],
824 err: [ 'invalid topic url (failed to parse url)' ],
825 topicId: undefined,
826 status: 400,
827 statusMessage: 'Bad Request',
828 }];
829 });
830 it('covers json response', function () {
831 ctx.responseType = 'application/json';
832 const expected = '[{"href":"https://example.com/first","status":202,"statusMessage":"Accepted","errors":[],"warnings":[]},{"href":"not a url","status":400,"statusMessage":"Bad Request","errors":["invalid topic url (failed to parse url)"],"warnings":[]}]';
833 const result = Manager.multiPublishContent(ctx, publishTopics);
834 assert.deepStrictEqual(result, expected);
835 });
836 it('covers text response', function () {
837 ctx.responseType = 'text/plain';
838 const expected = `https://example.com/first [202 Accepted]
839 ----
840 not a url [400 Bad Request]
841 \terror: invalid topic url (failed to parse url)`;
842 const result = Manager.multiPublishContent(ctx, publishTopics);
843 assert.deepStrictEqual(result, expected);
844 });
845 }); // multiPublishContent
846
847 describe('processTasks', function () {
848 it('covers', async function () {
849 sinon.stub(manager.communication.worker, 'process').resolves();
850 await manager.processTasks(res, ctx);
851 assert(manager.communication.worker.process.called);
852 assert(res.end.called);
853 });
854 it('covers error', async function () {
855 sinon.stub(manager.communication.worker, 'process').rejects();
856 await manager.processTasks(res, ctx);
857 assert(manager.communication.worker.process.called);
858 assert(res.end.called);
859 });
860 }); // processTasks
861
862 describe('updateTopic', function () {
863 it('fails if no topic exists', async function () {
864 try {
865 await manager.updateTopic(res, ctx);
866 assert.fail(noExpectedException);
867 } catch (e) {
868 assert(e instanceof Errors.ResponseError);
869 }
870 });
871 it('deletes', async function () {
872 ctx.method = 'DELETE';
873 manager.db.topicGetById.resolves({});
874 await manager.updateTopic(res, ctx);
875 assert(manager.db.topicDeleted.called);
876 });
877 it('does not patch without data', async function () {
878 manager.db.topicGetById.resolves({});
879 await manager.updateTopic(res, ctx);
880 assert(!manager.db.topicUpdate.called);
881 assert.strictEqual(res.statusCode, 204);
882 });
883 it('does not patch with same data', async function () {
884 manager.db.topicGetById.resolves({
885 leaseSecondsPreferred: '86400',
886 });
887 ctx.parsedBody = {
888 leaseSecondsPreferred: '86400',
889 };
890 await manager.updateTopic(res, ctx);
891 assert(!manager.db.topicUpdate.called);
892 assert.strictEqual(res.statusCode, 204);
893 });
894 it('patches', async function () {
895 ctx.queryParams = {
896 leaseSecondsPreferred: '86400',
897 };
898 manager.db.topicGetById.resolves({});
899 await manager.updateTopic(res, ctx);
900 assert(manager.db.topicUpdate.called);
901 });
902 it('handles validation error', async function () {
903 ctx.queryParams = {
904 leaseSecondsPreferred: 'blorp',
905 };
906 manager.db.topicGetById.resolves({});
907 manager.db.topicUpdate.rejects(new DBErrors.DataValidation('something'));
908 try {
909 await manager.updateTopic(res, ctx);
910 assert.fail(noExpectedException);
911 } catch (e) {
912 assert(e instanceof Errors.ResponseError);
913 assert.strictEqual(e.statusCode, 400);
914 }
915 });
916 it('handles generic error', async function () {
917 const expected = new Error('blah');
918 ctx.queryParams = {
919 leaseSecondsPreferred: '123',
920 };
921 manager.db.topicGetById.resolves({});
922 manager.db.topicUpdate.rejects(expected);
923 try {
924 await manager.updateTopic(res, ctx);
925 assert.fail(noExpectedException);
926 } catch (e) {
927 assert.deepStrictEqual(e, expected);
928 }
929 });
930 }); // updateTopic
931
932 describe('updateSubscription', function () {
933 it('fails if no subscription exists', async function () {
934 try {
935 await manager.updateSubscription(res, ctx);
936 assert.fail(noExpectedException);
937 } catch (e) {
938 assert(e instanceof Errors.ResponseError);
939 }
940 });
941 it('deletes', async function () {
942 ctx.method = 'DELETE';
943 manager.db.subscriptionGetById.resolves({});
944 await manager.updateSubscription(res, ctx);
945 assert(manager.db.verificationInsert.called);
946 });
947 it('does not patch without data', async function () {
948 manager.db.subscriptionGetById.resolves({});
949 await manager.updateSubscription(res, ctx);
950 assert(!manager.db.subscriptionUpdate.called);
951 assert.strictEqual(res.statusCode, 204);
952 });
953 it('does not patch with same data', async function () {
954 manager.db.subscriptionGetById.resolves({
955 signatureAlgorithm: 'sha256',
956 });
957 ctx.parsedBody = {
958 signatureAlgorithm: 'sha256',
959 };
960 await manager.updateSubscription(res, ctx);
961 assert(!manager.db.subscriptionUpdate.called);
962 assert.strictEqual(res.statusCode, 204);
963 });
964 it('patches', async function () {
965 ctx.queryParams = {
966 signatureAlgorithm: 'sha256',
967 };
968 manager.db.subscriptionGetById.resolves({});
969 await manager.updateSubscription(res, ctx);
970 assert(manager.db.subscriptionUpdate.called);
971 });
972 it('handles validation error', async function () {
973 ctx.queryParams = {
974 signatureAlgorithm: 123,
975 };
976 manager.db.subscriptionGetById.resolves({});
977 manager.db.subscriptionUpdate.rejects(new DBErrors.DataValidation('something'));
978 try {
979 await manager.updateSubscription(res, ctx);
980 assert.fail(noExpectedException);
981 } catch (e) {
982 assert(e instanceof Errors.ResponseError);
983 assert.strictEqual(e.statusCode, 400);
984 }
985 });
986 it('handles generic error', async function () {
987 const expected = new Error('blah');
988 ctx.queryParams = {
989 signatureAlgorithm: 'blorp',
990 };
991 manager.db.subscriptionGetById.resolves({});
992 manager.db.subscriptionUpdate.rejects(expected);
993 try {
994 await manager.updateSubscription(res, ctx);
995 assert.fail(noExpectedException);
996 } catch (e) {
997 assert.deepStrictEqual(e, expected);
998 }
999 });
1000 }); // updateSubscription
1001
1002 }); // Manager