server/bot-deletion-write-failure.e2e.test.ts
Rama main · réplica de lectura del repositorio original.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import { launchVerificationServer, runControlOmb } from "../scripts/control-omb.ts";
it("preserves conversations and scheduled work when deletion cannot save, then safely retries", async () => {
const fixture = await launchVerificationServer();
const { url, dataDir, logPath } = fixture.info;
const botsFile = join(dataDir, "bots.json");
const retainedBotsFile = join(dataDir, "bots.before-delete-fixture.json");
let faultInstalled = false;
const evidence: unknown[] = [{ fixture: fixture.info }];
const api = async (method: string, path: string, body?: unknown, expected = 200) => {
const response = await fetch(url + path, { method, headers: { "content-type": "application/json", origin: url },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(10_000) });
const result = await response.json() as any;
expect(response.status, `${method} ${path}: ${JSON.stringify(result)}`).toBe(expected);
return result;
};
const control = async (...args: string[]) => {
const result = await runControlOmb([...args, "--url", url]) as any;
evidence.push({ command: args, result });
return result;
};
const restoreBotsFile = () => {
if (faultInstalled) {
rmdirSync(botsFile); // Only the empty fixture directory installed below.
faultInstalled = false;
}
if (existsSync(retainedBotsFile)) renameSync(retainedBotsFile, botsFile);
};
const schedules = async () => ({
routines: (await api("GET", "/api/routines")).routines,
webhooks: (await api("GET", "/api/webhooks")).webhooks,
calls: (await api("GET", "/api/calendar-calls")).calls,
});
const scheduleDiskHashes = () => Object.fromEntries(["routines.json", "webhooks.json", "calendar-calls.json"].map(name =>
[name, createHash("sha256").update(readFileSync(join(dataDir, name))).digest("hex")]));
try {
const bot = (await control("new-bot", "--name", "Deletion failure fixture")).bot;
const guest = (await control("new-bot", "--name", "Surviving fixture guest")).bot;
await control("send", "--bot", bot.id, "--text", "Keep this fixture conversation until deletion is saved.");
expect(await control("wait", "--bot", bot.id, "--timeout", "30")).toMatchObject({ status: "settled" });
const beforeMessages = (await control("messages", "--bot", bot.id, "--limit", "20")).messages;
const later = Date.now() + 86_400_000;
const { routine } = await api("POST", "/api/routines", { name: "Retain this routine", botId: bot.id,
prompt: "Prepare the fixture report.", enabled: true, schedule: { type: "interval", everyMinutes: 60, anchorAt: later } }, 201);
const { webhook } = await api("POST", "/api/webhooks", { name: "Retain this webhook", botId: bot.id,
prompt: "Summarize the fixture event.", enabled: true }, 201);
const { call: solo } = await api("POST", "/api/calendar-calls", { name: "Retain this solo call", botIds: [bot.id],
schedule: { type: "once", at: later }, durationMinutes: 10 }, 201);
const { call: shared } = await api("POST", "/api/calendar-calls", { name: "Retain this shared call", botIds: [bot.id, guest.id],
schedule: { type: "once", at: later }, durationMinutes: 10 }, 201);
const before = await schedules();
expect(before.routines.find((item: any) => item.id === routine.id)?.enabled).toBe(true);
expect(before.webhooks.find((item: any) => item.id === webhook.id)?.enabled).toBe(true);
const beforeBot = (await api("GET", "/api/bots?messages=0")).bots.find((item: any) => item.id === bot.id);
const beforeDisk = scheduleDiskHashes();
const originalBots = readFileSync(botsFile, "utf8");
// An existing directory at the atomic replacement target makes rename
// fail on the real filesystem, without production fault-injection hooks
// or relying on chmod (which can be bypassed by a privileged test user).
renameSync(botsFile, retainedBotsFile);
mkdirSync(botsFile);
faultInstalled = true;
const failure = await api("DELETE", `/api/bots/${bot.id}`, undefined, 500);
expect(failure.error).toBeTruthy();
expect((await api("GET", "/api/bots?messages=0")).bots.find((item: any) => item.id === bot.id)).toEqual(beforeBot);
expect((await control("messages", "--bot", bot.id, "--limit", "20")).messages).toEqual(beforeMessages);
const afterFailure = await schedules();
expect(afterFailure).toEqual(before);
expect(scheduleDiskHashes()).toEqual(beforeDisk);
expect(readFileSync(retainedBotsFile, "utf8")).toBe(originalBots);
evidence.push({ failedDelete: failure, before, afterFailure, diskUnchanged: true, conversationUnchanged: true });
restoreBotsFile();
expect(await api("DELETE", `/api/bots/${bot.id}`)).toEqual({ ok: true });
const bots = (await api("GET", "/api/bots?messages=0")).bots;
expect(bots.some((item: any) => item.id === bot.id)).toBe(false);
expect(bots.some((item: any) => item.id === guest.id)).toBe(true);
const afterRetry = await schedules();
// A deleted bot's automations go with it.
expect(afterRetry.routines.some((item: any) => item.id === routine.id)).toBe(false);
expect(afterRetry.webhooks.some((item: any) => item.id === webhook.id)).toBe(false);
expect(afterRetry.calls.some((item: any) => item.id === solo.id)).toBe(false);
expect(afterRetry.calls.find((item: any) => item.id === shared.id)?.botIds).toEqual([guest.id]);
evidence.push({ retryApplied: true, afterRetry, survivingGuest: guest.id });
} finally {
restoreBotsFile();
await fixture.close();
const evidencePath = `${logPath}.bot-deletion-write-failure.json`;
writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
expect(existsSync(dataDir)).toBe(false);
console.info(JSON.stringify({ evidencePath, logPath, fixtureRemoved: true }));
}
}, 60_000);