Merge pull request #813 from PicPeak/feat/harden-picpeak-restore-robustness

feat(security): harden .picpeak restore robustness — sessions, roles, sequences
This commit is contained in:
Paul Nothaft
2026-07-16 13:39:17 +02:00
committed by GitHub
10 changed files with 595 additions and 17 deletions
@@ -0,0 +1,190 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:[email protected]:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: '[email protected]', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: '[email protected]', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: '[email protected]', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: '[email protected]', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: '[email protected]' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: '[email protected]', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: '[email protected]', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: '[email protected]', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: '[email protected]' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: '[email protected]', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -0,0 +1,105 @@
/**
* Tests for preserveOperatorRole — re-establishing the operator's authorization
* after a restore replaces the roles / permissions / role_permissions tables.
* Real in-memory SQLite so the joins and inserts behave as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await db.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.string('category');
});
await db.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable();
t.integer('permission_id').notNullable();
t.primary(['role_id', 'permission_id']);
});
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('email');
t.integer('role_id');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/picpeakImportService');
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
await db.destroy();
});
test('captureOperatorRole returns the role + its permission names', async () => {
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
await db('permissions').insert([
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
const snap = await svc.captureOperatorRole(1);
expect(snap.role.name).toBe('super_admin');
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
});
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
await db('admin_users').insert({ id: 3, email: '[email protected]', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(7); // bound to restored super_admin by name
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
});
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
const snapshot = {
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
permissions: ['events.create', 'users.manage', 'gone.permission'],
};
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
await db('permissions').insert([
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('admin_users').insert({ id: 3, email: '[email protected]', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const recreated = await db('roles').where({ name: 'super_admin' }).first();
expect(recreated).toBeTruthy(); // role re-created, not left missing
expect(recreated.id).toBe(3); // max(2)+1
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
});
test('preserveOperatorRole no-ops when the operator had no role', async () => {
await db('admin_users').insert({ id: 3, email: '[email protected]', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBeNull();
});
@@ -0,0 +1,56 @@
/**
* 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);
});