FN-8695: fix dashboard assertion CRUD validation
Restore dashboard assertion status edits and deletes for store-generated IDs. - Validate assertion route IDs against the MissionStore-generated format while preserving legacy IDs. - Dispatch assertion deletes through the shared confirmation handler and surface request failures. - Cover current ID CRUD routes and desktop/mobile deletion behavior. Files changed: .changeset/fn-8695-assertion-id-validation.md | 7 ++ docs/missions.md | 2 +- .../dashboard/app/components/MissionManager.tsx | 51 ++++++++--- .../MissionManager.delete-confirm.test.tsx | 99 ++++++++++++++++++++ .../mission-assertion-id-validation.test.ts | 100 +++++++++++++++++++++ packages/dashboard/src/mission-routes.ts | 8 +- 6 files changed, 249 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-8695 Fusion-Task-Lineage: b7e2d70f-d3c7-4fdd-92f6-989cf414570b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8695-assertion-id-validation.md
Normal file
7
.changeset/fn-8695-assertion-id-validation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix mission assertion status edits and deletes failing with invalid assertion IDs.
|
||||
category: fix
|
||||
dev: Align dashboard assertion ID validation with MissionStore-generated IDs and surface delete failures.
|
||||
@@ -451,7 +451,7 @@ Contract assertions (`MissionContractAssertion`) formalize what must be true for
|
||||
|
||||
```typescript
|
||||
interface MissionContractAssertion {
|
||||
id: string; // e.g., "CA-A3B7CD-E9F2"
|
||||
id: string; // e.g., "CA-MS39KJP3-000A-8ABO" (legacy: "CA-A3B7CD-E9F2")
|
||||
milestoneId: string; // Parent milestone
|
||||
sourceFeatureId?: string;// Store-managed feature assertion owner
|
||||
scope: "feature" | "milestone";
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
fetchAssertions,
|
||||
createAssertion,
|
||||
updateAssertion,
|
||||
deleteAssertion,
|
||||
linkFeatureToAssertion,
|
||||
unlinkFeatureFromAssertion,
|
||||
fetchFeaturesForAssertion,
|
||||
@@ -940,7 +941,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}, [isActive, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError]);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string; milestoneId?: string } | null>(null);
|
||||
|
||||
// Assertion panel state
|
||||
const [assertionsByMilestone, setAssertionsByMilestone] = useState<Map<string, MissionContractAssertion[]>>(new Map());
|
||||
@@ -2212,6 +2213,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}
|
||||
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
|
||||
|
||||
const handleDeleteAssertion = useCallback(async (assertionId: string, milestoneId: string) => {
|
||||
await deleteAssertion(assertionId, projectId);
|
||||
addToast(t("missions.assertionDeleted", "Assertion deleted"), "success");
|
||||
await loadAssertionsForMilestone(milestoneId);
|
||||
await loadValidationRollup(milestoneId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
|
||||
|
||||
const handleEditAssertion = useCallback((assertion: MissionContractAssertion) => {
|
||||
setEditingAssertionId(assertion.id);
|
||||
setAssertionForm({
|
||||
@@ -4065,8 +4074,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id })}
|
||||
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id, milestoneId: milestone.id })}
|
||||
title={t("missions.deleteAssertion", "Delete assertion")}
|
||||
aria-label={t("missions.deleteAssertion", "Delete assertion")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -4975,6 +4985,30 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:MissionAssertions 2026-08-01-19:44:
|
||||
Every deleteConfirmId type must dispatch a deletion, and the shared confirmation panel must surface rejected requests. Assertion deletion is an operator recovery path for validation failures, so a silent no-op would leave stale rollups unrepairable.
|
||||
*/
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
|
||||
try {
|
||||
if (deleteConfirmId.type === "milestone") {
|
||||
await handleDeleteMilestone(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "slice") {
|
||||
await handleDeleteSlice(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "feature") {
|
||||
await handleDeleteFeature(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "assertion" && deleteConfirmId.milestoneId) {
|
||||
await handleDeleteAssertion(deleteConfirmId.id, deleteConfirmId.milestoneId);
|
||||
} else if (deleteConfirmId.type === "interview_draft") {
|
||||
await handleDiscardInterviewSession(deleteConfirmId.id);
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete item"), "error");
|
||||
}
|
||||
}, [addToast, deleteConfirmId, handleDeleteAssertion, handleDeleteFeature, handleDeleteMilestone, handleDeleteSlice, handleDiscardInterviewSession, t]);
|
||||
|
||||
const renderDeleteConfirmPanel = () => {
|
||||
if (deleteConfirmId?.type === "mission") {
|
||||
return null;
|
||||
@@ -4992,18 +5026,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<div className="mission-confirm-panel__actions">
|
||||
<button
|
||||
className="mission-btn mission-btn--danger"
|
||||
onClick={async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
if (deleteConfirmId.type === "milestone") {
|
||||
await handleDeleteMilestone(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "slice") {
|
||||
await handleDeleteSlice(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "feature") {
|
||||
await handleDeleteFeature(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "interview_draft") {
|
||||
await handleDiscardInterviewSession(deleteConfirmId.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => { void handleConfirmDelete(); }}
|
||||
>
|
||||
{isInterviewDraftDelete ? t("missions.discardButton", "Discard") : t("missions.deleteButton", "Delete")}
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ const mockFetchAiSession = vi.fn();
|
||||
const mockFetchMissionInterviewDrafts = vi.fn();
|
||||
const mockDiscardMissionInterviewDraft = vi.fn();
|
||||
const mockDeleteMission = vi.fn();
|
||||
const mockDeleteAssertion = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
@@ -68,6 +69,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
|
||||
discardMissionInterviewDraft: (...args: unknown[]) => mockDiscardMissionInterviewDraft(...args),
|
||||
deleteMission: (...args: unknown[]) => mockDeleteMission(...args),
|
||||
deleteAssertion: (...args: unknown[]) => mockDeleteAssertion(...args),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
};
|
||||
});
|
||||
@@ -139,6 +141,7 @@ function setupMocks() {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
|
||||
mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true });
|
||||
mockDeleteMission.mockResolvedValue(undefined);
|
||||
mockDeleteAssertion.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
function renderMissionManager(addToast = vi.fn()) {
|
||||
@@ -190,6 +193,49 @@ async function findMissionListItem(title: string): Promise<HTMLElement> {
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function makeAssertion(id: string) {
|
||||
return {
|
||||
id,
|
||||
milestoneId: "MS-001",
|
||||
title: `Assertion ${id}`,
|
||||
assertion: "The contract holds",
|
||||
status: "pending" as const,
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function missionWithAssertions() {
|
||||
return {
|
||||
...missionDetails.get("M-001"),
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-001",
|
||||
title: "Quality gate",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
interviewState: "not_started",
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
slices: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
async function openAssertionDeletePanel(assertionId: string) {
|
||||
fireEvent.click(await findMissionListItem("Build Auth System"));
|
||||
await screen.findByText("Quality gate");
|
||||
const assertion = await screen.findByText(`Assertion ${assertionId}`);
|
||||
const row = assertion.closest(".mission-assertion");
|
||||
expect(row).not.toBeNull();
|
||||
const deleteButton = (row as HTMLElement).querySelector('button[title="Delete assertion"]');
|
||||
expect(deleteButton).not.toBeNull();
|
||||
fireEvent.click(deleteButton as HTMLButtonElement);
|
||||
return getConfirmPanel();
|
||||
}
|
||||
|
||||
async function openListDeleteDialog(title: string, container: HTMLElement) {
|
||||
const item = await findMissionListItem(title);
|
||||
fireEvent.click(within(item).getByRole("button", { name: "Delete mission" }));
|
||||
@@ -286,6 +332,59 @@ describe("MissionManager mission delete confirmation", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error");
|
||||
});
|
||||
|
||||
it.each(["desktop", "mobile"] as const)("deletes an assertion and refreshes its milestone in the %s panel", async (viewport) => {
|
||||
mockViewportMode.mockReturnValue(viewport);
|
||||
mockFetchMission.mockResolvedValue(missionWithAssertions());
|
||||
mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-TEST")]);
|
||||
renderMissionManager();
|
||||
|
||||
await openAssertionDeletePanel("CA-CURRENT-0001-TEST");
|
||||
clickConfirmPanelAction("Delete");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAssertion).toHaveBeenCalledWith("CA-CURRENT-0001-TEST", projectId);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAssertions.mock.calls.filter(([milestoneId]) => milestoneId === "MS-001")).toHaveLength(2);
|
||||
expect(mockFetchMilestoneValidation).toHaveBeenCalledWith("MS-001", projectId);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an assertion delete failure instead of silently swallowing it", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchMission.mockResolvedValue(missionWithAssertions());
|
||||
mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-FAIL")]);
|
||||
mockDeleteAssertion.mockRejectedValueOnce(new Error("assertion delete failed upstream"));
|
||||
renderMissionManager(addToast);
|
||||
|
||||
const panel = await openAssertionDeletePanel("CA-CURRENT-0001-FAIL");
|
||||
clickConfirmPanelAction("Delete");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("assertion delete failed upstream", "error");
|
||||
});
|
||||
expect(panel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps zero and multiple assertion states distinct", async () => {
|
||||
mockFetchMission.mockResolvedValue(missionWithAssertions());
|
||||
mockFetchAssertions.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
makeAssertion("CA-CURRENT-0001-FIRST"),
|
||||
makeAssertion("CA-CURRENT-0002-SECOND"),
|
||||
]);
|
||||
renderMissionManager();
|
||||
|
||||
fireEvent.click(await findMissionListItem("Build Auth System"));
|
||||
const milestone = await screen.findByText("Quality gate");
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll(".mission-assertion")).toHaveLength(0);
|
||||
});
|
||||
fireEvent.click(milestone);
|
||||
fireEvent.click(milestone);
|
||||
expect(await screen.findByText("Assertion CA-CURRENT-0001-FIRST")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assertion CA-CURRENT-0002-SECOND")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discards the selected desktop draft row without removing duplicate-titled drafts or missions", async () => {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([
|
||||
makeDraft({ id: "draft-duplicate-a", title: "Shared draft", status: "awaiting_input" }),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
FNXC:MissionAssertions 2026-08-01-19:44:
|
||||
Route validation must accept IDs emitted by MissionStore rather than a hand-written approximation. This protects every assertion CRUD and feature-link route when the store's generated ID segments evolve.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { MissionStore, type Database, type TaskStore } from "@fusion/core";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const FEATURE_ID = "F-TEST";
|
||||
const LEGACY_ASSERTION_ID = "CA-A3B7CD-E9F2";
|
||||
|
||||
function createStoreGeneratedAssertionId(): string {
|
||||
const database = {
|
||||
prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }),
|
||||
bumpLastModified: vi.fn(),
|
||||
} as unknown as Database;
|
||||
const missionStore = new MissionStore("/tmp/mission-assertion-id-validation", database);
|
||||
|
||||
return (missionStore as unknown as { generateId(prefix: string): string }).generateId("CA");
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const assertionId = createStoreGeneratedAssertionId();
|
||||
const assertion = { id: assertionId, milestoneId: "MS-TEST", title: "Assertion", assertion: "It holds", status: "pending" };
|
||||
const missionStore = {
|
||||
getContractAssertion: vi.fn(async (id: string) => id.startsWith("CA-") ? { ...assertion, id } : undefined),
|
||||
updateContractAssertion: vi.fn(async (id: string) => ({ ...assertion, id, status: "passed" })),
|
||||
deleteContractAssertion: vi.fn(async () => undefined),
|
||||
linkFeatureToAssertion: vi.fn(async () => undefined),
|
||||
unlinkFeatureFromAssertion: vi.fn(async () => undefined),
|
||||
listFeaturesForAssertion: vi.fn(async () => [{ id: FEATURE_ID }]),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
const store = {
|
||||
getMissionStore: () => missionStore,
|
||||
getGoalStore: () => ({ getGoal: vi.fn(), listGoals: vi.fn() }),
|
||||
getRootDir: () => "/tmp/mission-assertion-id-validation",
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
backendMode: true,
|
||||
} as unknown as TaskStore;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/missions", createMissionRouter(store));
|
||||
|
||||
return { app, assertionId, missionStore };
|
||||
}
|
||||
|
||||
describe("mission assertion ID validation", () => {
|
||||
let fixture: ReturnType<typeof createFixture>;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = createFixture();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET /assertions/:assertionId", "GET", (id: string) => `/api/missions/assertions/${id}`],
|
||||
["PATCH /assertions/:assertionId", "PATCH", (id: string) => `/api/missions/assertions/${id}`],
|
||||
["DELETE /assertions/:assertionId", "DELETE", (id: string) => `/api/missions/assertions/${id}`],
|
||||
["POST /features/:featureId/assertions/:assertionId/link", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/link`],
|
||||
["POST /features/:featureId/assertions/:assertionId/unlink", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/unlink`],
|
||||
["GET /assertions/:assertionId/features", "GET", (id: string) => `/api/missions/assertions/${id}/features`],
|
||||
] as const)("accepts a current MissionStore ID on %s", async (_name, method, pathFor) => {
|
||||
const response = await request(
|
||||
fixture.app,
|
||||
method,
|
||||
pathFor(fixture.assertionId),
|
||||
method === "PATCH" ? JSON.stringify({ status: "passed" }) : undefined,
|
||||
method === "PATCH" ? { "content-type": "application/json" } : undefined,
|
||||
);
|
||||
|
||||
expect(fixture.assertionId).toMatch(/^CA-[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
|
||||
expect(response.status).not.toBe(400);
|
||||
expect(response.body).not.toEqual({ error: "Invalid assertion ID format" });
|
||||
});
|
||||
|
||||
it("accepts legacy two-segment assertion IDs", async () => {
|
||||
const response = await request(fixture.app, "GET", `/api/missions/assertions/${LEGACY_ASSERTION_ID}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"M-A3B7CD-0001-E9F2",
|
||||
"CA-",
|
||||
"%20",
|
||||
"CA-%2E%2E%2F%2E%2E%2Fetc",
|
||||
"CA-A3B7CD%2F0001-E9F2",
|
||||
"CA-A3B7CD-0001-INVALID!",
|
||||
])("rejects invalid assertion ID %j", async (assertionId) => {
|
||||
const response = await request(fixture.app, "GET", `/api/missions/assertions/${assertionId}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: "Invalid assertion ID format" });
|
||||
});
|
||||
});
|
||||
@@ -155,10 +155,12 @@ function validateOptionalWorkflowId(workflowId: unknown): string | null | undefi
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionAssertions 2026-08-01-19:44:
|
||||
The assertion guard landed on 2026-04-11 for two-segment IDs, but MissionStore.generateId added its idSequence segment on 2026-05-04. Keep this validator aligned with every dash-separated alphanumeric segment emitted by MissionStore while preserving legacy assertion rows.
|
||||
*/
|
||||
function validateAssertionId(id: string): boolean {
|
||||
// Assertion IDs follow format: CA-{base36timestamp}-{random}
|
||||
// e.g., CA-A3B7CD-E9F2
|
||||
return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id);
|
||||
return /^CA-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
|
||||
}
|
||||
|
||||
function validateGoalId(id: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user