import { lstat, mkdtemp, mkdir, readFile, readlink, rename, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { canonicalValueBytes } from "../../src/security/canonical-digest.ts"; import { buildReleasePackage, buildReleasePackageForTest, } from "../../scripts/build-release.mjs"; import { activateRelease, activateReleaseForTest, cleanupOrphanStages, prepareRelease, prepareReleaseForTest, readControlState, rollbackReleaseForTest, } from "../../scripts/deploy/lib/release-transaction.mjs"; import { createReleaseFixture, initializeReleaseRoot, } from "./release-test-fixture.ts"; type Fixture = Awaited>; function buildInput(fixture: Fixture, outputPath: string) { return { distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath, trustPolicyPath: fixture.trustPolicyPath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, protectedState: fixture.protectedState, now: "2026-09-02T01:00:00Z", outputPath, }; } function transactionInput( root: string, archivePath: string, transactionId: string, fixture: Fixture, ) { return { root, archivePath, transactionId, testOnlyVerification: { trustPolicyPath: fixture.trustPolicyPath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, }, testClock: () => "2026-09-02T01:00:00Z", smokeCheck: async ({ releaseDir }: { releaseDir: string }) => { await readFile(join(releaseDir, "index.html")); return Buffer.from("internal-nginx-smoke-ok\n"); }, }; } async function setup(prefix: string) { const temp = await mkdtemp(join(tmpdir(), prefix)); const fixture = await createReleaseFixture(join(temp, "fixture")); const root = join(temp, "release-root"); await initializeReleaseRoot( root, fixture.protectedState, fixture.trustPolicy, ); const archive = join(temp, "release.tar"); await buildReleasePackageForTest(buildInput(fixture, archive)); return { temp, fixture, root, archive }; } describe("fenced atomic Nginx release transaction", () => { test("prepares immutably without changing current, then activates with one atomic pointer swap", async () => { const { fixture, root, archive } = await setup("portfolio-atomic-"); const input = transactionInput(root, archive, "tx-a", fixture); await prepareReleaseForTest(input); await expect(lstat(join(root, "current"))).rejects.toThrow(); await activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, }); const target = await readlink(join(root, "current")); expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`); expect((await lstat(join(root, target, "index.html"))).mode & 0o777).toBe( 0o444, ); expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555); expect((await lstat(join(root, target, "index.html"))).uid).toBe( (await lstat(root)).uid, ); }); test("rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer", async () => { const { fixture, root, archive } = await setup("portfolio-fence-"); const input = transactionInput(root, archive, "tx-a", fixture); await prepareReleaseForTest(input); await expect( activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, }), ).rejects.toThrow("fence"); await expect( activateReleaseForTest({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, }), ).rejects.toThrow("predecessor"); await expect( activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, testClock: () => fixture.expiresAt, }), ).rejects.toThrow("expired"); const attempts = await Promise.allSettled([ activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, }), activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, }), ]); expect( attempts.filter((result) => result.status === "fulfilled"), ).toHaveLength(1); expect( attempts.filter((result) => result.status === "rejected"), ).toHaveLength(1); }); test("rechecks trusted time after smoke so an expiry crossing never becomes ready", async () => { const { fixture, root, archive } = await setup( "portfolio-expiry-crossing-", ); const times = ["2026-09-02T01:00:00Z", fixture.expiresAt]; await expect( prepareReleaseForTest({ ...transactionInput(root, archive, "tx-a", fixture), testClock: () => times.shift() ?? fixture.expiresAt, }), ).rejects.toThrow("expired while staging"); expect((await readControlState(root)).activeTransaction).toBeNull(); await expect(lstat(join(root, "current"))).rejects.toThrow(); }); test.each([ "after-intent", "after-extract", "after-immutable", "after-release-rename", "after-receipt", ] as const)( "recovers an interrupted preparation at %s and safely retries", async (testPreparationCrashPoint) => { const { fixture, root, archive } = await setup( `portfolio-prepare-crash-${testPreparationCrashPoint}-`, ); const input = transactionInput(root, archive, "tx-a", fixture); await expect( prepareReleaseForTest({ ...input, testPreparationCrashPoint }), ).rejects.toThrow("process death"); expect((await readControlState(root)).activeTransaction?.phase).toBe( "preparing", ); await prepareReleaseForTest(input); expect((await readControlState(root)).activeTransaction?.phase).toBe( "ready", ); await expect(lstat(join(root, "current"))).rejects.toThrow(); }, ); test("a crash before swap leaves the predecessor visible and retry after a post-swap crash converges", async () => { const { fixture, root, archive } = await setup( "portfolio-activation-crash-", ); const input = { ...transactionInput(root, archive, "tx-a", fixture), expectedCurrentManifestDigest: null, expectedFence: 1, }; await prepareReleaseForTest(input); await expect( activateReleaseForTest({ ...input, crashPoint: "before-swap" }), ).rejects.toThrow("before pointer"); await expect(lstat(join(root, "current"))).rejects.toThrow(); await expect( activateReleaseForTest({ ...input, crashPoint: "after-swap" }), ).rejects.toThrow("after pointer"); expect(await readlink(join(root, "current"))).toBe( `releases/release-a-${fixture.manifestDigest}/public`, ); const recovered = await activateReleaseForTest(input); expect(recovered.recoveredAfterPointerSwap).toBe(true); expect((await readControlState(root)).currentManifestDigest).toBe( fixture.manifestDigest, ); }); test("a protected ledger revocation racing final publication is preserved and blocks activation", async () => { const { fixture, root, archive } = await setup( "portfolio-revocation-race-", ); const input = transactionInput(root, archive, "tx-a", fixture); await prepareReleaseForTest(input); await expect( activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, testBeforePointerSwap: async () => { const state = await readControlState(root); await writeFile( join(root, "control/state.next"), canonicalValueBytes({ ...state, latestLedgerHeadHash: "a".repeat(64), latestLedgerRevision: state.latestLedgerRevision + 1, }), ); await rename( join(root, "control/state.next"), join(root, "control/state.json"), ); }, }), ).rejects.toThrow("authority changed"); expect((await readControlState(root)).latestLedgerRevision).toBe( fixture.protectedState.latestLedgerRevision + 1, ); await expect(lstat(join(root, "current"))).rejects.toThrow(); }); test("rechecks trusted time at the final pointer publication boundary", async () => { const { fixture, root, archive } = await setup( "portfolio-final-expiry-", ); const input = transactionInput(root, archive, "tx-a", fixture); await prepareReleaseForTest(input); const times = ["2026-09-02T01:00:00Z", fixture.expiresAt]; await expect( activateReleaseForTest({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, testClock: () => times.shift() ?? fixture.expiresAt, }), ).rejects.toThrow(/expired.*publication/i); await expect(lstat(join(root, "current"))).rejects.toThrow(); }); test("rollback requires a fresh signed manifest that binds the selected old artifact closure", async () => { const { temp, fixture, root, archive } = await setup("portfolio-rollback-"); const inputA = transactionInput(root, archive, "tx-a", fixture); await prepareReleaseForTest(inputA); await activateReleaseForTest({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, }); const releaseA = (await readlink(join(root, "current"))).split( "/", )[1] as string; fixture.protectedState.currentManifestDigest = fixture.manifestDigest; fixture.protectedState.publisherFence = 1; await writeFile( join(fixture.distDir, "index.html"), "Portfolio B\n", ); const signedB = await fixture.signFor({ buildId: "release-b", distDir: fixture.distDir, predecessorManifestDigest: fixture.manifestDigest, publisherFence: 1, outputPath: fixture.signedManifestPath, }); const archiveB = join(temp, "b.tar"); await buildReleasePackageForTest({ ...buildInput(fixture, archiveB), now: "2026-09-02T02:00:00Z", }); const inputB = { ...transactionInput(root, archiveB, "tx-b", fixture), testClock: () => "2026-09-02T02:00:00Z", }; await prepareReleaseForTest(inputB); await activateReleaseForTest({ ...inputB, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 2, }); await expect( rollbackReleaseForTest({ ...inputA, targetReleaseId: releaseA, transactionId: "tx-replay", expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, }), ).rejects.toThrow(); fixture.protectedState.currentManifestDigest = signedB.manifestDigest; fixture.protectedState.publisherFence = 2; await writeFile( join(fixture.distDir, "index.html"), "Portfolio\n", ); const freshSigned = join(temp, "rollback-signed.json"); const fresh = await fixture.signFor({ buildId: "rollback-a", distDir: fixture.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned, }); fixture.signedManifestPath = freshSigned; const rollbackArchive = join(temp, "rollback.tar"); await buildReleasePackageForTest({ ...buildInput(fixture, rollbackArchive), now: "2026-09-02T03:00:00Z", }); const rollback = await rollbackReleaseForTest({ ...transactionInput(root, rollbackArchive, "tx-rollback", fixture), targetReleaseId: releaseA, expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, testClock: () => "2026-09-02T03:00:00Z", }); expect(rollback.manifestDigest).toBe(fresh.manifestDigest); expect(await readlink(join(root, "current"))).toContain("rollback-a-"); }); test("production APIs reject caller authority fields and archives outside the upload-only inbox", async () => { const { fixture, root, archive } = await setup( "portfolio-production-authority-", ); await expect( prepareRelease({ ...transactionInput(root, archive, "tx-a", fixture), smokeCommand: "/bin/true", } as any), ).rejects.toThrow("unknown"); await expect( prepareRelease({ root, archivePath: archive, transactionId: "tx-a", smokeCommand: "/bin/true", }), ).rejects.toThrow("upload-only inbox"); await expect( activateRelease({ root, transactionId: "tx-a", expectedCurrentManifestDigest: null, expectedFence: 1, now: "2026-09-02T01:00:00Z", } as any), ).rejects.toThrow("unknown"); await expect( buildReleasePackage(buildInput(fixture, join(root, "bad.tar")) as any), ).rejects.toThrow("rejects caller"); }); test("orphan cleanup and all mutations remain confined to a validated explicit release root", async () => { const temp = await mkdtemp(join(tmpdir(), "portfolio-cleanup-")); const outside = join(temp, "outside.txt"); await writeFile(outside, "keep"); const invalidRoot = join(temp, "not-a-release-root"); await mkdir(join(invalidRoot, "staging", "orphan.partial"), { recursive: true, }); await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow("sentinel"); expect(await readFile(outside, "utf8")).toBe("keep"); const root = join(temp, "release-root"); await initializeReleaseRoot(root); await mkdir(join(root, "staging", "orphan.partial"), { recursive: true }); await writeFile( join(root, "staging", "orphan.partial", "marker"), "orphan", ); expect(await cleanupOrphanStages(root)).toEqual(["orphan.partial"]); await expect( lstat(join(root, "staging", "orphan.partial")), ).rejects.toThrow(); expect(await readFile(outside, "utf8")).toBe("keep"); await expect(cleanupOrphanStages("/")).rejects.toThrow("Broad"); }); });