// auth-routes cookie behaviour — login sets cookies, logout clears them, // switch-tenant rotates them, cookieSameSite controls the SameSite flag. // // Uses a stub Dispatcher so the tests exercise ONLY the HTTP-layer cookie // logic — full-stack login via real loginHandler is covered by the // auth-cookie sample integration test. import { describe, expect, test } from "bun:test"; import type { Hono } from "hono"; import { Hono as HonoCtor } from "hono"; import type { SessionUser } from "../../engine/types"; import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher"; import { TestUsers } from "../../stack"; import { getSetCookieRaw, getSetCookies } from "../../testing/http-cookies"; import { PUBLIC_API_PATHS } from "../api-constants"; import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware"; import { type AuthRoutesConfig, createAuthRoutes } from "../auth-routes"; import { createJwtHelper } from "../jwt"; const JWT_SECRET = "auth-routes-cookie-test-secret-min-32-characters"; function createStubDispatcher(overrides?: Partial): Dispatcher { const base: Dispatcher = { async write(): Promise { // Explicit `const: WriteResult` locks the `isSuccess: true` branch of // the union without an `as`-cast (which widens + silences the // compiler). Success shape has to satisfy `{ isSuccess: true; data }`. const ok: WriteResult = { isSuccess: true, data: { kind: "auth-session", session: TestUsers.user, }, }; return ok; }, async query(): Promise { return []; }, async *stream(): AsyncGenerator {}, async command(): Promise {}, async batch(): Promise { const ok: BatchResult = { isSuccess: true, results: [] }; return ok; }, async resolveAuthClaims(): Promise> { return {}; }, }; return { ...base, ...overrides }; } async function buildApp( overrides: Partial = {}, dispatcher: Dispatcher = createStubDispatcher(), ttlSeconds?: number, ): Promise<{ app: Hono; validToken: string }> { const jwt = ttlSeconds ? createJwtHelper(JWT_SECRET, undefined, ttlSeconds) : createJwtHelper(JWT_SECRET); const validToken = await jwt.sign(TestUsers.user); const config: AuthRoutesConfig = { membershipQuery: "tenant:query:memberships", loginHandler: "auth:write:login", loginRateLimit: null, // don't need rate-limit interference in cookie tests ...overrides, }; const app = new HonoCtor(); const jwtGuard = authMiddleware(jwt); app.use("/api/*", async (c, next) => { if (PUBLIC_API_PATHS.has(c.req.path)) return next(); return jwtGuard(c, next); }); app.route("/api", createAuthRoutes(dispatcher, jwt, config)); return { app, validToken }; } describe("auth-routes cookie behaviour on /auth/login", () => { test("login sets both kumiko_auth and kumiko_csrf cookies", async () => { const { app } = await buildApp(); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); expect(res.status).toBe(200); const cookies = getSetCookies(res); expect(cookies.get(AUTH_COOKIE_NAME)).toBeDefined(); expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined(); }); test("cookieSameSite defaults to Lax", async () => { const { app } = await buildApp(); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/SameSite=Lax/i); }); test("cookieSameSite: strict → SameSite=Strict flag", async () => { const { app } = await buildApp({ cookieSameSite: "strict" }); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/SameSite=Strict/i); }); test("auth cookie is HttpOnly, csrf cookie is not", async () => { const { app } = await buildApp(); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/HttpOnly/i); expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/HttpOnly/i); }); test("login still returns token in body (for native bearer clients)", async () => { const { app } = await buildApp(); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); const body = (await res.json()) as { isSuccess: boolean; token: string }; expect(body.isSuccess).toBe(true); expect(typeof body.token).toBe("string"); expect(body.token.length).toBeGreaterThan(20); }); test("cookie maxAge tracks the jwt helper's ttlSeconds, not a hardcoded default", async () => { const customTtl = 60 * 60; // 1h — distinct from the 24h default const { app } = await buildApp({}, undefined, customTtl); const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`)); expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`)); }); }); describe("auth-routes cookie behaviour on /auth/logout", () => { test("logout clears both cookies via Max-Age=0", async () => { const { app, validToken } = await buildApp(); const res = await app.request("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${validToken}` }, }); expect(res.status).toBe(200); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Max-Age=0/i); expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Max-Age=0/i); }); }); describe("auth-routes cookie behaviour on /auth/switch-tenant", () => { test("switch-tenant rotates both cookies", async () => { const otherTenant = TestUsers.otherTenant; const dispatcher = createStubDispatcher({ async query(type: string, _payload: unknown, _user: SessionUser): Promise { if (type === "tenant:query:memberships") { return [ { userId: TestUsers.user.id, tenantId: otherTenant.tenantId, roles: otherTenant.roles, }, ]; } return []; }, }); const { app, validToken } = await buildApp({}, dispatcher); const res = await app.request("/api/auth/switch-tenant", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${validToken}`, }, body: JSON.stringify({ tenantId: otherTenant.tenantId }), }); expect(res.status).toBe(200); const cookies = getSetCookies(res); const newAuth = cookies.get(AUTH_COOKIE_NAME); expect(newAuth).toBeDefined(); expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined(); expect(newAuth?.value).not.toBe(validToken); // new jwt }); }); // cookieDomain — Bug-Bash-2 Wave I (Auth auf Marketing-Host): Login auf // dem Apex muss eine Session setzen die admin. mitliest. Ohne // Option bleibt das Cookie host-only (kein Domain-Attribut). describe("auth-routes cookieDomain", () => { async function login(app: Hono): Promise { return app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "a@b.c", password: "pw" }), }); } test("ohne cookieDomain: kein Domain-Attribut auf den Cookies", async () => { const { app } = await buildApp(); const res = await login(app); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).not.toMatch(/Domain=/i); expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/Domain=/i); }); test("login: cookieDomain setzt Domain auf BEIDEN Cookies", async () => { const { app } = await buildApp({ cookieDomain: "example.eu" }); const res = await login(app); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i); expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i); }); // Symmetrisch zum Logout (#321/1): wer mit cookieDomain SETZT, muss die // host-only-Variante mit-invalidieren — sonst koexistiert nach dem ersten // Deploy mit cookieDomain das alte host-only-Cookie neben dem neuen // Domain-Cookie und der Server liest umgebungsabhängig das veraltete. test("login: cookieDomain invalidiert zusätzlich die host-only-Variante", async () => { const { app } = await buildApp({ cookieDomain: "example.eu" }); const raw = (await login(app)).headers.getSetCookie(); const authCookies = raw.filter((c) => c.startsWith(`${AUTH_COOKIE_NAME}=`)); const csrfCookies = raw.filter((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`)); // host-only-Delete: Max-Age=0 ohne Domain-Attribut. expect(authCookies.some((c) => /Max-Age=0/i.test(c) && !/Domain=/i.test(c))).toBe(true); expect(csrfCookies.some((c) => /Max-Age=0/i.test(c) && !/Domain=/i.test(c))).toBe(true); // ...neben dem echten Domain-Cookie (positive Max-Age). expect(authCookies.some((c) => /Domain=example\.eu/i.test(c) && !/Max-Age=0/i.test(c))).toBe( true, ); }); test("ohne cookieDomain: KEIN zusätzlicher host-only-Delete beim Login", async () => { const { app } = await buildApp(); const raw = (await login(app)).headers.getSetCookie(); // Nur das eine Set-Cookie pro Name, kein Max-Age=0-Delete. expect(raw.filter((c) => c.startsWith(`${AUTH_COOKIE_NAME}=`)).length).toBe(1); expect(raw.some((c) => c.startsWith(`${AUTH_COOKIE_NAME}=`) && /Max-Age=0/i.test(c))).toBe( false, ); }); test("switch-tenant: rotierte Cookies tragen das Domain-Attribut", async () => { const otherTenant = TestUsers.otherTenant; const dispatcher = createStubDispatcher({ async query(type: string, _payload: unknown, _user: SessionUser): Promise { if (type === "tenant:query:memberships") { return [ { userId: TestUsers.user.id, tenantId: otherTenant.tenantId, roles: otherTenant.roles, }, ]; } return []; }, }); const { app, validToken } = await buildApp({ cookieDomain: "example.eu" }, dispatcher); const res = await app.request("/api/auth/switch-tenant", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${validToken}`, }, body: JSON.stringify({ tenantId: otherTenant.tenantId }), }); expect(res.status).toBe(200); expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i); // CSRF cookie is rotated with the same common options as the auth cookie: // without a Domain attribute it would be host-only and the double-submit check could read the wrong one. expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i); }); test("logout löscht beide Varianten: mit Domain UND host-only", async () => { const { app, validToken } = await buildApp({ cookieDomain: "example.eu" }); const res = await app.request("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${validToken}` }, }); expect(res.status).toBe(200); const raw = res.headers.getSetCookie(); const authDeletes = raw.filter( (c) => c.startsWith(`${AUTH_COOKIE_NAME}=`) && /Max-Age=0/i.test(c), ); const csrfDeletes = raw.filter( (c) => c.startsWith(`${CSRF_COOKIE_NAME}=`) && /Max-Age=0/i.test(c), ); // Host-only-Bestand (vor cookieDomain gesetzt) + aktuelle Domain- // Variante — beide müssen invalidiert werden, sonst wirkt der Logout // auf dem alten Cookie nicht. expect(authDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true); expect(authDeletes.some((c) => !/Domain=/i.test(c))).toBe(true); expect(csrfDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true); expect(csrfDeletes.some((c) => !/Domain=/i.test(c))).toBe(true); }); });