// @vitest-environment node // // Regression coverage for the B-009 follow-up bug: // validateCSRFToken used to call `request.clone().formData()` even when the // route action had already drained `request.formData()`. That second read // raised `TypeError: unusable` from undici and crashed every UI form-submit // that happened to take the body-token fallback path. // // This file simulates the production scenario — an action handler that // reads form fields first, then validates the CSRF token — and asserts // that the single-read API (passing the parsed FormData back into the // helper) keeps the request usable and validation green. import { describe, expect, it, vi } from "vitest"; type TestWebConfig = { nodeEnv: string; sessionSecret: string; routing: { appBaseDomain: string; protocol: "http" | "https"; }; }; const testWebConfig: TestWebConfig = { nodeEnv: "test", sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=", routing: { appBaseDomain: "localhost", protocol: "http", }, }; vi.doMock("@toolctrl/config", async () => { const actual = await vi.importActual("@toolctrl/config"); return { ...actual, loadRequiredWebConfig: () => testWebConfig, }; }); vi.mock("./db/tenants.db.server", () => ({ getMyTenants: vi.fn(), getTenant: vi.fn(), })); vi.mock("./db/users.db.server", () => ({ getUser: vi.fn(), })); const { storage, validateCSRFToken } = await import("./session.server"); async function buildAuthenticatedRequest(body: BodyInit, csrfToken = "session-token") { const session = await storage.getSession(); session.set("csrf", csrfToken); return new Request("https://example.test/app/settings/profile", { method: "POST", headers: { Cookie: (await storage.commitSession(session)).split(";")[0], }, body, }); } describe("CSRF body-already-consumed regression (B-009 follow-up)", () => { it("UI form submit: action reads formData, then validates with the parsed body — no TypeError: unusable", async () => { const body = new FormData(); body.set("csrf", "session-token"); body.set("displayName", "ada-lovelace"); body.set("email", "ada@example.test"); const request = await buildAuthenticatedRequest(body); // Simulate the canonical broken pattern: the action reads form fields // BEFORE validating CSRF. With the single-read helper, the parsed // FormData is handed back in. const form = await request.formData(); expect(form.get("displayName")).toBe("ada-lovelace"); expect(request.bodyUsed).toBe(true); // Capture any TypeError so the regression assertion is precise. let thrown: unknown; try { await validateCSRFToken(request, { formData: form }); } catch (error) { thrown = error; } expect(thrown).toBeUndefined(); }); it("URL-encoded form submit: action reads formData, validates with parsed body", async () => { const params = new URLSearchParams(); params.set("csrf", "session-token"); params.set("locale", "de"); const request = new Request("https://example.test/preferences", { method: "POST", headers: { Cookie: (await storage.commitSession(await sessionWithCsrf())).split(";")[0], "Content-Type": "application/x-www-form-urlencoded", }, body: params, }); const form = await request.formData(); expect(form.get("locale")).toBe("de"); await expect(validateCSRFToken(request, { formData: form })).resolves.toBeUndefined(); }); it("forgetting to inject formData surfaces a CSRF-specific error rather than TypeError: unusable", async () => { const body = new FormData(); body.set("csrf", "session-token"); const request = await buildAuthenticatedRequest(body); await request.formData(); // No formData injection — the old code path raised an opaque // `TypeError: unusable` here. The helper now surfaces a CSRF-specific // error message that points at the misuse. let error: unknown; try { await validateCSRFToken(request); } catch (err) { error = err; } expect(error).toBeInstanceOf(Error); expect((error as Error).message).toMatch(/CSRF body already consumed/); // Crucially, NOT the undici-internal TypeError. expect((error as Error).name).not.toBe("TypeError"); }); it("header-token path stays untouched and never reads the body even after action consumed it", async () => { const body = new FormData(); body.set("csrf", "session-token"); body.set("displayName", "ada-lovelace"); const session = await storage.getSession(); session.set("csrf", "session-token"); const request = new Request("https://example.test/app/settings/profile", { method: "POST", headers: { Cookie: (await storage.commitSession(session)).split(";")[0], "x-csrf-token": "session-token", }, body, }); await request.formData(); const cloneSpy = vi.spyOn(request, "clone"); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); expect(cloneSpy).not.toHaveBeenCalled(); cloneSpy.mockRestore(); }); }); async function sessionWithCsrf() { const session = await storage.getSession(); session.set("csrf", "session-token"); return session; }