FN-7564: block approve/reject-plan API bypass of release-authorization holds

Enforce the FN-7559 release-authorization hold in the approve-plan and reject-plan routes, since a direct API call previously bypassed the dashboard's UI-only protection.

- Add a guard in register-task-workflow-routes.ts: reject approve-plan and reject-plan requests with 400 when task.awaitingApprovalReason === "release-authorization", instructing the caller to add the **Release Authorized By User:** yes marker instead.
- Add regression tests in routes-github.test.ts covering both approve-plan and reject-plan against release-authorization-held tasks.
- Add changeset fn-7564-approve-plan-release-authorization-guard.md (patch, security) documenting the fix.

Files changed:
 .changeset/fn-7564-approve-plan-release-authorization-guard.md | 7 ++
 packages/dashboard/src/__tests__/routes-github.test.ts | 82 ++++++++++++++++++++++
 packages/dashboard/src/routes/register-task-workflow-routes.ts | 23 ++++++
 3 files changed, 112 insertions(+)

Fusion-Task-Id: FN-7564

Fusion-Task-Lineage: ccadd492-ee2b-4f0e-af2f-5f37c6351922

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 22:35:30 -07:00
parent 71dfd3aeca
commit 24b27e8b38
3 changed files with 112 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Plan approve/reject API now blocks release-authorization holds, requiring the authorization marker first.
category: fix
dev: FN-7564 — POST /tasks/:id/approve-plan and /reject-plan now return 400 when task.awaitingApprovalReason === "release-authorization" (FN-7559 discriminator), enforcing the FN-6481 release-authorization gate at the API layer regardless of client. Manual-approval holds are unaffected.

View File

@@ -2043,6 +2043,48 @@ describe("POST /tasks/:id/approve-plan", () => {
expect(res.status).toBe(500);
expect(res.body.error).toBe("Database error");
});
// FN-7564: a release-authorization hold (FN-7559's awaitingApprovalReason
// discriminator) must reject a direct approve-plan API call with 400 — the
// FN-6481 authorization-marker requirement must not be bypassable outside the UI.
it("returns 400 and does not move the task for a release-authorization hold", async () => {
const releaseHoldTask = {
...FAKE_TASK_DETAIL,
column: "triage" as const,
status: "awaiting-approval" as const,
awaitingApprovalReason: "release-authorization" as const,
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(releaseHoldTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
expect(res.status).toBe(400);
expect(res.body.error).toContain("release authorization");
expect(res.body.error).toContain("Release Authorized By User");
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
// Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason)
// must still approve exactly as before — the guard is scoped to release-authorization only.
it("still approves a manual-approval hold with awaitingApprovalReason unset", async () => {
const awaitingTask = {
...FAKE_TASK_DETAIL,
column: "triage" as const,
status: "awaiting-approval" as const,
awaitingApprovalReason: undefined,
};
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...movedTask, status: undefined });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
});
describe("POST /tasks/:id/reject-plan", () => {
@@ -2119,6 +2161,46 @@ describe("POST /tasks/:id/reject-plan", () => {
expect(res.status).toBe(500);
expect(res.body.error).toBe("Database error");
});
// FN-7564: a release-authorization hold must reject a direct reject-plan API
// call with 400 too — rejecting must not silently wipe/regenerate the spec
// without the operator ever acknowledging the FN-6481 authorization gate.
it("returns 400 and does not clear status or remove PROMPT.md for a release-authorization hold", async () => {
const releaseHoldTask = {
...FAKE_TASK_DETAIL,
column: "triage" as const,
status: "awaiting-approval" as const,
awaitingApprovalReason: "release-authorization" as const,
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(releaseHoldTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
expect(res.status).toBe(400);
expect(res.body.error).toContain("release authorization");
expect(res.body.error).toContain("Release Authorized By User");
expect(store.updateTask).not.toHaveBeenCalled();
});
// Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason)
// must still reject exactly as before — the guard is scoped to release-authorization only.
it("still rejects a manual-approval hold with awaitingApprovalReason unset", async () => {
const awaitingTask = {
...FAKE_TASK_DETAIL,
column: "triage" as const,
status: "awaiting-approval" as const,
awaitingApprovalReason: undefined,
};
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(awaitingTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
});
});
// --- Task diff route tests ---

View File

@@ -2737,6 +2737,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (task.status !== "awaiting-approval") {
throw badRequest("Task must have status 'awaiting-approval' to approve plan");
}
// FNXC:ReleaseAuthorizationGate 2026-07-04-22:30:
// FN-6481 requires a release-class task to carry an explicit
// "**Release Authorized By User:** yes" marker before it can dispatch.
// FN-7559 parks such tasks with awaitingApprovalReason === "release-authorization"
// (distinct from an ordinary manual plan-approval hold) and hides the
// Approve/Reject Plan buttons in the dashboard UI, but a direct API call bypasses
// that UI protection. Enforce the gate here too so no client can dispatch a
// release-class task without the authorization marker.
if (task.awaitingApprovalReason === "release-authorization") {
throw badRequest(
"This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of approving the plan.",
);
}
// Log the approval
await scopedStore.logEntry(task.id, "Plan approved by user");
@@ -2769,6 +2782,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (task.status !== "awaiting-approval") {
throw badRequest("Task must have status 'awaiting-approval' to reject plan");
}
// FNXC:ReleaseAuthorizationGate 2026-07-04-22:30:
// Mirror of the approve-plan guard above: a release-authorization hold
// (FN-7559's awaitingApprovalReason discriminator) must not be rejectable
// through the API either, since rejecting would wipe/regenerate the spec
// without the operator ever acknowledging the FN-6481 authorization gate.
if (task.awaitingApprovalReason === "release-authorization") {
throw badRequest(
"This task is held for release authorization. Add the **Release Authorized By User:** yes marker to its PROMPT.md and resubmit the spec instead of rejecting the plan.",
);
}
// Log the rejection
await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated");