Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/session.server.test.ts
T

989 lines
39 KiB
TypeScript

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