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:
@@ -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.
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user