Skip to content

Commit 9d70ae5

Browse files
committed
test(cli): cover restore.ts's HTTP branches and the repair-path purge notice
restoreRule() (packages/cli/src/api/restore.ts) had five branches with no coverage: 401 -> unauthorized, a thrown fetch -> unavailable, a non-ok status -> unavailable, an unparsable body -> unavailable, and a `rules` field that is not an array -> unavailable. Each is now driven directly against a bare HTTP server, since restore-integration.test.ts only exercised the happy path via the built CLI. Also covers two branches in rules/runtime/plan.ts's planRuntime that the existing repair-integration suite never forced to fire: reconcile's own `unauthorized` outcome, and materializeRuntimeRules throwing (via a .taskless/.gitignore blocked as a directory). And it covers the PurgeIncompleteError wording plan.ts's repair path renders on a restore that wrote the rule but left a stale file behind -- singular and plural -- which deliver.test.ts could only prove in isolation on the raw error. Every added assertion was mutation-checked against a matching source change (flipped 401, dropped Array.isArray guard, removed try/catches, swapped plural/singular wording) and confirmed to fail before the source was reverted; see the PR description for the mutation table.
1 parent 7c5c7e8 commit 9d70ae5

1 file changed

Lines changed: 319 additions & 3 deletions

File tree

‎packages/cli/test/repair-integration.test.ts‎

Lines changed: 319 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { execFile } from "node:child_process";
22
import { existsSync } from "node:fs";
3-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import {
4+
chmod,
5+
mkdir,
6+
mkdtemp,
7+
readFile,
8+
rm,
9+
writeFile,
10+
} from "node:fs/promises";
411
import { createServer, type Server } from "node:http";
512
import { tmpdir } from "node:os";
613
import { join, resolve } from "node:path";
714
import { promisify } from "node:util";
815

916
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1017

18+
import { restoreRule } from "../src/api/restore";
1119
import { canonicalHash } from "../src/rules/rule-hash";
1220
import { migrateFixture } from "./support/current-project";
1321

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

