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

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