fix(#338): Route all state-changing fetch() calls through API client

- Replace raw fetch() with apiPost/apiPatch/apiDelete in:
  - ImportExportActions.tsx: POST for file imports
  - KanbanBoard.tsx: PATCH for task status updates
  - ActiveProjectsWidget.tsx: POST for widget data fetches
  - useLayouts.ts: POST/PATCH/DELETE for layout management
- Add apiPostFormData() method to API client for FormData uploads
- Ensures CSRF token is included in all state-changing requests
- Update tests to mock CSRF token fetch for API client usage

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 17:06:23 -06:00
parent 5ae07f7a84
commit 344e5df3bb
7 changed files with 198 additions and 119 deletions

View File

@@ -8,6 +8,7 @@ import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { KanbanColumn } from "./KanbanColumn";
import { TaskCard } from "./TaskCard";
import { apiPatch } from "@/lib/api/client";
interface KanbanBoardProps {
tasks: Task[];
@@ -93,19 +94,9 @@ export function KanbanBoard({ tasks, onStatusChange }: KanbanBoardProps): React.
const task = (tasks || []).find((t) => t.id === taskId);
if (task && task.status !== newStatus) {
// Call PATCH /api/tasks/:id to update status
// Call PATCH /api/tasks/:id to update status (using API client for CSRF protection)
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status: newStatus }),
});
if (!response.ok) {
throw new Error(`Failed to update task status: ${response.statusText}`);
}
await apiPatch(`/api/tasks/${taskId}`, { status: newStatus });
// Optionally call the callback for parent component to refresh
if (onStatusChange) {

View File

@@ -2,6 +2,7 @@
import { useState, useRef } from "react";
import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { apiPostFormData } from "@/lib/api/client";
interface ImportResult {
filename: string;
@@ -63,17 +64,8 @@ export function ImportExportActions({
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/knowledge/import", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = (await response.json()) as { message?: string };
throw new Error(error.message ?? "Import failed");
}
const result = (await response.json()) as ImportResponse;
// Use API client to ensure CSRF token is included
const result = await apiPostFormData<ImportResponse>("/api/knowledge/import", formData);
setImportResult(result);
// Notify parent component

View File

@@ -6,6 +6,7 @@
import { useState, useEffect } from "react";
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
import { apiPost } from "@/lib/api/client";
interface ActiveProject {
id: string;
@@ -43,14 +44,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
useEffect(() => {
const fetchProjects = async (): Promise<void> => {
try {
const response = await fetch("/api/widgets/data/active-projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (response.ok) {
const data = (await response.json()) as ActiveProject[];
setProjects(data);
}
// Use API client to ensure CSRF token is included
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
setProjects(data);
} catch (error) {
console.error("Failed to fetch active projects:", error);
} finally {
@@ -71,14 +67,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
useEffect(() => {
const fetchAgentSessions = async (): Promise<void> => {
try {
const response = await fetch("/api/widgets/data/agent-chains", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (response.ok) {
const data = (await response.json()) as AgentSession[];
setAgentSessions(data);
}
// Use API client to ensure CSRF token is included
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
setAgentSessions(data);
} catch (error) {
console.error("Failed to fetch agent sessions:", error);
} finally {

View File

@@ -3,26 +3,48 @@
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
import userEvent from "@testing-library/user-event";
import { clearCsrfToken } from "@/lib/api/client";
// Mock fetch for API calls
global.fetch = vi.fn() as typeof global.fetch;
// Helper to create mock CSRF token response
const mockCsrfResponse = (): Response =>
({
ok: true,
json: () => Promise.resolve({ token: "test-csrf-token" }),
}) as Response;
describe("ActiveProjectsWidget", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
});
it("should render loading state initially", (): void => {
vi.mocked(global.fetch).mockImplementation(
() =>
new Promise(() => {
// Intentionally empty - creates a never-resolving promise for loading state
})
);
// First call returns CSRF token, subsequent calls never resolve (loading state)
let csrfReturned = false;
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token on first request
if (urlString.includes("csrf") && !csrfReturned) {
csrfReturned = true;
return Promise.resolve(mockCsrfResponse());
}
// All other requests never resolve
return new Promise(() => {
// Intentionally empty - creates a never-resolving promise for loading state
});
});
render(<ActiveProjectsWidget id="active-projects-1" />);
@@ -57,6 +79,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
if (urlString.includes("active-projects")) {
return Promise.resolve({
ok: true,
@@ -103,6 +129,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
if (urlString.includes("active-projects")) {
return Promise.resolve({
ok: true,
@@ -127,12 +157,18 @@ describe("ActiveProjectsWidget", (): void => {
});
it("should handle empty states", async (): Promise<void> => {
vi.mocked(global.fetch).mockImplementation(() =>
Promise.resolve({
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
} as Response)
);
} as Response);
});
render(<ActiveProjectsWidget id="active-projects-1" />);
@@ -161,6 +197,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
if (urlString.includes("agent-chains")) {
return Promise.resolve({
ok: true,
@@ -207,6 +247,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
if (urlString.includes("agent-chains")) {
return Promise.resolve({
ok: true,
@@ -251,6 +295,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
// Return CSRF token
if (urlString.includes("csrf")) {
return Promise.resolve(mockCsrfResponse());
}
if (urlString.includes("active-projects")) {
return Promise.resolve({
ok: true,