diff --git a/scripts/workflow-registry-disable-plan.mjs b/scripts/workflow-registry-disable-plan.mjs new file mode 100644 index 000000000..143a2f0cf --- /dev/null +++ b/scripts/workflow-registry-disable-plan.mjs @@ -0,0 +1,305 @@ +const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; +const WORKFLOW_PATH_PREFIX = ".github/workflows/"; +const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/; +const ISO_UTC_MILLISECOND = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const AUTHENTIC_PLANS = new WeakSet(); + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function freezePlan(plan, authenticate = false) { + const disablements = Object.freeze( + plan.disablements.map((disablement) => Object.freeze({ ...disablement })), + ); + const failures = Object.freeze( + plan.failures.map((failure) => Object.freeze({ ...failure })), + ); + const frozen = Object.freeze({ ...plan, disablements, failures }); + if (authenticate) AUTHENTIC_PLANS.add(frozen); + return frozen; +} + +function failedPlan(repository, defaultBranchSha, code, detail) { + return freezePlan({ + status: "FAIL", + repository_full_name: repository ?? null, + default_branch_sha: defaultBranchSha ?? null, + disablements: [], + failures: [{ code, detail }], + }); +} + +function validWorkflowId(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function validWorkflowPath(value) { + if ( + typeof value !== "string" + || !value.startsWith(WORKFLOW_PATH_PREFIX) + || !/\.ya?ml$/.test(value) + || value.includes("\\") + || value.includes("\0") + ) { + return false; + } + + const relativePath = value.slice(WORKFLOW_PATH_PREFIX.length); + const pathSegments = relativePath.split("/"); + return ( + pathSegments.length > 0 + && pathSegments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..") + ); +} + +function validObservedAt(value) { + return ( + typeof value === "string" + && ISO_UTC_MILLISECOND.test(value) + && !Number.isNaN(Date.parse(value)) + ); +} + +function validPaginationReceipts(value) { + if (!Array.isArray(value) || value.length === 0) return false; + + return value.every((receipt, index) => ( + isRecord(receipt) + && receipt.page === index + 1 + && Number.isSafeInteger(receipt.itemCount) + && receipt.itemCount >= 0 + && typeof receipt.hasNext === "boolean" + && receipt.hasNext === (index < value.length - 1) + )); +} + +function validPlanAuthority(plan) { + return AUTHENTIC_PLANS.has(plan); +} + +/** + * Build a fail-closed disablement plan from one exact workflow-registry audit and + * an immediately refreshed live registry snapshot. Active-orphan findings are the + * only audit failures that can authorize a plan; every other failure invalidates it. + * Passing plans are immutable, process-local authorities: serialized or reconstructed + * lookalikes remain review evidence but cannot authorize mutation. + * + * @param {object} input exact audit, expected protected-main identity, and live registry + * @returns {object} bounded plan containing only exact active-orphan identities + */ +export function buildWorkflowDisablementPlan(input) { + const audit = input?.audit; + const repository = input?.expectedRepository; + const defaultBranchSha = input?.expectedDefaultBranchSha; + const liveWorkflows = Array.isArray(input?.liveWorkflows) ? input.liveWorkflows : []; + + if (repository !== EXPECTED_REPOSITORY || audit?.repository_full_name !== repository) { + return failedPlan( + repository, + defaultBranchSha, + "repository_identity_invalid", + `Workflow disablement evidence must be bound to exact repository ${EXPECTED_REPOSITORY}.`, + ); + } + + if ( + !LOWERCASE_SHA_40.test(defaultBranchSha ?? "") + || audit?.default_branch_sha !== defaultBranchSha + ) { + return failedPlan( + repository, + defaultBranchSha, + "default_branch_identity_changed", + "Workflow disablement evidence is not bound to the exact protected-main commit.", + ); + } + + if ( + !Array.isArray(audit?.failures) + || !Array.isArray(audit?.workflows) + || !Array.isArray(input?.liveWorkflows) + ) { + return failedPlan( + repository, + defaultBranchSha, + "disablement_evidence_invalid", + "Workflow disablement planning requires complete audit failures, workflow records, and live registry records.", + ); + } + + if ( + audit.schema_version !== 1 + || audit.status !== "FAIL" + || !validObservedAt(audit.observed_at) + || !validPaginationReceipts(audit.pagination_receipts) + ) { + return failedPlan( + repository, + defaultBranchSha, + "disablement_audit_not_authoritative", + "Workflow disablement requires a complete schema-v1 failing registry audit with canonical observation and pagination evidence.", + ); + } + + if ( + audit.failures.length === 0 + || audit.failures.some( + (failure) => !isRecord(failure) + || failure.code !== "active_orphan_workflow" + || !validWorkflowId(failure.workflow_id), + ) + ) { + return failedPlan( + repository, + defaultBranchSha, + "disablement_audit_not_authoritative", + "Workflow disablement is blocked unless the registry audit contains one or more exact active-orphan failures and no other failure type.", + ); + } + + const candidates = audit.workflows.filter( + (workflow) => isRecord(workflow) && workflow.classification === "active_orphan", + ); + const candidateIds = candidates.map((workflow) => workflow.workflow_id).sort((left, right) => left - right); + const failureIds = audit.failures.map((failure) => failure.workflow_id).sort((left, right) => left - right); + if ( + candidates.length === 0 + || new Set(candidateIds).size !== candidateIds.length + || new Set(failureIds).size !== failureIds.length + || JSON.stringify(candidateIds) !== JSON.stringify(failureIds) + ) { + return failedPlan( + repository, + defaultBranchSha, + "active_orphan_evidence_inconsistent", + "Every active-orphan workflow must have exactly one matching active-orphan audit failure and vice versa.", + ); + } + + const liveIds = liveWorkflows.map((workflow) => workflow?.id); + if (new Set(liveIds).size !== liveIds.length) { + return failedPlan( + repository, + defaultBranchSha, + "workflow_identity_changed", + "The live workflow registry contains a duplicate workflow ID.", + ); + } + const livePaths = liveWorkflows.map((workflow) => workflow?.path); + if (new Set(livePaths).size !== livePaths.length) { + return failedPlan( + repository, + defaultBranchSha, + "workflow_identity_changed", + "The live workflow registry contains a reused workflow path.", + ); + } + + const disablements = []; + for (const candidate of candidates) { + if ( + !validWorkflowId(candidate.workflow_id) + || !validWorkflowPath(candidate.workflow_path) + || candidate.workflow_state !== "active" + ) { + return failedPlan( + repository, + defaultBranchSha, + "workflow_identity_changed", + "An audited active-orphan workflow does not have a safe canonical identity.", + ); + } + + const live = liveWorkflows.find( + (workflow) => workflow?.id === candidate.workflow_id + && workflow?.path === candidate.workflow_path, + ); + if (!live || live.state !== "active") { + return failedPlan( + repository, + defaultBranchSha, + "workflow_identity_changed", + "An audited active-orphan workflow no longer has the exact active live registry identity.", + ); + } + + disablements.push({ + workflow_id: candidate.workflow_id, + workflow_path: candidate.workflow_path, + expected_state: "active", + }); + } + + disablements.sort((left, right) => left.workflow_id - right.workflow_id); + return freezePlan({ + status: "PASS", + repository_full_name: repository, + default_branch_sha: defaultBranchSha, + disablements, + failures: [], + }, true); +} + +/** + * Disable exactly one candidate from a freshly built process-local plan after + * revalidating its live workflow ID, path, and state. Callers supply the authorized + * mutation primitive; this module never owns credentials, transport, or batch authority. + * + * @param {object} input authentic plan, candidate, live reader, and disable callback + * @returns {Promise} immutable description of the single completed mutation + */ +export async function executeWorkflowDisablement(input) { + const plan = input?.plan; + const candidate = input?.candidate; + if (plan?.status !== "PASS" || !Array.isArray(plan?.disablements)) { + throw new Error("candidate is not part of the exact disablement plan"); + } + if (!validPlanAuthority(plan)) { + throw new Error("disablement plan authority is invalid"); + } + if ( + typeof input?.revalidateWorkflow !== "function" + || typeof input?.disableWorkflow !== "function" + ) { + throw new Error("disablement executor is invalid"); + } + if (candidate?.expected_state !== "active") { + throw new Error("candidate is not part of the exact disablement plan"); + } + + const planned = plan.disablements.find( + (item) => item.workflow_id === candidate.workflow_id + && item.workflow_path === candidate.workflow_path + && item.expected_state === "active", + ); + + if (!planned) { + throw new Error("candidate is not part of the exact disablement plan"); + } + + const live = await input.revalidateWorkflow({ + repository: plan.repository_full_name, + workflowId: planned.workflow_id, + }); + if ( + live?.id !== planned.workflow_id + || live?.path !== planned.workflow_path + || live?.state !== "active" + ) { + throw new Error("workflow identity changed before disablement"); + } + + await input.disableWorkflow({ + repository: plan.repository_full_name, + workflowId: planned.workflow_id, + }); + + return Object.freeze({ + repository_full_name: plan.repository_full_name, + workflow_id: planned.workflow_id, + workflow_path: planned.workflow_path, + prior_state: "active", + mutation: "disable", + }); +} diff --git a/test/workflow-registry-disable-plan-hardening.test.ts b/test/workflow-registry-disable-plan-hardening.test.ts new file mode 100644 index 000000000..57de0f906 --- /dev/null +++ b/test/workflow-registry-disable-plan-hardening.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildWorkflowDisablementPlan, + executeWorkflowDisablement, +} from "../scripts/workflow-registry-disable-plan.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const DEFAULT_BRANCH_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ORPHAN = { + workflow_id: 410, + workflow_path: ".github/workflows/one-shot-old-repair.yml", + workflow_state: "active", + classification: "active_orphan", +}; + +function audit(overrides: Record = {}) { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: DEFAULT_BRANCH_SHA, + observed_at: "2026-08-14T03:30:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "FAIL", + failures: [ + { + code: "active_orphan_workflow", + workflow_id: ORPHAN.workflow_id, + detail: "bounded test orphan", + }, + ], + workflows: [ORPHAN], + ...overrides, + }; +} + +function build(overrides: Record = {}) { + return buildWorkflowDisablementPlan({ + audit: audit(), + expectedRepository: REPOSITORY, + expectedDefaultBranchSha: DEFAULT_BRANCH_SHA, + liveWorkflows: [ + { + id: ORPHAN.workflow_id, + path: ORPHAN.workflow_path, + state: "active", + }, + ], + ...overrides, + }); +} + +function forgedPassingPlan() { + return { + status: "PASS", + repository_full_name: REPOSITORY, + default_branch_sha: DEFAULT_BRANCH_SHA, + disablements: [ + { + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + expected_state: "active", + }, + ], + failures: [], + }; +} + +describe("workflow disablement authority hardening", () => { + it.each([ + { name: "missing schema version", audit: audit({ schema_version: undefined }) }, + { name: "unsupported schema version", audit: audit({ schema_version: 2 }) }, + { name: "non-failing audit status", audit: audit({ status: "PASS" }) }, + { name: "missing observation time", audit: audit({ observed_at: undefined }) }, + { name: "invalid observation time", audit: audit({ observed_at: "not-a-date" }) }, + { name: "missing pagination receipts", audit: audit({ pagination_receipts: undefined }) }, + { name: "empty pagination receipts", audit: audit({ pagination_receipts: [] }) }, + { + name: "incomplete pagination", + audit: audit({ pagination_receipts: [{ page: 1, itemCount: 100, hasNext: true }] }), + }, + { + name: "non-sequential pagination", + audit: audit({ + pagination_receipts: [ + { page: 1, itemCount: 100, hasNext: true }, + { page: 3, itemCount: 1, hasNext: false }, + ], + }), + }, + { + name: "empty orphan authority", + audit: audit({ failures: [], workflows: [] }), + }, + ])("rejects $name", ({ audit: untrustedAudit }) => { + const result = build({ audit: untrustedAudit }); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + }); + + it.each([ + ".github/workflows/../current.yml", + ".github/workflows//orphan.yml", + ".github/workflows/orphan.txt", + ".github/workflows/orphan.yml/child", + ".github/workflows/./orphan.yml", + ".github/workflows/orphan\\repair.yml", + ])("rejects a non-canonical workflow path %s", (workflowPath) => { + const candidate = { ...ORPHAN, workflow_path: workflowPath }; + const result = build({ + audit: audit({ workflows: [candidate] }), + liveWorkflows: [{ id: ORPHAN.workflow_id, path: workflowPath, state: "active" }], + }); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + }); + + it.each([ + { + name: "different repository", + plan: { + ...forgedPassingPlan(), + repository_full_name: "ContextualWisdomLab/other", + }, + }, + { + name: "invalid protected-main SHA", + plan: { + ...forgedPassingPlan(), + default_branch_sha: "not-a-sha", + }, + }, + { + name: "unsafe planned workflow path", + plan: { + ...forgedPassingPlan(), + disablements: [ + { + workflow_id: ORPHAN.workflow_id, + workflow_path: ".github/workflows/../current.yml", + expected_state: "active", + }, + ], + }, + }, + { + name: "structurally valid but unauthenticated plan", + plan: forgedPassingPlan(), + }, + { + name: "serialized clone of an authentic plan", + plan: structuredClone(build()), + }, + ])("does not execute a forged PASS plan with $name", async ({ plan }) => { + const revalidateWorkflow = vi.fn(async () => ({ + id: ORPHAN.workflow_id, + path: ORPHAN.workflow_path, + state: "active", + })); + const disableWorkflow = vi.fn(async () => undefined); + + await expect( + executeWorkflowDisablement({ + plan, + candidate: plan.disablements[0], + revalidateWorkflow, + disableWorkflow, + }), + ).rejects.toThrow("disablement plan authority is invalid"); + expect(revalidateWorkflow).not.toHaveBeenCalled(); + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it("returns an immutable in-process plan authority", () => { + const plan = build(); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(plan.disablements)).toBe(true); + expect(Object.isFrozen(plan.disablements[0])).toBe(true); + expect(Object.isFrozen(plan.failures)).toBe(true); + }); +}); diff --git a/test/workflow-registry-disable-plan.test.ts b/test/workflow-registry-disable-plan.test.ts new file mode 100644 index 000000000..e799ccdd5 --- /dev/null +++ b/test/workflow-registry-disable-plan.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildWorkflowDisablementPlan, + executeWorkflowDisablement, +} from "../scripts/workflow-registry-disable-plan.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const DEFAULT_BRANCH_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ORPHAN = { + workflow_id: 410, + workflow_path: ".github/workflows/one-shot-old-repair.yml", + workflow_state: "active", + classification: "active_orphan", +}; + +function authoritativeAudit(overrides: Record = {}) { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: DEFAULT_BRANCH_SHA, + observed_at: "2026-08-14T03:30:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "FAIL", + failures: [ + { + code: "active_orphan_workflow", + workflow_id: ORPHAN.workflow_id, + detail: "bounded test orphan", + }, + ], + workflows: [ORPHAN], + ...overrides, + }; +} + +function matchingLiveRegistry() { + return [ + { + id: ORPHAN.workflow_id, + path: ORPHAN.workflow_path, + state: "active", + }, + ]; +} + +function plan(overrides: Record = {}) { + return buildWorkflowDisablementPlan({ + audit: authoritativeAudit(), + expectedRepository: REPOSITORY, + expectedDefaultBranchSha: DEFAULT_BRANCH_SHA, + liveWorkflows: matchingLiveRegistry(), + ...overrides, + }); +} + +describe("workflow registry disablement planning", () => { + it("plans only exact active-orphan identities from authoritative evidence", () => { + expect(plan()).toEqual({ + status: "PASS", + repository_full_name: REPOSITORY, + default_branch_sha: DEFAULT_BRANCH_SHA, + disablements: [ + { + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + expected_state: "active", + }, + ], + failures: [], + }); + }); + + it("fails closed with null evidence identities when planning input is absent", () => { + const result = buildWorkflowDisablementPlan(undefined); + expect(result.status).toBe("FAIL"); + expect(result.repository_full_name).toBeNull(); + expect(result.default_branch_sha).toBeNull(); + expect(result.disablements).toEqual([]); + expect(result.failures[0]?.code).toBe("repository_identity_invalid"); + }); + + it.each([ + { + name: "repository drift", + overrides: { + audit: authoritativeAudit({ repository_full_name: "ContextualWisdomLab/other" }), + }, + }, + { + name: "unsupported expected repository", + overrides: { + expectedRepository: "ContextualWisdomLab/other", + audit: authoritativeAudit({ repository_full_name: "ContextualWisdomLab/other" }), + }, + }, + { + name: "default-branch drift", + overrides: { + expectedDefaultBranchSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + }, + { + name: "missing default-branch identity", + overrides: { expectedDefaultBranchSha: undefined }, + }, + { + name: "invalid default-branch identity", + overrides: { expectedDefaultBranchSha: "not-a-sha" }, + }, + { + name: "missing audit failures", + overrides: { audit: authoritativeAudit({ failures: undefined }) }, + }, + { + name: "missing audit workflows", + overrides: { audit: authoritativeAudit({ workflows: undefined }) }, + }, + { + name: "missing live registry", + overrides: { liveWorkflows: undefined }, + }, + { + name: "non-orphan audit failure", + overrides: { + audit: authoritativeAudit({ + failures: [ + ...authoritativeAudit().failures, + { code: "workflow_pagination_incomplete", detail: "missing page" }, + ], + }), + }, + }, + { + name: "live state changed", + overrides: { + liveWorkflows: [ + { + id: ORPHAN.workflow_id, + path: ORPHAN.workflow_path, + state: "disabled_manually", + }, + ], + }, + }, + { + name: "live identity changed", + overrides: { + liveWorkflows: [ + { + id: ORPHAN.workflow_id, + path: ".github/workflows/current.yml", + state: "active", + }, + ], + }, + }, + { + name: "live identity missing", + overrides: { liveWorkflows: [] }, + }, + { + name: "duplicate live workflow id", + overrides: { liveWorkflows: [...matchingLiveRegistry(), ...matchingLiveRegistry()] }, + }, + { + name: "live path is reused by another workflow id", + overrides: { + liveWorkflows: [ + ...matchingLiveRegistry(), + { id: 411, path: ORPHAN.workflow_path, state: "active" }, + ], + }, + }, + ])("fails closed on $name", ({ overrides }) => { + const result = plan(overrides); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + expect(result.failures.length).toBeGreaterThan(0); + }); + + it.each([ + { name: "unsafe workflow id", workflow: { ...ORPHAN, workflow_id: 0 } }, + { name: "non-string workflow path", workflow: { ...ORPHAN, workflow_path: 123 } }, + { name: "out-of-scope workflow path", workflow: { ...ORPHAN, workflow_path: "README.md" } }, + { name: "non-active audit state", workflow: { ...ORPHAN, workflow_state: "disabled_manually" } }, + ])("rejects malformed active-orphan evidence: $name", ({ workflow }) => { + const result = plan({ + audit: authoritativeAudit({ workflows: [workflow] }), + }); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + }); + + it("rejects an active-orphan candidate without its matching audit failure", () => { + const result = plan({ audit: authoritativeAudit({ failures: [] }) }); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + }); + + it("rejects an active-orphan failure that has no candidate workflow", () => { + const result = plan({ audit: authoritativeAudit({ workflows: [] }) }); + expect(result.status).toBe("FAIL"); + expect(result.disablements).toEqual([]); + }); + + it("sorts a multi-orphan plan deterministically by workflow id", () => { + const second = { + workflow_id: 409, + workflow_path: ".github/workflows/older-one-shot.yml", + workflow_state: "active", + classification: "active_orphan", + }; + const result = plan({ + audit: authoritativeAudit({ + workflows: [ORPHAN, second], + failures: [ + { code: "active_orphan_workflow", workflow_id: ORPHAN.workflow_id }, + { code: "active_orphan_workflow", workflow_id: second.workflow_id }, + ], + }), + liveWorkflows: [ + ...matchingLiveRegistry(), + { id: second.workflow_id, path: second.workflow_path, state: "active" }, + ], + }); + + expect(result.status).toBe("PASS"); + expect(result.disablements.map((item: { workflow_id: number }) => item.workflow_id)).toEqual([ + 409, + 410, + ]); + }); +}); + +describe("single workflow disablement execution", () => { + it("revalidates the exact id, path, and active state immediately before disablement", async () => { + const currentPlan = plan(); + const candidate = currentPlan.disablements[0]!; + const revalidateWorkflow = vi.fn(async () => matchingLiveRegistry()[0]); + const disableWorkflow = vi.fn(async () => undefined); + + await expect( + executeWorkflowDisablement({ + plan: currentPlan, + candidate, + revalidateWorkflow, + disableWorkflow, + }), + ).resolves.toEqual({ + repository_full_name: REPOSITORY, + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + prior_state: "active", + mutation: "disable", + }); + + expect(revalidateWorkflow).toHaveBeenCalledWith({ + repository: REPOSITORY, + workflowId: ORPHAN.workflow_id, + }); + expect(disableWorkflow).toHaveBeenCalledWith({ + repository: REPOSITORY, + workflowId: ORPHAN.workflow_id, + }); + }); + + it.each([ + { name: "id changed", live: { id: 999, path: ORPHAN.workflow_path, state: "active" } }, + { + name: "path changed", + live: { id: ORPHAN.workflow_id, path: ".github/workflows/current.yml", state: "active" }, + }, + { + name: "state changed", + live: { id: ORPHAN.workflow_id, path: ORPHAN.workflow_path, state: "disabled_manually" }, + }, + ])("does not mutate when immediate revalidation has $name", async ({ live }) => { + const currentPlan = plan(); + const candidate = currentPlan.disablements[0]!; + const disableWorkflow = vi.fn(async () => undefined); + + await expect( + executeWorkflowDisablement({ + plan: currentPlan, + candidate, + revalidateWorkflow: async () => live, + disableWorkflow, + }), + ).rejects.toThrow("workflow identity changed before disablement"); + + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "unplanned workflow id", + candidate: { + workflow_id: 999, + workflow_path: ORPHAN.workflow_path, + expected_state: "active", + }, + }, + { + name: "unplanned workflow path", + candidate: { + workflow_id: ORPHAN.workflow_id, + workflow_path: ".github/workflows/not-planned.yml", + expected_state: "active", + }, + }, + { + name: "unplanned expected state", + candidate: { + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + expected_state: "disabled_manually", + }, + }, + ])("rejects $name", async ({ candidate }) => { + const disableWorkflow = vi.fn(async () => undefined); + await expect( + executeWorkflowDisablement({ + plan: plan(), + candidate, + revalidateWorkflow: async () => matchingLiveRegistry()[0], + disableWorkflow, + }), + ).rejects.toThrow("candidate is not part of the exact disablement plan"); + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it("rejects candidates from a non-passing plan", async () => { + const failed = plan({ expectedRepository: "ContextualWisdomLab/other" }); + const disableWorkflow = vi.fn(async () => undefined); + await expect( + executeWorkflowDisablement({ + plan: failed, + candidate: { + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + expected_state: "active", + }, + revalidateWorkflow: async () => matchingLiveRegistry()[0], + disableWorkflow, + }), + ).rejects.toThrow("candidate is not part of the exact disablement plan"); + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it("rejects a malformed passing plan that has no disablement inventory", async () => { + const disableWorkflow = vi.fn(async () => undefined); + await expect( + executeWorkflowDisablement({ + plan: { + status: "PASS", + repository_full_name: REPOSITORY, + default_branch_sha: DEFAULT_BRANCH_SHA, + }, + candidate: { + workflow_id: ORPHAN.workflow_id, + workflow_path: ORPHAN.workflow_path, + expected_state: "active", + }, + revalidateWorkflow: async () => matchingLiveRegistry()[0], + disableWorkflow, + }), + ).rejects.toThrow("candidate is not part of the exact disablement plan"); + expect(disableWorkflow).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index fef24afa4..1109f0f35 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "scripts/normalize-commercial-readiness-evidence.mjs", "scripts/prepare-agent-pr-message.mjs", "scripts/workflow-registry-audit.mjs", + "scripts/workflow-registry-disable-plan.mjs", "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/external-scheduler-evidence-audit.mjs", ],