rea/tests/process-global/setupConfigurationRollback....

326 lines
11 KiB
TypeScript

import { open, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { afterEach, expect, it, onTestFinished, vi } from "vitest";
const state = vi.hoisted(() => ({
readbackFailed: false,
rejectRestore: false,
rejectRemove: false,
removeCopiedRecovery: false,
initialCleanupFailure: undefined as unknown,
readbackCleanupFailure: undefined as unknown,
}));
afterEach(() => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.removeCopiedRecovery = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
});
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
rm: (
...arguments_: Parameters<typeof original.rm>
): ReturnType<typeof original.rm> => {
if (state.rejectRemove && state.readbackFailed)
return Promise.reject(new Error("injected restore remove failure"));
return original.rm(...arguments_);
},
writeFile: (
...arguments_: Parameters<typeof original.writeFile>
): ReturnType<typeof original.writeFile> => {
if (state.rejectRestore && state.readbackFailed)
return Promise.reject(new Error("injected restore write failure"));
return original.writeFile(...arguments_);
},
copyFile: async (
...arguments_: Parameters<typeof original.copyFile>
): Promise<void> => {
await original.copyFile(...arguments_);
const destination = arguments_[1];
if (
state.removeCopiedRecovery &&
typeof destination === "string" &&
destination.endsWith(".recovery")
)
await original.rm(destination);
},
};
});
vi.mock("write-file-atomic", async (importOriginal) => {
const original = await importOriginal<{
default: typeof import("write-file-atomic");
}>();
return {
...original,
default: async (...arguments_: Parameters<typeof original.default>) => {
if (state.initialCleanupFailure !== undefined)
throw new Error("unexpected config write before initial read");
const result = await original.default(...arguments_);
state.readbackFailed = true;
return result;
},
};
});
vi.mock("../../src/application/RegularFileRead.js", async (importOriginal) => {
const original =
await importOriginal<
typeof import("../../src/application/RegularFileRead.js")
>();
return {
...original,
readRegularFileText: (
...arguments_: Parameters<typeof original.readRegularFileText>
) =>
state.initialCleanupFailure !== undefined
? Promise.reject(state.initialCleanupFailure)
: state.readbackFailed && state.readbackCleanupFailure !== undefined
? Promise.reject(state.readbackCleanupFailure)
: state.readbackFailed
? Promise.reject(new Error("injected readback failure"))
: original.readRegularFileText(...arguments_),
};
});
import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js";
import { RegularFileCleanupFailure } from "../../src/application/RegularFileRead.js";
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
import type { DoctorHost } from "../../src/application/Doctor.js";
import { systemSetupHost } from "../../src/application/SetupHost.js";
import { ConfigurationError } from "../../src/domain/configurationErrors.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const ownReadHandle = async (path: string) => {
const owner = new OwnedFileHandle(await open(path, "r"));
onTestFinished(() => owner.close());
return owner;
};
it("reports a failed config rollback and retains its backup path", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-rollback-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
state.rejectRestore = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
expect(result).toEqual({
status: "failed",
reason: "rollback",
backupPath: `${path}.rea.backup`,
failure: {
kind: "restore",
path,
detail: "injected restore write failure",
},
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({
mcpServers: { rea: { command: "rea", args: ["mcp"] } },
});
});
it("preserves the first backup and reports a verified backup of the current original", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-stale-backup-");
const path = join(root, "client.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
await import("node:fs/promises").then(async ({ writeFile }) => {
await writeFile(path, currentOriginal);
await writeFile(`${path}.rea.backup`, firstOriginal);
});
state.rejectRestore = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.rejectRestore = false;
const recoveryPath = `${path}.rea.backup.${createHash("sha256")
.update(currentOriginal, "utf8")
.digest("hex")}.recovery`;
expect(result).toMatchObject({
status: "failed",
reason: "rollback",
backupPath: recoveryPath,
failure: {
kind: "restore",
path,
detail: "injected restore write failure",
},
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(firstOriginal);
expect(await readFile(recoveryPath, "utf8")).toBe(currentOriginal);
});
it("does not write config when a recovery copy disappears before verification", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.removeCopiedRecovery = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-vanished-recovery-");
const path = join(root, "client.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
await import("node:fs/promises").then(async ({ writeFile }) => {
await writeFile(path, currentOriginal);
await writeFile(`${path}.rea.backup`, firstOriginal);
});
state.removeCopiedRecovery = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.removeCopiedRecovery = false;
expect(result).toEqual({ status: "failed", reason: "backup" });
expect(await readFile(path, "utf8")).toBe(currentOriginal);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(firstOriginal);
});
it("does not roll back or remove config when its initial read cleanup fails", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-initial-cleanup-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
const failure = new RegularFileCleanupFailure(
path,
await ownReadHandle(path),
{ kind: "completed", value: original },
new Error("injected initial close failure"),
);
state.initialCleanupFailure = failure;
await expect(
configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
),
).rejects.toBe(failure);
expect(await readFile(path, "utf8")).toBe(original);
});
it("reports first-run rollback removal failure without a backup", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-first-run-rollback-");
const path = join(root, "client.json");
state.rejectRemove = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.rejectRemove = false;
expect(result).toMatchObject({
status: "failed",
reason: "rollback",
failure: {
kind: "restore",
path,
detail: "injected restore remove failure",
},
});
expect(result).not.toHaveProperty("backupPath");
expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({
mcpServers: { rea: { command: "rea", args: ["mcp"] } },
});
});
it("retains readback cleanup ownership when rollback also fails", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-readback-cleanup-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
const owner = await ownReadHandle(path);
const cleanupFailure = new RegularFileCleanupFailure(
path,
owner,
{ kind: "completed", value: "" },
new Error("injected readback close failure"),
);
state.readbackCleanupFailure = cleanupFailure;
state.rejectRestore = true;
const host = systemSetupHost(
{ platform: "linux", homeDirectory: root } as DoctorHost,
{ HOME: root },
);
let caught: unknown;
try {
await host.configureClient(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
} catch (cause: unknown) {
caught = cause;
} finally {
await host.close?.();
}
if (!(caught instanceof ConfigurationError)) throw caught;
expect(caught.message).toContain("injected restore write failure");
expect(caught.message).toContain(`${path}.rea.backup`);
const cleanupOwnerError = caught.cause;
if (!(cleanupOwnerError instanceof RegularFileCleanupFailure))
throw new Error("Configuration failure lost its read owner");
expect(cleanupOwnerError).toMatchObject({
path,
outcome: { kind: "completed", value: "" },
cleanupCause: expect.any(AggregateError),
});
expect(cleanupOwnerError.owner).toBe(owner);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
});