Merge remote-tracking branch 'origin/main' into feat/oidc-sso-phase1
# Conflicts: # backend/src/middleware/maintenance.js
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Backup credential exposure regression tests.
|
||||
*
|
||||
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
||||
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
||||
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
||||
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
||||
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
||||
* form round-trips without clobbering stored credentials.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'test-admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
describe('backup credential masking', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
||||
const seed = [
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
||||
];
|
||||
for (const row of seed) {
|
||||
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('masks the credentials in GET /admin/backup/config', async () => {
|
||||
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
// Non-secret fields stay readable for the form.
|
||||
expect(res.body.backup_s3_bucket).toBe('backups');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_endpoint: 'https://s3.example.com',
|
||||
backup_s3_bucket: 'renamed-bucket',
|
||||
backup_s3_access_key: 'AKIAEXAMPLE',
|
||||
backup_s3_secret_key: '••••••••',
|
||||
backup_rsync_ssh_key: '••••••••',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
||||
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
||||
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
||||
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
||||
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
||||
});
|
||||
});
|
||||
@@ -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,127 @@
|
||||
/**
|
||||
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
|
||||
* the gallery password.
|
||||
*
|
||||
* POST /auth/gallery/share-login validates only the share token. For a
|
||||
* password-protected gallery it previously minted a full `type:'gallery'`
|
||||
* access token on the share token alone, letting anyone holding the share URL
|
||||
* read the gallery without the password. The fix: when the gallery requires a
|
||||
* password, return `{ requires_password: true }` with NO token and NO cookie.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
process.env.JWT_SECRET = 'share-login-test-secret';
|
||||
|
||||
const events = [];
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
function dbFn(table) {
|
||||
if (table === 'events') {
|
||||
let filter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
filter = (row) => Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() { return events.find(filter); },
|
||||
};
|
||||
}
|
||||
return { where() { return this; }, async first() { return undefined; } };
|
||||
}
|
||||
dbFn.raw = async () => {};
|
||||
return { db: dbFn, logActivity: async () => {} };
|
||||
});
|
||||
|
||||
// Share token is stored plainly on the fake event row.
|
||||
jest.mock('../../src/services/shareLinkService', () => ({
|
||||
getEventShareToken: (event) => event.share_token,
|
||||
resolveShareIdentifier: async () => ({ event: null }),
|
||||
}));
|
||||
|
||||
const mockSetGalleryAuthCookies = jest.fn();
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
setAdminAuthCookies: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/authSecurity', () => ({
|
||||
trackFailedAttempt: jest.fn(async () => {}),
|
||||
trackSuccessfulLogin: jest.fn(async () => {}),
|
||||
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
|
||||
resetLockout: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Collaborators the router imports at load but the share-login path doesn't hit.
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
|
||||
jest.mock('../../src/services/mfaService', () => ({}));
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const SHARE_TOKEN = 'a'.repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
mockSetGalleryAuthCookies.mockClear();
|
||||
});
|
||||
|
||||
describe('POST /auth/gallery/share-login password enforcement', () => {
|
||||
it('does NOT mint a token for a password-protected gallery', async () => {
|
||||
events.push({
|
||||
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requires_password).toBe(true);
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a token for a public (no-password) gallery', async () => {
|
||||
events.push({
|
||||
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.event).toBeDefined();
|
||||
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a wrong share token regardless of password setting', async () => {
|
||||
events.push({
|
||||
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Point storage at a throwaway temp dir before requiring the service so the
|
||||
// module-level getStoragePath() picks it up if evaluated.
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('strips directory-traversal components from the stored filename', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: '../../uploads/logos/evil.svg',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
|
||||
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
|
||||
expect(meta.filename).toBe('evil.svg');
|
||||
});
|
||||
|
||||
it('keeps a normal filename intact', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
expect(uploadId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a filename that collapses to nothing', async () => {
|
||||
await expect(
|
||||
chunkedUpload.initializeUpload({
|
||||
filename: '../',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
})
|
||||
).rejects.toThrow(/Invalid filename/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
|
||||
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
|
||||
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
|
||||
* as in production. Reconciliation is non-destructive (update-in-place / rename,
|
||||
* never delete) so restored rows referenced by FKs keep their ids.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let reinjectCurrentAdmin;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
|
||||
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.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');
|
||||
t.integer('created_by');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.destroy(); });
|
||||
|
||||
const operator = {
|
||||
id: 1, username: 'admin', email: '[email protected]',
|
||||
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
|
||||
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
|
||||
};
|
||||
|
||||
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 7, username: 'someoneelse', email: '[email protected]',
|
||||
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
|
||||
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users');
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0];
|
||||
expect(row.id).toBe(7); // id preserved → FK refs hold
|
||||
expect(row.username).toBe('admin');
|
||||
expect(row.password_hash).toBe('OP_HASH');
|
||||
expect(Boolean(row.two_factor_enabled)).toBe(true);
|
||||
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
|
||||
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
|
||||
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
|
||||
// dangling role_id/created_by on a cross-instance restore) — the restored
|
||||
// row keeps its own already-valid values.
|
||||
expect(row.role_id).toBe(4);
|
||||
expect(row.created_by).toBe(5);
|
||||
});
|
||||
|
||||
test('renames (not deletes) a different row holding the operator username', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 3, username: 'admin', email: '[email protected]',
|
||||
password_hash: 'OTHER', is_active: 1, role_id: 4,
|
||||
});
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
|
||||
const other = rows.find((r) => r.id === 3);
|
||||
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
|
||||
expect(other.email).toBe('[email protected]');
|
||||
const op = rows.find((r) => r.username === 'admin');
|
||||
expect(op.password_hash).toBe('OP_HASH');
|
||||
});
|
||||
|
||||
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
|
||||
await db('admin_users').insert([
|
||||
{ id: 4, username: 'someoneelse', email: '[email protected]', password_hash: 'A', role_id: 4 },
|
||||
{ id: 5, username: 'admin', email: '[email protected]', password_hash: 'B', role_id: 4 },
|
||||
]);
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // both rows survive
|
||||
const opRow = rows.find((r) => r.id === 4); // email match updated in place
|
||||
expect(opRow.username).toBe('admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
|
||||
expect(renamed.username).toBe('admin__restored_5');
|
||||
});
|
||||
|
||||
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 9, username: 'backupadmin', email: '[email protected]', password_hash: 'B', role_id: 1,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // backup admin untouched
|
||||
const opRow = rows.find((r) => r.username === 'admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
expect(opRow.id).toBe(10); // max(9)+1, no collision
|
||||
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
@@ -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,41 @@
|
||||
const path = require('path');
|
||||
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
|
||||
|
||||
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
|
||||
const root = path.join('/tmp', 'picpeak-extract-root');
|
||||
|
||||
it('accepts entries that stay within the extraction root', () => {
|
||||
const entries = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'category/nested/photo.png' },
|
||||
{ name: 'photos_manifest.json' },
|
||||
{ name: 'subdir/' },
|
||||
];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a parent-traversal entry', () => {
|
||||
const entries = [{ name: '../../uploads/logos/evil.svg' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects an absolute-path entry', () => {
|
||||
const entries = [{ name: '/etc/cron.d/evil' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects when a safe entry is mixed with a traversal entry', () => {
|
||||
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('tolerates empty / nameless entries', () => {
|
||||
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not treat a sibling prefix directory as inside the root', () => {
|
||||
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
|
||||
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user