update depedencies, changes to support updated authentication-module
[squeep-indie-auther] / test / src / db / integration.js
1 /* eslint-env mocha */
2 /* eslint-disable sonarjs/no-identical-functions */
3 'use strict';
4
5 /**
6 * These are LIVE FIRE tests to exercise actual database operations.
7 * They should be configured to use local test databases, as they
8 * perform DESTRUCTIVE ACTIONS on all tables, beginning with a COMPLETE
9 * DATA WIPE.
10 *
11 * They will only run if all the appropriate environmental settings exist:
12 * - INTEGRATION_TESTS must be set
13 * - <ENGINE>_TEST_PATH must point to the endpoint/db
14 *
15 * These tests are sequential, relying on the state created along the way.
16 *
17 */
18
19 const assert = require('assert');
20 const { step } = require('mocha-steps'); // eslint-disable-line node/no-unpublished-require
21 const StubLogger = require('../../stub-logger');
22 // const DBErrors = require('../../../src/db/errors');
23 // const testData = require('../../test-data/db-integration');
24
25 describe('Database Integration', function () {
26 const implementations = [];
27
28 if (!process.env.INTEGRATION_TESTS) {
29 it.skip('integration tests not requested');
30 return;
31 }
32
33 if (process.env.POSTGRES_TEST_PATH) {
34 implementations.push({
35 name: 'PostgreSQL',
36 module: '../../../src/db/postgres',
37 config: {
38 db: {
39 connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`,
40 queryLogLevel: 'debug',
41 noWarnings: true,
42 },
43 },
44 });
45 }
46
47 if (process.env.SQLITE_TEST_PATH) {
48 implementations.push({
49 name: 'SQLite',
50 module: '../../../src/db/sqlite',
51 config: {
52 db: {
53 connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`,
54 queryLogLevel: 'debug',
55 sqliteOptimizeAfterChanges: 10,
56 },
57 },
58 });
59 }
60
61 if (!implementations.length) {
62 it('have some implementations to test', function () {
63 assert.fail('No implementations have been configured for requested integration tests');
64 });
65 }
66
67 implementations.forEach(function (i) {
68 describe(i.name, function () {
69 let logger;
70 let DB, db;
71 let profile, identifier;
72
73 before(async function () {
74 this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
75 logger = new StubLogger();
76 logger._reset();
77 // eslint-disable-next-line security/detect-non-literal-require
78 DB = require(i.module);
79 db = new DB(logger, i.config);
80 await db.initialize();
81 await db._purgeTables(true);
82 });
83 after(async function () {
84 await db._closeConnection();
85 });
86
87 beforeEach(function () {
88 identifier = 'username';
89 profile = 'https://example.com/profile';
90 });
91
92 describe('Healthcheck', function () {
93 it('should succeed', async function () {
94 const result = await db.healthCheck();
95 assert(result);
96 });
97 });
98
99 describe('Resources', function () {
100 let resourceId, secret, description;
101 before(function () {
102 secret = 'shared secret';
103 description = 'A resource server that needs to verify our tokens.';
104 });
105 step('returns nothing when resource does not exist', async function () {
106 await db.context(async (dbCtx) => {
107 const badResourceId = 'f1669969-c87e-46f8-83bb-a6712981d15d';
108 const result = await db.resourceGet(dbCtx, badResourceId);
109 assert(!result);
110 });
111 });
112 step('creates resource', async function () {
113 await db.context(async (dbCtx) => {
114 const result = await db.resourceUpsert(dbCtx, undefined, secret, description);
115 assert(result.resourceId);
116 resourceId = result.resourceId;
117 });
118 });
119 step('gets resource', async function () {
120 await db.context(async (dbCtx) => {
121 const result = await db.resourceGet(dbCtx, resourceId);
122 assert.strictEqual(result.secret, secret);
123 db._validateResource(result);
124 });
125 });
126 step('updates resource', async function () {
127 await db.context(async (dbCtx) => {
128 secret = 'new shared secret';
129 description = 'Still a resource server, but with a new description.';
130 await db.resourceUpsert(dbCtx, resourceId, secret, description);
131 const result = await db.resourceGet(dbCtx, resourceId);
132 assert.strictEqual(result.resourceId, resourceId);
133 assert.strictEqual(result.secret, secret);
134 assert.strictEqual(result.description, description);
135 });
136 });
137 }); // Resources
138
139 describe('Users and Profiles and Scopes', function () {
140 let credential, otpKey;
141 beforeEach(function () {
142 credential = '$plain$myPassword';
143 otpKey = '1234567890123456789012';
144 });
145 step('returns nothing when auth does not exist', async function () {
146 await db.context(async (dbCtx) => {
147 const result = await db.authenticationGet(dbCtx, identifier);
148 assert(!result);
149 });
150 });
151 step('create auth entry', async function () {
152 await db.context(async (dbCtx) => {
153 await db.authenticationUpsert(dbCtx, identifier, credential);
154 });
155 });
156 step('get auth entry', async function () {
157 await db.context(async (dbCtx) => {
158 const authInfo = await db.authenticationGet(dbCtx, identifier);
159 assert.strictEqual(authInfo.credential, credential);
160 db._validateAuthentication(authInfo);
161 });
162 });
163 step('valid auth event', async function () {
164 await db.context(async (dbCtx) => {
165 await db.authenticationSuccess(dbCtx, identifier);
166 const authInfo = await db.authenticationGet(dbCtx, identifier);
167 db._validateAuthentication(authInfo);
168 assert.notStrictEqual(authInfo.lastAuthentication, undefined);
169 });
170 });
171 step('update auth entry', async function () {
172 await db.context(async (dbCtx) => {
173 credential = '$plain$myNewPassword';
174 await db.authenticationUpsert(dbCtx, identifier, credential, otpKey);
175 const authInfo = await db.authenticationGet(dbCtx, identifier);
176 assert.strictEqual(authInfo.credential, credential);
177 assert.strictEqual(authInfo.otpKey, otpKey);
178 });
179 });
180 step('update auth credential', async function () {
181 await db.context(async (dbCtx) => {
182 credential = '$plain$anotherNewPassword';
183 await db.authenticationUpdateCredential(dbCtx, identifier, credential);
184 const authInfo = await db.authenticationGet(dbCtx, identifier);
185 assert.strictEqual(authInfo.credential, credential);
186 });
187 });
188 step('update auth otp', async function () {
189 await db.context(async (dbCtx) => {
190 await db.authenticationUpdateOTPKey(dbCtx, identifier, otpKey);
191 const authInfo = await db.authenticationGet(dbCtx, identifier);
192 assert.strictEqual(authInfo.otpKey, otpKey);
193 });
194 });
195 step('profile is not valid', async function () {
196 await db.context(async (dbCtx) => {
197 const isValid = await db.profileIsValid(dbCtx, profile);
198 assert.strictEqual(isValid, false);
199 });
200 });
201 step('user-profile relation does not exist', async function () {
202 await db.context(async (dbCtx) => {
203 const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
204 const exists = profiles.includes(profile);
205 assert.strictEqual(exists, false);
206 });
207 });
208 step('create user-profile relation', async function () {
209 await db.context(async (dbCtx) => {
210 await db.profileIdentifierInsert(dbCtx, profile, identifier);
211 });
212 });
213 step('profile is valid', async function () {
214 await db.context(async (dbCtx) => {
215 const isValid = await db.profileIsValid(dbCtx, profile);
216 assert.strictEqual(isValid, true);
217 });
218 });
219 step('user-profile relation does exist', async function () {
220 await db.context(async (dbCtx) => {
221 const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
222 const exists = profiles.includes(profile);
223 assert.strictEqual(exists, true);
224 });
225 });
226 step('create scope', async function () {
227 await db.context(async (dbCtx) => {
228 await db.scopeUpsert(dbCtx, 'new_scope', '', 'Allows something to happen.');
229 });
230 });
231 step('create and delete scope', async function () {
232 await db.context(async (dbCtx) => {
233 await db.scopeUpsert(dbCtx, 'sacrificial', 'No App', 'Exists to be destroyed.', true);
234 const result = await db.scopeDelete(dbCtx, 'sacrificial');
235 assert.strictEqual(result, true);
236 });
237 });
238 step('do not delete in-use scope', async function () {
239 await db.context(async (dbCtx) => {
240 const result = await db.scopeDelete(dbCtx, 'profile');
241 assert.strictEqual(result, false);
242 });
243 });
244 step('ignore delete of non-existent scope', async function () {
245 await db.context(async (dbCtx) => {
246 await db.scopeDelete(dbCtx, 'non-existent');
247 });
248 });
249 step('assign scope to profile', async function () {
250 const scope = 'new_scope';
251 await db.context(async (dbCtx) => {
252 await db.profileScopeInsert(dbCtx, profile, scope);
253 const { scopeIndex, profileScopes, profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
254 const scopeExistsInProfile = scope in profileScopes[profile];
255 const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
256 const profileExists = profiles.includes(profile);
257 assert.strictEqual(scopeExistsInProfile, true);
258 assert.strictEqual(profileExistsInScope, true);
259 assert.strictEqual(profileExists, true);
260 });
261 });
262 step('update scope', async function () {
263 await db.context(async (dbCtx) => {
264 await db.scopeUpsert(dbCtx, 'new_scope', 'Application', 'Updated description.');
265 });
266 });
267 step('re-assigning scope to profile is ignored', async function () {
268 const scope = 'new_scope';
269 await db.context(async (dbCtx) => {
270 await db.profileScopeInsert(dbCtx, profile, scope);
271 const { scopeIndex, profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
272 const scopeExistsInProfile = scope in profileScopes[profile];
273 const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
274 assert.strictEqual(scopeExistsInProfile, true);
275 assert.strictEqual(profileExistsInScope, true);
276 });
277 });
278 step('clear all scopes for a profile', async function () {
279 const scopes = [];
280 await db.context(async (dbCtx) => {
281 await db.profileScopesSetAll(dbCtx, profile, scopes);
282 const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
283 const exists = profile in profileScopes;
284 assert(exists);
285 const numScopes = Object.keys(profileScopes[profile]).length;
286 assert.strictEqual(numScopes, 0);
287 });
288 });
289 step('set multiple scopes for a profile', async function () {
290 const scopes = ['profile', 'email', 'create'];
291 await db.context(async (dbCtx) => {
292 await db.profileScopesSetAll(dbCtx, profile, scopes);
293 const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
294 assert.strictEqual(Object.keys(profileScopes[profile]).length, scopes.length);
295 });
296 });
297 step('garbage-collect client scopes', async function () {
298 await db.context(async (dbCtx) => {
299 await db.scopeUpsert(dbCtx, 'extra_scope', 'useless', 'useless');
300 const result = await db.scopeCleanup(dbCtx, 0);
301 assert(result);
302 });
303 });
304 step('too-soon garbage-collect skips', async function () {
305 await db.context(async (dbCtx) => {
306 const result = await db.scopeCleanup(dbCtx, 86400000);
307 assert.strictEqual(result, undefined);
308 });
309 });
310 }); // Users and Profiles and Scopes
311
312 describe('Token', function () {
313 let created, codeId, profileCodeId, ticketCodeId, scopes, clientId, lifespanSeconds, resource;
314 beforeEach(function () {
315 created = new Date();
316 codeId = '907a95fc-384b-11ec-a541-0025905f714a';
317 profileCodeId = '93d6314a-384e-11ec-94e4-0025905f714a';
318 ticketCodeId = 'bc5c39a8-5ca0-11ed-94cd-0025905f714a';
319 clientId = 'https://app.example.com/';
320 scopes = ['create', 'email', 'profile'];
321 lifespanSeconds = 600;
322 resource = 'https://example.com/profile/feed';
323 });
324 step('redeems code for token', async function () {
325 await db.context(async (dbCtx) => {
326 lifespanSeconds = null;
327 const result = await db.redeemCode(dbCtx, {
328 created,
329 codeId,
330 isToken: true,
331 clientId,
332 profile,
333 identifier,
334 scopes,
335 lifespanSeconds,
336 refreshLifespanSeconds: null,
337 profileData: null,
338 });
339 assert.strictEqual(result, true);
340 const t = await db.tokenGetByCodeId(dbCtx, codeId);
341 assert(t);
342 db._validateToken(t);
343 });
344 });
345 step('revokes token', async function () {
346 await db.context(async (dbCtx) => {
347 await db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
348 const t = await db.tokenGetByCodeId(dbCtx, codeId);
349 assert.strictEqual(t.isRevoked, true);
350 });
351 });
352 step('redeems code for profile', async function () {
353 await db.context(async (dbCtx) => {
354 await db.redeemCode(dbCtx, {
355 created,
356 codeId: profileCodeId,
357 isToken: false,
358 clientId,
359 profile,
360 identifier,
361 lifespanSeconds,
362 scopes,
363 });
364 const t = await db.tokenGetByCodeId(dbCtx, codeId);
365 assert(t);
366 db._validateToken(t);
367 });
368 });
369 step('redeems ticket', async function () {
370 await db.context(async (dbCtx) => {
371 await db.redeemCode(dbCtx, {
372 created,
373 codeId: ticketCodeId,
374 isToken: true,
375 clientId,
376 resource,
377 profile,
378 identifier,
379 scopes,
380 });
381 });
382 });
383 step('gets tokens', async function () {
384 await db.context(async (dbCtx) => {
385 const tokens = await db.tokensGetByIdentifier(dbCtx, identifier);
386 assert(tokens.length);
387 });
388 });
389 step('revokes multiply-redeemed code', async function () {
390 await db.context(async (dbCtx) => {
391 const result = await db.redeemCode(dbCtx, {
392 created,
393 codeId,
394 isToken: false,
395 clientId,
396 profile,
397 identifier,
398 scopes,
399 });
400 assert.strictEqual(result, false);
401 const t = await db.tokenGetByCodeId(dbCtx, codeId);
402 assert.strictEqual(t.isRevoked, true);
403 });
404 });
405 step('garbage-collect tokens', async function () {
406 await db.context(async (dbCtx) => {
407 const result = await db.tokenCleanup(dbCtx, -86400, 0);
408 assert(result);
409 });
410 });
411 step('too-soon garbage-collect skips', async function () {
412 await db.context(async (dbCtx) => {
413 const result = await db.tokenCleanup(dbCtx, 0, 86400000);
414 assert.strictEqual(result, undefined);
415 });
416 });
417 step('garbage collection is recorded', async function () {
418 await db.context(async (dbCtx) => {
419 const result = await db.almanacGetAll(dbCtx);
420 assert(result?.length);
421 });
422 });
423 }); // Token
424
425 describe('Ticket Token Tracking', function () {
426 let redeemedData;
427 beforeEach(function () {
428 redeemedData = {
429 subject: 'https://entity.example.com/',
430 resource: 'https://blog.example.com/secret_entry',
431 iss: 'https://idp.example.com/',
432 ticket: 'xxxTICKETxxx',
433 token: 'xxxTOKENxxx',
434 };
435 });
436 step('stores redeemed ticket data', async function () {
437 await db.context(async (dbCtx) => {
438 await db.ticketRedeemed(dbCtx, redeemedData);
439 });
440 });
441 step('gets one pending-publish ticket tokens', async function () {
442 await db.context(async (dbCtx) => {
443 const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
444 assert.strictEqual(unpublished.length, 1);
445 const record = unpublished[0];
446 assert(record.created);
447 assert(!record.published);
448 assert(record.ticketId);
449 delete record.created;
450 delete record.published;
451 delete record.ticketId;
452 assert.deepStrictEqual(record, redeemedData);
453 });
454 });
455 step('stores published ticket token data', async function () {
456 await db.context(async (dbCtx) => {
457 await db.ticketTokenPublished(dbCtx, redeemedData);
458 });
459 });
460 step('gets no pending-publish ticket tokens', async function () {
461 await db.context(async (dbCtx) => {
462 const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
463 assert.strictEqual(unpublished.length, 0);
464 });
465 });
466 }); // Ticket Token Tracking
467
468 describe('Bookkeeping', function () {
469 let event, date;
470 beforeEach(function () {
471 event = 'integrationTestEvent';
472 date = new Date('Fri Dec 22 03:27 UTC 2023');
473 });
474 step('inserts event', async function () {
475 await db.context(async (dbCtx) => {
476 await db.almanacUpsert(dbCtx, event, date);
477 const result = await db.almanacGetAll(dbCtx);
478 const [storedEvent] = result.filter((e) => e.event === event);
479 assert.deepStrictEqual(storedEvent.date, date);
480 });
481 });
482 }); // Bookkeeping
483
484 describe('Refreshable Token', function () {
485 let created, codeId, scopes, clientId, profileData, lifespanSeconds, refreshLifespanSeconds, removeScopes;
486 beforeEach(function () {
487 created = new Date();
488 codeId = '20ff1c5e-24d9-11ed-83b9-0025905f714a';
489 scopes = ['profile', 'email', 'create', 'fancy:scope'];
490 clientId = 'https://app.example.com/';
491 lifespanSeconds = 86400;
492 refreshLifespanSeconds = 172800;
493 profileData = {
494 url: 'https://profile.example.com/',
495 name: 'Namey McUser',
496 photo: 'https://profile.example.com/picture.jpg',
497 email: 'usey@example.com',
498 };
499 removeScopes = [];
500 });
501 step('redeems code for refreshable token', async function () {
502 await db.context(async (dbCtx) => {
503 const result = await db.redeemCode(dbCtx, {
504 created,
505 codeId,
506 isToken: true,
507 clientId,
508 profile,
509 identifier,
510 scopes,
511 lifespanSeconds,
512 refreshLifespanSeconds,
513 profileData,
514 });
515 assert.strictEqual(result, true);
516 const t = await db.tokenGetByCodeId(dbCtx, codeId);
517 assert(t);
518 db._validateToken(t);
519 const requestedScopesSet = new Set(scopes);
520 const tokenScopesSet = new Set(t.scopes);
521 for (const s of tokenScopesSet) {
522 if (requestedScopesSet.has(s)) {
523 requestedScopesSet.delete(s);
524 } else {
525 requestedScopesSet.add(s);
526 }
527 }
528 assert(!requestedScopesSet.size, [...requestedScopesSet].toString());
529 });
530 });
531 step('refreshes token', async function () {
532 await db.context(async (dbCtx) => {
533 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
534 assert(result);
535 assert(result.expires);
536 assert(result.refreshExpires);
537 assert(!result.scopes);
538 });
539 });
540 step('refreshes token and reduces scope', async function () {
541 await db.context(async (dbCtx) => {
542 removeScopes = ['create', 'fancy:scope'];
543 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
544 assert(result);
545 assert(result.scopes);
546 const t = await db.tokenGetByCodeId(dbCtx, codeId);
547 const remainingScopesSet = new Set(scopes);
548 removeScopes.forEach((s) => remainingScopesSet.delete(s));
549 const tokenScopesSet = new Set(t.scopes);
550 for (const s of tokenScopesSet) {
551 if (remainingScopesSet.has(s)) {
552 remainingScopesSet.delete(s);
553 } else {
554 remainingScopesSet.add(s);
555 }
556 }
557 assert(!remainingScopesSet.size, [...remainingScopesSet].toString());
558
559 });
560 });
561 step('revokes token refreshability', async function () {
562 await db.context(async (dbCtx) => {
563 await db.tokenRefreshRevokeByCodeId(dbCtx, codeId);
564 const t = await db.tokenGetByCodeId(dbCtx, codeId);
565 assert(!t.refreshExpires);
566 });
567 });
568 step('token not refreshable', async function () {
569 await db.context(async (dbCtx) => {
570 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
571 assert(!result);
572 });
573 });
574 }); // Refreshable Token
575
576 }); // specific implementation
577 }); // foreach
578
579 }); // Database Integration