From f9b287a0a8ac6fae5a2235f1d7166167420f1a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 19:18:35 +0000 Subject: [PATCH] test: strengthen mutation coverage for github.ts and doctor.ts github.ts: add behavioral tests for header/URL construction, pagination boundaries, malformed-payload validation, runner deletion semantics, queued-job runner-group matching, GHCR parsing/lookup, and token-fetch metric emission. Mutation score 61.89% -> 84.14% (covered 72.05% -> 88.22%). doctor.ts: add exact summary/detail/pluralization assertions, skip and failure-surface checks, Lume artifact handling, missing host-field detail, and pool-slot metric emission. Mutation score 54.09% -> 65.97% (covered 59.12% -> 69.54%). Remaining survivors are concentrated in the duplicated string-literal summaries of the parallel linux-docker doctor path and defensive type-guard internals in poolSlotMetricsForCheck; these are low-value formatting mutants and are intentionally deferred. Both files remain well above the Stryker break threshold of 50. Closes #96 Closes #98 https://claude.ai/code/session_01QxQ71Yrf2Cn6zVfM4LY7AR --- test/doctor.test.ts | 428 +++++++++++++++++++++++++++ test/github.test.ts | 697 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1124 insertions(+), 1 deletion(-) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index abc7a97..106a15a 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1027,3 +1027,431 @@ function createTempDir(): string { tempPaths.push(directory); return directory; } + +function findCheck( + report: { checks: Array<{ id: string }> }, + id: string +) { + const check = report.checks.find((entry) => entry.id === id); + if (!check) { + throw new Error(`expected a doctor check with id ${id}`); + } + return check as { + id: string; + status: string; + summary: string; + detail?: string; + data?: unknown; + }; +} + +describe("doctor summary, detail, and observability mutation coverage", () => { + const originalFetch = globalThis.fetch; + const originalEndpoint = process.env.METRICS_ENDPOINT; + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalEndpoint === undefined) { + delete process.env.METRICS_ENDPOINT; + } else { + process.env.METRICS_ENDPOINT = originalEndpoint; + } + }); + + function writeSynologyScaffold( + directory: string, + options: { pools: Array<{ key: string; size: number }>; pat?: boolean } + ) { + const envPath = path.join(directory, ".env"); + const auditLogPath = path.join(directory, "audit.jsonl"); + fs.writeFileSync(auditLogPath, "audit-entry\n", "utf8"); + fs.writeFileSync( + envPath, + [ + options.pat === false ? "" : "GITHUB_PAT=secret", + "SYNOLOGY_HOST=nas.example.com", + "SYNOLOGY_USERNAME=admin", + "SYNOLOGY_PASSWORD=secret", + `SYNOLOGY_RUNNER_BASE_DIR=${directory}/synology`, + `AUDIT_LOG_FILE=${auditLogPath}`, + "" + ].join("\n"), + "utf8" + ); + + const poolsPath = path.join(directory, "pools.yaml"); + const poolBlocks = options.pools + .map( + (pool) => ` - key: ${pool.key} + visibility: private + organization: example + runnerGroup: ${pool.key} + repositoryAccess: all + labels: [] + size: ${pool.size} + architecture: auto + runnerRoot: \${SYNOLOGY_RUNNER_BASE_DIR}/pools/${pool.key}` + ) + .join("\n"); + fs.writeFileSync( + poolsPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: +${poolBlocks} +`, + "utf8" + ); + + return { envPath, poolsPath, auditLogPath }; + } + + test("emits exact summaries, details, pluralization, and pool metrics", async () => { + const directory = createTempDir(); + const { envPath, poolsPath } = writeSynologyScaffold(directory, { + pools: [ + { key: "synology-private", size: 1 }, + { key: "synology-public", size: 3 } + ] + }); + + const metricsFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + globalThis.fetch = metricsFetch as unknown as typeof fetch; + process.env.METRICS_ENDPOINT = "https://metrics.example.com/push"; + + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/actions/runner-groups")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + runner_groups: [ + { id: 1, name: "synology-private", default: false }, + { id: 2, name: "synology-public", default: false } + ] + }) + }; + } + if (url.includes("/packages/container/")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify([ + { id: 9, metadata: { container: { tags: ["0.1.9"] } } } + ]) + }; + } + throw new Error(`unexpected URL: ${url}`); + }); + + const report = await runDoctor({ + mode: "synology", + envPath, + configPath: poolsPath, + fetchImpl: fetchMock + }); + + expect(findCheck(report, "synology-env").summary).toBe( + "required Synology deployment env is configured" + ); + expect(findCheck(report, "synology-config").summary).toBe( + `loaded ${poolsPath} with 2 pools` + ); + expect(findCheck(report, "synology-config-warnings").summary).toBe( + "no Synology config warnings were detected" + ); + expect(findCheck(report, "synology-runner-groups")).toMatchObject({ + status: "pass", + summary: "verified 2 Synology runner groups in GitHub" + }); + expect(findCheck(report, "synology-image").summary).toBe( + "verified ghcr.io/example/github-runner-fleet:0.1.9 in GitHub Packages" + ); + expect(findCheck(report, "audit-log").detail).toBe("size 12 bytes"); + expect(findCheck(report, "audit-log").data).toMatchObject({ + sizeBytes: 12 + }); + + const metricsBody = metricsFetch.mock.calls + .map((call) => (call[1] as { body: string }).body) + .join(""); + expect(metricsBody).toContain( + 'pool_slot_count{plane="synology",pool="synology-private"} 1' + ); + expect(metricsBody).toContain( + 'pool_slot_count{plane="synology",pool="synology-public"} 3' + ); + expect(metricsBody).toContain( + 'doctor_check_result{check="synology-env",status="pass"} 1' + ); + + const stderrWrite = vi.mocked(process.stderr.write); + const envLog = stderrWrite.mock.calls + .map((call) => JSON.parse(String(call[0])) as { check: string; level: string }) + .find((entry) => entry.check === "synology-env"); + expect(envLog?.level).toBe("info"); + }); + + test("uses singular nouns and exact failure detail when env and PAT are missing", async () => { + const directory = createTempDir(); + const envPath = path.join(directory, ".env"); + fs.writeFileSync( + envPath, + `SYNOLOGY_USERNAME=admin +SYNOLOGY_PASSWORD=secret +SYNOLOGY_RUNNER_BASE_DIR=${directory}/synology +`, + "utf8" + ); + const poolsPath = path.join(directory, "pools.yaml"); + fs.writeFileSync( + poolsPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: + - key: synology-private + visibility: private + organization: example + runnerGroup: synology-private + repositoryAccess: all + labels: [] + size: 1 + architecture: auto + runnerRoot: \${SYNOLOGY_RUNNER_BASE_DIR}/pools/synology-private +`, + "utf8" + ); + + const report = await runDoctor({ + mode: "synology", + envPath, + configPath: poolsPath + }); + + const envCheck = findCheck(report, "synology-env"); + expect(envCheck.status).toBe("fail"); + expect(envCheck.summary).toBe( + "required Synology deployment env is incomplete" + ); + expect(envCheck.detail).toBe("missing GITHUB_PAT, SYNOLOGY_HOST"); + expect(findCheck(report, "synology-config").summary).toBe( + `loaded ${poolsPath} with 1 pool` + ); + expect(findCheck(report, "synology-runner-groups")).toMatchObject({ + status: "skip", + summary: "skipped Synology runner-group verification", + detail: "GITHUB_PAT is not configured" + }); + expect(findCheck(report, "synology-image").detail).toBe( + "GITHUB_PAT is not configured" + ); + expect(report.ok).toBe(false); + + const stderrWrite = vi.mocked(process.stderr.write); + const failLog = stderrWrite.mock.calls + .map((call) => JSON.parse(String(call[0])) as { check: string; level: string }) + .find((entry) => entry.check === "synology-env"); + expect(failLog?.level).toBe("error"); + }); + + test("surfaces the runner-group verification failure detail", async () => { + const directory = createTempDir(); + const { envPath, poolsPath } = writeSynologyScaffold(directory, { + pools: [{ key: "synology-private", size: 1 }] + }); + + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/actions/runner-groups")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + runner_groups: [{ id: 1, name: "default", default: true }] + }) + }; + } + if (url.includes("/packages/container/")) { + return { ok: false, status: 404, text: async () => "Not Found" }; + } + throw new Error(`unexpected URL: ${url}`); + }); + + const report = await runDoctor({ + mode: "synology", + envPath, + configPath: poolsPath, + fetchImpl: fetchMock + }); + + const groupCheck = findCheck(report, "synology-runner-groups"); + expect(groupCheck.status).toBe("fail"); + expect(groupCheck.summary).toBe("failed Synology runner-group verification"); + expect(groupCheck.detail).toContain( + "pool synology-private expects runner group synology-private in organization example, but GitHub returned: default" + ); + + const imageCheck = findCheck(report, "synology-image"); + expect(imageCheck.status).toBe("fail"); + expect(imageCheck.summary).toBe( + "failed image verification for ghcr.io/example/github-runner-fleet:0.1.9" + ); + }); + + test("warns with exact detail for missing Lume artifacts and unhealthy results", async () => { + const directory = createTempDir(); + const envPath = path.join(directory, ".env"); + const lumeBaseDir = path.join(directory, "lume"); + fs.mkdirSync(lumeBaseDir, { recursive: true }); + const lumeRunnerEnvPath = path.join(lumeBaseDir, "runner.env"); + fs.writeFileSync( + envPath, + `GITHUB_PAT=secret +LUME_RUNNER_BASE_DIR=${lumeBaseDir} +LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret +`, + "utf8" + ); + const lumePath = path.join(directory, "lume-runners.yaml"); + fs.writeFileSync( + lumePath, + `version: 1 +pool: + key: macos-private + organization: example + runnerGroup: macos-private + size: 1 + vmBaseName: macos-runner-base + vmSlotPrefix: macos-runner-slot +`, + "utf8" + ); + + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/actions/runner-groups")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + runner_groups: [{ id: 1, name: "macos-private", default: false }] + }) + }; + } + throw new Error(`unexpected URL: ${url}`); + }); + + const missingResult = await runDoctor({ + mode: "lume", + envPath, + lumeConfigPath: lumePath, + fetchImpl: fetchMock + }); + const envFileCheck = findCheck(missingResult, "lume-env-file"); + expect(envFileCheck.status).toBe("warn"); + expect(envFileCheck.summary).toBe("Lume runner env file is missing"); + expect(envFileCheck.detail).toBe( + `${lumeRunnerEnvPath} does not exist yet` + ); + const artifactCheck = findCheck(missingResult, "lume-project-result"); + expect(artifactCheck.status).toBe("warn"); + expect(artifactCheck.summary).toBe( + "Lume project result artifact is missing" + ); + expect(artifactCheck.detail).toBe( + `run install-lume-project to create ${path.join( + lumeBaseDir, + "lume-project-result.json" + )}` + ); + + fs.writeFileSync( + path.join(lumeBaseDir, "lume-project-result.json"), + `${JSON.stringify({ + plane: "lume", + action: "install", + status: "failed", + recordedAt: "2026-04-21T00:00:00.000Z", + configPath: lumePath, + resultPath: path.join(lumeBaseDir, "lume-project-result.json"), + pidFile: path.join(lumeBaseDir, "lume-project.pid"), + logFile: path.join(lumeBaseDir, "logs", "lume-project.log"), + pool: { + key: "macos-private", + organization: "example", + runnerGroup: "macos-private", + size: 1 + }, + slots: [] + })}\n`, + "utf8" + ); + + const unhealthy = await runDoctor({ + mode: "lume", + envPath, + lumeConfigPath: lumePath, + fetchImpl: fetchMock + }); + const unhealthyCheck = findCheck(unhealthy, "lume-project-result"); + expect(unhealthyCheck.status).toBe("warn"); + expect(unhealthyCheck.summary).toBe( + "latest Lume project result action=install status=failed" + ); + }); + + test("reports missing Windows Docker host fields with an exact detail", async () => { + const directory = createTempDir(); + const envPath = path.join(directory, ".env"); + fs.writeFileSync( + envPath, + `GITHUB_PAT=secret +WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\\github-runner-fleet\\windows-docker +`, + "utf8" + ); + const windowsPath = path.join(directory, "windows-runners.yaml"); + fs.writeFileSync( + windowsPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: selected + allowedRepositories: + - example/windows-app +`, + "utf8" + ); + + const report = await runDoctor({ + mode: "windows-docker", + envPath, + windowsConfigPath: windowsPath, + fetchImpl: vi.fn(async () => { + throw new Error("runner-group verification should not run"); + }) + }); + + const configCheck = findCheck(report, "windows-docker-config"); + expect(configCheck.status).toBe("fail"); + expect(configCheck.summary).toBe( + "Windows Docker config is missing target host fields" + ); + expect(configCheck.detail).toBe( + "missing windows-private:host, windows-private:sshUser" + ); + }); +}); diff --git a/test/github.test.ts b/test/github.test.ts index 12d1822..e62d61f 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { + buildGitHubApiHeaders, buildRegistrationTokenRequest, deleteOrganizationRunner, buildRemoveTokenRequest, fetchOrganizationRepositories, + fetchOrganizationRunnerGroupRunners, fetchOrganizationRunnerGroups, fetchOrganizationRunners, fetchQueuedWorkflowRuns, @@ -743,3 +745,696 @@ describe("github runner API helpers", () => { ).rejects.toThrow(/does not include tag 0\.1\.5; available tags: 0\.1\.4, latest/); }); }); + +function jsonResponse(payload: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload) + }; +} + +function rawResponse(body: string, status: number) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body + }; +} + +describe("github API header and URL construction", () => { + test("includes the fixed GitHub API headers and omits auth without a token", () => { + expect(buildGitHubApiHeaders()).toEqual({ + Accept: "application/vnd.github+json", + "User-Agent": "github-runner-fleet", + "X-GitHub-Api-Version": "2022-11-28" + }); + }); + + test("adds a bearer Authorization header when a token is supplied", () => { + expect(buildGitHubApiHeaders("abc123")).toEqual({ + Accept: "application/vnd.github+json", + "User-Agent": "github-runner-fleet", + "X-GitHub-Api-Version": "2022-11-28", + Authorization: "Bearer abc123" + }); + }); + + test("treats an empty token as unauthenticated", () => { + expect(buildGitHubApiHeaders("")).not.toHaveProperty("Authorization"); + }); + + test("strips every trailing slash from the API URL", () => { + expect( + buildRegistrationTokenRequest( + "https://ghe.example.com/api/v3///", + "example", + "secret" + ).url + ).toBe( + "https://ghe.example.com/api/v3/orgs/example/actions/runners/registration-token" + ); + expect( + buildRemoveTokenRequest("https://api.github.com", "acme", "tok").url + ).toBe("https://api.github.com/orgs/acme/actions/runners/remove-token"); + }); +}); + +describe("github runner token failures and observability", () => { + const originalFetch = globalThis.fetch; + const originalEndpoint = process.env.METRICS_ENDPOINT; + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalEndpoint === undefined) { + delete process.env.METRICS_ENDPOINT; + } else { + process.env.METRICS_ENDPOINT = originalEndpoint; + } + vi.restoreAllMocks(); + }); + + test("surfaces the status and body on a non-ok token response", async () => { + await expect( + fetchRunnerToken( + buildRegistrationTokenRequest("https://api.github.com", "example", "x"), + vi.fn().mockResolvedValue(rawResponse("forbidden detail", 403)) + ) + ).rejects.toThrow("GitHub token request failed with 403: forbidden detail"); + }); + + test("emits a token fetch duration metric tagged with the supplied plane", async () => { + process.env.METRICS_ENDPOINT = "https://metrics.example.com/push"; + const metricsFetch = vi + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + globalThis.fetch = metricsFetch as unknown as typeof fetch; + + await fetchRunnerToken( + buildRegistrationTokenRequest("https://api.github.com", "example", "x"), + vi.fn().mockResolvedValue(jsonResponse({ token: "t" }, 201)), + { plane: "synology" } + ); + + expect(metricsFetch).toHaveBeenCalledTimes(1); + const body = metricsFetch.mock.calls[0][1].body as string; + expect(body).toContain("runner_token_fetch_duration_seconds"); + expect(body).toContain('plane="synology"'); + }); + + test("defaults the metric plane to unknown when none is provided", async () => { + process.env.METRICS_ENDPOINT = "https://metrics.example.com/push"; + const metricsFetch = vi + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + globalThis.fetch = metricsFetch as unknown as typeof fetch; + + await fetchRunnerToken( + buildRegistrationTokenRequest("https://api.github.com", "example", "x"), + vi.fn().mockResolvedValue(jsonResponse({ token: "t" }, 201)) + ); + + expect(metricsFetch.mock.calls[0][1].body as string).toContain( + 'plane="unknown"' + ); + }); + + test("still emits the duration metric when the token request throws", async () => { + process.env.METRICS_ENDPOINT = "https://metrics.example.com/push"; + const metricsFetch = vi + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + globalThis.fetch = metricsFetch as unknown as typeof fetch; + + await expect( + fetchRunnerToken( + buildRegistrationTokenRequest("https://api.github.com", "example", "x"), + vi.fn().mockRejectedValue(new Error("network down")), + { plane: "lume" } + ) + ).rejects.toThrow("network down"); + + expect(metricsFetch).toHaveBeenCalledTimes(1); + expect(metricsFetch.mock.calls[0][1].body as string).toContain( + 'plane="lume"' + ); + }); +}); + +describe("github release validation", () => { + test("reports the status and body when the release lookup is not ok", async () => { + await expect( + fetchLatestRunnerRelease( + "https://api.github.com", + "secret", + vi.fn().mockResolvedValue(rawResponse("rate limited", 429)) + ) + ).rejects.toThrow( + "GitHub runner release lookup failed with 429: rate limited" + ); + }); + + test("rejects a release payload without a tag_name", async () => { + await expect( + fetchLatestRunnerRelease( + "https://api.github.com", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ html_url: "x" })) + ) + ).rejects.toThrow("GitHub release response did not include tag_name"); + }); + + test("requests the latest release endpoint with GET and auth headers", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ tag_name: "v2.1.0" })); + + await fetchLatestRunnerRelease("https://api.github.com", "tok", fetchMock); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/actions/runner/releases/latest", + { + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer tok" }) + } + ); + }); +}); + +describe("github pagination boundaries", () => { + test("stops paginating runners exactly at a short final page", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + runners: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + name: `r-${index + 1}`, + status: "online", + labels: [] + })) + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + runners: [{ id: 101, name: "r-101", status: "offline", labels: [] }] + }) + ); + + const runners = await fetchOrganizationRunners( + "https://api.github.com", + "example", + "secret", + fetchMock + ); + + expect(runners).toHaveLength(101); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/orgs/example/actions/runners?per_page=100&page=2", + expect.objectContaining({ method: "GET" }) + ); + }); + + test("does not request a second page when the first page is not full", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + runners: [{ id: 1, name: "only", status: "online", labels: [] }] + }) + ); + + await fetchOrganizationRunners( + "https://api.github.com", + "example", + "secret", + fetchMock + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("paginates repositories and workflow runs across pages", async () => { + const repoFetch = vi + .fn() + .mockResolvedValueOnce( + jsonResponse( + Array.from({ length: 100 }, (_, index) => ({ + full_name: `example/repo-${index}` + })) + ) + ) + .mockResolvedValueOnce(jsonResponse([{ full_name: "example/last" }])); + + await expect( + fetchOrganizationRepositories( + "https://api.github.com", + "example", + "secret", + repoFetch + ) + ).resolves.toHaveLength(101); + expect(repoFetch).toHaveBeenCalledTimes(2); + + const runsFetch = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + workflow_runs: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + jobs_url: `https://api.github.com/runs/${index + 1}/jobs` + })) + }) + ) + .mockResolvedValueOnce( + jsonResponse({ workflow_runs: [{ id: 999, jobs_url: "u" }] }) + ); + + await expect( + fetchQueuedWorkflowRuns( + "https://api.github.com", + "example/app", + "secret", + runsFetch + ) + ).resolves.toHaveLength(101); + expect(runsFetch).toHaveBeenNthCalledWith( + 1, + "https://api.github.com/repos/example/app/actions/runs?status=queued&per_page=100&page=1", + expect.objectContaining({ method: "GET" }) + ); + }); + + test("uses the runner group id as a fallback when entries omit it", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + runners: [ + { + id: 7, + name: "grouped", + status: "online", + labels: [{ name: "self-hosted" }, { name: 42 }] + } + ] + }) + ); + + const runners = await fetchOrganizationRunnerGroupRunners( + "https://api.github.com", + "example", + 99, + "secret", + fetchMock + ); + + expect(runners[0].runnerGroupId).toBe(99); + expect(runners[0].labels).toEqual(["self-hosted"]); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/orgs/example/actions/runner-groups/99/runners?per_page=100&page=1", + expect.objectContaining({ method: "GET" }) + ); + }); +}); + +describe("github malformed payload handling", () => { + test("rejects runner-group payloads that are not arrays", async () => { + await expect( + fetchOrganizationRunnerGroups( + "https://api.github.com", + "acme", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ runner_groups: "nope" })) + ) + ).rejects.toThrow( + "GitHub runner group response for acme did not include runner_groups" + ); + }); + + test("rejects runner-group entries that lack an id or name", async () => { + await expect( + fetchOrganizationRunnerGroups( + "https://api.github.com", + "acme", + "secret", + vi + .fn() + .mockResolvedValue( + jsonResponse({ runner_groups: [{ id: 1 }] }) + ) + ) + ).rejects.toThrow( + "GitHub runner group response for acme included an invalid group entry" + ); + }); + + test("rejects runner payloads and entries that are malformed", async () => { + await expect( + fetchOrganizationRunners( + "https://api.github.com", + "acme", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ runners: { not: "array" } })) + ) + ).rejects.toThrow( + "GitHub runner response for acme did not include runners" + ); + + await expect( + fetchOrganizationRunners( + "https://api.github.com", + "acme", + "secret", + vi + .fn() + .mockResolvedValue( + jsonResponse({ runners: [{ id: 1, name: "x" }] }) + ) + ) + ).rejects.toThrow( + "GitHub runner response for acme included an invalid runner entry" + ); + }); + + test("propagates the status on a non-ok runner-group runner lookup", async () => { + await expect( + fetchOrganizationRunnerGroupRunners( + "https://api.github.com", + "acme", + 5, + "secret", + vi.fn().mockResolvedValue(rawResponse("boom", 500)) + ) + ).rejects.toThrow( + "GitHub runner group runner lookup failed for acme/5 with 500: boom" + ); + }); + + test("rejects repository payloads that are not arrays and entries without full_name", async () => { + await expect( + fetchOrganizationRepositories( + "https://api.github.com", + "acme", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ nope: true })) + ) + ).rejects.toThrow( + "GitHub repository response for acme did not return an array" + ); + + await expect( + fetchOrganizationRepositories( + "https://api.github.com", + "acme", + "secret", + vi.fn().mockResolvedValue(jsonResponse([{ id: 1 }])) + ) + ).rejects.toThrow( + "GitHub repository response for acme included an invalid repository entry" + ); + }); + + test("rejects workflow run and job payloads that are malformed", async () => { + await expect( + fetchQueuedWorkflowRuns( + "https://api.github.com", + "example/app", + "secret", + vi.fn().mockResolvedValue(rawResponse("down", 503)), + "in_progress" + ) + ).rejects.toThrow( + "GitHub in_progress workflow run lookup failed for example/app with 503: down" + ); + + await expect( + fetchQueuedWorkflowRuns( + "https://api.github.com", + "example/app", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ workflow_runs: 1 })) + ) + ).rejects.toThrow( + "GitHub workflow run response for example/app did not include workflow_runs" + ); + + await expect( + fetchWorkflowRunJobs( + "https://api.github.com/jobs", + "secret", + vi.fn().mockResolvedValue(jsonResponse({ jobs: "no" })) + ) + ).rejects.toThrow("GitHub workflow job response did not include jobs"); + + await expect( + fetchWorkflowRunJobs( + "https://api.github.com/jobs", + "secret", + vi + .fn() + .mockResolvedValue(jsonResponse({ jobs: [{ status: "queued" }] })) + ) + ).rejects.toThrow("GitHub workflow job response included an invalid job entry"); + }); + + test("appends pagination params with the correct separator for job URLs", async () => { + const withQuery = vi + .fn() + .mockResolvedValue(jsonResponse({ jobs: [] })); + await fetchWorkflowRunJobs( + "https://api.github.com/jobs?filter=latest", + "secret", + withQuery + ); + expect(withQuery).toHaveBeenCalledWith( + "https://api.github.com/jobs?filter=latest&per_page=100&page=1", + expect.objectContaining({ method: "GET" }) + ); + + const withoutQuery = vi + .fn() + .mockResolvedValue(jsonResponse({ jobs: [] })); + await fetchWorkflowRunJobs( + "https://api.github.com/jobs", + "secret", + withoutQuery + ); + expect(withoutQuery).toHaveBeenCalledWith( + "https://api.github.com/jobs?per_page=100&page=1", + expect.objectContaining({ method: "GET" }) + ); + }); +}); + +describe("github runner deletion semantics", () => { + test("treats a 404 as an already-removed runner", async () => { + await expect( + deleteOrganizationRunner( + "https://api.github.com", + "example", + "secret", + 7, + vi.fn().mockResolvedValue(rawResponse("missing", 404)) + ) + ).resolves.toBe(false); + }); + + test("throws with the status and body on other non-ok deletions", async () => { + await expect( + deleteOrganizationRunner( + "https://api.github.com", + "example", + "secret", + 7, + vi.fn().mockResolvedValue(rawResponse("conflict", 409)) + ) + ).rejects.toThrow( + "GitHub runner deletion failed for example/7 with 409: conflict" + ); + }); +}); + +describe("queued job runner-group matching", () => { + function jobsCountFetch(jobs: unknown[]) { + return vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + workflow_runs: [ + { id: 1, jobs_url: "https://api.github.com/runs/1/jobs" } + ] + }) + ) + .mockResolvedValueOnce(jsonResponse({ workflow_runs: [] })) + .mockResolvedValueOnce(jsonResponse({ jobs })); + } + + const request = { + organization: "example", + runnerGroup: "synology-private", + repositories: ["example/app"], + labels: ["synology", "shell-only"] + }; + + test("counts only queued jobs whose runner group name matches", async () => { + await expect( + getQueuedJobCount( + "https://api.github.com", + "secret", + request, + jobsCountFetch([ + { id: 1, status: "queued", runner_group_name: "synology-private" }, + { id: 2, status: "queued", runner_group_name: "other-group" }, + { id: 3, status: "in_progress", runner_group_name: "synology-private" } + ]) + ) + ).resolves.toBe(1); + }); + + test("falls back to label matching only when the group name is absent", async () => { + await expect( + getQueuedJobCount( + "https://api.github.com", + "secret", + request, + jobsCountFetch([ + { id: 1, status: "queued", labels: ["synology", "shell-only", "x"] }, + { id: 2, status: "queued", labels: ["synology"] } + ]) + ) + ).resolves.toBe(1); + }); + + test("does not match on labels when the request has none", async () => { + await expect( + getQueuedJobCount( + "https://api.github.com", + "secret", + { ...request, labels: [] }, + jobsCountFetch([ + { id: 1, status: "queued", labels: ["synology", "shell-only"] } + ]) + ) + ).resolves.toBe(0); + }); +}); + +describe("ghcr image reference parsing and lookup", () => { + test("rejects image references that are not ghcr.io//:", async () => { + await expect( + verifyContainerImageTag( + "https://api.github.com", + "secret", + "docker.io/library/node:20", + vi.fn() + ) + ).rejects.toThrow( + "image reference docker.io/library/node:20 must match ghcr.io//:" + ); + }); + + test("paginates package versions and url-encodes the package name", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse( + Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + metadata: { container: { tags: [`v${index}`] } } + })) + ) + ) + .mockResolvedValueOnce( + jsonResponse([ + { id: 500, metadata: { container: { tags: ["release"] } } } + ]) + ); + + await expect( + verifyContainerImageTag( + "https://api.github.com", + "secret", + "ghcr.io/acme/team/app:release", + fetchMock + ) + ).resolves.toMatchObject({ versionId: 500, ownerType: "orgs" }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/orgs/acme/packages/container/team%2Fapp/versions?per_page=100&page=2", + expect.objectContaining({ method: "GET" }) + ); + }); + + test("reports the available tags when the requested tag is missing", async () => { + await expect( + verifyContainerImageTag( + "https://api.github.com", + "secret", + "ghcr.io/acme/app:9.9.9", + vi + .fn() + .mockResolvedValue( + jsonResponse([ + { id: 1, metadata: { container: { tags: ["b", "a"] } } } + ]) + ) + ) + ).rejects.toThrow( + "GitHub container package acme/app does not include tag 9.9.9; available tags: a, b" + ); + }); + + test("reports that the package was not found across both owner scopes", async () => { + await expect( + verifyContainerImageTag( + "https://api.github.com", + "secret", + "ghcr.io/acme/app:1.0.0", + vi.fn().mockResolvedValue(rawResponse("Not Found", 404)) + ) + ).rejects.toThrow( + "GitHub container package acme/app was not found for ghcr.io/acme/app:1.0.0" + ); + }); + + test("rejects a non-ok container package response with status and body", async () => { + await expect( + verifyContainerImageTag( + "https://api.github.com", + "secret", + "ghcr.io/acme/app:1.0.0", + vi.fn().mockResolvedValue(rawResponse("server error", 500)) + ) + ).rejects.toThrow( + "GitHub container package lookup failed for ghcr.io/acme/app:1.0.0 with 500: server error" + ); + }); +}); + +describe("verify runner groups failure detail", () => { + test("lists the sorted available group names when a group is missing", async () => { + await expect( + verifyRunnerGroups( + "https://api.github.com", + "secret", + [ + { + poolKey: "synology-private", + organization: "example", + runnerGroup: "missing-group" + } + ], + vi.fn().mockResolvedValue( + jsonResponse({ + runner_groups: [ + { id: 2, name: "zeta", default: false }, + { id: 1, name: "alpha", default: true } + ] + }) + ) + ) + ).rejects.toThrow( + "pool synology-private expects runner group missing-group in organization example, but GitHub returned: alpha, zeta" + ); + }); +});