import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const SCRIPT = join(import.meta.dir, 'deploy-claim.sh'); let dir: string; const run = (...args: string[]) => { const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } }); return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() }; }; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'deploy-claim-')); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); // The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding. describe('deploy-claim', () => { it('grants once and refuses after, whatever the outcome was', () => { expect(run('claim', '2026.09.01.01').code).toBe(0); expect(run('claim', '2026.09.01.01').code).toBe(1); // Still refused after a failure — no .done was ever written. This is the retry storm not happening. expect(run('claim', '2026.09.01.01').code).toBe(1); expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed'); }); it('refuses a release already deployed, even with no claim beside it', () => { // A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case. writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n'); expect(run('claim', '2026.08.21.01').code).toBe(1); expect(run('claim', '2026.08.21.01').err).toContain('already deployed'); }); it('lets a human hand the claim back, and only a human', () => { expect(run('claim', '2026.09.01.02').code).toBe(0); rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true }); expect(run('claim', '2026.09.01.02').code).toBe(0); }); it('records success separately from the claim', () => { run('claim', '2026.09.01.03'); run('done', '2026.09.01.03'); expect(run('state', '2026.09.01.03').out.trim()).toBe('done'); expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true); }); // The version comes off a git tag, and it is concatenated into a path. it('refuses anything that is not digits and dots', () => { for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) { expect(run('claim', bad).code).toBe(2); } expect(existsSync(join(dir, '..', 'etc'))).toBe(false); }); it('says so plainly when nothing has been attempted', () => { expect(run('list').out).toContain('no releases attempted'); }); });