138 lines
6.4 KiB
TypeScript
138 lines
6.4 KiB
TypeScript
/**
|
|
* Unit tests for backups/route-guards.ts — the RBAC + per-environment access
|
|
* layer for backup routes (closes the cross-env snapshot/config bypass, task
|
|
* #14). Security-critical + fail-closed, so every deny branch is pinned here.
|
|
*
|
|
* The module transitively imports the DB/engine via ./index, so we mock.module()
|
|
* ./index and ../../db BEFORE importing it — the guard LOGIC under test is pure
|
|
* (decisions over an injected AuthorizationContext + the two resolver results).
|
|
*/
|
|
import { describe, it, expect, mock, beforeAll } from 'bun:test';
|
|
|
|
// Controllable fakes for the heavy imports, set per-test.
|
|
let resolveResult: { envId: number | null | undefined; resolved: boolean } | (() => never) = { envId: 5, resolved: true };
|
|
let configById: (id: number) => any = () => null;
|
|
|
|
mock.module('../../src/lib/server/backups/index', () => ({
|
|
resolveSnapshotEnvId: async () => {
|
|
if (typeof resolveResult === 'function') resolveResult();
|
|
return resolveResult;
|
|
},
|
|
filterSnapshotsByAccessibleEnv: async (snaps: any[], can: (id: number) => Promise<boolean>) => {
|
|
const out: any[] = [];
|
|
for (const s of snaps) {
|
|
const envTag = (s.tags ?? []).find((t: string) => t.startsWith('dockhand:envid='));
|
|
const v = envTag ? envTag.slice('dockhand:envid='.length) : null;
|
|
if (v === 'local' || v == null) { out.push(s); continue; } // unscoped kept
|
|
const n = Number(v);
|
|
if (Number.isInteger(n) && (await can(n))) out.push(s); // else dropped (fail closed)
|
|
}
|
|
return out;
|
|
},
|
|
}));
|
|
mock.module('../../src/lib/server/db', () => ({
|
|
getBackupConfig: async (id: number) => configById(id),
|
|
}));
|
|
|
|
let rg: typeof import('../../src/lib/server/backups/route-guards');
|
|
beforeAll(async () => { rg = await import('../../src/lib/server/backups/route-guards'); });
|
|
|
|
/** Build an AuthorizationContext with just the fields the guards read. */
|
|
function auth(over: Partial<{ authEnabled: boolean; isEnterprise: boolean; can: boolean; accessibleEnvs: number[] | 'all' }> = {}): any {
|
|
const accessible = over.accessibleEnvs ?? 'all';
|
|
return {
|
|
authEnabled: over.authEnabled ?? true,
|
|
isEnterprise: over.isEnterprise ?? true,
|
|
can: async () => over.can ?? true,
|
|
canAccessEnvironment: async (id: number) => accessible === 'all' || accessible.includes(id),
|
|
};
|
|
}
|
|
const status = (r: Response | null) => (r ? r.status : null);
|
|
|
|
describe('requireBackups — global RBAC gate', () => {
|
|
it('allows when auth is disabled (no gate)', async () => {
|
|
expect(await rg.requireBackups(auth({ authEnabled: false, can: false }), 'manage')).toBeNull();
|
|
});
|
|
it('allows when the permission is granted', async () => {
|
|
expect(await rg.requireBackups(auth({ can: true }), 'view')).toBeNull();
|
|
});
|
|
it('DENIES with 403 when the permission is missing', async () => {
|
|
expect(status(await rg.requireBackups(auth({ can: false }), 'manage'))).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('loadConfigGateEnv — config load + per-env gate in one', () => {
|
|
it('400 on a non-numeric id', async () => {
|
|
const r = await rg.loadConfigGateEnv('abc', auth());
|
|
expect('response' in r && r.response.status).toBe(400);
|
|
});
|
|
it('404 when the config does not exist', async () => {
|
|
configById = () => null;
|
|
const r = await rg.loadConfigGateEnv('7', auth());
|
|
expect('response' in r && r.response.status).toBe(404);
|
|
});
|
|
it('403 when enterprise caller lacks access to the config env', async () => {
|
|
configById = (id) => ({ id, environmentId: 9 });
|
|
const r = await rg.loadConfigGateEnv('7', auth({ isEnterprise: true, accessibleEnvs: [1, 2] }));
|
|
expect('response' in r && r.response.status).toBe(403);
|
|
});
|
|
it('returns the config when access is allowed', async () => {
|
|
configById = (id) => ({ id, environmentId: 2 });
|
|
const r = await rg.loadConfigGateEnv('7', auth({ accessibleEnvs: [2] }));
|
|
expect('config' in r && r.config.id).toBe(7);
|
|
});
|
|
it('does NOT env-gate a non-enterprise caller (free edition)', async () => {
|
|
configById = (id) => ({ id, environmentId: 9 });
|
|
const r = await rg.loadConfigGateEnv('7', auth({ isEnterprise: false, accessibleEnvs: [] }));
|
|
expect('config' in r).toBe(true);
|
|
});
|
|
it('does NOT env-gate a config with no environmentId', async () => {
|
|
configById = (id) => ({ id, environmentId: null });
|
|
const r = await rg.loadConfigGateEnv('7', auth({ accessibleEnvs: [] }));
|
|
expect('config' in r).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('guardSnapshotEnvAccess — per-env snapshot content gate (fail closed)', () => {
|
|
it('no-op (null) for a non-enterprise caller', async () => {
|
|
expect(await rg.guardSnapshotEnvAccess(auth({ isEnterprise: false }), 1, 'snap')).toBeNull();
|
|
});
|
|
it('DENIES (403) when the snapshot env cannot be resolved (fail closed)', async () => {
|
|
resolveResult = { envId: undefined, resolved: false };
|
|
expect(status(await rg.guardSnapshotEnvAccess(auth(), 1, 'snap'))).toBe(403);
|
|
});
|
|
it('DENIES (403) when resolving the env THROWS (fail closed)', async () => {
|
|
resolveResult = () => { throw new Error('restic down'); };
|
|
expect(status(await rg.guardSnapshotEnvAccess(auth(), 1, 'snap'))).toBe(403);
|
|
});
|
|
it('allows an unscoped (local, envId=null) snapshot', async () => {
|
|
resolveResult = { envId: null, resolved: true };
|
|
expect(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [] }), 1, 'snap')).toBeNull();
|
|
});
|
|
it('DENIES (403) when the caller cannot access the resolved env', async () => {
|
|
resolveResult = { envId: 9, resolved: true };
|
|
expect(status(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [1] }), 1, 'snap'))).toBe(403);
|
|
});
|
|
it('allows when the caller CAN access the resolved env', async () => {
|
|
resolveResult = { envId: 3, resolved: true };
|
|
expect(await rg.guardSnapshotEnvAccess(auth({ accessibleEnvs: [3] }), 1, 'snap')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('filterSnapshotsByEnvAccess — drop inaccessible-env snapshots (list branch)', () => {
|
|
const mk = (envid: string) => ({ id: envid, tags: [`dockhand:envid=${envid}`] });
|
|
it('returns all snapshots unchanged for a non-enterprise caller', async () => {
|
|
const snaps = [mk('9'), mk('local')];
|
|
expect(await rg.filterSnapshotsByEnvAccess(auth({ isEnterprise: false }), snaps)).toHaveLength(2);
|
|
});
|
|
it('keeps accessible + local, DROPS inaccessible (fail closed)', async () => {
|
|
const snaps = [mk('3'), mk('9'), mk('local')];
|
|
const out = await rg.filterSnapshotsByEnvAccess(auth({ accessibleEnvs: [3] }), snaps);
|
|
const ids = out.map((s: any) => s.id).sort();
|
|
expect(ids).toEqual(['3', 'local']); // env 9 dropped
|
|
});
|
|
it('is a no-op on an empty list', async () => {
|
|
expect(await rg.filterSnapshotsByEnvAccess(auth(), [])).toEqual([]);
|
|
});
|
|
});
|