Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/session.csrf-regression.test.ts
T

165 lines
5.2 KiB
TypeScript

// @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<typeof import("@toolctrl/config")>("@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;
}