Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/session.server.test.ts
T
2026-07-08 16:15:37 +02:00

760 lines
28 KiB
TypeScript

// @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("@hivegrid/config", async () => {
const actual = await vi.importActual<typeof import("@hivegrid/config")>("@hivegrid/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("hivegrid_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("hivegrid_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_session=");
// Restore the canonical test config so subsequent suites see a signed cookie.
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");
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");
});
it("fails open and labels the error generically when the revoke check rejects with a non-Error", async () => {
// Non-Error rejection drives the `error instanceof Error ? error.name : "Error"` fallback.
authStoreMock.getActiveSessionByToken.mockRejectedValueOnce("boom not an error");
await expect(requireUserId(await requestWithToken("tok-string-error"))).resolves.toBe("user-1");
});
});
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] },
});
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: "user-1", 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 lifecycle = await import("~/services/auth/sessionLifecycle.server");
const endServerSessionMock = vi.mocked(lifecycle.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(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 lifecycle = await import("~/services/auth/sessionLifecycle.server");
const endServerSessionMock = vi.mocked(lifecycle.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(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 lifecycle = await import("~/services/auth/sessionLifecycle.server");
const endServerSessionMock = vi.mocked(lifecycle.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(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 lifecycle = await import("~/services/auth/sessionLifecycle.server");
const endServerSessionMock = vi.mocked(lifecycle.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 lifecycle = await import("~/services/auth/sessionLifecycle.server");
const endServerSessionMock = vi.mocked(lifecycle.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",
});
expect(setCookie).toContain("hivegrid_session=");
const info = await readBack(setCookie);
expect(info.pendingMfaUserId).toBe("user-99");
expect(info.pendingMfaRedirectTo).toBe("/app/dashboard");
// 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",
});
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",
});
const info = await readBack(setCookie);
expect(info.pendingMfaUserId).toBe("user-99");
expect(info.pendingMfaRedirectTo).toBeUndefined();
});
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: "",
});
const info = await readBack(setCookie);
expect(info.pendingMfaUserId).toBe("user-99");
expect(info.pendingMfaRedirectTo).toBeUndefined();
});
});
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,
});
});
});