/** * Authorization + data-shape tests for the three deploy-run routes under * api/stacks/[name]/deploys/. These do NOT inherit the no-auth model of * GET/DELETE /api/jobs/{id} (unguessable UUID, ten-minute in-memory job) -- * every route here checks stacks:view/stacks:edit for itself, against a * small sequential integer id that persists forever in schedule_executions. * * $lib/server/db transitively loads better-sqlite3 (ERR_DLOPEN_FAILED under * Bun), so it has to be faked before the route modules are imported -- same * problem tests/backups/route-guards.test.ts already solved. Fakes for it are * registered via ../helpers/db-fake (registerDbFake), NOT via a separate * mock.module('$lib/server/db', ...) call here -- see that helper's doc * comment for why a second direct call collides with route-guards.test.ts's * (mock.module() replaces the whole module for the whole process; whichever * of the two files registers last wins for BOTH, deterministically, and the * loser's import throws "Export named 'X' not found"). * * $lib/server/authorize is faked via tests/helpers/authorize-fake.ts (same * collision reason as the db fake above -- see that helper's doc comment), driven * by a per-test `authState`. $lib/server/deploy-log-store is NOT mocked: it only * touches node:fs/promises, so real file operations against a throwaway DATA_DIR * exercise the actual read/delete paths. */ import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { registerDbFake } from './helpers/db-fake'; import { registerAuthorizeFake } from './helpers/authorize-fake'; // -- $lib/server/authorize: fully replaced, driven by `authState` ---------- let authState: { authEnabled: boolean; isAuthenticated: boolean; isEnterprise: boolean; can: boolean; // H1 regression coverage needs `can()` to answer DIFFERENTLY depending on // which environmentId it's asked about (a caller can have stacks:view for // environment 1 but not environment 9) -- a single flat boolean can't // express that. Left undefined, `can` (the flat boolean below) is the // answer for every environmentId, unchanged from before -- every // pre-existing test in this file that never touches canByEnv keeps its // original behavior. Set only by the tests that specifically exercise // per-environment scoping. canByEnv?: (environmentId: number | undefined) => boolean; accessibleEnvs: number[] | 'all'; }; function resetAuthState() { authState = { authEnabled: true, isAuthenticated: true, isEnterprise: false, can: true, canByEnv: undefined, accessibleEnvs: 'all' }; } resetAuthState(); registerAuthorizeFake(async () => ({ authEnabled: authState.authEnabled, isAuthenticated: authState.isAuthenticated, isEnterprise: authState.isEnterprise, can: async (_resource: string, _action: string, environmentId?: number) => authState.canByEnv ? authState.canByEnv(environmentId) : authState.can, canAccessEnvironment: async (id: number) => authState.accessibleEnvs === 'all' || authState.accessibleEnvs.includes(id) })); // -- $lib/server/db: an in-memory schedule_executions fake ----------------- let execStore: Map; let deletedIds: number[]; let lastListFilters: any; function resetDbState() { execStore = new Map(); deletedIds = []; lastListFilters = undefined; } resetDbState(); registerDbFake('getScheduleExecution', async (id: number) => execStore.get(id)); registerDbFake('deleteScheduleExecution', async (id: number) => { deletedIds.push(id); execStore.delete(id); }); registerDbFake('getScheduleExecutions', async (filters: any) => { lastListFilters = filters; const all = [...execStore.values()].filter( (e) => (filters.scheduleType === undefined || e.scheduleType === filters.scheduleType) && (filters.entityName === undefined || e.entityName === filters.entityName) && (filters.environmentId === undefined || e.environmentId === filters.environmentId) ); return { executions: all, total: all.length, limit: filters.limit ?? 50, offset: 0 }; }); // -- Real deploy-log-store against a throwaway DATA_DIR --------------------- // // DATA_DIR is process-global and read at call time by every other test file // too (belatedly discovered the hard way for exactly this variable in // tests/fs-guard.test.ts / tests/selfhst-icons.test.ts -- a file that sets it // and never restores it can break an unrelated, later-running file). Save and // restore the prior value so this file leaves no trace once its tests finish. const previousDataDir = process.env.DATA_DIR; let dataDir: string; beforeAll(async () => { dataDir = await mkdtemp(join(tmpdir(), 'dv-t12-deploy-logs-')); process.env.DATA_DIR = dataDir; }); afterAll(async () => { await rm(dataDir, { recursive: true, force: true }); if (previousDataDir === undefined) { delete process.env.DATA_DIR; } else { process.env.DATA_DIR = previousDataDir; } }); const { appendRunLog, readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); // -- Routes under test, imported AFTER all the fakes above are registered --- const listRoute = await import('../src/routes/api/stacks/[name]/deploys/+server'); const runRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/+server'); const logRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/log/+server'); // -- Fixtures ---------------------------------------------------------------- const STACK = 'demo-stack'; const RUN = { id: 1, scheduleType: 'stack_deploy', scheduleId: 0, environmentId: null, entityName: STACK, triggeredBy: 'manual', triggeredAt: '2026-01-01T00:00:00.000Z', startedAt: null, completedAt: null, duration: 4200, status: 'success', errorMessage: null, details: { summary: 'ok' }, logs: null, createdAt: null }; function makeEvent(over: { params?: Record; url?: string } = {}) { return { params: { name: STACK, runId: '1', ...over.params }, url: new URL(over.url ?? 'http://x/'), cookies: { get: () => undefined } as any } as any; } beforeEach(async () => { resetAuthState(); resetDbState(); // Every test defaults to runId '1' -- clear its log file so tests don't // leak content into each other via the shared DATA_DIR. F5 fix: the log // file now lives under its OWN environment's directory, and this file's // tests use both RUN's default (environmentId: null) and an overridden // environmentId: 9 -- clear both so leftovers from either can't leak // between tests. await deleteRunLog(null, '1'); await deleteRunLog(9, '1'); }); // ============================================================================= // GET /api/stacks/[name]/deploys (list) // ============================================================================= describe('GET /api/stacks/[name]/deploys (list)', () => { test('no session -> 401 at the permission check, not a later error', async () => { authState.isAuthenticated = false; const res = await listRoute.GET(makeEvent()); expect(res.status).toBe(401); expect((await res.json()).error).toBe('Authentication required'); }); // H3 regression: on a single (local-only) environment install, EVERY // recorded run had environmentId === NULL and was completely unreachable // through this route -- `env` was hard-required, and there was no value a // caller could pass that matched a NULL row (env=0 included; environment // ids are never 0). See the module doc comment for the full chain. test('H3: omitted env -> 200, treated as the LOCAL environment (environmentId null), not a 400', async () => { execStore.set(1, { ...RUN, environmentId: null }); const res = await listRoute.GET(makeEvent()); // no ?env= expect(res.status).toBe(200); const body = await res.json(); expect(body.runs).toHaveLength(1); expect(body.runs[0].id).toBe(1); expect(lastListFilters.environmentId).toBe(null); }); test('H3: env=null (the literal string) -> the SAME local-environment result as omitting env', async () => { execStore.set(1, { ...RUN, environmentId: null }); const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=null' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.runs).toHaveLength(1); expect(lastListFilters.environmentId).toBe(null); }); test('non-numeric, non-"null" env -> 400 (still not a value getScheduleExecutions can filter on)', async () => { const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=abc' })); expect(res.status).toBe(400); expect((await res.json()).error).toBe( 'env query parameter must be an integer, or omitted/"null" for the local environment' ); }); test('cross-environment leak: omitting env must NOT surface a run from a DIFFERENT (non-null) environment', async () => { authState.isEnterprise = true; authState.accessibleEnvs = 'all'; // access is not the point of this test -- the null filter itself is execStore.set(1, { ...RUN, environmentId: null }); // local run, matches the omitted-env filter execStore.set(2, { ...RUN, id: 2, environmentId: 9 }); // foreign, non-null env, same stack name const res = await listRoute.GET(makeEvent()); // no ?env= expect(res.status).toBe(200); const body = await res.json(); expect(body.runs).toHaveLength(1); expect(body.runs[0].id).toBe(1); expect(JSON.stringify(body)).not.toContain('"environmentId":9'); }); test('caller lacking stacks:view for the local environment (global merge) -> 403, even though env is omitted', async () => { authState.can = false; const res = await listRoute.GET(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('authenticated but lacking permission -> 403', async () => { authState.can = false; const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('lists only runs for THIS stack, without log text', async () => { execStore.set(1, { ...RUN, environmentId: 1 }); execStore.set(2, { ...RUN, id: 2, environmentId: 1, entityName: 'other-stack' }); const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.runs).toHaveLength(1); expect(body.runs[0].id).toBe(1); expect(body.runs[0]).not.toHaveProperty('logs'); expect(lastListFilters.scheduleType).toBe('stack_deploy'); expect(lastListFilters.entityName).toBe(STACK); expect(lastListFilters.environmentId).toBe(1); }); test('skips a non-deploy schedule execution that happens to share the entity name', async () => { execStore.set(1, { ...RUN, environmentId: 1, scheduleType: 'backup' }); const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); const body = await res.json(); expect(body.runs).toHaveLength(0); }); }); // ============================================================================= // GET /api/stacks/[name]/deploys/[runId] (single) // ============================================================================= describe('GET /api/stacks/[name]/deploys/[runId] (single)', () => { test('no session -> 401 at the permission check, not a later error', async () => { authState.isAuthenticated = false; const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(401); expect((await res.json()).error).toBe('Authentication required'); }); test('authenticated but lacking permission -> 403', async () => { authState.can = false; const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('nonexistent run -> 404', async () => { const res = await runRoute.GET(makeEvent({ params: { runId: '999' } })); expect(res.status).toBe(404); expect((await res.json()).error).toBe('Deploy run not found'); }); test('run belonging to a DIFFERENT stack -> the SAME 404 as nonexistent', async () => { execStore.set(1, { ...RUN, entityName: 'other-stack' }); const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(404); expect((await res.json()).error).toBe('Deploy run not found'); }); test('non-numeric run id -> 400', async () => { const res = await runRoute.GET(makeEvent({ params: { runId: 'abc' } })); expect(res.status).toBe(400); }); test('cross-environment bypass: a caller-accessible env does not grant access to a DIFFERENT run env', async () => { authState.isEnterprise = true; authState.accessibleEnvs = [1]; // caller can access env 1 execStore.set(1, { ...RUN, environmentId: 9 }); // but this run belongs to env 9 const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(403); }); test('accessible run -> 200 with metadata, no log text', async () => { execStore.set(1, RUN); const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(200); const body = await res.json(); expect(body.id).toBe(1); expect(body.duration).toBe(4200); expect(body).not.toHaveProperty('logs'); }); }); // ============================================================================= // DELETE /api/stacks/[name]/deploys/[runId] // ============================================================================= describe('DELETE /api/stacks/[name]/deploys/[runId]', () => { test('no session -> 401 at the permission check, not a later error', async () => { authState.isAuthenticated = false; const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(401); expect((await res.json()).error).toBe('Authentication required'); }); test('authenticated but lacking permission -> 403', async () => { authState.can = false; const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('run belonging to a DIFFERENT stack -> 404, nothing deleted', async () => { execStore.set(1, { ...RUN, entityName: 'other-stack' }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(404); expect(deletedIds).toEqual([]); expect(execStore.has(1)).toBe(true); }); test('deletes the DB record AND the log file', async () => { execStore.set(1, RUN); // RUN.environmentId is null await appendRunLog(null, '1', 'some deploy output'); expect(await readRunLog(null, '1')).toBe('some deploy output'); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(200); expect((await res.json()).success).toBe(true); expect(deletedIds).toEqual([1]); expect(execStore.has(1)).toBe(false); expect(await readRunLog(null, '1')).toBeNull(); }); test('a terminal, non-success status (failed) is still deletable', async () => { execStore.set(1, { ...RUN, status: 'failed' }); await appendRunLog(null, '1', 'compose exploded'); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(200); expect((await res.json()).success).toBe(true); expect(deletedIds).toEqual([1]); expect(execStore.has(1)).toBe(false); expect(await readRunLog(null, '1')).toBeNull(); }); // Finding B: DELETE had no status check at all -- a caller could delete // their OWN still-running deploy's record. The background deploy process // keeps writing to the log file after the record is gone (appendRunLog // recreates it), updateScheduleExecution() on end() becomes a silent // no-op (UPDATE WHERE id=x, 0 rows), and the default-enabled reconcile // job then purges the recreated file as an orphan -- erasing every trace // of a deploy that actually ran and had real host side effects. test('Finding B: a RUNNING run cannot be deleted -> 409, nothing touched', async () => { execStore.set(1, { ...RUN, status: 'running' }); await appendRunLog(null, '1', 'still going...'); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(409); expect((await res.json()).error).toBe('Cannot delete a running deploy run'); expect(deletedIds).toEqual([]); expect(execStore.has(1)).toBe(true); expect(await readRunLog(null, '1')).toBe('still going...'); }); test('Finding B: a QUEUED run cannot be deleted -> 409, nothing touched', async () => { execStore.set(1, { ...RUN, status: 'queued' }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(409); expect(deletedIds).toEqual([]); expect(execStore.has(1)).toBe(true); }); // Fail-safe direction: a status this repo's ScheduleStatus type doesn't // even list (never actually produced by this code, but the column is // free text -- see db.ts's ScheduleStatus comment) must NOT be treated as // terminal just because it isn't 'queued'/'running'. Guessing "finished" // for an unrecognized status is exactly the kind of assumption that lets // a genuinely in-progress run slip through under a status this code // hasn't been taught about yet. test('Finding B: an unrecognized status is treated as NOT terminal (fail safe) -> 409', async () => { execStore.set(1, { ...RUN, status: 'some-future-status' }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(409); expect(deletedIds).toEqual([]); expect(execStore.has(1)).toBe(true); }); test('Finding B: the 404 (wrong stack) still wins over the running-run 409 -- ownership is checked first', async () => { execStore.set(1, { ...RUN, status: 'running', entityName: 'other-stack' }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(404); expect((await res.json()).error).toBe('Deploy run not found'); }); test('Finding B: the 403 (permission denied) still wins over the running-run 409', async () => { authState.can = false; execStore.set(1, { ...RUN, status: 'running' }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); }); // ============================================================================= // GET /api/stacks/[name]/deploys/[runId]/log // ============================================================================= describe('GET /api/stacks/[name]/deploys/[runId]/log', () => { test('no session -> 401 at the permission check, not a later error', async () => { authState.isAuthenticated = false; const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(401); expect((await res.json()).error).toBe('Authentication required'); }); test('authenticated but lacking permission -> 403', async () => { authState.can = false; const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('run belonging to a DIFFERENT stack -> 404, log file never touched', async () => { execStore.set(1, { ...RUN, entityName: 'other-stack' }); // still environmentId: null await appendRunLog(null, '1', 'secret content for the other stack'); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(404); expect((await res.json()).error).toBe('Deploy run not found'); }); test('run exists but the log file does not -> 404, does not wrongly look safe', async () => { execStore.set(1, RUN); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(404); expect((await res.json()).error).toBe('Log not found'); }); test('cross-environment bypass applies here too', async () => { authState.isEnterprise = true; authState.accessibleEnvs = [1]; execStore.set(1, { ...RUN, environmentId: 9 }); await appendRunLog(9, '1', 'should not be reachable'); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(403); }); test('returns the log text as text/plain', async () => { execStore.set(1, RUN); // RUN.environmentId is null await appendRunLog(null, '1', 'line one\nline two'); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/plain'); expect(await res.text()).toBe('line one\nline two'); }); }); // ============================================================================= // H1 regression: env-scoped permission on the three single-run routes // // Reproduces the branch-review scenario: a caller has stacks:view/edit for // SOME environment (which alone is enough to pass the coarse, un-scoped // `can('stacks', 'view'|'edit')` gate every route still runs first -- see // "authenticated but lacking permission -> 403" above) but NOT for the // environment the run under test actually belongs to. Before the fix, none // of the three routes checked permission against run.environmentId at all, // so the coarse gate's global OR-across-roles merge was the only check -- // a caller scoped to environment 1 could read/delete a run that belongs to // environment 9. // ============================================================================= describe('H1: permission is scoped to the RUN\'s own environment, not the global merge', () => { beforeEach(() => { authState.isEnterprise = true; authState.accessibleEnvs = 'all'; // isolate this from the SEPARATE canAccessEnvironment check (already correct, see "kein Befund") }); test('GET single run: caller has stacks:view broadly (passes the coarse gate) but NOT for env 9, and the run belongs to env 9 -> 403, not the 200 the pre-fix global merge produced', async () => { authState.canByEnv = (envId) => envId !== 9; // granted everywhere EXCEPT env 9 -- still passes the coarse can('stacks','view') gate (called with environmentId=undefined), so this isolates the NEW scoped check execStore.set(1, { ...RUN, environmentId: 9 }); const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Permission denied'); }); test('GET single run: caller has stacks:view for the run\'s OWN env -> 200', async () => { authState.canByEnv = (envId) => envId === undefined || envId === 9; // undefined: satisfies the coarse gate; 9: satisfies the scoped check execStore.set(1, { ...RUN, environmentId: 9 }); const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(200); expect((await res.json()).id).toBe(1); }); test('DELETE: caller has stacks:edit broadly but NOT for env 9, and the run belongs to env 9 -> 403, nothing deleted', async () => { authState.canByEnv = (envId) => envId !== 9; execStore.set(1, { ...RUN, environmentId: 9 }); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(403); expect(deletedIds).toEqual([]); expect(execStore.has(1)).toBe(true); }); test('DELETE: caller has stacks:edit for the run\'s OWN env -> 200, both halves deleted', async () => { authState.canByEnv = (envId) => envId === undefined || envId === 9; execStore.set(1, { ...RUN, environmentId: 9 }); await appendRunLog(9, '1', 'output'); const res = await runRoute.DELETE(makeEvent()); expect(res.status).toBe(200); expect(deletedIds).toEqual([1]); expect(await readRunLog(9, '1')).toBeNull(); }); test('GET log: caller has stacks:view broadly but NOT for env 9, and the run belongs to env 9 -> 403, log file never read', async () => { authState.canByEnv = (envId) => envId !== 9; execStore.set(1, { ...RUN, environmentId: 9 }); await appendRunLog(9, '1', 'env-9-only secret output'); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(403); }); test('GET log: caller has stacks:view for the run\'s OWN env -> 200 with the log text', async () => { authState.canByEnv = (envId) => envId === undefined || envId === 9; execStore.set(1, { ...RUN, environmentId: 9 }); await appendRunLog(9, '1', 'env-9 output'); const res = await logRoute.GET(makeEvent()); expect(res.status).toBe(200); expect(await res.text()).toBe('env-9 output'); }); test('a run with NO environment (local install, environmentId null) checks against the global merge, not against a specific env', async () => { // null run.environmentId maps to `can(..., undefined)` (the `?? undefined` // in the fix) -- there is no per-environment role to scope against for a // run that was never attributed to one. canByEnv(undefined) is the same // check the coarse gate above already performs, so this is really // confirming the scoped check does not ITSELF reject a local run once the // coarse gate already let it through. authState.canByEnv = (envId) => envId === undefined; execStore.set(1, { ...RUN, environmentId: null }); const res = await runRoute.GET(makeEvent()); expect(res.status).toBe(200); }); });