340d91bdd5
Implements the three restore-hardening items deferred from the #811 Codex review (all validated against a real Postgres, see __tests__/integration/ picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport). 1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/ customer/event ids, so ANY pre-restore JWT can rebind to a different restored principal. Revoking just the importing token wasn't enough. importFromPicpeak now stamps a unix-second cutoff in app_settings after the restore commits, and adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token whose iat predates it (cached 30s → one in-memory compare on the hot path). The operator's forced re-login mints a token past the cutoff, so it passes. 2. Role preservation across an RBAC replace (captureOperatorRole / preserveOperatorRole). The operator's role + granted permission NAMES are captured before the wipe; after roles/role_permissions are replaced the role is resolved by NAME against the restored data, and re-created with its grants if the backup omits it — so a crafted or cross-instance backup can't silently downgrade or lock out the operator. reinjectCurrentAdmin now returns the operator's id so the row can be re-pointed at the resolved role. 3. Postgres identity-sequence resync (resyncSequences). batchInsert writes explicit ids without advancing the sequences, so the next natural insert into any restored table collided on the PK. Runs AFTER commit (setval isn't transactional) and guards every table with a column-existence check — pg_get_serial_sequence RAISES on id-less tables like role_permissions. No-op on SQLite. Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence resync, the id-less-table guard, explicit-id reinject, role re-creation, and a full cross-instance replaceAllTables run asserting operator preservation, role re-establishment, FK integrity, and collision-free post-restore inserts. Stacks on #811 (shares the reinject hardening); merge after it.
57 lines
2.1 KiB
JavaScript
57 lines
2.1 KiB
JavaScript
/**
|
|
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
|
|
* real in-memory SQLite `app_settings` table so the read/write/parse path is
|
|
* exercised exactly as in production.
|
|
*/
|
|
const knex = require('knex');
|
|
|
|
let db;
|
|
let cutoff;
|
|
|
|
beforeEach(async () => {
|
|
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
|
await db.schema.createTable('app_settings', (t) => {
|
|
t.increments('id');
|
|
t.string('setting_key').notNullable().unique();
|
|
t.text('setting_value');
|
|
t.string('setting_type');
|
|
t.timestamp('updated_at');
|
|
});
|
|
jest.resetModules();
|
|
jest.doMock('../../src/database/db', () => ({ db }));
|
|
cutoff = require('../../src/utils/sessionCutoff');
|
|
cutoff._resetCache();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
jest.dontMock('../../src/database/db');
|
|
await db.destroy();
|
|
});
|
|
|
|
test('no cutoff set → nothing is invalidated', async () => {
|
|
expect(await cutoff.getSessionsValidAfter()).toBe(0);
|
|
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
|
|
});
|
|
|
|
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
|
|
await cutoff.setSessionsValidAfter(2000);
|
|
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
|
|
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
|
|
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
|
|
});
|
|
|
|
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
|
|
await cutoff.setSessionsValidAfter(1000);
|
|
await cutoff.setSessionsValidAfter(3000);
|
|
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
|
|
expect(rows).toHaveLength(1);
|
|
cutoff._resetCache();
|
|
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
|
|
});
|
|
|
|
test('a token without iat is never treated as before the cutoff', async () => {
|
|
await cutoff.setSessionsValidAfter(2000);
|
|
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
|
|
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
|
|
});
|