redeem proffered tickets, db schema 1.1.0
[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;
141 beforeEach(function () {
142 credential = '$plain$myPassword';
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);
174 const authInfo = await db.authenticationGet(dbCtx, identifier);
175 assert.strictEqual(authInfo.credential, credential);
176 });
177 });
178 step('profile is not valid', async function () {
179 await db.context(async (dbCtx) => {
180 const isValid = await db.profileIsValid(dbCtx, profile);
181 assert.strictEqual(isValid, false);
182 });
183 });
184 step('user-profile relation does not exist', async function () {
185 await db.context(async (dbCtx) => {
186 const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
187 const exists = profiles.includes(profile);
188 assert.strictEqual(exists, false);
189 });
190 });
191 step('create user-profile relation', async function () {
192 await db.context(async (dbCtx) => {
193 await db.profileIdentifierInsert(dbCtx, profile, identifier);
194 });
195 });
196 step('profile is valid', async function () {
197 await db.context(async (dbCtx) => {
198 const isValid = await db.profileIsValid(dbCtx, profile);
199 assert.strictEqual(isValid, true);
200 });
201 });
202 step('user-profile relation does exist', async function () {
203 await db.context(async (dbCtx) => {
204 const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
205 const exists = profiles.includes(profile);
206 assert.strictEqual(exists, true);
207 });
208 });
209 step('create scope', async function () {
210 await db.context(async (dbCtx) => {
211 await db.scopeUpsert(dbCtx, 'new_scope', '', 'Allows something to happen.');
212 });
213 });
214 step('create and delete scope', async function () {
215 await db.context(async (dbCtx) => {
216 await db.scopeUpsert(dbCtx, 'sacrificial', 'No App', 'Exists to be destroyed.', true);
217 const result = await db.scopeDelete(dbCtx, 'sacrificial');
218 assert.strictEqual(result, true);
219 });
220 });
221 step('do not delete in-use scope', async function () {
222 await db.context(async (dbCtx) => {
223 const result = await db.scopeDelete(dbCtx, 'profile');
224 assert.strictEqual(result, false);
225 });
226 });
227 step('ignore delete of non-existent scope', async function () {
228 await db.context(async (dbCtx) => {
229 await db.scopeDelete(dbCtx, 'non-existent');
230 });
231 });
232 step('assign scope to profile', async function () {
233 const scope = 'new_scope';
234 await db.context(async (dbCtx) => {
235 await db.profileScopeInsert(dbCtx, profile, scope);
236 const { scopeIndex, profileScopes, profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
237 const scopeExistsInProfile = scope in profileScopes[profile];
238 const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
239 const profileExists = profiles.includes(profile);
240 assert.strictEqual(scopeExistsInProfile, true);
241 assert.strictEqual(profileExistsInScope, true);
242 assert.strictEqual(profileExists, true);
243 });
244 });
245 step('update scope', async function () {
246 await db.context(async (dbCtx) => {
247 await db.scopeUpsert(dbCtx, 'new_scope', 'Application', 'Updated description.');
248 });
249 });
250 step('re-assigning scope to profile is ignored', async function () {
251 const scope = 'new_scope';
252 await db.context(async (dbCtx) => {
253 await db.profileScopeInsert(dbCtx, profile, scope);
254 const { scopeIndex, profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
255 const scopeExistsInProfile = scope in profileScopes[profile];
256 const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
257 assert.strictEqual(scopeExistsInProfile, true);
258 assert.strictEqual(profileExistsInScope, true);
259 });
260 });
261 step('clear all scopes for a profile', async function () {
262 const scopes = [];
263 await db.context(async (dbCtx) => {
264 await db.profileScopesSetAll(dbCtx, profile, scopes);
265 const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
266 const exists = profile in profileScopes;
267 assert(exists);
268 const numScopes = Object.keys(profileScopes[profile]).length;
269 assert.strictEqual(numScopes, 0);
270 });
271 });
272 step('set multiple scopes for a profile', async function () {
273 const scopes = ['profile', 'email', 'create'];
274 await db.context(async (dbCtx) => {
275 await db.profileScopesSetAll(dbCtx, profile, scopes);
276 const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
277 assert.strictEqual(Object.keys(profileScopes[profile]).length, scopes.length);
278 });
279 });
280 step('garbage-collect client scopes', async function () {
281 await db.context(async (dbCtx) => {
282 await db.scopeUpsert(dbCtx, 'extra_scope', 'useless', 'useless');
283 const result = await db.scopeCleanup(dbCtx, 0);
284 assert(result);
285 });
286 });
287 step('too-soon garbage-collect skips', async function () {
288 await db.context(async (dbCtx) => {
289 const result = await db.scopeCleanup(dbCtx, 86400000);
290 assert.strictEqual(result, undefined);
291 });
292 });
293 }); // Users and Profiles and Scopes
294
295 describe('Token', function () {
296 let created, codeId, profileCodeId, ticketCodeId, scopes, clientId, lifespanSeconds, resource;
297 beforeEach(function () {
298 created = new Date();
299 codeId = '907a95fc-384b-11ec-a541-0025905f714a';
300 profileCodeId = '93d6314a-384e-11ec-94e4-0025905f714a';
301 ticketCodeId = 'bc5c39a8-5ca0-11ed-94cd-0025905f714a';
302 clientId = 'https://app.example.com/';
303 scopes = ['create', 'email', 'profile'];
304 lifespanSeconds = 600;
305 resource = 'https://example.com/profile/feed';
306 });
307 step('redeems code for token', async function () {
308 await db.context(async (dbCtx) => {
309 lifespanSeconds = null;
310 const result = await db.redeemCode(dbCtx, {
311 created,
312 codeId,
313 isToken: true,
314 clientId,
315 profile,
316 identifier,
317 scopes,
318 lifespanSeconds,
319 refreshLifespanSeconds: null,
320 profileData: null,
321 });
322 assert.strictEqual(result, true);
323 const t = await db.tokenGetByCodeId(dbCtx, codeId);
324 assert(t);
325 db._validateToken(t);
326 });
327 });
328 step('revokes token', async function () {
329 await db.context(async (dbCtx) => {
330 await db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
331 const t = await db.tokenGetByCodeId(dbCtx, codeId);
332 assert.strictEqual(t.isRevoked, true);
333 });
334 });
335 step('redeems code for profile', async function () {
336 await db.context(async (dbCtx) => {
337 await db.redeemCode(dbCtx, {
338 created,
339 codeId: profileCodeId,
340 isToken: false,
341 clientId,
342 profile,
343 identifier,
344 lifespanSeconds,
345 scopes,
346 });
347 const t = await db.tokenGetByCodeId(dbCtx, codeId);
348 assert(t);
349 db._validateToken(t);
350 });
351 });
352 step('redeems ticket', async function () {
353 await db.context(async (dbCtx) => {
354 await db.redeemCode(dbCtx, {
355 created,
356 codeId: ticketCodeId,
357 isToken: true,
358 clientId,
359 resource,
360 profile,
361 identifier,
362 scopes,
363 });
364 });
365 });
366 step('gets tokens', async function () {
367 await db.context(async (dbCtx) => {
368 const tokens = await db.tokensGetByIdentifier(dbCtx, identifier);
369 assert(tokens.length);
370 });
371 });
372 step('revokes multiply-redeemed code', async function () {
373 await db.context(async (dbCtx) => {
374 const result = await db.redeemCode(dbCtx, {
375 created,
376 codeId,
377 isToken: false,
378 clientId,
379 profile,
380 identifier,
381 scopes,
382 });
383 assert.strictEqual(result, false);
384 const t = await db.tokenGetByCodeId(dbCtx, codeId);
385 assert.strictEqual(t.isRevoked, true);
386 });
387 });
388 step('garbage-collect tokens', async function () {
389 await db.context(async (dbCtx) => {
390 const result = await db.tokenCleanup(dbCtx, -86400, 0);
391 assert(result);
392 });
393 });
394 step('too-soon garbage-collect skips', async function () {
395 await db.context(async (dbCtx) => {
396 const result = await db.tokenCleanup(dbCtx, 0, 86400000);
397 assert.strictEqual(result, undefined);
398 });
399 });
400 step('garbage collection is recorded', async function () {
401 await db.context(async (dbCtx) => {
402 const result = await db.almanacGetAll(dbCtx);
403 assert(result?.length);
404 });
405 });
406 }); // Token
407
408 describe('Ticket Token Tracking', function () {
409 let redeemedData;
410 beforeEach(function () {
411 redeemedData = {
412 subject: 'https://entity.example.com/',
413 resource: 'https://blog.example.com/secret_entry',
414 iss: 'https://idp.example.com/',
415 ticket: 'xxxTICKETxxx',
416 token: 'xxxTOKENxxx',
417 };
418 });
419 step('stores redeemed ticket data', async function () {
420 await db.context(async (dbCtx) => {
421 await db.ticketRedeemed(dbCtx, redeemedData);
422 });
423 });
424 step('gets one pending-publish ticket tokens', async function () {
425 await db.context(async (dbCtx) => {
426 const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
427 assert.strictEqual(unpublished.length, 1);
428 const record = unpublished[0];
429 assert(record.created);
430 assert(!record.published);
431 assert(record.ticketId);
432 delete record.created;
433 delete record.published;
434 delete record.ticketId;
435 assert.deepStrictEqual(record, redeemedData);
436 });
437 });
438 step('stores published ticket token data', async function () {
439 await db.context(async (dbCtx) => {
440 await db.ticketTokenPublished(dbCtx, redeemedData);
441 });
442 });
443 step('gets no pending-publish ticket tokens', async function () {
444 await db.context(async (dbCtx) => {
445 const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
446 assert.strictEqual(unpublished.length, 0);
447 });
448 });
449 }); // Ticket Token Tracking
450
451 describe('Bookkeeping', function () {
452 let event, date;
453 beforeEach(function () {
454 event = 'integrationTestEvent';
455 date = new Date('Fri Dec 22 03:27 UTC 2023');
456 });
457 step('inserts event', async function () {
458 await db.context(async (dbCtx) => {
459 await db.almanacUpsert(dbCtx, event, date);
460 const result = await db.almanacGetAll(dbCtx);
461 const [storedEvent] = result.filter((e) => e.event === event);
462 assert.deepStrictEqual(storedEvent.date, date);
463 });
464 });
465 }); // Bookkeeping
466
467 describe('Refreshable Token', function () {
468 let created, codeId, scopes, clientId, profileData, lifespanSeconds, refreshLifespanSeconds, removeScopes;
469 beforeEach(function () {
470 created = new Date();
471 codeId = '20ff1c5e-24d9-11ed-83b9-0025905f714a';
472 scopes = ['profile', 'email', 'create', 'fancy:scope'];
473 clientId = 'https://app.example.com/';
474 lifespanSeconds = 86400;
475 refreshLifespanSeconds = 172800;
476 profileData = {
477 url: 'https://profile.example.com/',
478 name: 'Namey McUser',
479 photo: 'https://profile.example.com/picture.jpg',
480 email: 'usey@example.com',
481 };
482 removeScopes = [];
483 });
484 step('redeems code for refreshable token', async function () {
485 await db.context(async (dbCtx) => {
486 const result = await db.redeemCode(dbCtx, {
487 created,
488 codeId,
489 isToken: true,
490 clientId,
491 profile,
492 identifier,
493 scopes,
494 lifespanSeconds,
495 refreshLifespanSeconds,
496 profileData,
497 });
498 assert.strictEqual(result, true);
499 const t = await db.tokenGetByCodeId(dbCtx, codeId);
500 assert(t);
501 db._validateToken(t);
502 const requestedScopesSet = new Set(scopes);
503 const tokenScopesSet = new Set(t.scopes);
504 for (const s of tokenScopesSet) {
505 if (requestedScopesSet.has(s)) {
506 requestedScopesSet.delete(s);
507 } else {
508 requestedScopesSet.add(s);
509 }
510 }
511 assert(!requestedScopesSet.size, [...requestedScopesSet].toString());
512 });
513 });
514 step('refreshes token', async function () {
515 await db.context(async (dbCtx) => {
516 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
517 assert(result);
518 assert(result.expires);
519 assert(result.refreshExpires);
520 assert(!result.scopes);
521 });
522 });
523 step('refreshes token and reduces scope', async function () {
524 await db.context(async (dbCtx) => {
525 removeScopes = ['create', 'fancy:scope'];
526 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
527 assert(result);
528 assert(result.scopes);
529 const t = await db.tokenGetByCodeId(dbCtx, codeId);
530 const remainingScopesSet = new Set(scopes);
531 removeScopes.forEach((s) => remainingScopesSet.delete(s));
532 const tokenScopesSet = new Set(t.scopes);
533 for (const s of tokenScopesSet) {
534 if (remainingScopesSet.has(s)) {
535 remainingScopesSet.delete(s);
536 } else {
537 remainingScopesSet.add(s);
538 }
539 }
540 assert(!remainingScopesSet.size, [...remainingScopesSet].toString());
541
542 });
543 });
544 step('revokes token refreshability', async function () {
545 await db.context(async (dbCtx) => {
546 await db.tokenRefreshRevokeByCodeId(dbCtx, codeId);
547 const t = await db.tokenGetByCodeId(dbCtx, codeId);
548 assert(!t.refreshExpires);
549 });
550 });
551 step('token not refreshable', async function () {
552 await db.context(async (dbCtx) => {
553 const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
554 assert(!result);
555 });
556 });
557 }); // Refreshable Token
558
559 }); // specific implementation
560 }); // foreach
561
562 }); // Database Integration