534 lines
19 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|