2 /* eslint-disable sonarjs/no-identical-functions */
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
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
15 * These tests are sequential, relying on the state created along the way.
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');
25 describe('Database Integration', function () {
26 const implementations
= [];
28 if (!process
.env
.INTEGRATION_TESTS
) {
29 it
.skip('integration tests not requested');
33 if (process
.env
.POSTGRES_TEST_PATH
) {
34 implementations
.push({
36 module: '../../../src/db/postgres',
39 connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`,
40 queryLogLevel: 'debug',
47 if (process
.env
.SQLITE_TEST_PATH
) {
48 implementations
.push({
50 module: '../../../src/db/sqlite',
53 connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`,
54 queryLogLevel: 'debug',
55 sqliteOptimizeAfterChanges: 10,
61 if (!implementations
.length
) {
62 it('have some implementations to test', function () {
63 assert
.fail('No implementations have been configured for requested integration tests');
67 implementations
.forEach(function (i
) {
68 describe(i
.name
, function () {
71 let profile
, identifier
;
73 before(async
function () {
74 this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
75 logger
= new StubLogger();
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);
83 after(async
function () {
84 await db
._closeConnection();
87 beforeEach(function () {
88 identifier
= 'username';
89 profile
= 'https://example.com/profile';
92 describe('Healthcheck', function () {
93 it('should succeed', async
function () {
94 const result
= await db
.healthCheck();
99 describe('Resources', function () {
100 let resourceId
, secret
, description
;
102 secret
= 'shared secret';
103 description
= 'A resource server that needs to verify our tokens.';
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
);
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
;
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
);
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
);
139 describe('Users and Profiles and Scopes', function () {
141 beforeEach(function () {
142 credential
= '$plain$myPassword';
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
);
150 step('create auth entry', async
function () {
151 await db
.context(async (dbCtx
) => {
152 await db
.authenticationUpsert(dbCtx
, identifier
, credential
);
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
);
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);
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
);
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);
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);
191 step('create user-profile relation', async
function () {
192 await db
.context(async (dbCtx
) => {
193 await db
.profileIdentifierInsert(dbCtx
, profile
, identifier
);
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);
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);
209 step('create scope', async
function () {
210 await db
.context(async (dbCtx
) => {
211 await db
.scopeUpsert(dbCtx
, 'new_scope', '', 'Allows something to happen.');
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);
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);
227 step('ignore delete of non-existent scope', async
function () {
228 await db
.context(async (dbCtx
) => {
229 await db
.scopeDelete(dbCtx
, 'non-existent');
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);
245 step('update scope', async
function () {
246 await db
.context(async (dbCtx
) => {
247 await db
.scopeUpsert(dbCtx
, 'new_scope', 'Application', 'Updated description.');
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);
261 step('clear all scopes for a profile', async
function () {
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
;
268 const numScopes
= Object
.keys(profileScopes
[profile
]).length
;
269 assert
.strictEqual(numScopes
, 0);
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
);
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);
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);
293 }); // Users and Profiles and Scopes
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';
307 step('redeems code for token', async
function () {
308 await db
.context(async (dbCtx
) => {
309 lifespanSeconds
= null;
310 const result
= await db
.redeemCode(dbCtx
, {
319 refreshLifespanSeconds: null,
322 assert
.strictEqual(result
, true);
323 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
325 db
._validateToken(t
);
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);
335 step('redeems code for profile', async
function () {
336 await db
.context(async (dbCtx
) => {
337 await db
.redeemCode(dbCtx
, {
339 codeId: profileCodeId
,
347 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
349 db
._validateToken(t
);
352 step('redeems ticket', async
function () {
353 await db
.context(async (dbCtx
) => {
354 await db
.redeemCode(dbCtx
, {
356 codeId: ticketCodeId
,
366 step('gets tokens', async
function () {
367 await db
.context(async (dbCtx
) => {
368 const tokens
= await db
.tokensGetByIdentifier(dbCtx
, identifier
);
369 assert(tokens
.length
);
372 step('revokes multiply-redeemed code', async
function () {
373 await db
.context(async (dbCtx
) => {
374 const result
= await db
.redeemCode(dbCtx
, {
383 assert
.strictEqual(result
, false);
384 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
385 assert
.strictEqual(t
.isRevoked
, true);
388 step('garbage-collect tokens', async
function () {
389 await db
.context(async (dbCtx
) => {
390 const result
= await db
.tokenCleanup(dbCtx
, -86400, 0);
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);
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
);
408 describe('Ticket Token Tracking', function () {
410 beforeEach(function () {
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',
419 step('stores redeemed ticket data', async
function () {
420 await db
.context(async (dbCtx
) => {
421 await db
.ticketRedeemed(dbCtx
, redeemedData
);
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
);
438 step('stores published ticket token data', async
function () {
439 await db
.context(async (dbCtx
) => {
440 await db
.ticketTokenPublished(dbCtx
, redeemedData
);
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);
449 }); // Ticket Token Tracking
451 describe('Bookkeeping', function () {
453 beforeEach(function () {
454 event
= 'integrationTestEvent';
455 date
= new Date('Fri Dec 22 03:27 UTC 2023');
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
);
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;
477 url: 'https://profile.example.com/',
478 name: 'Namey McUser',
479 photo: 'https://profile.example.com/picture.jpg',
480 email: 'usey@example.com',
484 step('redeems code for refreshable token', async
function () {
485 await db
.context(async (dbCtx
) => {
486 const result
= await db
.redeemCode(dbCtx
, {
495 refreshLifespanSeconds
,
498 assert
.strictEqual(result
, true);
499 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
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
);
508 requestedScopesSet
.add(s
);
511 assert(!requestedScopesSet
.size
, [...requestedScopesSet
].toString());
514 step('refreshes token', async
function () {
515 await db
.context(async (dbCtx
) => {
516 const result
= await db
.refreshCode(dbCtx
, codeId
, new Date(), removeScopes
);
518 assert(result
.expires
);
519 assert(result
.refreshExpires
);
520 assert(!result
.scopes
);
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
);
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
);
537 remainingScopesSet
.add(s
);
540 assert(!remainingScopesSet
.size
, [...remainingScopesSet
].toString());
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
);
551 step('token not refreshable', async
function () {
552 await db
.context(async (dbCtx
) => {
553 const result
= await db
.refreshCode(dbCtx
, codeId
, new Date(), removeScopes
);
557 }); // Refreshable Token
559 }); // specific implementation
562 }); // Database Integration