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