Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 319 additions & 3 deletions packages/cli/test/repair-integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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";
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";

Expand All @@ -33,6 +41,28 @@ interface Mock {
close: () => Promise<void>;
}

/**
* 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 }[];
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 '<n> 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<void>((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<void>((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/);
});
});
Loading