/** * 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 { 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); }); });