165 lines
5.2 KiB
TypeScript
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;
|
|
}
|