989 lines
39 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|