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