diff --git a/packages/cli/test/repair-integration.test.ts b/packages/cli/test/repair-integration.test.ts index 07f518a6..72340082 100644 --- a/packages/cli/test/repair-integration.test.ts +++ b/packages/cli/test/repair-integration.test.ts @@ -1,6 +1,13 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -8,6 +15,7 @@ import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { restoreRule } from "../src/api/restore"; import { canonicalHash } from "../src/rules/rule-hash"; import { migrateFixture } from "./support/current-project"; @@ -33,6 +41,28 @@ interface Mock { close: () => Promise; } +/** + * A reconcile handler normally returns the response body directly (always + * HTTP 200). A handler that instead needs to exercise a non-2xx reconcile + * response (e.g. the unauthorized branch of `planRuntime`) returns this + * wrapper, distinguished by its `statusCode` field — no ordinary reconcile + * response body (`{ run, unsafe, unknown, missing }`) has one. + */ +interface HttpOverride { + statusCode: number; + body: unknown; +} + +function isHttpOverride(value: unknown): value is HttpOverride { + return ( + typeof value === "object" && + value !== null && + "statusCode" in value && + typeof (value as { statusCode: unknown }).statusCode === "number" && + "body" in value + ); +} + function startMock(handlers: { reconcile: (body: { files: { file: string; signature: string }[]; @@ -46,10 +76,13 @@ function startMock(handlers: { request.on("end", () => { const url = request.url ?? ""; if (url === "/cli/api/reconcile") { - const body = handlers.reconcile( + const result = handlers.reconcile( JSON.parse(raw) as { files: { file: string; signature: string }[] } ); - response.writeHead(200, { "content-type": "application/json" }); + const { statusCode, body } = isHttpOverride(result) + ? result + : { statusCode: 200, body: result }; + response.writeHead(statusCode, { "content-type": "application/json" }); response.end(JSON.stringify(body)); return; } @@ -449,4 +482,287 @@ describe("repairing a drifted runtime rule, end to end", () => { await mock.close(); } }); + + it("says '1 stale entry ... it' when the repair leaves exactly one behind", async () => { + // plan.ts's `repairWithheldRules` wraps `PurgeIncompleteError` in its own + // wording rather than reusing the error's message, and pluralizes it by + // hand — a seam `deliver.test.ts` cannot see, since it only ever throws + // the error, never renders this notice. + const rule = join(directory, ".taskless", "rules", "runtime", "demo"); + await mkdir(join(rule, "blocked"), { recursive: true }); + await writeFile(join(rule, "blocked", "a.txt"), "stuck\n", "utf8"); + await chmod(join(rule, "blocked"), 0o500); + + const blessed = await canonicalHash(BLESSED); + const mock = await startMock({ + reconcile: driftedReconcile(blessed), + restore: () => ({ + statusCode: 200, + body: { + ruleId: "demo", + rules: [ + { + id: "demo", + engine: "runtime", + files: [ + { path: "check.ts", content: BLESSED }, + { path: "captures/logs.yml", content: CAPTURE }, + ], + signature: blessed, + }, + ], + }, + }), + }); + try { + const { stdout } = await runCli(["check", "-d", directory, "--json"], { + TASKLESS_TOKEN: "fake.token", + TASKLESS_API_URL: mock.apiUrl, + }); + const notices = (envelope(stdout).notices ?? []).join("\n"); + expect(notices).toMatch( + /was rewritten with the bytes the service blessed, but 1 stale entry could not be removed and an engine still reads it/ + ); + expect(notices).toContain("blocked/a.txt"); + // The bytes still landed — a failed cleanup is not a failed write. + await expect(readFile(checkFile, "utf8")).resolves.toBe(BLESSED); + } finally { + await chmod(join(rule, "blocked"), 0o700); + await mock.close(); + } + }); + + it("says ' stale entries ... them' when the repair leaves several behind", async () => { + const rule = join(directory, ".taskless", "rules", "runtime", "demo"); + await mkdir(join(rule, "blocked-one"), { recursive: true }); + await writeFile(join(rule, "blocked-one", "a.txt"), "stuck\n", "utf8"); + await chmod(join(rule, "blocked-one"), 0o500); + await mkdir(join(rule, "blocked-two"), { recursive: true }); + await writeFile(join(rule, "blocked-two", "b.txt"), "stuck\n", "utf8"); + await chmod(join(rule, "blocked-two"), 0o500); + + const blessed = await canonicalHash(BLESSED); + const mock = await startMock({ + reconcile: driftedReconcile(blessed), + restore: () => ({ + statusCode: 200, + body: { + ruleId: "demo", + rules: [ + { + id: "demo", + engine: "runtime", + files: [ + { path: "check.ts", content: BLESSED }, + { path: "captures/logs.yml", content: CAPTURE }, + ], + signature: blessed, + }, + ], + }, + }), + }); + try { + const { stdout } = await runCli(["check", "-d", directory, "--json"], { + TASKLESS_TOKEN: "fake.token", + TASKLESS_API_URL: mock.apiUrl, + }); + const notices = (envelope(stdout).notices ?? []).join("\n"); + expect(notices).toMatch( + /was rewritten with the bytes the service blessed, but 2 stale entries could not be removed and an engine still reads them/ + ); + expect(notices).toContain("blocked-one/a.txt"); + expect(notices).toContain("blocked-two/b.txt"); + await expect(readFile(checkFile, "utf8")).resolves.toBe(BLESSED); + } finally { + await chmod(join(rule, "blocked-one"), 0o700); + await chmod(join(rule, "blocked-two"), 0o700); + await mock.close(); + } + }); + + it("skips every runtime rule when reconcile's own authentication is rejected", async () => { + // `planRuntime`'s `unauthorized` branch (plan.ts) — distinct from + // restore's `unauthorized`, and reachable only when the reconcile call + // itself, not the later restore call, gets a 401. + const mock = await startMock({ + reconcile: () => ({ statusCode: 401, body: {} }), + restore: () => ({ statusCode: 500, body: {} }), + }); + try { + const { stdout } = await runCli(["check", "-d", directory, "--json"], { + TASKLESS_TOKEN: "fake.token", + TASKLESS_API_URL: mock.apiUrl, + }); + const output = envelope(stdout) as { + skipped?: { rule: string; reason: string }[]; + }; + expect(output.skipped).toHaveLength(1); + expect(output.skipped?.[0]?.rule).toBe("demo"); + expect(output.skipped?.[0]?.reason).toContain( + "authentication was rejected" + ); + // Rejected before any restore could even be considered. + expect(mock.restoreCalls).toEqual([]); + await expect(readFile(checkFile, "utf8")).resolves.toBe(DRIFTED); + } finally { + await mock.close(); + } + }); + + it("skips every runtime rule when materialization fails, without failing the run", async () => { + // plan.ts's materialize-failure catch. Blessing whatever `check.ts` was + // actually reported sidesteps needing to know `signRuleFile`'s exact + // envelope format — the point of this test is the catch, not the sign. + // + // `.taskless/.gitignore` is replaced with a directory so `addToGitignore`'s + // `writeFile` (called from `materializeRuntimeRules`) fails with EISDIR. + // The scaffold migration (`migrateFixture`, inside `runCli`) writes that + // file itself as part of bringing the fixture current, so the block has to + // go on AFTER migration — doing it first makes the migration itself throw, + // before `check` ever runs. + await migrateFixture(["check", "-d", directory]); + await rm(join(directory, ".taskless", ".gitignore"), { force: true }); + await mkdir(join(directory, ".taskless", ".gitignore")); + + const mock = await startMock({ + reconcile: (body) => ({ + run: body.files.map((file) => ({ + ruleId: "demo", + file: file.file, + signature: file.signature, + })), + unsafe: [], + unknown: [], + missing: [], + }), + restore: () => ({ statusCode: 500, body: {} }), + }); + try { + let stdout: string; + let exitCode: number; + try { + ({ stdout } = await execFileAsync( + "node", + [binPath, "check", "-d", directory, "--json"], + { + env: { + ...process.env, + TASKLESS_TOKEN: "fake.token", + TASKLESS_API_URL: mock.apiUrl, + }, + } + )); + exitCode = 0; + } catch (error) { + const failure = error as { stdout: string; code: number }; + stdout = failure.stdout ?? ""; + exitCode = failure.code; + } + // A materialize failure is a notice, never a failed run. + expect(exitCode).toBe(0); + const output = envelope(stdout) as { + skipped?: { rule: string; reason: string }[]; + }; + expect(output.skipped).toHaveLength(1); + expect(output.skipped?.[0]?.rule).toBe("demo"); + expect(output.skipped?.[0]?.reason).toContain( + "runtime rules could not be materialized" + ); + expect(mock.restoreCalls).toEqual([]); + } finally { + await mock.close(); + } + }); +}); + +describe("restoreRule's HTTP contract", () => { + let server: Server; + let originalApiUrl: string | undefined; + let respond: (response: import("node:http").ServerResponse) => void; + + beforeEach(async () => { + originalApiUrl = process.env.TASKLESS_API_URL; + server = createServer((request, response) => { + request.resume(); + request.on("end", () => respond(response)); + }); + await new Promise((done) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + process.env.TASKLESS_API_URL = `http://127.0.0.1:${String(port)}/cli`; + done(); + }); + }); + }); + + afterEach(async () => { + await new Promise((done) => server.close(() => done())); + if (originalApiUrl === undefined) { + delete process.env.TASKLESS_API_URL; + } else { + process.env.TASKLESS_API_URL = originalApiUrl; + } + }); + + const request = { + ruleId: "demo", + repositoryUrl: "https://github.com/acme/widgets.git", + }; + + it("maps HTTP 401 to `unauthorized`", async () => { + respond = (response) => { + response.writeHead(401, { "content-type": "application/json" }); + response.end("{}"); + }; + await expect(restoreRule("token", request)).resolves.toEqual({ + status: "unauthorized", + }); + }); + + it("maps a non-ok status to `unavailable`", async () => { + respond = (response) => { + response.writeHead(500, { "content-type": "application/json" }); + response.end("{}"); + }; + await expect(restoreRule("token", request)).resolves.toEqual({ + status: "unavailable", + reason: "HTTP 500", + }); + }); + + it("maps a body `response.json()` cannot parse to `unavailable`", async () => { + respond = (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end("not json"); + }; + await expect(restoreRule("token", request)).resolves.toEqual({ + status: "unavailable", + reason: "invalid response body", + }); + }); + + it("maps a body without an array `rules` to `unavailable`", async () => { + respond = (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ruleId: "demo" })); + }; + await expect(restoreRule("token", request)).resolves.toEqual({ + status: "unavailable", + reason: "response carried no `rules`", + }); + }); + + it("maps a fetch that throws to `unavailable`", async () => { + // No handler is registered on the running mock server for this one — the + // request is pointed at a closed local port instead, so `fetch` itself + // rejects (ECONNREFUSED) rather than the server answering. + process.env.TASKLESS_API_URL = "http://127.0.0.1:1/cli"; + const outcome = await restoreRule("token", request); + expect(outcome.status).toBe("unavailable"); + expect( + (outcome as { status: "unavailable"; reason: string }).reason + ).toMatch(/network error/); + }); });