server/boat-delete-journal.test.ts
Rama main · réplica de lectura del repositorio original.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const MODULE_URL = pathToFileURL(join(process.cwd(), "server", "boat-delete-journal.ts")).href;
const OPERATION_ID = "bdop_0123456789abcdef0123456789abcdef";
function operation(
targetId = "bx_23456789",
status: "pending" | "processing" | "blocked" | "completed" = "pending",
) {
return { id: OPERATION_ID, kind: "sandbox" as const, targetId, status };
}
function worker(dataDir: string, source: string) {
const child = spawn(process.execPath, [
"--no-warnings",
"--experimental-strip-types",
"--input-type=module",
"--eval",
source,
], {
env: { ...process.env, OMB_DATA_DIR: dataDir },
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => (stderr += chunk));
const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator]();
return { child, lines, exited: once(child, "exit"), stderr: () => stderr };
}
async function nextLine(subject: ReturnType<typeof worker>, label: string): Promise<string> {
const line = await subject.lines.next();
if (line.done) throw new Error(`${label} exited before replying: ${subject.stderr()}`);
return line.value;
}
async function expectCleanExit(subject: ReturnType<typeof worker>, label: string): Promise<void> {
const [code, signal] = await subject.exited;
expect({ code, signal, stderr: subject.stderr() }, label).toEqual({ code: 0, signal: null, stderr: "" });
}
describe("Boat deletion journal", () => {
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "omb-box-delete-journal-"));
vi.stubEnv("OMB_DATA_DIR", dataDir);
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
rmSync(dataDir, { recursive: true, force: true });
});
it("persists a prepared target across a module restart without credentials", async () => {
let journal = await import("./boat-delete-journal.ts");
const prepared = journal.prepareBoatDeletion({
boxId: "bx_23456789",
name: "ogb-0123456789ab-owner-abcdef",
ownerBotId: "owner-bot",
});
expect(prepared).toMatchObject({
boxId: "bx_23456789",
ownerBotId: "owner-bot",
phase: "prepared",
});
vi.resetModules();
journal = await import("./boat-delete-journal.ts");
expect(journal.getBoatDeletion("bx_23456789")).toEqual(prepared);
expect(journal.getBoatDeletion("bx_23456789")).not.toBeNull();
expect(journal.hasPendingBoatDeletionForBot("owner-bot")).toBe(true);
const path = join(dataDir, "box-delete-requests.json");
const stored = readFileSync(path, "utf8");
expect(JSON.parse(stored)).toEqual({ version: 1, records: [prepared] });
expect(stored).not.toMatch(/token|authorization|secret/i);
// Windows reports 0666 for a 0600 file: the mode is a POSIX guarantee.
if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600);
});
it("advances a target-bound operation monotonically and retires idempotently", async () => {
const journal = await import("./boat-delete-journal.ts");
journal.prepareBoatDeletion({ boxId: "bx_23456789", name: "owned-boat", ownerBotId: "owner-bot" });
const pending = journal.markBoatDeletionAccepted("bx_23456789", operation());
expect(pending).toMatchObject({ phase: "accepted", operationId: OPERATION_ID, status: "pending" });
const processing = journal.markBoatDeletionAccepted("bx_23456789", operation("bx_23456789", "processing"));
expect(processing.status).toBe("processing");
expect(journal.markBoatDeletionAccepted("bx_23456789", operation()).status).toBe("processing");
const completed = journal.markBoatDeletionAccepted("bx_23456789", operation("bx_23456789", "completed"));
expect(completed.status).toBe("completed");
expect(journal.boatDeletionSnapshot()).toEqual([completed]);
const snapshot = journal.boatDeletionSnapshot();
snapshot[0]!.name = "mutated outside";
expect(journal.getBoatDeletion("bx_23456789")?.name).toBe("owned-boat");
journal.retireBoatDeletion("bx_23456789");
journal.retireBoatDeletion("bx_23456789");
expect(journal.boatDeletionSnapshot()).toEqual([]);
});
it("records a block, keeps the target fenced, and supports an explicit deletion retry", async () => {
const journal = await import("./boat-delete-journal.ts");
const identity = { boxId: "bx_23456789", name: "orphan-box", ownerBotId: null };
journal.prepareBoatDeletion(identity);
journal.markBoatDeletionAccepted("bx_23456789", operation());
const blocked = journal.markBoatDeletionBlocked(
"bx_23456789",
operation("bx_23456789", "blocked"),
);
expect(blocked).toMatchObject({ phase: "blocked", operationId: OPERATION_ID, status: "blocked" });
expect(journal.getBoatDeletion("bx_23456789")).not.toBeNull();
const retried = journal.prepareBoatDeletion(identity);
expect(retried).toMatchObject({ ...identity, phase: "prepared" });
expect(retried).not.toHaveProperty("operationId");
expect(retried).not.toHaveProperty("status");
expect(journal.getBoatDeletion("bx_23456789")).not.toBeNull();
});
it("refuses identity conflicts and non-target-bound provider receipts", async () => {
const journal = await import("./boat-delete-journal.ts");
journal.prepareBoatDeletion({ boxId: "bx_23456789", name: "owned-boat", ownerBotId: "owner-bot" });
expect(() => journal.prepareBoatDeletion({
boxId: "bx_23456789",
name: "renamed-box",
ownerBotId: "owner-bot",
})).toThrow(/conflicted/i);
expect(() => journal.markBoatDeletionAccepted(
"bx_23456789",
operation("bx_3456789a"),
)).toThrow(/mismatched/i);
expect(() => journal.markBoatDeletionAccepted("bx_23456789", {
...operation(),
kind: "workspace",
})).toThrow(/mismatched/i);
expect(() => journal.markBoatDeletionAccepted("bx_23456789", {
...operation(),
id: "operation-not-provider-shaped",
})).toThrow(/mismatched/i);
journal.markBoatDeletionAccepted("bx_23456789", operation());
expect(() => journal.markBoatDeletionAccepted("bx_23456789", {
...operation(),
id: "bdop_ffffffffffffffffffffffffffffffff",
})).toThrow(/another deletion operation/i);
journal.prepareBoatDeletion({ boxId: "bx_3456789a", name: "second-box", ownerBotId: null });
expect(() => journal.markBoatDeletionAccepted("bx_3456789a", operation("bx_3456789a")))
.toThrow(/another deletion operation/i);
});
it("rejects invalid targets before writing state", async () => {
const journal = await import("./boat-delete-journal.ts");
expect(() => journal.prepareBoatDeletion({
boxId: "../../config.json",
name: "box",
ownerBotId: null,
})).toThrow(/invalid cloud computer id/i);
expect(() => journal.prepareBoatDeletion({
boxId: "bx_23456789",
name: "bad\nname",
ownerBotId: null,
})).toThrow(/invalid cloud computer name/i);
expect(() => journal.prepareBoatDeletion({
boxId: "bx_23456789",
name: "box",
ownerBotId: "bad owner",
})).toThrow(/invalid cloud computer owner/i);
expect(() => readFileSync(join(dataDir, "box-delete-requests.json"), "utf8")).toThrow();
});
it("fails closed on malformed or duplicate persisted authority", async () => {
const journal = await import("./boat-delete-journal.ts");
journal.prepareBoatDeletion({ boxId: "bx_23456789", name: "owned-boat", ownerBotId: "owner-bot" });
const path = join(dataDir, "box-delete-requests.json");
writeFileSync(path, "{ truncated\n");
expect(() => journal.boatDeletionSnapshot()).toThrow(/recovery state is unreadable/i);
expect(readFileSync(path, "utf8")).toBe("{ truncated\n");
const now = Date.now();
const duplicate = {
boxId: "bx_3456789a",
name: "second-box",
ownerBotId: null,
phase: "accepted",
operationId: OPERATION_ID,
status: "pending",
requestedAt: now,
updatedAt: now,
};
writeFileSync(path, `${JSON.stringify({ version: 1, records: [
{ ...duplicate, boxId: "bx_23456789" },
duplicate,
] })}\n`);
expect(() => journal.boatDeletionSnapshot()).toThrow(/recovery state is invalid/i);
});
it("serializes independent processes without losing either prepared deletion", async () => {
const concurrentDir = mkdtempSync(join(tmpdir(), "omb-box-delete-processes-"));
const source = (boxId: string, name: string) => `
const journal = await import(${JSON.stringify(MODULE_URL)});
journal.boatDeletionSnapshot();
process.stdout.write("ready\\n");
process.stdin.once("data", () => {
const result = journal.prepareBoatDeletion({
boxId: ${JSON.stringify(boxId)},
name: ${JSON.stringify(name)},
ownerBotId: null,
});
process.stdout.write(JSON.stringify(result) + "\\n");
});
`;
const first = worker(concurrentDir, source("bx_23456789", "first-box"));
const second = worker(concurrentDir, source("bx_3456789a", "second-box"));
try {
expect(await nextLine(first, "first worker")).toBe("ready");
expect(await nextLine(second, "second worker")).toBe("ready");
first.child.stdin.end("prepare\n");
second.child.stdin.end("prepare\n");
await Promise.all([nextLine(first, "first worker"), nextLine(second, "second worker")]);
await Promise.all([expectCleanExit(first, "first worker"), expectCleanExit(second, "second worker")]);
const saved = JSON.parse(readFileSync(join(concurrentDir, "box-delete-requests.json"), "utf8"));
expect(saved.records).toHaveLength(2);
expect(saved.records).toEqual(expect.arrayContaining([
expect.objectContaining({ boxId: "bx_23456789", phase: "prepared" }),
expect.objectContaining({ boxId: "bx_3456789a", phase: "prepared" }),
]));
} finally {
if (first.child.exitCode === null) first.child.kill("SIGKILL");
if (second.child.exitCode === null) second.child.kill("SIGKILL");
rmSync(concurrentDir, { recursive: true, force: true });
}
});
it("recovers a complete lock left by an exited process", async () => {
const staleDir = mkdtempSync(join(tmpdir(), "omb-box-delete-stale-lock-"));
const exited = spawn(process.execPath, ["--eval", ""], { stdio: "ignore" });
const exitedPid = exited.pid;
expect(exitedPid).toBeTypeOf("number");
await once(exited, "exit");
mkdirSync(staleDir, { recursive: true });
writeFileSync(join(staleDir, "box-delete-requests.lock"), JSON.stringify({
version: 1,
pid: exitedPid,
token: randomUUID(),
createdAt: Date.now(),
}));
const source = `
const journal = await import(${JSON.stringify(MODULE_URL)});
const result = journal.prepareBoatDeletion({
boxId: "bx_23456789",
name: "stale-lock-box",
ownerBotId: null,
});
process.stdout.write(JSON.stringify(result) + "\\n");
`;
const subject = worker(staleDir, source);
try {
expect(JSON.parse(await nextLine(subject, "stale-lock worker"))).toMatchObject({ phase: "prepared" });
await expectCleanExit(subject, "stale-lock worker");
expect(JSON.parse(readFileSync(join(staleDir, "box-delete-requests.json"), "utf8")).records).toHaveLength(1);
} finally {
if (subject.child.exitCode === null) subject.child.kill("SIGKILL");
rmSync(staleDir, { recursive: true, force: true });
}
});
it("does not overwrite a corrupt lock", async () => {
const lockPath = join(dataDir, "box-delete-requests.lock");
writeFileSync(lockPath, "not-json\n");
const journal = await import("./boat-delete-journal.ts");
expect(() => journal.boatDeletionSnapshot()).toThrow(/recovery state is lock is invalid/i);
expect(readFileSync(lockPath, "utf8")).toBe("not-json\n");
});
});