44+
/**
45+
* A reconcile handler normally returns the response body directly (always
46+
* HTTP 200). A handler that instead needs to exercise a non-2xx reconcile
47+
* response (e.g. the unauthorized branch of `planRuntime`) returns this
48+
* wrapper, distinguished by its `statusCode` field — no ordinary reconcile
49+
* response body (`{ run, unsafe, unknown, missing }`) has one.
50+
*/
51+
interface HttpOverride {
52+
statusCode: number;
53+
body: unknown;
54+
}
55+
56+
function isHttpOverride(value: unknown): value is HttpOverride {
57+
return (
58+
typeof value === "object" &&
59+
value !== null &&
60+
"statusCode" in value &&
61+
typeof (value as { statusCode: unknown }).statusCode === "number" &&
62+
"body" in value
63+
);
64+
}
65+
3666
function startMock(handlers: {
3767
reconcile: (body: {
3868
files: { file: string; signature: string }[];
@@ -46,10 +76,13 @@ function startMock(handlers: {
4676
request.on("end", () => {
4777
const url = request.url ?? "";
4878
if (url === "/cli/api/reconcile") {
49-
const body = handlers.reconcile(
79+
const result = handlers.reconcile(
5080
JSON.parse(raw) as { files: { file: string; signature: string }[] }
5181
);
52-
response.writeHead(200, { "content-type": "application/json" });
82+
const { statusCode, body } = isHttpOverride(result)
83+
? result
84+
: { statusCode: 200, body: result };
85+
response.writeHead(statusCode, { "content-type": "application/json" });
5386
response.end(JSON.stringify(body));
5487
return;
5588
}
@@ -449,4 +482,287 @@ describe("repairing a drifted runtime rule, end to end", () => {
449482
await mock.close();
450483
}
451484
});
485+
486+
it("says '1 stale entry ... it' when the repair leaves exactly one behind", async () => {
487+
// plan.ts's `repairWithheldRules` wraps `PurgeIncompleteError` in its own
488+
// wording rather than reusing the error's message, and pluralizes it by
489+
// hand — a seam `deliver.test.ts` cannot see, since it only ever throws
490+
// the error, never renders this notice.
491+
const rule = join(directory, ".taskless", "rules", "runtime", "demo");
492+
await mkdir(join(rule, "blocked"), { recursive: true });
493+
await writeFile(join(rule, "blocked", "a.txt"), "stuck\n", "utf8");
494+
await chmod(join(rule, "blocked"), 0o500);
495+
496+
const blessed = await canonicalHash(BLESSED);
497+
const mock = await startMock({
498+
reconcile: driftedReconcile(blessed),
499+
restore: () => ({
500+
statusCode: 200,
501+
body: {
502+
ruleId: "demo",
503+
rules: [
504+
{
505+
id: "demo",
506+
engine: "runtime",
507+
files: [
508+
{ path: "check.ts", content: BLESSED },
509+
{ path: "captures/logs.yml", content: CAPTURE },
510+
],
511+
signature: blessed,
512+
},
513+
],
514+
},
515+
}),
516+
});
517+
try {
518+
const { stdout } = await runCli(["check", "-d", directory, "--json"], {
519+
TASKLESS_TOKEN: "fake.token",
520+
TASKLESS_API_URL: mock.apiUrl,
521+
});
522+
const notices = (envelope(stdout).notices ?? []).join("\n");
523+
expect(notices).toMatch(
524+
/was rewritten with the bytes the service blessed, but 1 stale entry could not be removed and an engine still reads it/
525+
);
526+
expect(notices).toContain("blocked/a.txt");
527+
// The bytes still landed — a failed cleanup is not a failed write.
528+
await expect(readFile(checkFile, "utf8")).resolves.toBe(BLESSED);
529+
} finally {
530+
await chmod(join(rule, "blocked"), 0o700);
531+
await mock.close();
532+
}
533+
});
534+
535+
it("says '<n> stale entries ... them' when the repair leaves several behind", async () => {
536+
const rule = join(directory, ".taskless", "rules", "runtime", "demo");
537+
await mkdir(join(rule, "blocked-one"), { recursive: true });
538+
await writeFile(join(rule, "blocked-one", "a.txt"), "stuck\n", "utf8");
539+
await chmod(join(rule, "blocked-one"), 0o500);
540+
await mkdir(join(rule, "blocked-two"), { recursive: true });
541+
await writeFile(join(rule, "blocked-two", "b.txt"), "stuck\n", "utf8");
542+
await chmod(join(rule, "blocked-two"), 0o500);
543+
544+
const blessed = await canonicalHash(BLESSED);
545+
const mock = await startMock({
546+
reconcile: driftedReconcile(blessed),
547+
restore: () => ({
548+
statusCode: 200,
549+
body: {
550+
ruleId: "demo",
551+
rules: [
552+
{
553+
id: "demo",
554+
engine: "runtime",
555+
files: [
556+
{ path: "check.ts", content: BLESSED },
557+
{ path: "captures/logs.yml", content: CAPTURE },
558+
],
559+
signature: blessed,
560+
},
561+
],
562+
},
563+
}),
564+
});
565+
try {
566+
const { stdout } = await runCli(["check", "-d", directory, "--json"], {
567+
TASKLESS_TOKEN: "fake.token",
568+
TASKLESS_API_URL: mock.apiUrl,
569+
});
570+
const notices = (envelope(stdout).notices ?? []).join("\n");
571+
expect(notices).toMatch(
572+
/was rewritten with the bytes the service blessed, but 2 stale entries could not be removed and an engine still reads them/
573+
);
574+
expect(notices).toContain("blocked-one/a.txt");
575+
expect(notices).toContain("blocked-two/b.txt");
576+
await expect(readFile(checkFile, "utf8")).resolves.toBe(BLESSED);
577+
} finally {
578+
await chmod(join(rule, "blocked-one"), 0o700);
579+
await chmod(join(rule, "blocked-two"), 0o700);
580+
await mock.close();
581+
}
582+
});
583+
584+
it("skips every runtime rule when reconcile's own authentication is rejected", async () => {
585+
// `planRuntime`'s `unauthorized` branch (plan.ts) — distinct from
586+
// restore's `unauthorized`, and reachable only when the reconcile call
587+
// itself, not the later restore call, gets a 401.
588+
const mock = await startMock({
589+
reconcile: () => ({ statusCode: 401, body: {} }),
590+
restore: () => ({ statusCode: 500, body: {} }),
591+
});
592+
try {
593+
const { stdout } = await runCli(["check", "-d", directory, "--json"], {
594+
TASKLESS_TOKEN: "fake.token",
595+
TASKLESS_API_URL: mock.apiUrl,
596+
});
597+
const output = envelope(stdout) as {
598+
skipped?: { rule: string; reason: string }[];
599+
};
600+
expect(output.skipped).toHaveLength(1);
601+
expect(output.skipped?.[0]?.rule).toBe("demo");
602+
expect(output.skipped?.[0]?.reason).toContain(
603+
"authentication was rejected"
604+
);
605+
// Rejected before any restore could even be considered.
606+
expect(mock.restoreCalls).toEqual([]);
607+
await expect(readFile(checkFile, "utf8")).resolves.toBe(DRIFTED);
608+
} finally {
609+
await mock.close();
610+
}
611+
});
612+
613+
it("skips every runtime rule when materialization fails, without failing the run", async () => {
614+
// plan.ts's materialize-failure catch. Blessing whatever `check.ts` was
615+
// actually reported sidesteps needing to know `signRuleFile`'s exact
616+
// envelope format — the point of this test is the catch, not the sign.
617+
//
618+
// `.taskless/.gitignore` is replaced with a directory so `addToGitignore`'s
619+
// `writeFile` (called from `materializeRuntimeRules`) fails with EISDIR.
620+
// The scaffold migration (`migrateFixture`, inside `runCli`) writes that
621+
// file itself as part of bringing the fixture current, so the block has to
622+
// go on AFTER migration — doing it first makes the migration itself throw,
623+
// before `check` ever runs.
624+
await migrateFixture(["check", "-d", directory]);
625+
await rm(join(directory, ".taskless", ".gitignore"), { force: true });
626+
await mkdir(join(directory, ".taskless", ".gitignore"));
627+
628+
const mock = await startMock({
629+
reconcile: (body) => ({
630+
run: body.files.map((file) => ({
631+
ruleId: "demo",
632+
file: file.file,
633+
signature: file.signature,
634+
})),
635+
unsafe: [],
636+
unknown: [],
637+
missing: [],
638+
}),
639+
restore: () => ({ statusCode: 500, body: {} }),
640+
});
641+
try {
642+
let stdout: string;
643+
let exitCode: number;
644+
try {
645+
({ stdout } = await execFileAsync(
646+
"node",
647+
[binPath, "check", "-d", directory, "--json"],
648+
{
649+
env: {
650+
...process.env,
651+
TASKLESS_TOKEN: "fake.token",
652+
TASKLESS_API_URL: mock.apiUrl,
653+
},
654+
}
655+
));
656+
exitCode = 0;
657+
} catch (error) {
658+
const failure = error as { stdout: string; code: number };
659+
stdout = failure.stdout ?? "";
660+
exitCode = failure.code;
661+
}
662+
// A materialize failure is a notice, never a failed run.
663+
expect(exitCode).toBe(0);
664+
const output = envelope(stdout) as {
665+
skipped?: { rule: string; reason: string }[];
666+
};
667+
expect(output.skipped).toHaveLength(1);
668+
expect(output.skipped?.[0]?.rule).toBe("demo");
669+
expect(output.skipped?.[0]?.reason).toContain(
670+
"runtime rules could not be materialized"
671+
);
672+
expect(mock.restoreCalls).toEqual([]);
673+
} finally {
674+
await mock.close();
675+
}
676+
});
677+
});
678+
679+
describe("restoreRule's HTTP contract", () => {
680+
let server: Server;
681+
let originalApiUrl: string | undefined;
682+
let respond: (response: import("node:http").ServerResponse) => void;
683+
684+
beforeEach(async () => {
685+
originalApiUrl = process.env.TASKLESS_API_URL;
686+
server = createServer((request, response) => {
687+
request.resume();
688+
request.on("end", () => respond(response));
689+
});
690+
await new Promise<void>((done) => {
691+
server.listen(0, "127.0.0.1", () => {
692+
const address = server.address();
693+
const port = typeof address === "object" && address ? address.port : 0;
694+
process.env.TASKLESS_API_URL = `http://127.0.0.1:${String(port)}/cli`;
695+
done();
696+
});
697+
});
698+
});
699+
700+
afterEach(async () => {
701+
await new Promise<void>((done) => server.close(() => done()));
702+
if (originalApiUrl === undefined) {
703+
delete process.env.TASKLESS_API_URL;
704+
} else {
705+
process.env.TASKLESS_API_URL = originalApiUrl;
706+
}
707+
});
708+
709+
const request = {
710+
ruleId: "demo",
711+
repositoryUrl: "https://github.com/acme/widgets.git",
712+
};
713+
714+
it("maps HTTP 401 to `unauthorized`", async () => {
715+
respond = (response) => {
716+
response.writeHead(401, { "content-type": "application/json" });
717+
response.end("{}");
718+
};
719+
await expect(restoreRule("token", request)).resolves.toEqual({
720+
status: "unauthorized",
721+
});
722+
});
723+
724+
it("maps a non-ok status to `unavailable`", async () => {
725+
respond = (response) => {
726+
response.writeHead(500, { "content-type": "application/json" });
727+
response.end("{}");
728+
};
729+
await expect(restoreRule("token", request)).resolves.toEqual({
730+
status: "unavailable",
731+
reason: "HTTP 500",
732+
});
733+
});
734+
735+
it("maps a body `response.json()` cannot parse to `unavailable`", async () => {
736+
respond = (response) => {
737+
response.writeHead(200, { "content-type": "application/json" });
738+
response.end("not json");
739+
};
740+
await expect(restoreRule("token", request)).resolves.toEqual({
741+
status: "unavailable",
742+
reason: "invalid response body",
743+
});
744+
});
745+
746+
it("maps a body without an array `rules` to `unavailable`", async () => {
747+
respond = (response) => {
748+
response.writeHead(200, { "content-type": "application/json" });
749+
response.end(JSON.stringify({ ruleId: "demo" }));
750+
};
751+
await expect(restoreRule("token", request)).resolves.toEqual({
752+
status: "unavailable",
753+
reason: "response carried no `rules`",
754+
});
755+
});
756+
757+
it("maps a fetch that throws to `unavailable`", async () => {
758+
// No handler is registered on the running mock server for this one — the
759+
// request is pointed at a closed local port instead, so `fetch` itself
760+
// rejects (ECONNREFUSED) rather than the server answering.
761+
process.env.TASKLESS_API_URL = "http://127.0.0.1:1/cli";
762+
const outcome = await restoreRule("token", request);
763+
expect(outcome.status).toBe("unavailable");
764+
expect(
765+
(outcome as { status: "unavailable"; reason: string }).reason
766+
).toMatch(/network error/);
767+
});
452768
});

0 commit comments

Comments
 (0)