1 /* eslint-disable security/detect-object-injection */
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
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
14 * These tests are sequential, relying on the state created along the way.
18 const assert
= require('assert');
19 const { step
} = require('mocha-steps');
20 const StubLogger
= require('../../stub-logger');
22 describe('Database Integration', function () {
23 const implementations
= [];
25 if (!process
.env
.INTEGRATION_TESTS
) {
26 it
.skip('integration tests not requested');
30 if (process
.env
.POSTGRES_TEST_PATH
) {
31 implementations
.push({
33 module: '../../../src/db/postgres',
36 connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`,
37 queryLogLevel: 'debug',
44 if (process
.env
.SQLITE_TEST_PATH
) {
45 implementations
.push({
47 module: '../../../src/db/sqlite',
50 connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`,
51 queryLogLevel: 'debug',
52 sqliteOptimizeAfterChanges: 10,
58 if (!implementations
.length
) {
59 it('have some implementations to test', function () {
60 assert
.fail('No implementations have been configured for requested integration tests');
64 implementations
.forEach(function (i
) {
65 describe(i
.name
, function () {
68 let profile
, identifier
;
70 before(async
function () {
71 this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
72 logger
= new StubLogger();
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);
80 after(async
function () {
81 await db
._closeConnection();
84 beforeEach(function () {
85 identifier
= 'username';
86 profile
= 'https://example.com/profile';
89 describe('Healthcheck', function () {
90 it('should succeed', async
function () {
91 const result
= await db
.healthCheck();
96 describe('Resources', function () {
97 let resourceId
, secret
, description
;
99 secret
= 'shared secret';
100 description
= 'A resource server that needs to verify our tokens.';
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
);
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
;
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
);
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
);
136 describe('Users and Profiles and Scopes', function () {
137 let credential
, otpKey
;
138 beforeEach(function () {
139 credential
= '$plain$myPassword';
140 otpKey
= '1234567890123456789012';
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
);
148 step('create auth entry', async
function () {
149 await db
.context(async (dbCtx
) => {
150 await db
.authenticationUpsert(dbCtx
, identifier
, credential
);
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
);
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);
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
);
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
);
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
);
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);
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);
205 step('create user-profile relation', async
function () {
206 await db
.context(async (dbCtx
) => {
207 await db
.profileIdentifierInsert(dbCtx
, profile
, identifier
);
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);
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);
223 step('create scope', async
function () {
224 await db
.context(async (dbCtx
) => {
225 await db
.scopeUpsert(dbCtx
, 'new_scope', '', 'Allows something to happen.');
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);
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);
241 step('ignore delete of non-existent scope', async
function () {
242 await db
.context(async (dbCtx
) => {
243 await db
.scopeDelete(dbCtx
, 'non-existent');
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);
259 step('update scope', async
function () {
260 await db
.context(async (dbCtx
) => {
261 await db
.scopeUpsert(dbCtx
, 'new_scope', 'Application', 'Updated description.');
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);
275 step('clear all scopes for a profile', async
function () {
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
;
282 const numScopes
= Object
.keys(profileScopes
[profile
]).length
;
283 assert
.strictEqual(numScopes
, 0);
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
);
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);
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);
307 }); // Users and Profiles and Scopes
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';
321 step('redeems code for token', async
function () {
322 await db
.context(async (dbCtx
) => {
323 lifespanSeconds
= null;
324 const result
= await db
.redeemCode(dbCtx
, {
333 refreshLifespanSeconds: null,
336 assert
.strictEqual(result
, true);
337 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
339 db
._validateToken(t
);
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);
349 step('redeems code for profile', async
function () {
350 await db
.context(async (dbCtx
) => {
351 await db
.redeemCode(dbCtx
, {
353 codeId: profileCodeId
,
361 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
363 db
._validateToken(t
);
366 step('redeems ticket', async
function () {
367 await db
.context(async (dbCtx
) => {
368 await db
.redeemCode(dbCtx
, {
370 codeId: ticketCodeId
,
380 step('gets tokens', async
function () {
381 await db
.context(async (dbCtx
) => {
382 const tokens
= await db
.tokensGetByIdentifier(dbCtx
, identifier
);
383 assert(tokens
.length
);
386 step('revokes multiply-redeemed code', async
function () {
387 await db
.context(async (dbCtx
) => {
388 const result
= await db
.redeemCode(dbCtx
, {
397 assert
.strictEqual(result
, false);
398 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
399 assert
.strictEqual(t
.isRevoked
, true);
402 step('garbage-collect tokens', async
function () {
403 await db
.context(async (dbCtx
) => {
404 const result
= await db
.tokenCleanup(dbCtx
, -86400, 0);
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);
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
);
422 describe('Ticket Token Tracking', function () {
424 beforeEach(function () {
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',
433 step('stores redeemed ticket data', async
function () {
434 await db
.context(async (dbCtx
) => {
435 await db
.ticketRedeemed(dbCtx
, redeemedData
);
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
);
452 step('stores published ticket token data', async
function () {
453 await db
.context(async (dbCtx
) => {
454 await db
.ticketTokenPublished(dbCtx
, redeemedData
);
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);
463 }); // Ticket Token Tracking
465 describe('Bookkeeping', function () {
467 beforeEach(function () {
468 event
= 'integrationTestEvent';
469 date
= new Date('Fri Dec 22 03:27 UTC 2023');
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
);
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;
491 url: 'https://profile.example.com/',
492 name: 'Namey McUser',
493 photo: 'https://profile.example.com/picture.jpg',
494 email: 'usey@example.com',
498 step('redeems code for refreshable token', async
function () {
499 await db
.context(async (dbCtx
) => {
500 const result
= await db
.redeemCode(dbCtx
, {
509 refreshLifespanSeconds
,
512 assert
.strictEqual(result
, true);
513 const t
= await db
.tokenGetByCodeId(dbCtx
, codeId
);
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
);
522 requestedScopesSet
.add(s
);
525 assert(!requestedScopesSet
.size
, [...requestedScopesSet
].toString());
528 step('refreshes token', async
function () {
529 await db
.context(async (dbCtx
) => {
530 const result
= await db
.refreshCode(dbCtx
, codeId
, new Date(), removeScopes
);
532 assert(result
.expires
);
533 assert(result
.refreshExpires
);
534 assert(!result
.scopes
);
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
);
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
);
551 remainingScopesSet
.add(s
);
554 assert(!remainingScopesSet
.size
, [...remainingScopesSet
].toString());
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
);
565 step('token not refreshable', async
function () {
566 await db
.context(async (dbCtx
) => {
567 const result
= await db
.refreshCode(dbCtx
, codeId
, new Date(), removeScopes
);
571 }); // Refreshable Token
573 }); // specific implementation
576 }); // Database Integration