// @vitest-environment node 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(), })); // R-P2a S4b — Store/Lifecycle hinter dem additiven Revoke-Check mocken, um die Revoke-Durchsetzung // in requireUserId deterministisch zu testen. const authStoreMock = vi.hoisted(() => ({ getActiveSessionByToken: vi.fn() })); vi.mock("~/services/auth/sessionStore.server", () => ({ getActiveSessionByToken: (...args: unknown[]) => authStoreMock.getActiveSessionByToken(...args), })); vi.mock("~/services/auth/sessionLifecycle.server", () => ({ endServerSession: vi.fn(), startServerSession: vi.fn(), extractSessionDeviceContext: vi.fn(), })); let sessionModule = await import("./session.server"); let { createUserSession, generateCSRFToken, getUserInfo, requireUser, requireUserId, setCSRFToken, storage, validateCSRFToken } = sessionModule; async function importSessionServerWithConfig(config: typeof testWebConfig) { Object.assign(testWebConfig, config); vi.resetModules(); sessionModule = await import("./session.server"); ({ createUserSession, generateCSRFToken, getUserInfo, requireUser, requireUserId, setCSRFToken, storage, validateCSRFToken } = sessionModule); return sessionModule; } async function requestWithSession({ body, csrf, headers, legacyCrsf, }: { body?: BodyInit; csrf?: string; headers?: HeadersInit; legacyCrsf?: string; }) { const session = await storage.getSession(); if (csrf !== undefined) session.set("csrf", csrf); if (legacyCrsf !== undefined) session.set("crsf", legacyCrsf); return new Request("https://example.test/register", { method: "POST", headers: { Cookie: (await storage.commitSession(session)).split(";")[0], ...headers, }, body, }); } describe("setLoggedUser tenant caching", () => { it("fetches the tenant once and exposes it on the result so callers can skip a second getTenant", async () => { const tenants = await import("./db/tenants.db.server"); const getTenantMock = vi.mocked(tenants.getTenant); const getMyTenantsMock = vi.mocked(tenants.getMyTenants); getTenantMock.mockReset(); getMyTenantsMock.mockReset(); getTenantMock.mockResolvedValue({ id: "tenant-1", slug: "tenant-1", name: "T1" } as never); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: "tenant-1" }); expect(getTenantMock).toHaveBeenCalledTimes(1); expect(getMyTenantsMock).not.toHaveBeenCalled(); expect(result).toEqual({ userId: "user-1", defaultTenantId: "tenant-1", defaultTenant: { id: "tenant-1", slug: "tenant-1", name: "T1" }, }); }); it("falls back to the first tenant membership when the stored default tenant is missing", async () => { const tenants = await import("./db/tenants.db.server"); const getTenantMock = vi.mocked(tenants.getTenant); const getMyTenantsMock = vi.mocked(tenants.getMyTenants); getTenantMock.mockReset(); getMyTenantsMock.mockReset(); getTenantMock.mockResolvedValue(null); getMyTenantsMock.mockResolvedValue([{ id: "tenant-fallback", slug: "fallback", name: "Fallback" }] as never); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: "tenant-missing" }); expect(getTenantMock).toHaveBeenCalledWith("tenant-missing"); expect(getMyTenantsMock).toHaveBeenCalledWith("user-1"); expect(result).toEqual({ userId: "user-1", defaultTenantId: "tenant-fallback", defaultTenant: null, }); }); it("returns an empty default tenant id when the user has no tenant memberships", async () => { const tenants = await import("./db/tenants.db.server"); const getTenantMock = vi.mocked(tenants.getTenant); const getMyTenantsMock = vi.mocked(tenants.getMyTenants); getTenantMock.mockReset(); getMyTenantsMock.mockReset(); getMyTenantsMock.mockResolvedValue([]); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: null }); expect(getTenantMock).not.toHaveBeenCalled(); expect(getMyTenantsMock).toHaveBeenCalledWith("user-1"); expect(result).toEqual({ userId: "user-1", defaultTenantId: "", defaultTenant: null, }); }); }); describe("session cookie policy", () => { it("serializes hardened production session cookie attributes without exposing the session secret", async () => { const productionSecret = "production-session-secret-with-enough-entropy"; const { storage } = await importSessionServerWithConfig({ nodeEnv: "production", sessionSecret: productionSecret, routing: { appBaseDomain: "example.com", protocol: "https", }, }); const session = await storage.getSession(); session.set("userId", "user-1"); const header = await storage.commitSession(session); expect(header).toContain("RJ_session="); expect(header).toContain("Domain=example.com"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Secure"); expect(header).toContain("HttpOnly"); expect(header).toContain("Path=/"); expect(header).not.toContain(productionSecret); }); it("keeps local session cookies domainless", async () => { const { storage } = await importSessionServerWithConfig({ nodeEnv: "test", sessionSecret: "local-session-secret-with-enough-entropy", routing: { appBaseDomain: "localhost", protocol: "http", }, }); const header = await storage.commitSession(await storage.getSession()); expect(header).toContain("RJ_session="); expect(header).not.toContain("Domain="); expect(header).not.toContain("Secure"); }); }); describe("session CSRF helpers", () => { it("generates and stores CSRF tokens on the csrf session key", async () => { const session = await storage.getSession(); const token = setCSRFToken(session); expect(token).toEqual(expect.any(String)); expect(token).toHaveLength(generateCSRFToken().length); expect(session.get("csrf")).toBe(token); expect(session.get("crsf")).toBeUndefined(); }); it("emits 64-hex-char (32-byte) CSRF tokens — NIST-recommended size, not 100 bytes (perf-quickwins item 8)", () => { const token = generateCSRFToken(); expect(token).toHaveLength(64); expect(token).toMatch(/^[a-f0-9]{64}$/); }); it("reads csrf from the central session contract", async () => { const session = await storage.getSession(); session.set("csrf", "session-token"); session.set("crsf", "legacy-token"); const request = new Request("https://example.test", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); await expect(getUserInfo(request)).resolves.toMatchObject({ csrf: "session-token" }); }); it("validates a matching request token", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ csrf: "session-token" }), }); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); }); it("validates a matching request token from a csrf header", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "x-csrf-token": "session-token" }, body: JSON.stringify({}), }); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); }); it("does not read the request body when the csrf token is supplied via header (perf-quickwins item 5)", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "x-csrf-token": "session-token", "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ csrf: "session-token" }), }); const cloneSpy = vi.spyOn(request, "clone"); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); expect(cloneSpy).not.toHaveBeenCalled(); cloneSpy.mockRestore(); }); it("validates a matching request token from multipart form data", async () => { const body = new FormData(); body.set("csrf", "session-token"); const request = await requestWithSession({ csrf: "session-token", body, }); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); }); it("validates a matching request token from json body data", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ csrf: "session-token" }), }); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); }); it("falls back to URLSearchParams when clients send form bodies with a json content type", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "Content-Type": "application/json" }, body: "csrf=session-token", }); await expect(validateCSRFToken(request)).resolves.toBeUndefined(); }); it("rejects missing session tokens and ignores legacy crsf session data", async () => { const request = await requestWithSession({ legacyCrsf: "legacy-token", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ csrf: "legacy-token" }), }); await expect(validateCSRFToken(request)).rejects.toThrow("CSRF Session Token not included."); }); it("rejects missing request tokens", async () => { const request = await requestWithSession({ csrf: "session-token" }); await expect(validateCSRFToken(request)).rejects.toThrow("CSRF Request Token not included."); }); it("rejects mismatched request tokens", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ csrf: "other-token" }), }); await expect(validateCSRFToken(request)).rejects.toThrow("CSRF tokens do not match, try refreshing the page."); }); it("validates from injected formData without touching the request body (B-009 follow-up)", async () => { const body = new FormData(); body.set("csrf", "session-token"); body.set("name", "ada"); const request = await requestWithSession({ csrf: "session-token", body, }); // Consume the request body BEFORE calling validateCSRFToken — this is the // exact pattern that broke production: an action handler reads form fields // first and validates afterwards. const consumed = await request.formData(); expect(consumed.get("name")).toBe("ada"); const cloneSpy = vi.spyOn(request, "clone"); await expect(validateCSRFToken(request, { formData: consumed })).resolves.toBeUndefined(); expect(cloneSpy).not.toHaveBeenCalled(); cloneSpy.mockRestore(); }); it("rejects when injected formData carries a mismatched token", async () => { const body = new FormData(); body.set("csrf", "other-token"); const request = await requestWithSession({ csrf: "session-token", body, }); const consumed = await request.formData(); await expect(validateCSRFToken(request, { formData: consumed })).rejects.toThrow( "CSRF tokens do not match, try refreshing the page.", ); }); it("treats blank injected formData csrf values as missing request tokens", async () => { const body = new FormData(); body.set("csrf", " "); const request = await requestWithSession({ csrf: "session-token", body, }); const consumed = await request.formData(); await expect(validateCSRFToken(request, { formData: consumed })).rejects.toThrow("CSRF Request Token not included."); }); it("headerOnly mode skips the body fallback entirely", async () => { const body = new FormData(); body.set("csrf", "session-token"); const request = await requestWithSession({ csrf: "session-token", body, }); const cloneSpy = vi.spyOn(request, "clone"); await expect(validateCSRFToken(request, { headerOnly: true })).rejects.toThrow("CSRF Request Token not included."); expect(cloneSpy).not.toHaveBeenCalled(); cloneSpy.mockRestore(); }); it("headerOnly mode accepts a valid header token", async () => { const request = await requestWithSession({ csrf: "session-token", headers: { "x-csrf-token": "session-token" }, }); await expect(validateCSRFToken(request, { headerOnly: true })).resolves.toBeUndefined(); }); it("surfaces a clear error when the body was consumed without supplying formData", async () => { const body = new FormData(); body.set("csrf", "session-token"); const request = await requestWithSession({ csrf: "session-token", body, }); // Caller drained the body without passing it to validateCSRFToken — the // old code path raised an opaque `TypeError: unusable` from undici. We now // surface a CSRF-specific error so the misuse is obvious. await request.formData(); await expect(validateCSRFToken(request)).rejects.toThrow(/CSRF body already consumed/); }); }); describe("session user guards", () => { it("requireUserId returns the current user id from the session", async () => { const session = await storage.getSession(); session.set("userId", "user-1"); const request = new Request("https://example.test/app/settings", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); await expect(requireUserId(request)).resolves.toBe("user-1"); }); it("requireUserId redirects anonymous requests with the current path as redirectTo", async () => { const request = new Request("https://example.test/app/settings?tab=profile"); let response: unknown; try { await requireUserId(request); } catch (error) { response = error; } expect(response).toBeInstanceOf(Response); expect((response as Response).status).toBe(302); expect((response as Response).headers.get("Location")).toBe("/login?redirectTo=%2Fapp%2Fsettings"); }); it("requireUser loads the current user and redirects when the user record no longer exists", async () => { const users = await import("./db/users.db.server"); const getUserMock = vi.mocked(users.getUser); const session = await storage.getSession(); session.set("userId", "user-1"); const request = new Request("https://example.test/app", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); getUserMock.mockResolvedValueOnce({ id: "user-1", email: "user@example.test" } as never); await expect(requireUser({ request })).resolves.toEqual({ id: "user-1", email: "user@example.test" }); getUserMock.mockResolvedValueOnce(null); let response: unknown; try { await requireUser({ request }); } catch (error) { response = error; } expect(response).toBeInstanceOf(Response); expect((response as Response).status).toBe(302); expect((response as Response).headers.get("Location")).toBe("/login"); }); }); describe("requireUserId server-session revocation (R-P2a S4b)", () => { async function requestWithToken(sessionToken: string) { const session = await storage.getSession(); session.set("userId", "user-1"); session.set("sessionToken", sessionToken); return new Request("https://example.test/app/x", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); } it("logs out and destroys the cookie when the server session token is revoked", async () => { authStoreMock.getActiveSessionByToken.mockResolvedValueOnce(null); const request = await requestWithToken("tok-revoked"); let response: unknown; try { await requireUserId(request); } catch (error) { response = error; } expect(response).toBeInstanceOf(Response); expect((response as Response).status).toBe(302); expect((response as Response).headers.get("Location")).toContain("/login"); expect((response as Response).headers.get("Set-Cookie")).toBeTruthy(); }); it("allows the request when the server session is still active", async () => { authStoreMock.getActiveSessionByToken.mockResolvedValueOnce({ id: "s1" }); await expect(requireUserId(await requestWithToken("tok-active"))).resolves.toBe("user-1"); }); it("fails open (treats the session as valid) when the revoke check throws", async () => { authStoreMock.getActiveSessionByToken.mockRejectedValueOnce(new Error("db down")); await expect(requireUserId(await requestWithToken("tok-error"))).resolves.toBe("user-1"); }); }); describe("createUserSession", () => { it("persists supported session metadata and omits falsey password-policy flags", async () => { const response = await createUserSession( { userId: "user-1", lightOrDarkMode: "dark", lng: "en", csrf: "csrf-token", metrics: { enabled: true, logToConsole: true, saveToDatabase: false, ignoreUrls: ["/healthz"] }, cookies: [{ category: "analytics", allowed: true }], theme: "system", passwordPolicyOutdated: false, }, "/dashboard" ); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/dashboard"); const request = new Request("https://example.test/dashboard", { headers: { Cookie: (response.headers.get("Set-Cookie") ?? "").split(";")[0] }, }); await expect(getUserInfo(request)).resolves.toMatchObject({ userId: "user-1", lightOrDarkMode: "dark", lng: "en", csrf: "csrf-token", metrics: { enabled: true, logToConsole: true, saveToDatabase: false, ignoreUrls: ["/healthz"] }, cookies: [{ category: "analytics", allowed: true }], theme: "system", passwordPolicyOutdated: undefined, }); }); });