diff --git a/scripts/workflow-registry-disable-plan.mjs b/scripts/workflow-registry-disable-plan.mjs index b48d7cf55..0a8820e37 100644 --- a/scripts/workflow-registry-disable-plan.mjs +++ b/scripts/workflow-registry-disable-plan.mjs @@ -1,3 +1,5 @@ +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; + const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const WORKFLOW_PATH_PREFIX = ".github/workflows/"; const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/; @@ -5,6 +7,7 @@ const ISO_UTC_MILLISECOND = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const GITHUB_API_ROOT = "https://api.github.com"; const GITHUB_API_VERSION = "2026-03-10"; const GITHUB_REQUEST_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const AUTHENTIC_PLANS = new WeakSet(); /** @@ -235,17 +238,48 @@ export function createGithubWorkflowDisablementTransport(input) { } /** - * Parse a successful GitHub JSON response while hiding invalid remote bytes. + * Parse a successful GitHub JSON response through the same bounded byte-level + * boundary used by the live registry collector. Response size, UTF-8 validity, + * duplicate decoded object keys, and JSON syntax are all fail-closed before + * any remote field can become protected-main or workflow mutation evidence. * * @param {Response} response successful GitHub response * @returns {Promise} parsed JSON value */ async function parseResponseJson(response) { + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) { + throw new Error( + "GitHub workflow disablement transport response exceeds the bounded size limit", + ); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + throw new Error( + "GitHub workflow disablement transport response exceeds the bounded size limit", + ); + } + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("GitHub workflow disablement transport response contains invalid UTF-8"); + } + + let duplicateKeys; try { - return JSON.parse(await response.text()); + duplicateKeys = hasDuplicateJsonObjectKeys(text); } catch { throw new Error("GitHub workflow disablement transport returned invalid JSON"); } + if (duplicateKeys) { + throw new Error( + "GitHub workflow disablement transport response contains duplicate decoded JSON keys", + ); + } + return JSON.parse(text); } /** diff --git a/scripts/workflow-registry-live-disable.mjs b/scripts/workflow-registry-live-disable.mjs new file mode 100644 index 000000000..831cbc11c --- /dev/null +++ b/scripts/workflow-registry-live-disable.mjs @@ -0,0 +1,331 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDelegatedGithubToken } from "./lib/delegated-github-token.mjs"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; +import { + buildWorkflowDisablementPlan, + createGithubWorkflowDisablementTransport, + executeWorkflowDisablement, +} from "./workflow-registry-disable-plan.mjs"; +import { + collectLiveWorkflowRegistryAudit, + workflowPageFromResponse, +} from "./workflow-registry-live-audit.mjs"; + +const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; +const GITHUB_API_ROOT = "https://api.github.com"; +const GITHUB_API_VERSION = "2026-03-10"; +const PER_PAGE = 100; +const MAX_PAGES = 1_000; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 10_000; + +function validWorkflowId(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function boundedError(error) { + const raw = error instanceof Error ? error.message : String(error); + return raw + .replace(/\bbearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, "[REDACTED]") + .replace(/\bgh[pousr]_[A-Za-z0-9_]+\b/g, "[REDACTED]") + .replace(/[\u0000-\u001f\u007f]/g, "") + .slice(0, 2_048); +} + +/** + * Create a bounded, repository-pinned GitHub JSON reader for the live operator. + * The delegated token is closure-private and never copied into process environment. + * + * @param {{token: string, fetchImpl?: typeof fetch}} input delegated token and fetch primitive + * @returns {(endpoint: string) => Promise} exact-repository JSON reader + */ +export function createWorkflowRegistryGithubJsonReader(input) { + if (typeof input?.token !== "string" || input.token.length === 0) { + throw new Error("workflow registry GitHub reader requires a delegated token"); + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new Error("workflow registry GitHub reader requires fetch capability"); + } + const token = input.token; + + return async (endpoint) => { + if (typeof endpoint !== "string" || endpoint.length === 0 || endpoint.includes("\\")) { + throw new Error("workflow registry GitHub endpoint is invalid"); + } + const url = new URL(endpoint, `${GITHUB_API_ROOT}/`); + if ( + url.origin !== GITHUB_API_ROOT + || !url.pathname.startsWith(`/repos/${EXPECTED_REPOSITORY}/`) + || url.username + || url.password + || url.hash + ) { + throw new Error("workflow registry GitHub endpoint escapes the Noema repository boundary"); + } + + let response; + try { + response = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "ContextualWisdomLab-Noema-workflow-registry-operator", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + if (error?.name === "TimeoutError") { + throw new Error("workflow registry GitHub request timed out"); + } + throw new Error("workflow registry GitHub request failed before receiving an HTTP response"); + } + if (!response.ok) { + throw new Error(`workflow registry GitHub request failed with HTTP ${response.status}`); + } + + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) { + throw new Error("workflow registry GitHub response exceeds the bounded size limit"); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + throw new Error("workflow registry GitHub response exceeds the bounded size limit"); + } + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("workflow registry GitHub response contains invalid UTF-8"); + } + + let duplicateKeys; + try { + duplicateKeys = hasDuplicateJsonObjectKeys(text); + } catch { + throw new Error("workflow registry GitHub response returned invalid JSON"); + } + if (duplicateKeys) { + throw new Error("workflow registry GitHub response contains duplicate decoded JSON keys"); + } + try { + return JSON.parse(text); + } catch { + throw new Error("workflow registry GitHub response returned invalid JSON"); + } + }; +} + +/** + * Re-read the complete workflow registry immediately before building mutation authority. + * Every page must agree on total count and retained records must exactly match that total. + * + * @param {{repository?: string, ghJson: (endpoint: string) => Promise}} input repository and reader + * @returns {Promise} complete fresh raw workflow registry + */ +export async function collectLiveWorkflowRecords(input) { + const repository = input?.repository ?? EXPECTED_REPOSITORY; + if (repository !== EXPECTED_REPOSITORY || typeof input?.ghJson !== "function") { + throw new Error("live workflow registry collection is restricted to ContextualWisdomLab/noema"); + } + + const workflows = []; + let expectedTotal; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const parsed = workflowPageFromResponse( + await input.ghJson(`repos/${repository}/actions/workflows?per_page=${PER_PAGE}&page=${page}`), + page, + PER_PAGE, + ); + if (expectedTotal === undefined) expectedTotal = parsed.totalCount; + if (parsed.totalCount !== expectedTotal) { + throw new Error("workflow registry total changed during immediate pre-mutation refresh"); + } + workflows.push(...parsed.workflows); + if (!parsed.hasNext) { + if (workflows.length !== expectedTotal) { + throw new Error("workflow registry refresh did not retain the advertised record count"); + } + return workflows; + } + } + throw new Error("workflow registry pagination exceeded the bounded page limit"); +} + +/** + * Execute one and only one requested active-orphan workflow disablement. + * The operator collects a full exact-main audit, immediately refreshes raw registry + * identities, builds process-local mutation authority, revalidates main plus workflow + * state around the mutation, and then performs a second full audit before returning a receipt. + * + * @param {object} input exact repository, workflow id, audit/live collectors, and transport + * @returns {Promise} bounded postcondition receipt + */ +export async function runWorkflowRegistryDisablement(input) { + const repository = input?.repository ?? EXPECTED_REPOSITORY; + const workflowId = input?.workflowId; + if (repository !== EXPECTED_REPOSITORY) { + throw new Error(`workflow disablement is restricted to ${EXPECTED_REPOSITORY}`); + } + if (!validWorkflowId(workflowId)) { + throw new Error("requested workflow id must be a positive safe integer"); + } + if (typeof input?.collectAudit !== "function" || typeof input?.collectLiveWorkflows !== "function") { + throw new Error("workflow disablement operator is missing fresh evidence collectors"); + } + const transport = input?.transport; + if ( + typeof transport?.revalidateDefaultBranch !== "function" + || typeof transport?.revalidateWorkflow !== "function" + || typeof transport?.disableWorkflow !== "function" + ) { + throw new Error("workflow disablement operator is missing authorized transport"); + } + + const audit = await input.collectAudit(); + const exactMain = audit?.default_branch_sha; + const liveWorkflows = await input.collectLiveWorkflows(); + const plan = buildWorkflowDisablementPlan({ + audit, + expectedRepository: repository, + expectedDefaultBranchSha: exactMain, + liveWorkflows, + }); + if (plan.status !== "PASS") { + const firstFailure = plan.failures?.[0]?.code ?? "unknown"; + throw new Error(`fresh workflow disablement plan is non-authorizing: ${firstFailure}`); + } + + const candidate = plan.disablements.find((item) => item.workflow_id === workflowId); + if (!candidate) { + throw new Error("requested workflow is not an exact active-orphan candidate"); + } + + const mutation = await executeWorkflowDisablement({ + plan, + candidate, + revalidateDefaultBranch: transport.revalidateDefaultBranch, + revalidateWorkflow: transport.revalidateWorkflow, + disableWorkflow: transport.disableWorkflow, + }); + + const postAudit = await input.collectAudit(); + if (postAudit?.repository_full_name !== repository) { + throw new Error("repository identity changed during post-disablement verification"); + } + if (postAudit?.default_branch_sha !== plan.default_branch_sha) { + throw new Error("protected main changed during post-disablement verification"); + } + const postWorkflow = Array.isArray(postAudit?.workflows) + ? postAudit.workflows.find((item) => item?.workflow_id === workflowId) + : undefined; + if ( + postWorkflow?.workflow_path !== candidate.workflow_path + || postWorkflow?.workflow_state !== "disabled_manually" + || postWorkflow?.classification !== "disabled_registry_record" + ) { + throw new Error("full post-disablement audit did not retain the exact disabled workflow identity"); + } + + return Object.freeze({ + schema_version: 1, + repository_full_name: repository, + protected_main_sha: plan.default_branch_sha, + workflow_id: mutation.workflow_id, + workflow_path: mutation.workflow_path, + prior_state: mutation.prior_state, + final_state: mutation.final_state, + mutation: mutation.mutation, + post_audit_status: postAudit.status, + }); +} + +/** + * Operator entrypoint. The target workflow ID is an explicit argument and the + * short-lived delegated GitHub capability comes only from the reviewed token file. + * No batch mode exists: each invocation can mutate at most one exact audited orphan. + * + * @returns {Promise} verified disablement receipt + */ +export async function main() { + const repository = String(process.env.GITHUB_REPOSITORY ?? EXPECTED_REPOSITORY).trim(); + const tokenPath = String(process.env.NOEMA_MAINTAINER_TOKEN_PATH ?? "").trim(); + const workflowId = Number(process.argv[2] ?? ""); + if (repository !== EXPECTED_REPOSITORY) { + throw new Error(`workflow disablement is restricted to ${EXPECTED_REPOSITORY}`); + } + if (!validWorkflowId(workflowId)) { + throw new Error("requested workflow id must be a positive safe integer"); + } + const token = readDelegatedGithubToken(tokenPath); + const ghJson = createWorkflowRegistryGithubJsonReader({ token }); + const transport = createGithubWorkflowDisablementTransport({ + token, + fetchImpl: globalThis.fetch, + }); + const collectAudit = () => collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + ghJson, + }); + const receipt = await runWorkflowRegistryDisablement({ + repository, + workflowId, + collectAudit, + collectLiveWorkflows: () => collectLiveWorkflowRecords({ repository, ghJson }), + transport, + }); + console.log(JSON.stringify(receipt, null, 2)); + return receipt; +} + +/** + * Execute the live-disable CLI with isolated error and exit-code boundaries. + * + * @param {{mainFn?: () => Promise, stderr?: (value: unknown) => void, setExitCode?: (code: number) => void}} [options] + * @returns {Promise} operation result or undefined after a bounded failure + */ +export async function startCli({ + mainFn = main, + stderr = console.error, + setExitCode = (code) => { process.exitCode = code; }, +} = {}) { + try { + return await mainFn(); + } catch (error) { + stderr(`workflow-registry-disable failed: ${boundedError(error)}`); + setExitCode(1); + return undefined; + } +} + +/** + * Dispatch the CLI only when this module is the process entrypoint. + * + * @param {{scriptUrl?: string, argv?: string[], pathToFileUrlFn?: (path: string) => {href: string}, starter?: () => unknown}} [options] + * @returns {boolean} whether direct-entry execution was selected + */ +export function runIfDirect({ + scriptUrl = import.meta.url, + argv = process.argv, + pathToFileUrlFn = (value) => pathToFileURL(resolve(value)), + starter = startCli, +} = {}) { + const invokedAsScript = ( + typeof argv[1] === "string" + && argv[1].length > 0 + && pathToFileUrlFn(argv[1]).href === scriptUrl + ); + if (invokedAsScript) void starter(); + return invokedAsScript; +} + +runIfDirect(); diff --git a/test/workflow-registry-disable-plan-json-boundary.test.ts b/test/workflow-registry-disable-plan-json-boundary.test.ts new file mode 100644 index 000000000..70874de7e --- /dev/null +++ b/test/workflow-registry-disable-plan-json-boundary.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGithubWorkflowDisablementTransport } from "../scripts/workflow-registry-disable-plan.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const WORKFLOW_ID = 410; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + +function transportFor(response: Response) { + return createGithubWorkflowDisablementTransport({ + token: "delegated-token", + fetchImpl: vi.fn(async () => response), + }); +} + +describe("privileged workflow disablement JSON boundary", () => { + it("rejects oversized successful GitHub JSON before parsing", async () => { + const response = new Response(`{"padding":"${"x".repeat(MAX_RESPONSE_BYTES)}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response exceeds the bounded size limit"); + }); + + it("rejects malformed UTF-8 instead of accepting replacement-character decoding", async () => { + const response = new Response(new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response contains invalid UTF-8"); + }); + + it("rejects duplicate decoded object keys before last-key-wins JSON parsing", async () => { + const response = new Response( + `{"commit":{"sha":"${MAIN_SHA}"},"comm\\u0069t":{"sha":"${"b".repeat(40)}"}}`, + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response contains duplicate decoded JSON keys"); + }); + + it("applies the same strict JSON boundary to workflow identity revalidation", async () => { + const response = new Response( + `{"id":${WORKFLOW_ID},"path":".github/workflows/a.yml","state":"active","st\\u0061te":"disabled_manually"}`, + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await expect( + transportFor(response).revalidateWorkflow({ repository: REPOSITORY, workflowId: WORKFLOW_ID }), + ).rejects.toThrow("response contains duplicate decoded JSON keys"); + }); +}); diff --git a/test/workflow-registry-live-disable-branch-coverage.test.ts b/test/workflow-registry-live-disable-branch-coverage.test.ts new file mode 100644 index 000000000..693b18727 --- /dev/null +++ b/test/workflow-registry-live-disable-branch-coverage.test.ts @@ -0,0 +1,272 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + collectLiveWorkflowRecords, + createWorkflowRegistryGithubJsonReader, + main, + runIfDirect, + runWorkflowRegistryDisablement, + startCli, +} from "../scripts/workflow-registry-live-disable.mjs"; + +const ORIGINAL_ENV = { ...process.env }; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); +} + +afterEach(() => { + restoreEnvironment(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("workflow registry live-disable branch coverage", () => { + it("rejects missing delegated token and fetch capability", () => { + expect(() => createWorkflowRegistryGithubJsonReader({ token: "" })).toThrow( + "requires a delegated token", + ); + vi.stubGlobal("fetch", undefined); + expect(() => createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + })).toThrow("requires fetch capability"); + }); + + it("rejects malformed and escaping GitHub endpoints before network I/O", async () => { + const fetchImpl = vi.fn(); + const ghJson = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + await expect(ghJson("")).rejects.toThrow("endpoint is invalid"); + await expect(ghJson("repos\\ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow( + "endpoint is invalid", + ); + await expect(ghJson("https://example.com/repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("escapes the Noema repository boundary"); + await expect(ghJson("https://user@example.com/repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("escapes the Noema repository boundary"); + await expect(ghJson("https://github.com/repos/ContextualWisdomLab/noema/actions/workflows#fragment")) + .rejects.toThrow("escapes the Noema repository boundary"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("normalizes non-timeout transport rejection without leaking the rejected value", async () => { + const ghJson = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockRejectedValue("remote-secret") as unknown as typeof fetch, + }); + + await expect(ghJson("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("failed before receiving an HTTP response"); + }); + + it("rejects advertised and actual response sizes above the bounded limit", async () => { + const advertised = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => String((8 * 1024 * 1024) + 1) }, + arrayBuffer: vi.fn(), + }) as unknown as typeof fetch, + }); + await expect(advertised("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("bounded size limit"); + + const bytes = new Uint8Array((8 * 1024 * 1024) + 1); + const actual = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => bytes.buffer, + }) as unknown as typeof fetch, + }); + await expect(actual("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("bounded size limit"); + }); + + it("rejects malformed UTF-8 and duplicate decoded JSON keys", async () => { + const malformedUtf8 = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => new Uint8Array([0xc3, 0x28]).buffer, + }) as unknown as typeof fetch, + }); + await expect(malformedUtf8("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("invalid UTF-8"); + + const duplicateBytes = new TextEncoder().encode('{"workflows":[],"\\u0077orkflows":[]}'); + const duplicateKeys = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => duplicateBytes.buffer, + }) as unknown as typeof fetch, + }); + await expect(duplicateKeys("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("duplicate decoded JSON keys"); + }); + + it("fails closed when live record collection input is missing", async () => { + await expect(collectLiveWorkflowRecords({ + repository: "ContextualWisdomLab/noema", + ghJson: undefined as unknown as (endpoint: string) => Promise, + })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + }); + + it("rejects missing post-audit workflow evidence after a successful mutation", async () => { + const audit = { + schema_version: 1, + status: "FAIL", + repository_full_name: "ContextualWisdomLab/noema", + default_branch_sha: "a".repeat(40), + observed_at: "2026-08-16T00:00:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + workflows: [{ + workflow_id: 101, + workflow_path: ".github/workflows/orphan.yml", + workflow_state: "active", + classification: "active_orphan", + }], + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + }; + const liveWorkflows = [{ id: 101, path: ".github/workflows/orphan.yml", state: "active" }]; + let workflowState = "active"; + let auditCalls = 0; + + await expect(runWorkflowRegistryDisablement({ + repository: "ContextualWisdomLab/noema", + workflowId: 101, + collectAudit: async () => { + auditCalls += 1; + if (auditCalls === 1) return audit; + return { ...audit, workflows: [] }; + }, + collectLiveWorkflows: async () => liveWorkflows, + transport: { + revalidateDefaultBranch: async () => ({ sha: audit.default_branch_sha }), + revalidateWorkflow: async () => ({ + id: 101, + path: ".github/workflows/orphan.yml", + state: workflowState, + }), + disableWorkflow: async () => { + workflowState = "disabled_manually"; + }, + }, + })).rejects.toThrow("full post-disablement audit did not retain the exact disabled workflow identity"); + }); + + it("rejects a malformed post-audit record without substituting identity", async () => { + const audit = { + schema_version: 1, + status: "FAIL", + repository_full_name: "ContextualWisdomLab/noema", + default_branch_sha: "b".repeat(40), + observed_at: "2026-08-16T00:00:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + workflows: [{ + workflow_id: 101, + workflow_path: ".github/workflows/orphan.yml", + workflow_state: "active", + classification: "active_orphan", + }], + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + }; + let workflowState = "active"; + let auditCalls = 0; + + await expect(runWorkflowRegistryDisablement({ + repository: "ContextualWisdomLab/noema", + workflowId: 101, + collectAudit: async () => { + auditCalls += 1; + if (auditCalls === 1) return audit; + return { ...audit, workflows: null }; + }, + collectLiveWorkflows: async () => [ + { id: 101, path: ".github/workflows/orphan.yml", state: "active" }, + ], + transport: { + revalidateDefaultBranch: async () => ({ sha: audit.default_branch_sha }), + revalidateWorkflow: async () => ({ + id: 101, + path: ".github/workflows/orphan.yml", + state: workflowState, + }), + disableWorkflow: async () => { + workflowState = "disabled_manually"; + }, + }, + })).rejects.toThrow("full post-disablement audit did not retain the exact disabled workflow identity"); + }); + + it("bounds non-Error CLI failures and redacts secrets", async () => { + const errors: string[] = []; + const exitCodes: number[] = []; + await startCli({ + mainFn: async () => { + throw "Bearer secret\nwith-control"; + }, + stderr: (value: unknown) => errors.push(String(value)), + setExitCode: (value: number) => exitCodes.push(value), + }); + expect(errors).toEqual(["workflow-registry-disable failed: Bearer [REDACTED]with-control"]); + expect(exitCodes).toEqual([1]); + }); + + it("does not dispatch a direct-run check for an empty executable target", () => { + const starter = vi.fn(); + const pathToFileUrlFn = vi.fn(); + expect(runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", ""], + pathToFileUrlFn, + starter, + })).toBe(false); + expect(pathToFileUrlFn).not.toHaveBeenCalled(); + expect(starter).not.toHaveBeenCalled(); + }); + + it("fails closed when CLI authority or workflow identity is absent while exercising defaults", async () => { + const originalArgv = process.argv; + const originalRepository = process.env.GITHUB_REPOSITORY; + const originalTokenPath = process.env.NOEMA_MAINTAINER_TOKEN_PATH; + const directory = await mkdtemp(join(tmpdir(), "noema-live-disable-defaults-")); + const tokenPath = join(directory, "github-token"); + try { + await writeFile(tokenPath, "delegated-token", { encoding: "utf8", mode: 0o600 }); + await chmod(tokenPath, 0o600); + delete process.env.GITHUB_REPOSITORY; + process.env.NOEMA_MAINTAINER_TOKEN_PATH = tokenPath; + process.argv = ["node", "workflow-registry-live-disable.mjs"]; + await expect(main()).rejects.toThrow("positive safe integer"); + + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs", "101"]; + await expect(main()).rejects.toThrow("Maintainer token file path is required."); + } finally { + process.argv = originalArgv; + if (originalRepository === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = originalRepository; + if (originalTokenPath === undefined) delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + else process.env.NOEMA_MAINTAINER_TOKEN_PATH = originalTokenPath; + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/workflow-registry-live-disable-cli.test.ts b/test/workflow-registry-live-disable-cli.test.ts new file mode 100644 index 000000000..dbf24466e --- /dev/null +++ b/test/workflow-registry-live-disable-cli.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runIfDirect, + startCli, +} from "../scripts/workflow-registry-live-disable.mjs"; + +describe("workflow registry live-disable CLI boundary", () => { + it("does not start when argv has no executable target", () => { + const starter = vi.fn(); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node"], + pathToFileUrlFn: vi.fn(), + starter, + }); + + expect(invoked).toBe(false); + expect(starter).not.toHaveBeenCalled(); + }); + + it("does not start when the resolved invocation URL is a different module", () => { + const starter = vi.fn(); + const pathToFileUrlFn = vi.fn(() => ({ href: "file:///different.mjs" })); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", "/tmp/operator.mjs"], + pathToFileUrlFn, + starter, + }); + + expect(invoked).toBe(false); + expect(pathToFileUrlFn).toHaveBeenCalledWith("/tmp/operator.mjs"); + expect(starter).not.toHaveBeenCalled(); + }); + + it("starts exactly once when the resolved invocation URL matches", () => { + const starter = vi.fn(); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", "/tmp/operator.mjs"], + pathToFileUrlFn: vi.fn(() => ({ href: "file:///operator.mjs" })), + starter, + }); + + expect(invoked).toBe(true); + expect(starter).toHaveBeenCalledTimes(1); + }); + + it("leaves exit state untouched after a successful CLI operation", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const mainFn = vi.fn(async () => ({ status: "PASS" })); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(mainFn).toHaveBeenCalledTimes(1); + expect(stderr).not.toHaveBeenCalled(); + expect(setExitCode).not.toHaveBeenCalled(); + }); + + it("bounds and redacts a failed CLI operation before setting exit code", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const mainFn = vi.fn(async () => { + throw new Error(`delegated token ghp_${"A".repeat(80)} was rejected`); + }); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(setExitCode).toHaveBeenCalledTimes(1); + expect(setExitCode).toHaveBeenCalledWith(1); + expect(stderr).toHaveBeenCalledTimes(1); + const emitted = String(stderr.mock.calls[0]?.[0] ?? ""); + expect(emitted).toContain("workflow-registry-disable failed:"); + expect(emitted).toContain("[REDACTED]"); + expect(emitted).not.toContain("ghp_"); + }); + + it("redacts a fine-grained GitHub PAT from failed CLI diagnostics", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const fineGrainedPat = `github_pat_${"B".repeat(82)}`; + const mainFn = vi.fn(async () => { + throw new Error(`delegated token ${fineGrainedPat} was rejected`); + }); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(setExitCode).toHaveBeenCalledWith(1); + expect(stderr).toHaveBeenCalledTimes(1); + const emitted = String(stderr.mock.calls[0]?.[0] ?? ""); + expect(emitted).toContain("[REDACTED]"); + expect(emitted).not.toContain("github_pat_"); + expect(emitted).not.toContain(fineGrainedPat); + }); +}); diff --git a/test/workflow-registry-live-disable-coverage.test.ts b/test/workflow-registry-live-disable-coverage.test.ts new file mode 100644 index 000000000..f98c05597 --- /dev/null +++ b/test/workflow-registry-live-disable-coverage.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; +import { + collectLiveWorkflowRecords, + createWorkflowRegistryGithubJsonReader, + runWorkflowRegistryDisablement, +} from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + +function fakeResponse({ ok = true, status = 200, headers = {}, body = "{}" }: { ok?: boolean; status?: number; headers?: Record; body?: string | Uint8Array } = {}) { + const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; + return { ok, status, headers: { get(name: string) { return headers[name.toLowerCase()] ?? null; } }, async arrayBuffer() { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } }; +} + +function activeAudit() { + return { schema_version: 1, repository_full_name: REPOSITORY, default_branch_sha: MAIN_SHA, observed_at: "2026-08-16T03:10:00.000Z", pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], status: "FAIL", failures: [{ code: "active_orphan_workflow", workflow_id: 101, detail: "Active workflow is absent from protected main." }], workflows: [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "active", classification: "active_orphan" }] }; +} + +function disabledAudit(overrides: Record = {}) { + return { schema_version: 1, repository_full_name: REPOSITORY, default_branch_sha: MAIN_SHA, observed_at: "2026-08-16T03:10:01.000Z", pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], status: "PASS", failures: [], workflows: [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "disabled_manually", classification: "disabled_registry_record" }], ...overrides }; +} + +function validTransport() { + return { revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), revalidateWorkflow: vi.fn().mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "active" }).mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "disabled_manually" }), disableWorkflow: vi.fn().mockResolvedValue(undefined) }; +} + +describe("workflow registry bounded GitHub reader", () => { + it("rejects missing credentials and fetch capability before making a request", () => { + expect(() => createWorkflowRegistryGithubJsonReader({ token: "" })).toThrow("requires a delegated token"); + expect(() => createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl: 0 as never })).toThrow("requires fetch capability"); + }); + + it("pins reads to safe Noema repository endpoints", async () => { + const fetchImpl = vi.fn(); + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl }); + await expect(reader("")).rejects.toThrow("endpoint is invalid"); + await expect(reader("repos\\ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("endpoint is invalid"); + await expect(reader("https://example.com/repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("escapes the Noema repository boundary"); + await expect(reader("repos/ContextualWisdomLab/other/actions/workflows")).rejects.toThrow("escapes the Noema repository boundary"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns validated JSON and sends the delegated token only in the request header", async () => { + const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({ body: '{"total_count":0,"workflows":[]}' })); + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl }); + await expect(reader("repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1")).resolves.toEqual({ total_count: 0, workflows: [] }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, options] = fetchImpl.mock.calls[0]; + expect(String(url)).toBe("https://api.github.com/repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1"); + expect(options.method).toBe("GET"); + expect(options.redirect).toBe("error"); + expect(options.cache).toBe("no-store"); + expect(options.headers.Authorization).toBe("Bearer delegated-token"); + }); + + it("maps timeout and network failures to bounded non-secret diagnostics", async () => { + const timeout = Object.assign(new Error("Bearer delegated-token"), { name: "TimeoutError" }); + const timeoutReader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl: vi.fn().mockRejectedValue(timeout) }); + await expect(timeoutReader("repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("request timed out"); + const networkReader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl: vi.fn().mockRejectedValue(new Error("ghp_secret")) }); + await expect(networkReader("repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("failed before receiving an HTTP response"); + }); + + it("fails closed on non-success, oversized, malformed UTF-8, duplicate-key, and invalid JSON bodies", async () => { + const endpoint = "repos/ContextualWisdomLab/noema/actions/workflows"; + const cases: Array<[ReturnType, string]> = [ + [fakeResponse({ ok: false, status: 503 }), "failed with HTTP 503"], + [fakeResponse({ headers: { "content-length": String(MAX_RESPONSE_BYTES + 1) } }), "exceeds the bounded size limit"], + [fakeResponse({ body: new Uint8Array(MAX_RESPONSE_BYTES + 1) }), "exceeds the bounded size limit"], + [fakeResponse({ body: new Uint8Array([0xff]) }), "contains invalid UTF-8"], + [fakeResponse({ body: '{"workflow":1,"workflow":2}' }), "duplicate decoded JSON keys"], + [fakeResponse({ body: "{" }), "returned invalid JSON"], + ]; + for (const [response, message] of cases) { + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl: vi.fn().mockResolvedValue(response) }); + await expect(reader(endpoint)).rejects.toThrow(message); + } + }); +}); + +describe("immediate full workflow registry refresh", () => { + it("rejects any repository or reader outside the exact Noema authority", async () => { + await expect(collectLiveWorkflowRecords({ repository: "ContextualWisdomLab/other", ghJson: vi.fn() })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: 0 as never })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + }); + + it("collects every paginated registry record and preserves page order", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, path: `.github/workflows/workflow-${index + 1}.yml`, state: "active" })); + const finalWorkflow = { id: 101, path: ".github/workflows/workflow-101.yml", state: "disabled_manually" }; + const ghJson = vi.fn().mockResolvedValueOnce({ total_count: 101, workflows: firstPage }).mockResolvedValueOnce({ total_count: 101, workflows: [finalWorkflow] }); + const workflows = await collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson }); + expect(workflows).toHaveLength(101); + expect(workflows[0]).toEqual(firstPage[0]); + expect(workflows[100]).toEqual(finalWorkflow); + expect(ghJson).toHaveBeenCalledTimes(2); + }); + + it("fails closed when total count changes or a terminal page does not retain that count", async () => { + const changedTotal = vi.fn().mockResolvedValueOnce({ total_count: 101, workflows: [] }).mockResolvedValueOnce({ total_count: 100, workflows: [] }); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: changedTotal })).rejects.toThrow("total changed during immediate pre-mutation refresh"); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: vi.fn().mockResolvedValue({ total_count: 2, workflows: [{ id: 1 }] }) })).rejects.toThrow("did not retain the advertised record count"); + }); + + it("bounds pathological pagination even when the remote count never terminates", async () => { + const ghJson = vi.fn().mockResolvedValue({ total_count: 100_001, workflows: [] }); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson })).rejects.toThrow("pagination exceeded the bounded page limit"); + expect(ghJson).toHaveBeenCalledTimes(1_000); + }); +}); + +describe("disablement input and postcondition boundaries", () => { + it("rejects invalid repository, workflow identity, collectors, and transport", async () => { + await expect(runWorkflowRegistryDisablement({ repository: "other", workflowId: 101 })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 0 })).rejects.toThrow("positive safe integer"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101 })).rejects.toThrow("missing fresh evidence collectors"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit: vi.fn(), collectLiveWorkflows: vi.fn(), transport: {} })).rejects.toThrow("missing authorized transport"); + }); + + it("does not mutate when fresh planning is non-authorizing", async () => { + const transport = validTransport(); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit: vi.fn().mockResolvedValue(activeAudit()), collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }, { id: 101, path: ".github/workflows/reused.yml", state: "active" }]), transport })).rejects.toThrow("fresh workflow disablement plan is non-authorizing"); + expect(transport.disableWorkflow).not.toHaveBeenCalled(); + }); + + it("rejects a post-audit repository substitution", async () => { + const collectAudit = vi.fn().mockResolvedValueOnce(activeAudit()).mockResolvedValueOnce(disabledAudit({ repository_full_name: "ContextualWisdomLab/other" })); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit, collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), transport: validTransport() })).rejects.toThrow("repository identity changed during post-disablement verification"); + }); + + it("rejects a post-audit that loses or changes the exact disabled workflow identity", async () => { + const invalidPostStates = [null, [], [{ workflow_id: 101, workflow_path: ".github/workflows/different.yml", workflow_state: "disabled_manually", classification: "disabled_registry_record" }], [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "active", classification: "active_orphan" }]]; + for (const workflows of invalidPostStates) { + const collectAudit = vi.fn().mockResolvedValueOnce(activeAudit()).mockResolvedValueOnce(disabledAudit({ workflows })); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit, collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), transport: validTransport() })).rejects.toThrow("did not retain the exact disabled workflow identity"); + } + }); +}); diff --git a/test/workflow-registry-live-disable-main.test.ts b/test/workflow-registry-live-disable-main.test.ts new file mode 100644 index 000000000..121441a40 --- /dev/null +++ b/test/workflow-registry-live-disable-main.test.ts @@ -0,0 +1,108 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { main } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const WORKFLOW_ID = 101; +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; + +function githubResponse(body: unknown, status = 200) { + const text = status === 204 ? "" : JSON.stringify(body); + const bytes = new TextEncoder().encode(text); + return { + ok: status >= 200 && status < 300, + status, + headers: { + get(name: string) { + return name.toLowerCase() === "content-length" ? String(bytes.byteLength) : null; + }, + }, + async arrayBuffer() { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }, + async text() { + return text; + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("workflow registry live-disable executable main", () => { + it("uses an owner-only delegated capability to disable one audited orphan and retain a full post-audit receipt", async () => { + const directory = await mkdtemp(join(tmpdir(), "noema-workflow-disable-")); + const tokenPath = join(directory, "github-token"); + const originalArgv = process.argv; + let workflowState = "active"; + + try { + await writeFile(tokenPath, "delegated-token", { encoding: "utf8", mode: 0o600 }); + await chmod(tokenPath, 0o600); + vi.stubEnv("GITHUB_REPOSITORY", REPOSITORY); + vi.stubEnv("NOEMA_MAINTAINER_TOKEN_PATH", tokenPath); + process.argv = ["node", "workflow-registry-live-disable.mjs", String(WORKFLOW_ID)]; + + const fetchImpl = vi.fn(async (input: URL | string, options: { method?: string } = {}) => { + const url = String(input); + const method = options.method ?? "GET"; + + if (url.endsWith(`/repos/${REPOSITORY}/branches/main`) && method === "GET") { + return githubResponse({ commit: { sha: MAIN_SHA } }); + } + if (url.includes(`/repos/${REPOSITORY}/git/trees/${MAIN_SHA}?recursive=1`) && method === "GET") { + return githubResponse({ + truncated: false, + tree: [{ type: "blob", path: ".github/workflows/ci.yml" }], + }); + } + if (url.includes(`/repos/${REPOSITORY}/actions/workflows?`) && method === "GET") { + return githubResponse({ + total_count: 1, + workflows: [{ id: WORKFLOW_ID, path: ORPHAN_PATH, state: workflowState }], + }); + } + if (url.includes(`/repos/${REPOSITORY}/pulls?state=open`) && method === "GET") { + return githubResponse([]); + } + if (url.endsWith(`/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}`) && method === "GET") { + return githubResponse({ id: WORKFLOW_ID, path: ORPHAN_PATH, state: workflowState }); + } + if (url.endsWith(`/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}/disable`) && method === "PUT") { + workflowState = "disabled_manually"; + return githubResponse(undefined, 204); + } + throw new Error(`unexpected GitHub request: ${method} ${url}`); + }); + vi.stubGlobal("fetch", fetchImpl); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const receipt = await main(); + + expect(receipt).toEqual({ + schema_version: 1, + repository_full_name: REPOSITORY, + protected_main_sha: MAIN_SHA, + workflow_id: WORKFLOW_ID, + workflow_path: ORPHAN_PATH, + prior_state: "active", + final_state: "disabled_manually", + mutation: "disable", + post_audit_status: "PASS", + }); + expect(workflowState).toBe("disabled_manually"); + expect(fetchImpl.mock.calls.filter(([, options]) => options?.method === "PUT")).toHaveLength(1); + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(String(consoleLog.mock.calls[0]?.[0] ?? "")).toContain('"final_state": "disabled_manually"'); + } finally { + process.argv = originalArgv; + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/workflow-registry-live-disable-operator.test.ts b/test/workflow-registry-live-disable-operator.test.ts new file mode 100644 index 000000000..c79b58317 --- /dev/null +++ b/test/workflow-registry-live-disable-operator.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkflowRegistryDisablement } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const OBSERVED_AT = "2026-08-16T03:10:00.000Z"; + +function activeAudit() { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: MAIN_SHA, + observed_at: OBSERVED_AT, + pagination_receipts: [{ page: 1, itemCount: 2, hasNext: false }], + status: "FAIL", + failures: [{ + code: "active_orphan_workflow", + workflow_id: 101, + detail: "Active workflow is absent from protected main.", + }], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "active", + classification: "active_orphan", + }, + { + workflow_id: 202, + workflow_path: ".github/workflows/ci.yml", + workflow_state: "active", + classification: "present_on_default_branch", + }, + ], + }; +} + +function postAudit(defaultBranchSha = MAIN_SHA) { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: defaultBranchSha, + observed_at: "2026-08-16T03:10:01.000Z", + pagination_receipts: [{ page: 1, itemCount: 2, hasNext: false }], + status: "PASS", + failures: [], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "disabled_manually", + classification: "disabled_registry_record", + }, + { + workflow_id: 202, + workflow_path: ".github/workflows/ci.yml", + workflow_state: "active", + classification: "present_on_default_branch", + }, + ], + }; +} + +const liveWorkflows = [ + { id: 101, path: ".github/workflows/obsolete-repair.yml", state: "active" }, + { id: 202, path: ".github/workflows/ci.yml", state: "active" }, +]; + +describe("live workflow-registry disablement operator", () => { + it("disables exactly the requested audited orphan and verifies the full post-state", async () => { + const collectAudit = vi + .fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce(postAudit()); + const disableWorkflow = vi.fn().mockResolvedValue(undefined); + const revalidateWorkflow = vi + .fn() + .mockResolvedValueOnce(liveWorkflows[0]) + .mockResolvedValueOnce({ ...liveWorkflows[0], state: "disabled_manually" }); + + const receipt = await runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow, + disableWorkflow, + }, + }); + + expect(disableWorkflow).toHaveBeenCalledTimes(1); + expect(disableWorkflow).toHaveBeenCalledWith({ repository: REPOSITORY, workflowId: 101 }); + expect(collectAudit).toHaveBeenCalledTimes(2); + expect(receipt).toEqual({ + schema_version: 1, + repository_full_name: REPOSITORY, + protected_main_sha: MAIN_SHA, + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + prior_state: "active", + final_state: "disabled_manually", + mutation: "disable", + post_audit_status: "PASS", + }); + }); + + it("refuses a workflow identity that is not an exact audited active orphan", async () => { + const disableWorkflow = vi.fn(); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 202, + collectAudit: vi.fn().mockResolvedValue(activeAudit()), + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn(), + revalidateWorkflow: vi.fn(), + disableWorkflow, + }, + })).rejects.toThrow("requested workflow is not an exact active-orphan candidate"); + + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it("fails the retained receipt if protected main moves during post-disablement verification", async () => { + const collectAudit = vi + .fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce(postAudit("b".repeat(40))); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce(liveWorkflows[0]) + .mockResolvedValueOnce({ ...liveWorkflows[0], state: "disabled_manually" }), + disableWorkflow: vi.fn().mockResolvedValue(undefined), + }, + })).rejects.toThrow("protected main changed during post-disablement verification"); + }); +}); diff --git a/test/workflow-registry-live-disable-residual-coverage.test.ts b/test/workflow-registry-live-disable-residual-coverage.test.ts new file mode 100644 index 000000000..ab1d4ad81 --- /dev/null +++ b/test/workflow-registry-live-disable-residual-coverage.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkflowRegistryDisablement } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; + +function validTransport() { + return { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi.fn(), + disableWorkflow: vi.fn(), + }; +} + +function activeAudit() { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: MAIN_SHA, + observed_at: "2026-08-16T06:10:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "FAIL", + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + workflows: [{ + workflow_id: 101, + workflow_path: ORPHAN_PATH, + workflow_state: "active", + classification: "active_orphan", + }], + }; +} + +describe("workflow live-disable residual optional-evidence boundaries", () => { + it("rejects an absent transport after both fresh collectors are present", async () => { + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn(), + collectLiveWorkflows: vi.fn(), + })).rejects.toThrow("missing authorized transport"); + }); + + it("treats an absent pre-mutation audit as non-authorizing evidence", async () => { + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn().mockResolvedValue(undefined), + collectLiveWorkflows: vi.fn().mockResolvedValue([]), + transport: validTransport(), + })).rejects.toThrow("fresh workflow disablement plan is non-authorizing: repository_identity_invalid"); + }); + + it("rejects a post-audit that retains repository identity but loses protected-main identity", async () => { + const transport = { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "active" }) + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "disabled_manually" }), + disableWorkflow: vi.fn().mockResolvedValue(undefined), + }; + const collectAudit = vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ repository_full_name: REPOSITORY }); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport, + })).rejects.toThrow("protected main changed during post-disablement verification"); + }); +}); diff --git a/test/workflow-registry-live-disable-secret-order.test.ts b/test/workflow-registry-live-disable-secret-order.test.ts new file mode 100644 index 000000000..f3d158c4e --- /dev/null +++ b/test/workflow-registry-live-disable-secret-order.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { main } from "../scripts/workflow-registry-live-disable.mjs"; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_ARGV = [...process.argv]; + +function restoreProcessState() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + process.argv = [...ORIGINAL_ARGV]; +} + +afterEach(() => { + restoreProcessState(); +}); + +describe("workflow registry live-disable credential materialization order", () => { + it("rejects a missing workflow identity before reading a delegated credential file", async () => { + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/noema"; + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs"]; + + await expect(main()).rejects.toThrow( + "requested workflow id must be a positive safe integer", + ); + }); + + it("rejects a repository substitution before reading a delegated credential file", async () => { + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/other"; + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs", "101"]; + + await expect(main()).rejects.toThrow( + "workflow disablement is restricted to ContextualWisdomLab/noema", + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5fb55464e..903c7f5bf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ "scripts/prepare-agent-pr-message.mjs", "scripts/workflow-registry-audit.mjs", "scripts/workflow-registry-disable-plan.mjs", + "scripts/workflow-registry-live-disable.mjs", "scripts/production-environment-governance-audit.mjs", "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/lib/stable-file-evidence.mjs",