Skip to content
Merged
Show file tree
Hide file tree
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
20 changes: 0 additions & 20 deletions src/sections/actions_secrets/actions_secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,26 +453,6 @@ describe("actions_secrets sealing key", () => {
/no usable \{key_id, key\} pair \(key_id is empty\)/,
);
});

test("a key that is not base64 or not X25519-sized fails with the endpoint named", async () => {
// The key parser rejects both malformations with the endpoint named, so a
// seal never receives invalid key material.
const notB64 = new MockApi({
[LIST]: listOf(),
[PUBLIC_KEY]: { data: { key_id: "k1", key: "!!not-base64!!" } },
});
await expect(plan(notB64, [{ name: "X", value: "$V" }])).rejects.toThrow(
/^actions_secrets: GET \/repos\/\{owner\}\/\{repo\}\/actions\/secrets\/public-key \(the actions_secrets sealing key\) returned a key that is not valid base64/,
);

const wrongLength = new MockApi({
[LIST]: listOf(),
[PUBLIC_KEY]: { data: { key_id: "k1", key: "AAAA" } },
});
await expect(plan(wrongLength, [{ name: "X", value: "$V" }])).rejects.toThrow(
/decodes to 3 bytes where an X25519 public key has 32/,
);
});
});

describe("actions_secrets contract", () => {
Expand Down
12 changes: 5 additions & 7 deletions src/sections/branches/branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ const NULL_FILLED = {
required_pull_request_reviews: null,
restrictions: null,
};
/** The whole failure for a user actor whose GraphQL lookup answers `user: null`. */
const GHOST_ACTOR_ERROR =
'branches: force_push_bypassers actor "ghost": the GraphQL lookup succeeded but returned no node id, so the allowance cannot be applied; re-run the workflow, and report this if it persists';

describe("branches", () => {
const declared: Desired = [{ name: "main", protection: { enforce_admins: true } }];
Expand Down Expand Up @@ -866,9 +869,7 @@ describe("branches GraphQL-routed keys", () => {
expect(api.calls.filter((c) => c.path.startsWith("BranchProtectionActor"))).toHaveLength(0);
const execution = await executePlan(result, branchesSection, api, REPO, NO_SECRETS);
expect(execution.status).toBe("failed");
expect(String((execution as { error: Error }).error.message)).toMatch(
/GraphQL lookup succeeded but returned no node id/,
);
expect(String((execution as { error: Error }).error.message)).toBe(GHOST_ACTOR_ERROR);
expect(execution.landed).toBe(0);
expect(api.mutations()).toHaveLength(0);
});
Expand Down Expand Up @@ -901,11 +902,8 @@ describe("branches GraphQL-routed keys", () => {
changes: [],
notes: [],
landed: 0,
error: expect.any(Error),
error: new Error(GHOST_ACTOR_ERROR),
});
expect(String((execution as { error: Error }).error.message)).toMatch(
/force_push_bypassers actor "ghost".*returned no node id/,
);
expect(api.mutations()).toHaveLength(0);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ describe("check_suite_preferences", () => {
expect(changes).toEqual(["applied check suite preferences (2 auto_trigger_checks entries)"]);
expect(notes).toEqual([]);
expect(second.ops.map((op) => op.role)).toEqual(["update"]);
expect(second.notes).toEqual(first.notes);
expect(first.notes).toEqual([note]);
expect(second.notes).toEqual([note]);
// Both executions (the proof also runs the converged plan) sent the
// declaration verbatim; nothing was ever read.
expect(api.calls).toEqual([
Expand Down
8 changes: 5 additions & 3 deletions src/sections/environments/pins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,11 @@ describe("environments pinned check mode", () => {
'environments.pinned: apply will also move "b" to position 2 in that reordering',
],
]);
expect(planDrift(planned).some((line) => /environments\[[abc]\]\.pinned/.test(line))).toBe(
false,
);
// No per-environment pin drift joins the two order lines.
expect(planDrift(planned)).toEqual([
"environments.pinned: the declared pin order is [a, b, c] but the live pinned order is [c, b, a]; apply will reorder the pins so the declared ones lead in declaration order",
'environments.pinned: apply will also move "b" to position 2 in that reordering',
]);
});

test("clean when the declared pins lead in declaration order; trailing undeclared pins earn nothing", async () => {
Expand Down
9 changes: 5 additions & 4 deletions src/sections/interaction_limits/interaction_limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { MockApi } from "../../../test/mock-api.js";
import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js";
import { REPO } from "../../../test/sections/section-run.js";
import { PermissionDenied } from "../contract/errors.js";
import { grantFor } from "../contract/permissions.js";
import { interactionLimitsSection } from "./index.js";
import type { InteractionLimitsConfig } from "./schema.js";

Expand Down Expand Up @@ -211,7 +210,9 @@ describe("interaction_limits", () => {
const denied = thrown as PermissionDenied;
expect(denied.section).toBe("interaction_limits");
expect(denied.status).toBe(404);
expect(denied.detail).toContain(grantFor({ repo: ["administration"] }));
expect(denied.detail).toContain(
'grant "Administration" (read and write) under the PAT\'s Repository permissions',
);
});

test("executing the plan converges: the base limit re-arms on every apply, nothing else recurs", async () => {
Expand Down Expand Up @@ -276,8 +277,8 @@ describe("interaction_limits", () => {
const silent = { role: "remove", drift: [], change: "" } as const;
// @ts-expect-error the DELETE is not alwaysRewrite, so it must carry drift
const _silent: Op = silent;
const rearm: Op = { role: "put", drift: [], change: "re-armed" };
expect(rearm.drift).toEqual([]);
// The PUT is alwaysRewrite, so a driftless re-arm is a valid plan.
const _rearm: Op = { role: "put", drift: [], change: "re-armed" };
const undeclared = {
role: "remove",
drift: ["x"],
Expand Down
6 changes: 5 additions & 1 deletion src/sections/labels/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ describe("labels", () => {
{ name: "bug", new_name: "triage" },
{ name: "enhancement", new_name: "Triage" },
]),
).rejects.toThrow(/name the same labels entry: "triage" and "Triage"/);
).rejects.toThrow(
new Error(
'labels: the settings file declares entries that name the same labels entry: "triage" and "Triage". Keep exactly one entry per resource',
),
);
expect(api.calls).toHaveLength(0);
});

Expand Down
17 changes: 11 additions & 6 deletions src/sections/labels/mock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,18 @@ describe("labels.create identity minting", () => {
);
create(state, { name: "bug", color: "d73a4a" });
create(state, { name: "docs", color: "0075ca" });
for (const label of state.labels) {
// The pool starts at 90_000_000 and each label, seeded or created, takes
// the next id; its node_id encodes that same id.
const identity = (label: unknown) => {
const body = label as Record<string, unknown>;
expect(body.node_id).toBe(`MDU6TGFiZWw${body.id}`);
}
// No two labels (seeded or created) share a node_id.
const nodeIds = state.labels.map((label) => (label as Record<string, unknown>).node_id);
expect(new Set(nodeIds).size).toBe(nodeIds.length);
return [body.name, body.id, body.node_id];
};
expect(state.labels.map(identity)).toEqual([
["area-1", 90_000_000, "MDU6TGFiZWw90000000"],
["area-2", 90_000_001, "MDU6TGFiZWw90000001"],
["bug", 90_000_002, "MDU6TGFiZWw90000002"],
["docs", 90_000_003, "MDU6TGFiZWw90000003"],
]);
});

test("the created label's url names the state slug", () => {
Expand Down
5 changes: 3 additions & 2 deletions src/sections/repository/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js"
import { REPO } from "../../../test/sections/section-run.js";
import { validateSectionShapes } from "../../engine/validate.js";
import { PermissionDenied } from "../contract/errors.js";
import { grantFor } from "../contract/permissions.js";
import { FEATURE_TOGGLES, normalizeTopics, repositorySection } from "./index.js";

/** The verdict's error prose, or null when the document validated. */
Expand All @@ -32,7 +31,9 @@ async function apply(api: GithubClient, desired: Desired) {
/** The rejection must be a PermissionDenied CARRYING the section's grant advice. */
function expectAdministrationDenied(thrown: unknown): void {
expect(thrown).toBeInstanceOf(PermissionDenied);
expect((thrown as PermissionDenied).detail).toContain(grantFor({ repo: ["administration"] }));
expect((thrown as PermissionDenied).detail).toContain(
'grant "Administration" (read and write) under the PAT\'s Repository permissions',
);
}

async function rejection(promise: Promise<unknown>): Promise<unknown> {
Expand Down
3 changes: 1 addition & 2 deletions src/sections/rulesets/rulesets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,7 @@ describe("rulesets", () => {
// is built first and assigned on one line, so the directive anchors to
// the assignment whichever property the compiler blames.
type Op = PlannedOp<typeof rulesetsSection.endpoints>;
const create: Op = { role: "create", payload: { name: "x" }, drift: ["missing"], change: "" };
expect(create.role).toBe("create");
const _create: Op = { role: "create", payload: { name: "x" }, drift: ["missing"], change: "" };
const read = { role: "get", params: { ruleset_id: "1" }, drift: ["x"], change: "" } as const;
// @ts-expect-error the get role is a read, not a plannable write
const _read: Op = read;
Expand Down
11 changes: 6 additions & 5 deletions src/sections/workflows/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,11 @@ describe("workflows", () => {
"PUT /repos/o/r/actions/workflows/1/disable",
"PUT /repos/o/r/actions/workflows/2/enable",
]);
// The unfixable finding survives both plans unchanged.
expect(first.drift).toHaveLength(1);
expect(second).toEqual({ ops: [], notes: [], drift: first.drift });
// The unfixable finding (a "deleted" live workflow is absent) survives both plans unchanged.
const gone =
"workflows[gone.yml]: declared in the settings file but no workflow with that path exists on the repo, so apply skips it - create the workflow file, or remove it from the workflows section";
expect(first.drift).toEqual([gone]);
expect(second).toEqual({ ops: [], notes: [], drift: [gone] });
});

test("the read port exposes exactly the list role, narrowed to its denied posture", () => {
Expand All @@ -188,13 +190,12 @@ describe("workflows", () => {
// is built first and assigned on one line, so the directive anchors to
// the assignment whichever property the compiler blames.
type Op = PlannedOp<typeof workflowsSection.endpoints>;
const enable: Op = {
const _enable: Op = {
role: "enable",
params: { workflow_id: "1" },
drift: ["enabling"],
change: "",
};
expect(enable.role).toBe("enable");
const read = { role: "list", drift: ["x"], change: "" } as const;
// @ts-expect-error the list role is a read, not a plannable write
const _read: Op = read;
Expand Down
54 changes: 41 additions & 13 deletions test/discovery/central.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,55 @@ import { resolveCentralTargets } from "../../src/discovery/central.js";

describe("resolveCentralTargets", () => {
test("reads owner-shorthand and owner/name files, warns on strays", () => {
const resolved = resolveCentralTargets("test/fixtures/repos", "viv");
if ("error" in resolved) {
throw new Error(resolved.error);
}
expect(resolved.targets.map((t) => t.slug).sort()).toEqual(["octo/web", "viv/api"]);
expect(resolved.warnings.some((w) => w.includes("README.md"))).toBe(true);
expect(resolved.warnings.some((w) => w.includes("deep"))).toBe(true);
// The walk sorts directory entries, so the target and warning order is
// fixed: top-level files first, then each owner directory.
expect(resolveCentralTargets("test/fixtures/repos", "viv")).toEqual({
targets: [
{
slug: "viv/api",
source: "central",
origin: "test/fixtures/repos/api.yml",
filePath: "test/fixtures/repos/api.yml",
},
{
slug: "octo/web",
source: "central",
origin: "test/fixtures/repos/octo/web.yml",
filePath: "test/fixtures/repos/octo/web.yml",
},
],
warnings: [
"ignoring test/fixtures/repos/README.md: not a .yml/.yaml file, so it defines no target repository",
"ignoring test/fixtures/repos/octo/deep: repos-dir supports only <name>.yml and <owner>/<name>.yml, nothing deeper. Move the files up or remove the directory",
],
});
});

test("shorthand without a known admin owner is an error", () => {
const resolved = resolveCentralTargets("test/fixtures/repos", "");
expect("error" in resolved && resolved.error).toContain("<owner>/<name>.yml");
expect(resolveCentralTargets("test/fixtures/repos", "")).toEqual({
error:
'repos-dir "test/fixtures/repos" has 1 invalid settings file(s):\n' +
"- cannot resolve test/fixtures/repos/api.yml: top-level repos-dir files use the current " +
"repository's owner, which is unknown outside GitHub Actions. Use the <owner>/<name>.yml " +
"layout instead",
});
});

test("the same repo defined twice is an error", () => {
const resolved = resolveCentralTargets("test/fixtures/repos-dup", "viv");
expect("error" in resolved && resolved.error).toContain("duplicate target viv/x");
expect(resolveCentralTargets("test/fixtures/repos-dup", "viv")).toEqual({
error:
'repos-dir "test/fixtures/repos-dup" has 1 invalid settings file(s):\n' +
"- duplicate target viv/x: defined by both test/fixtures/repos-dup/viv/x.yml and " +
"test/fixtures/repos-dup/x.yml. Keep exactly one settings file per repository",
});
});

test("missing dir errors with a checkout hint", () => {
const resolved = resolveCentralTargets("test/fixtures/nope", "viv");
expect("error" in resolved && resolved.error).toContain("actions/checkout");
expect(resolveCentralTargets("test/fixtures/nope", "viv")).toEqual({
error:
'repos-dir "test/fixtures/nope" does not exist in the workspace, so there are no central ' +
"settings files to read. Add an actions/checkout step before this action, or fix the " +
"repos-dir path",
});
});
});
41 changes: 31 additions & 10 deletions test/discovery/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,14 @@ describe("discoverRepos", () => {
},
});
const discovered = await discoverRepos(api, DEFAULT_DISCOVERY_FILTERS);
expect("error" in discovered && discovered.error).toContain("Discovery needs a user PAT");
expect(discovered).toEqual({
error:
'cannot discover repositories for repos: "*": GET /user/repos?affiliation=owner failed: ' +
"403 Resource not accessible. " +
"Discovery needs a user PAT; the workflow GITHUB_TOKEN and GitHub App installation tokens " +
"cannot enumerate a user's repositories. List the target repositories explicitly in the " +
'"repos" input',
});
});

test("a rate-limit 403 gets re-run advice, not PAT advice", async () => {
Expand All @@ -241,8 +248,12 @@ describe("discoverRepos", () => {
},
});
const discovered = await discoverRepos(api, DEFAULT_DISCOVERY_FILTERS);
expect("error" in discovered && discovered.error).toContain("re-run the workflow");
expect("error" in discovered && discovered.error).not.toContain("Discovery needs a user PAT");
expect(discovered).toEqual({
error:
'cannot discover repositories for repos: "*": GET /user/repos?affiliation=owner failed: ' +
"403 API rate limit exceeded for user. " +
"This is not a permission problem; re-run the workflow, and retry later if it persists",
});
});

test("an expired-token 401 explains the PAT requirement", async () => {
Expand All @@ -252,7 +263,14 @@ describe("discoverRepos", () => {
},
});
const discovered = await discoverRepos(api, DEFAULT_DISCOVERY_FILTERS);
expect("error" in discovered && discovered.error).toContain("Discovery needs a user PAT");
expect(discovered).toEqual({
error:
'cannot discover repositories for repos: "*": GET /user/repos?affiliation=owner failed: ' +
"401 Bad credentials. " +
"Discovery needs a user PAT; the workflow GITHUB_TOKEN and GitHub App installation tokens " +
"cannot enumerate a user's repositories. List the target repositories explicitly in the " +
'"repos" input',
});
});

test("a server error gets re-run advice, not PAT advice", async () => {
Expand All @@ -262,8 +280,12 @@ describe("discoverRepos", () => {
},
});
const discovered = await discoverRepos(api, DEFAULT_DISCOVERY_FILTERS);
expect("error" in discovered && discovered.error).toContain("re-run the workflow");
expect("error" in discovered && discovered.error).not.toContain("Discovery needs a user PAT");
expect(discovered).toEqual({
error:
'cannot discover repositories for repos: "*": GET /user/repos?affiliation=owner failed: ' +
"500 boom. " +
"This is not a permission problem; re-run the workflow, and retry later if it persists",
});
});
});

Expand All @@ -286,9 +308,9 @@ describe("formatSkipNotice", () => {

test("without redaction, only the first 20 slugs are listed", () => {
const repos = Array.from({ length: 23 }, (_, i) => ref(`o/r${i}`));
const notice = formatSkipNotice({ reason: "forks=exclude", repos }, false);
expect(notice).toContain("o/r19, and 3 more");
expect(notice).not.toContain("o/r20");
expect(formatSkipNotice({ reason: "forks=exclude", repos }, false)).toBe(
'repos: "*" discovery skipped 23 repositories by forks=exclude: o/r0, o/r1, o/r2, o/r3, o/r4, o/r5, o/r6, o/r7, o/r8, o/r9, o/r10, o/r11, o/r12, o/r13, o/r14, o/r15, o/r16, o/r17, o/r18, o/r19, and 3 more',
);
});

test("redaction lists public slugs and counts the rest", () => {
Expand Down Expand Up @@ -344,6 +366,5 @@ describe("formatSkipNotice", () => {
(_, i) => `o/pub${i}`,
).join(", ")}, and 2 more, and 1 private or internal repository`,
);
expect(notice).not.toContain("o/secret");
});
});
4 changes: 3 additions & 1 deletion test/discovery/repos-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ describe("parseReposInput", () => {

test("* mixed with slugs is an error", () => {
expect(parseReposInput("*, o/a")).toEqual({
error: expect.stringContaining('Use "*" alone to discover every repository'),
error:
'the "repos" input mixes "*" with explicit repositories. Use "*" alone to discover every ' +
"repository the token owns, or list the repositories without it",
});
});

Expand Down
Loading
Loading