// @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; type TestWebConfig = { nodeEnv: string; sessionSecret: string; i18n: { enabledLocales: readonly ["de", "en"]; }; routing: { appBaseDomain: string; protocol: "http" | "https"; webAppHost?: string; adminAppHost?: string; apiHost?: string; }; }; const testWebConfig: TestWebConfig = { nodeEnv: "test", sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=", i18n: { enabledLocales: ["de", "en"] }, routing: { appBaseDomain: "localhost", protocol: "http", }, }; vi.doMock("@hivegrid/config", async () => { const actual = await vi.importActual("@hivegrid/config"); return { ...actual, loadRequiredWebConfig: () => testWebConfig, }; }); vi.mock("./db/tenants.db.server", () => ({ getTenantSessionIdentityForUser: vi.fn(), })); vi.mock("./db/users.db.server", () => ({ getUser: vi.fn(), })); const authStoreMock = vi.hoisted(() => ({ getActiveSessionByToken: vi.fn() })); const adminSessionStoreMock = vi.hoisted(() => ({ getActiveAdminSessionByToken: vi.fn() })); const sessionLifecycleMock = vi.hoisted(() => ({ endServerSession: vi.fn(), startServerSession: vi.fn() })); vi.mock("~/services/auth/sessionStore.server", () => ({ getActiveSessionByToken: (...args: unknown[]) => authStoreMock.getActiveSessionByToken(...args), })); vi.mock("@hivegrid/db/admin-sessions.server", () => ({ getActiveAdminSessionByToken: (...args: unknown[]) => adminSessionStoreMock.getActiveAdminSessionByToken(...args), })); vi.mock("~/services/auth/sessionLifecycle.server", () => ({ endServerSession: (...args: unknown[]) => sessionLifecycleMock.endServerSession(...args), startServerSession: (...args: unknown[]) => sessionLifecycleMock.startServerSession(...args), extractSessionDeviceContext: vi.fn(), })); let sessionModule = await import("./session.server"); let { createUserSession, generateCSRFToken, getUserInfo, getValidatedServerSession, 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, getValidatedServerSession, 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, }); } beforeEach(() => { authStoreMock.getActiveSessionByToken.mockReset(); authStoreMock.getActiveSessionByToken.mockImplementation(async () => ({ id: "session-1", userId: "user-1" })); adminSessionStoreMock.getActiveAdminSessionByToken.mockReset(); adminSessionStoreMock.getActiveAdminSessionByToken.mockImplementation(async () => ({ id: "admin-session-1", adminUserId: "admin-1" })); sessionLifecycleMock.endServerSession.mockReset(); sessionLifecycleMock.startServerSession.mockReset(); sessionLifecycleMock.startServerSession.mockImplementation(async ({ userId }: { userId: string }) => `tok:${userId}`); }); describe("setLoggedUser tenant identity", () => { it("binds the authenticated user while resolving the first RLS-protected tenant", async () => { const tenants = await import("./db/tenants.db.server"); const { getRlsExecutionContext } = await import("@hivegrid/db/rls-context.server"); const getTenantSessionIdentityMock = vi.mocked(tenants.getTenantSessionIdentityForUser); getTenantSessionIdentityMock.mockReset(); getTenantSessionIdentityMock.mockImplementation(async () => { expect(getRlsExecutionContext()).toMatchObject({ userId: "user-1" }); return { id: "tenant-1", slug: "tenant-1", name: "T1", active: true, deactivatedReason: null }; }); const { setLoggedUser } = await import("./session.server"); await expect(setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: "tenant-1" })).resolves.toMatchObject({ userId: "user-1", defaultTenantId: "tenant-1", }); expect(getRlsExecutionContext()).toBeNull(); }); it("fetches a safe tenant identity once and exposes it to auth completion", async () => { const tenants = await import("./db/tenants.db.server"); const getTenantSessionIdentityMock = vi.mocked(tenants.getTenantSessionIdentityForUser); getTenantSessionIdentityMock.mockReset(); getTenantSessionIdentityMock.mockResolvedValue({ id: "tenant-1", slug: "tenant-1", name: "T1", active: true, deactivatedReason: null }); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: "tenant-1" }); expect(getTenantSessionIdentityMock).toHaveBeenCalledOnce(); expect(getTenantSessionIdentityMock).toHaveBeenCalledWith({ userId: "user-1", tenant: "tenant-1" }); expect(result).toEqual({ userId: "user-1", defaultTenantId: "tenant-1", defaultTenant: { id: "tenant-1", slug: "tenant-1", name: "T1", active: true, deactivatedReason: null }, }); }); 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 getTenantSessionIdentityMock = vi.mocked(tenants.getTenantSessionIdentityForUser); getTenantSessionIdentityMock.mockReset(); getTenantSessionIdentityMock .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: "tenant-fallback", slug: "fallback", name: "Fallback", active: true, deactivatedReason: null }); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: "tenant-missing" }); expect(getTenantSessionIdentityMock).toHaveBeenNthCalledWith(1, { userId: "user-1", tenant: "tenant-missing" }); expect(getTenantSessionIdentityMock).toHaveBeenNthCalledWith(2, { userId: "user-1" }); expect(result).toEqual({ userId: "user-1", defaultTenantId: "tenant-fallback", defaultTenant: { id: "tenant-fallback", slug: "fallback", name: "Fallback", active: true, deactivatedReason: 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 getTenantSessionIdentityMock = vi.mocked(tenants.getTenantSessionIdentityForUser); getTenantSessionIdentityMock.mockReset(); getTenantSessionIdentityMock.mockResolvedValue(null); const { setLoggedUser } = await import("./session.server"); const result = await setLoggedUser({ id: "user-1", email: "u@example.com", defaultTenantId: null }); expect(getTenantSessionIdentityMock).toHaveBeenCalledWith({ userId: "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("__Host-hivegrid_app_session="); expect(header).not.toContain("Domain="); 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("hivegrid_app_session="); expect(header).not.toContain("Domain="); expect(header).not.toContain("Secure"); }); it("builds the cookie storage with an empty secret list when no session secret is configured", async () => { // Drives the falsy side of `webConfig.sessionSecret ? [webConfig.sessionSecret] : []`. const { storage } = await importSessionServerWithConfig({ nodeEnv: "test", sessionSecret: "", routing: { appBaseDomain: "localhost", protocol: "http", }, }); const header = await storage.commitSession(await storage.getSession()); expect(header).toContain("hivegrid_app_session="); // Restore the canonical test config so subsequent suites see a signed cookie. await importSessionServerWithConfig({ nodeEnv: "test", sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=", routing: { appBaseDomain: "localhost", protocol: "http", }, }); }); it("selects separate host-only app and admin cookie namespaces from the request host", async () => { const module = await importSessionServerWithConfig({ nodeEnv: "production", sessionSecret: "production-session-secret-with-enough-entropy", routing: { appBaseDomain: "example.test", webAppHost: "app.example.test", adminAppHost: "admin.example.test", apiHost: "api.example.test", protocol: "https", }, }); const appSession = await module.getUserSession(new Request("https://app.example.test/login")); appSession.set("surface", "app"); const appHeader = await module.commitSession(appSession); const adminSession = await module.getUserSession(new Request("https://admin.example.test/login")); adminSession.set("surface", "admin"); const adminHeader = await module.commitSession(adminSession); expect(appHeader).toContain("__Host-hivegrid_app_session="); expect(adminHeader).toContain("__Host-hivegrid_admin_session="); expect(appHeader).not.toContain("Domain="); expect(adminHeader).not.toContain("Domain="); const plantedAppCookie = appHeader.split(";")[0]; const adminRead = await module.getUserSession(new Request("https://admin.example.test/dashboard", { headers: { Cookie: plantedAppCookie } })); expect(adminRead.get("surface")).toBeUndefined(); await importSessionServerWithConfig({ nodeEnv: "test", sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=", routing: { appBaseDomain: "localhost", protocol: "http" }, }); }); }); 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"); session.set("sessionToken", "tok-active"); 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"); session.set("sessionToken", "tok-active"); 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", userId: "user-1" }); await expect(requireUserId(await requestWithToken("tok-active"))).resolves.toBe("user-1"); }); it("fails closed when the session store throws", async () => { authStoreMock.getActiveSessionByToken.mockRejectedValueOnce(new Error("db down")); await expect(requireUserId(await requestWithToken("tok-error"))).rejects.toMatchObject({ status: 302 }); }); it("fails closed when the session store rejects with a non-Error", async () => { authStoreMock.getActiveSessionByToken.mockRejectedValueOnce("boom not an error"); await expect(requireUserId(await requestWithToken("tok-string-error"))).rejects.toMatchObject({ status: 302 }); }); it("rejects authenticated cookies without a server-session token", async () => { const session = await storage.getSession(); session.set("userId", "user-1"); const request = new Request("https://example.test/app/x", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); await expect(requireUserId(request)).rejects.toMatchObject({ status: 302 }); expect(authStoreMock.getActiveSessionByToken).not.toHaveBeenCalled(); }); it("rejects a valid token owned by another user", async () => { authStoreMock.getActiveSessionByToken.mockResolvedValueOnce({ id: "s-foreign", userId: "user-2" }); await expect(requireUserId(await requestWithToken("tok-foreign"))).rejects.toMatchObject({ status: 302 }); }); it("deduplicates server-session validation for repeated reads on one request", async () => { const request = await requestWithToken("tok-active"); const info = await getUserInfo(request); expect(info).toMatchObject({ userId: "user-1" }); await expect(getUserInfo(request)).resolves.toMatchObject({ userId: "user-1" }); await expect(getValidatedServerSession(request, info)).resolves.toMatchObject({ userId: "user-1" }); expect(authStoreMock.getActiveSessionByToken).toHaveBeenCalledTimes(1); }); it("returns no validated row for anonymous, tokenless, and inactive sessions", async () => { const request = new Request("https://example.test/app/x"); await expect(getValidatedServerSession(request)).resolves.toBeNull(); await expect( getValidatedServerSession(request, { userId: "user-1", lightOrDarkMode: "", lng: "de", cookies: [], }) ).resolves.toBeNull(); authStoreMock.getActiveSessionByToken.mockResolvedValueOnce(null); await expect( getValidatedServerSession(request, { userId: "user-1", lightOrDarkMode: "", lng: "de", cookies: [], sessionToken: "tok-inactive", }) ).resolves.toBeNull(); }); it("validates admin-host tokens against the admin session store", async () => { const module = await importSessionServerWithConfig({ nodeEnv: "production", sessionSecret: "production-session-secret-with-enough-entropy", routing: { appBaseDomain: "example.test", webAppHost: "app.example.test", adminAppHost: "admin.example.test", apiHost: "api.example.test", protocol: "https", }, }); const session = await module.getUserSession(new Request("https://admin.example.test/login")); session.set("userId", "admin-1"); session.set("sessionToken", "admin-token"); const request = new Request("https://admin.example.test/dashboard", { headers: { Cookie: (await module.commitSession(session)).split(";")[0] }, }); await expect(module.getValidatedServerSession(request)).resolves.toMatchObject({ adminUserId: "admin-1" }); expect(adminSessionStoreMock.getActiveAdminSessionByToken).toHaveBeenCalledWith("admin-token"); expect(authStoreMock.getActiveSessionByToken).not.toHaveBeenCalled(); await importSessionServerWithConfig({ nodeEnv: "test", sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=", routing: { appBaseDomain: "localhost", protocol: "http" }, }); }); }); describe("getUserInfo session token exposure", () => { it("returns a string sessionToken when the cookie carries one", async () => { const session = await storage.getSession(); session.set("userId", "user-1"); session.set("sessionToken", "tok-active"); const request = new Request("https://example.test/app", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); authStoreMock.getActiveSessionByToken.mockResolvedValueOnce({ id: "s1", userId: "user-1" }); await expect(getUserInfo(request)).resolves.toMatchObject({ userId: "user-1", sessionToken: "tok-active" }); }); it("omits a non-string sessionToken from the session info", async () => { const session = await storage.getSession(); session.set("userId", "user-1"); session.set("sessionToken", 12345); const request = new Request("https://example.test/app", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); await expect(getUserInfo(request)).resolves.toMatchObject({ userId: null, sessionToken: undefined }); }); }); describe("logout server-session revocation (R-P2a S4b)", () => { async function requestWithLogoutToken(sessionToken?: string) { const session = await storage.getSession(); session.set("userId", "user-1"); if (sessionToken !== undefined) session.set("sessionToken", sessionToken); return new Request("https://example.test/app/x", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); } it("ends the server session row and destroys the cookie when a session token is present", async () => { const { logout } = await import("./session.server"); const endServerSessionMock = sessionLifecycleMock.endServerSession; endServerSessionMock.mockReset(); endServerSessionMock.mockResolvedValueOnce(undefined as never); const response = await logout(await requestWithLogoutToken("tok-logout")); expect(endServerSessionMock).toHaveBeenCalledTimes(1); expect(endServerSessionMock).toHaveBeenCalledWith("tok-logout", expect.any(Request)); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); expect(response.headers.get("Set-Cookie")).toBeTruthy(); }); it("still destroys the cookie when ending the server session row rejects with an Error (best-effort)", async () => { const { logout } = await import("./session.server"); const endServerSessionMock = sessionLifecycleMock.endServerSession; endServerSessionMock.mockReset(); endServerSessionMock.mockRejectedValueOnce(new Error("revoke store offline")); // logout must NOT throw even though the revoke failed — the cookie is the // source of truth for the device and is destroyed regardless. const response = await logout(await requestWithLogoutToken("tok-error")); expect(endServerSessionMock).toHaveBeenCalledWith("tok-error", expect.any(Request)); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); expect(response.headers.get("Set-Cookie")).toBeTruthy(); }); it("swallows non-Error rejections from the revoke call and still logs out", async () => { const { logout } = await import("./session.server"); const endServerSessionMock = sessionLifecycleMock.endServerSession; endServerSessionMock.mockReset(); // A non-Error rejection drives the `error instanceof Error ? … : "Error"` fallback. endServerSessionMock.mockRejectedValueOnce("string failure" as never); const response = await logout(await requestWithLogoutToken("tok-string-error")); expect(endServerSessionMock).toHaveBeenCalledWith("tok-string-error", expect.any(Request)); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); }); it("does not touch the server-session store when the cookie carries no token", async () => { const { logout } = await import("./session.server"); const endServerSessionMock = sessionLifecycleMock.endServerSession; endServerSessionMock.mockReset(); const response = await logout(await requestWithLogoutToken()); expect(endServerSessionMock).not.toHaveBeenCalled(); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); expect(response.headers.get("Set-Cookie")).toBeTruthy(); }); it("does not touch the server-session store when the token is an empty string", async () => { const { logout } = await import("./session.server"); const endServerSessionMock = sessionLifecycleMock.endServerSession; endServerSessionMock.mockReset(); // Empty-string token: `typeof === "string"` is true but `length > 0` is false, // exercising the right-hand side of the `&&` guard. const response = await logout(await requestWithLogoutToken("")); expect(endServerSessionMock).not.toHaveBeenCalled(); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/login"); }); }); describe("commitPendingMfaLoginCookie (D1 — 2FA login gate)", () => { async function requestWithAuthenticatedSession() { const session = await storage.getSession(); session.set("userId", "user-1"); session.set("sessionToken", "tok-pre-mfa"); session.set("csrf", "csrf-keep"); return new Request("https://example.test/login/verify", { headers: { Cookie: (await storage.commitSession(session)).split(";")[0] }, }); } async function readBack(setCookie: string) { const request = new Request("https://example.test/login/verify", { headers: { Cookie: setCookie.split(";")[0] }, }); return getUserInfo(request); } it("writes the pending-MFA state, keeps the csrf token, and clears userId/sessionToken", async () => { const { commitPendingMfaLoginCookie } = await import("./session.server"); const setCookie = await commitPendingMfaLoginCookie(await requestWithAuthenticatedSession(), { userId: "user-99", redirectTo: "/app/dashboard", primaryMethod: "password", }); expect(setCookie).toContain("hivegrid_app_session="); const info = await readBack(setCookie); expect(info.pendingMfaUserId).toBe("user-99"); expect(info.pendingMfaRedirectTo).toBe("/app/dashboard"); expect(info.pendingMfaStartedAt).toEqual(expect.any(String)); expect(info.pendingMfaPrimaryMethod).toBe("password"); // userId and sessionToken must be gone — the pending state must NOT grant access. expect(info.userId).toBeNull(); expect(info.sessionToken).toBeUndefined(); // csrf survives so the verify route still passes the CSRF check. expect(info.csrf).toBe("csrf-keep"); }); it("requireUserId still denies access while only the pending-MFA state is set", async () => { const { commitPendingMfaLoginCookie } = await import("./session.server"); const setCookie = await commitPendingMfaLoginCookie(await requestWithAuthenticatedSession(), { userId: "user-99", redirectTo: "/app/dashboard", primaryMethod: "magic-link", }); const request = new Request("https://example.test/app/secret", { headers: { Cookie: setCookie.split(";")[0] }, }); 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"); }); it("unsets the redirect key when redirectTo is omitted (typeof guard is false)", async () => { const { commitPendingMfaLoginCookie } = await import("./session.server"); const setCookie = await commitPendingMfaLoginCookie(await requestWithAuthenticatedSession(), { userId: "user-99", primaryMethod: "invitation", }); const info = await readBack(setCookie); expect(info.pendingMfaUserId).toBe("user-99"); expect(info.pendingMfaRedirectTo).toBeUndefined(); expect(info.pendingMfaPrimaryMethod).toBe("invitation"); }); it("unsets the redirect key when redirectTo is an empty string (length guard is false)", async () => { const { commitPendingMfaLoginCookie } = await import("./session.server"); const setCookie = await commitPendingMfaLoginCookie(await requestWithAuthenticatedSession(), { userId: "user-99", redirectTo: "", primaryMethod: "password-reset", }); const info = await readBack(setCookie); expect(info.pendingMfaUserId).toBe("user-99"); expect(info.pendingMfaRedirectTo).toBeUndefined(); expect(info.pendingMfaPrimaryMethod).toBe("password-reset"); }); }); describe("createUserSession", () => { it("blocks an external redirect at the central session issuance boundary", async () => { const response = await createUserSession({ userId: "", lightOrDarkMode: "", lng: "de", cookies: [] }, "https://evil.example/phish", { request: new Request("https://app.localhost/login"), }); expect(response.headers.get("Location")).toBe("/"); }); 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", { request: new Request("https://example.test/login"), rotate: true } ); 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, sessionToken: "tok:user-1", }); expect(sessionLifecycleMock.startServerSession).toHaveBeenCalledWith({ request: expect.any(Request), userId: "user-1", }); }); it("does not issue an authenticated cookie when creating the server session fails", async () => { sessionLifecycleMock.startServerSession.mockRejectedValueOnce(new Error("session store offline")); await expect( createUserSession({ userId: "user-1", lightOrDarkMode: "", lng: "de", cookies: [] }, "/dashboard", { request: new Request("https://example.test/login"), rotate: true, }) ).rejects.toThrow("session store offline"); }); it("keeps an empty user id unauthenticated and does not create a server session", async () => { const response = await createUserSession({ userId: "", lightOrDarkMode: "", lng: "de", cookies: [] }, "/login", { request: new Request("https://example.test/login"), rotate: false, }); expect(sessionLifecycleMock.startServerSession).not.toHaveBeenCalled(); const request = new Request("https://example.test/login", { headers: { Cookie: (response.headers.get("Set-Cookie") ?? "").split(";")[0] }, }); await expect(getUserInfo(request)).resolves.toMatchObject({ userId: null, sessionToken: undefined }); }); it("revokes the previous token before rotating an authenticated session", async () => { const response = await createUserSession({ userId: "user-1", lightOrDarkMode: "", lng: "de", cookies: [], sessionToken: "tok-old" }, "/dashboard", { request: new Request("https://example.test/login"), rotate: true, }); expect(sessionLifecycleMock.endServerSession).toHaveBeenCalledWith("tok-old", expect.any(Request)); expect(sessionLifecycleMock.startServerSession).toHaveBeenCalledWith({ request: expect.any(Request), userId: "user-1", }); expect(response.status).toBe(302); }); it("forwards admin assurance metadata when rotating an admin session", async () => { const adminMfaAssuredAt = new Date("2026-08-02T03:00:00.000Z"); await createUserSession({ userId: "admin-1", lightOrDarkMode: "", lng: "de", cookies: [] }, "/admin", { request: new Request("https://admin.localhost/login"), rotate: true, adminAuthenticationMethods: ["password", "totp"], adminMfaAssuredAt, }); expect(sessionLifecycleMock.startServerSession).toHaveBeenCalledWith({ request: expect.any(Request), userId: "admin-1", adminAuthenticationMethods: ["password", "totp"], adminMfaAssuredAt, }); }); it("forwards admin assurance metadata when creating a new unrotated admin session", async () => { const adminMfaAssuredAt = new Date("2026-08-02T03:00:00.000Z"); await createUserSession({ userId: "admin-1", lightOrDarkMode: "", lng: "de", cookies: [] }, "/admin", { request: new Request("https://admin.localhost/login"), adminAuthenticationMethods: ["password"], adminMfaAssuredAt, }); expect(sessionLifecycleMock.startServerSession).toHaveBeenCalledWith({ request: expect.any(Request), userId: "admin-1", adminAuthenticationMethods: ["password"], adminMfaAssuredAt, }); }); });