Files
dockhand-upstream/tests/backups/snapshots.test.ts
T

213 lines
10 KiB
TypeScript

/**
* Unit tests for backups/snapshots.ts — the ownership guards that keep one
* installation / environment from reading another's backups on a shared repo.
* The restic reader and access context are injected fakes, so no repo is needed.
*/
import { describe, it, expect } from 'bun:test';
import {
assertInstanceOwned,
assertEnvAccess,
guardSnapshotAccess,
selectOwnedForForget,
listSnapshots,
resolveSnapshotEnvId,
filterSnapshotsByAccessibleEnv,
type ResticReader,
type AccessContext,
} from '../../src/lib/server/backups/snapshots';
import type { ResticRun } from '../../src/lib/server/backups/models';
const SNAP = 'a'.repeat(64);
/** A restic reader whose `snapshots` call returns a fixed set of snapshots. */
function reader(snapshots: Array<{ id: string; tags: string[] }>, exitCode = 0): ResticReader {
return {
async runLocal(): Promise<ResticRun> {
const stdout = JSON.stringify(snapshots.map((s) => ({ id: s.id, short_id: s.id.slice(0, 8), tags: s.tags })));
return { exitCode, stdout, stderr: exitCode === 0 ? '' : 'restic error' };
},
};
}
const freeAccess: AccessContext = { isEnterprise: false, canAccessEnvironment: async () => true };
function enterprise(allowed: number[]): AccessContext {
return { isEnterprise: true, canAccessEnvironment: async (id) => allowed.includes(id) };
}
describe('assertInstanceOwned', () => {
it('returns the tags when the snapshot is our own', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=3'] }]);
const tags = await assertInstanceOwned(r, {}, 'me', SNAP);
expect(tags).toContain('dockhand:envid=3');
});
it('NON-ENTERPRISE allows a FOREIGN Dockhand snapshot (orphan from a copied/shared repo) - #1351', async () => {
// A snapshot written by another instance is a legitimate restorable orphan in free.
const r = reader([{ id: SNAP, tags: ['dockhand:instance=OTHER', 'dockhand:envid=1'] }]);
const tags = await assertInstanceOwned(r, {}, 'me', SNAP, false);
expect(tags).toContain('dockhand:instance=OTHER');
});
it('ENTERPRISE still refuses a foreign snapshot (strict per-instance; enterprise does not copy repos)', async () => {
// Enterprise queries with the instance tag; restic returns nothing for a foreign snap.
const r = reader([]); // instance-filtered query finds nothing
await expect(assertInstanceOwned(r, {}, 'me', SNAP, true)).rejects.toThrow(/not found for this installation/);
});
it('refuses a NON-DOCKHAND snapshot (another app sharing the repo has no dockhand:instance tag)', async () => {
const r = reader([{ id: SNAP, tags: ['borgapp:host=nas'] }]);
await expect(assertInstanceOwned(r, {}, 'me', SNAP, false)).rejects.toThrow(/not found for this installation/);
});
it('refuses when the snapshot id is absent from the repo', async () => {
const r = reader([]);
await expect(assertInstanceOwned(r, {}, 'me', SNAP, false)).rejects.toThrow(/not found for this installation/);
});
it('rejects an invalid snapshot id before touching restic', async () => {
const r = reader([]);
await expect(assertInstanceOwned(r, {}, 'me', 'nothex!')).rejects.toThrow(/invalid snapshot id/);
});
it('surfaces a restic failure (not a silent empty)', async () => {
const r = reader([], 1);
await expect(assertInstanceOwned(r, {}, 'me', SNAP)).rejects.toThrow(/could not verify snapshot ownership/);
});
});
describe('assertEnvAccess — resolves env from the snapshot tag, fails closed', () => {
it('is a no-op for non-enterprise', async () => {
await expect(assertEnvAccess(freeAccess, [])).resolves.toBeUndefined();
});
it('allows an env the caller can access', async () => {
await expect(assertEnvAccess(enterprise([3]), ['dockhand:envid=3'])).resolves.toBeUndefined();
});
it('denies an env the caller cannot access', async () => {
await expect(assertEnvAccess(enterprise([1]), ['dockhand:envid=3'])).rejects.toThrow(/access denied/);
});
it('allows an unscoped (local) snapshot', async () => {
await expect(assertEnvAccess(enterprise([]), ['dockhand:envid=local'])).resolves.toBeUndefined();
});
it('FAILS CLOSED when the env tag is absent/malformed', async () => {
await expect(assertEnvAccess(enterprise([3]), ['no env tag'])).rejects.toThrow(/could not resolve/);
await expect(assertEnvAccess(enterprise([3]), ['dockhand:envid=abc'])).rejects.toThrow(/could not resolve/);
});
});
describe('guardSnapshotAccess — instance AND env, in order', () => {
it('passes for our snapshot in an accessible env', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=3'] }]);
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).resolves.toBeUndefined();
});
it('blocks a foreign-instance snapshot before the env check', async () => {
const r = reader([]); // not ours
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).rejects.toThrow(/not found/);
});
it('blocks our snapshot in an inaccessible env', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=9'] }]);
await expect(guardSnapshotAccess(r, {}, 'me', SNAP, enterprise([3]))).rejects.toThrow(/access denied/);
});
});
describe('selectOwnedForForget — the bulk-delete preVetted contract', () => {
const ids = ['a'.repeat(64), 'b'.repeat(64), 'c'.repeat(64)];
it('preVetted forwards every id and NEVER calls the guard (route already vetted them)', async () => {
let guardCalls = 0;
const guard = async () => { guardCalls++; };
const out = await selectOwnedForForget(ids, { preVetted: true, guard });
expect(out.owned).toEqual(ids);
expect(out.skipped).toEqual([]);
expect(guardCalls).toBe(0); // the whole point: no per-id ownership re-listing
});
it('without preVetted, a guard rejection drops that id to skipped (never forgotten)', async () => {
// 'b' is not owned / not accessible -> the guard throws -> it must be skipped, not owned.
const guard = async (id: string) => { if (id === ids[1]) throw new Error('not owned'); };
const out = await selectOwnedForForget(ids, { preVetted: false, guard });
expect(out.owned).toEqual([ids[0], ids[2]]);
expect(out.skipped).toEqual([ids[1]]);
});
it('without preVetted, all-rejected yields no owned ids (nothing reaches restic)', async () => {
const out = await selectOwnedForForget(ids, { preVetted: false, guard: async () => { throw new Error('x'); } });
expect(out.owned).toEqual([]);
expect(out.skipped).toEqual(ids);
});
});
describe('listSnapshots — filters to accessible environments (enterprise)', () => {
const snaps = [
{ id: 'x'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] },
{ id: 'y'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=9'] },
{ id: 'z'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=local'] },
];
it('non-enterprise sees all of its own snapshots', async () => {
const out = await listSnapshots(reader(snaps), {}, 'me', freeAccess);
expect(out).toHaveLength(3);
});
it('enterprise sees only accessible envs plus unscoped', async () => {
const out = await listSnapshots(reader(snaps), {}, 'me', enterprise([3]));
// env 3 (allowed) + local (unscoped); env 9 filtered out
expect(out.map((s) => s.tags.find((t) => t.startsWith('dockhand:envid'))).sort())
.toEqual(['dockhand:envid=3', 'dockhand:envid=local']);
});
it('enterprise FAILS CLOSED on a snapshot with an absent or malformed env tag (would otherwise leak a scoped snapshot)', async () => {
const scoped = [
{ id: 'a'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] }, // allowed
{ id: 'b'.repeat(64), tags: ['dockhand:instance=me'] }, // NO env tag -> drop
{ id: 'c'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=abc'] }, // malformed -> drop
{ id: 'd'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=9'] }, // not allowed -> drop
];
const out = await listSnapshots(reader(scoped), {}, 'me', enterprise([3]));
expect(out.map((s) => s.id.slice(0, 1))).toEqual(['a']); // ONLY the allowed, env-tagged one
});
it('surfaces a restic error instead of returning []', async () => {
await expect(listSnapshots(reader([], 1), {}, 'me', freeAccess)).rejects.toThrow();
});
});
describe('resolveSnapshotEnvId — stable-id env resolution, fail closed', () => {
it('resolves a numeric env from the snapshot tag', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=7'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: 7, resolved: true });
});
it('treats a local snapshot as unscoped (envId null, resolved)', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me', 'dockhand:envid=local'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: null, resolved: true });
});
it('fails closed when the snapshot is not ours (empty result)', async () => {
const r = reader([]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed on a restic error', async () => {
const r = reader([], 1);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed when the env tag is absent/malformed', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:instance=me'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', SNAP)).toEqual({ envId: undefined, resolved: false });
});
it('fails closed on an invalid snapshot id without touching restic', async () => {
const r = reader([{ id: SNAP, tags: ['dockhand:envid=7'] }]);
expect(await resolveSnapshotEnvId(r, {}, 'me', 'nothex!')).toEqual({ envId: undefined, resolved: false });
});
});
describe('filterSnapshotsByAccessibleEnv — stable-id env disclosure guard', () => {
const snaps = [
{ tags: ['dockhand:envid=3'], id: 'a' },
{ tags: ['dockhand:envid=9'], id: 'b' },
{ tags: ['dockhand:envid=local'], id: 'c' },
{ tags: ['no env tag'], id: 'd' },
];
it('keeps accessible envs + unscoped, drops inaccessible and unresolvable', async () => {
const out = await filterSnapshotsByAccessibleEnv(snaps, async (id) => id === 3);
expect(out.map((s) => s.id)).toEqual(['a', 'c']); // env 3 + local; 9 denied; no-tag dropped
});
it('memoises the access check per env id', async () => {
let calls = 0;
const many = [
{ tags: ['dockhand:envid=3'], id: '1' },
{ tags: ['dockhand:envid=3'], id: '2' },
{ tags: ['dockhand:envid=3'], id: '3' },
];
await filterSnapshotsByAccessibleEnv(many, async () => { calls++; return true; });
expect(calls).toBe(1);
});
});