97 lines
5.1 KiB
JavaScript
97 lines
5.1 KiB
JavaScript
const knex = require('knex');
|
|
const jwt = require('jsonwebtoken');
|
|
const { randomUUID } = require('crypto');
|
|
const migration = require('../../migrations/core/211_revocations_without_expiry');
|
|
|
|
for (const client of ['sqlite3', 'pg']) {
|
|
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
|
|
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
|
|
let db, owner, schema, revocation;
|
|
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
|
|
|
|
beforeAll(async () => {
|
|
if (client === 'pg') {
|
|
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
|
|
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
|
|
await owner.schema.createSchema(schema);
|
|
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
|
|
} else {
|
|
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
|
|
}
|
|
// Exercise the upgrade from the real legacy NOT NULL schema as well as
|
|
// repeated migration runs, without sharing another test's database.
|
|
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
|
|
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
|
|
await migration.up(db);
|
|
await migration.up(db);
|
|
jest.resetModules();
|
|
jest.doMock('../../src/database/db', () => ({ db }));
|
|
revocation = require('../../src/utils/tokenRevocation');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.destroy();
|
|
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
|
jest.dontMock('../../src/database/db');
|
|
});
|
|
|
|
it('preserves existing revocations and their unique key during upgrade', async () => {
|
|
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
|
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
|
|
});
|
|
|
|
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
|
|
const token = sign(withJti ? {} : { jti: undefined });
|
|
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
|
expect(await revocation.isTokenRevoked(payload)).toBe(false);
|
|
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
|
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
|
await revocation.cleanupExpiredRevocations();
|
|
expect(await revocation.isTokenRevoked(payload)).toBe(true);
|
|
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].expires_at).toBeNull();
|
|
});
|
|
|
|
it('cleans up expired revocations and retains future ones', async () => {
|
|
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
|
|
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
|
|
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
|
|
await revocation.cleanupExpiredRevocations();
|
|
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
|
|
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
|
|
});
|
|
|
|
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
|
|
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
|
|
const expiring = sign({ ...claims, exp: claims.iat - 60 });
|
|
const permanent = sign(claims);
|
|
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
|
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
|
|
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
|
await revocation.cleanupExpiredRevocations();
|
|
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
|
|
});
|
|
|
|
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
|
|
const token = sign({ exp: 1e100 });
|
|
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
|
await revocation.cleanupExpiredRevocations();
|
|
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
|
});
|
|
|
|
it('refuses a rollback that would remove permanent revocations', async () => {
|
|
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
|
|
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
|
|
// A rollback with only expiring records remains supported and reversible.
|
|
await db('revoked_tokens').whereNull('expires_at').delete();
|
|
await migration.down(db);
|
|
await migration.down(db);
|
|
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
|
|
await migration.up(db);
|
|
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
|
});
|
|
});
|
|
}
|