/** * OIDC authentication integration tests. * * Strategy: * - Tests that don't need OIDC routes (password guards, session fields, users * list) create OIDC users directly in the DB and use the default test app. * - Tests that need OIDC routes (config endpoint, login redirect) mutate the * cached `env` object and spin up a separate Fastify instance with OIDC * routes enabled. */ import { randomUUID } from "node:crypto"; import { createServer, type Server } from "node:http"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { env } from "../../apps/api/src/config.js"; import { db, schema } from "../../apps/api/src/db/index.js"; import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; // ── Helpers ────────────────────────────────────────────────────────── let testApp: TestApp; let adminToken: string; beforeAll(async () => { testApp = await buildTestApp(); adminToken = await loginAsAdmin(testApp.app); }, 30_000); afterAll(async () => { await testApp.cleanup(); }, 10_000); /** * Insert an OIDC-only user directly into the DB (no passwordHash). * Returns a session token for the user. */ async function createOidcUser( opts: { username?: string; email?: string; role?: string } = {}, ): Promise<{ userId: string; username: string; sessionToken: string; }> { const userId = randomUUID(); const username = opts.username || `oidc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; await db.insert(schema.users).values({ id: userId, username, passwordHash: null, role: opts.role || "user", team: "default-team-00000000", mustChangePassword: false, authProvider: "oidc", externalId: `sub-${userId}`, email: opts.email || `${username}@example.com`, }); // Create a session (simulates what the OIDC callback would do) const sessionToken = randomUUID(); await db.insert(schema.sessions).values({ id: sessionToken, userId, expiresAt: new Date(Date.now() + 3_600_000), idToken: "mock-id-token-jwt", }); return { userId, username, sessionToken }; } /** * Insert a session for an existing user with custom options. */ async function createOidcSession( userId: string, opts: { expiresAt?: Date; idToken?: string | null } = {}, ): Promise { const sessionToken = randomUUID(); await db.insert(schema.sessions).values({ id: sessionToken, userId, expiresAt: opts.expiresAt ?? new Date(Date.now() + 3_600_000), idToken: opts.idToken ?? null, }); return sessionToken; } /** * Insert a "hybrid" user -- has both a local password AND an OIDC link. */ async function _createHybridUser( passwordHash: string, opts: { username?: string; email?: string } = {}, ): { userId: string; username: string; sessionToken: string } { const userId = randomUUID(); const username = opts.username || `hybrid_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; await db.insert(schema.users).values({ id: userId, username, passwordHash, role: "user", team: "default-team-00000000", mustChangePassword: false, authProvider: "oidc", externalId: `sub-${userId}`, email: opts.email || `${username}@example.com`, }); const sessionToken = randomUUID(); await db.insert(schema.sessions).values({ id: sessionToken, userId, expiresAt: new Date(Date.now() + 3_600_000), }); return { userId, username, sessionToken }; } // ===================================================================== // SESSION RESPONSE FIELDS // ===================================================================== describe("Session response fields", () => { it("returns OIDC fields for an OIDC user session", async () => { const { sessionToken, username } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${sessionToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.username).toBe(username); expect(body.user.authProvider).toBe("oidc"); expect(body.user.loginMethod).toBe("oidc"); expect(body.user.hasLocalPassword).toBe(false); expect(body.user.hasOidcLink).toBe(true); expect(body.user.email).toMatch(/@example\.com$/); }); it("returns local fields for a local user session", async () => { // Admin is a local user const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.authProvider).toBe("local"); expect(body.user.loginMethod).toBe("local"); expect(body.user.hasLocalPassword).toBe(true); expect(body.user.hasOidcLink).toBe(false); }); it("Bearer token still works for session check", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.id).toBeTruthy(); expect(body.expiresAt).toBeTruthy(); }); }); // ===================================================================== // PASSWORD GUARDS // ===================================================================== describe("Password guards for OIDC users", () => { it("OIDC user (no passwordHash) cannot change password", async () => { const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: "/api/auth/change-password", headers: { authorization: `Bearer ${sessionToken}` }, payload: { currentPassword: "anything", newPassword: "NewValid1", }, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.code).toBe("OIDC_NO_PASSWORD"); }); it("admin cannot reset password for OIDC user", async () => { const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: `/api/auth/users/${userId}/reset-password`, headers: { authorization: `Bearer ${adminToken}` }, payload: { newPassword: "NewValid1" }, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.code).toBe("OIDC_NO_PASSWORD"); }); }); // ===================================================================== // USERS LIST // ===================================================================== describe("Users list includes OIDC fields", () => { it("GET /api/auth/users includes authProvider, hasLocalPassword, hasOidcLink", async () => { const { username: oidcUsername } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/users", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); // Find the OIDC user we just created const oidcEntry = body.users.find((u: any) => u.username === oidcUsername); expect(oidcEntry).toBeDefined(); expect(oidcEntry.authProvider).toBe("oidc"); expect(oidcEntry.hasLocalPassword).toBe(false); expect(oidcEntry.hasOidcLink).toBe(true); expect(oidcEntry.email).toMatch(/@example\.com$/); // The admin user should be local const adminEntry = body.users.find((u: any) => u.username === "admin"); expect(adminEntry).toBeDefined(); expect(adminEntry.authProvider).toBe("local"); expect(adminEntry.hasLocalPassword).toBe(true); expect(adminEntry.hasOidcLink).toBe(false); }); it("users list does not expose passwordHash or externalId directly", async () => { await createOidcUser(); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/users", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); for (const user of body.users) { expect(user).not.toHaveProperty("passwordHash"); expect(user).not.toHaveProperty("externalId"); } }); }); // ===================================================================== // BACKWARD COMPATIBILITY // ===================================================================== describe("Backward compatibility", () => { it("local login still works when OIDC users exist in the DB", async () => { // Create an OIDC user (just to prove it doesn't break local login) await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "admin", password: "Adminpass1" }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.token).toBeTruthy(); expect(body.user.username).toBe("admin"); }); it("OIDC user cannot log in via local login (no passwordHash)", async () => { const { username } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "anything" }, }); // Should fail because passwordHash is null expect(res.statusCode).toBe(401); }); }); // ===================================================================== // CONFIG ENDPOINT (requires OIDC_ENABLED = true) // ===================================================================== describe("Config endpoint with OIDC enabled", () => { let oidcApp: TestApp; // Save original env values const origOidcEnabled = env.OIDC_ENABLED; const origExternalUrl = env.EXTERNAL_URL; const origIssuerUrl = env.OIDC_ISSUER_URL; const origClientId = env.OIDC_CLIENT_ID; const origClientSecret = env.OIDC_CLIENT_SECRET; const origProviderName = env.OIDC_PROVIDER_NAME; beforeAll(async () => { // Mutate the cached env to enable OIDC for route registration (env as any).OIDC_ENABLED = true; (env as any).EXTERNAL_URL = "http://localhost:9999"; (env as any).OIDC_ISSUER_URL = "http://localhost:0"; (env as any).OIDC_CLIENT_ID = "test-client-id"; (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; (env as any).OIDC_PROVIDER_NAME = "TestProvider"; oidcApp = await buildTestApp(); }, 30_000); afterAll(async () => { // Restore original env values (env as any).OIDC_ENABLED = origOidcEnabled; (env as any).EXTERNAL_URL = origExternalUrl; (env as any).OIDC_ISSUER_URL = origIssuerUrl; (env as any).OIDC_CLIENT_ID = origClientId; (env as any).OIDC_CLIENT_SECRET = origClientSecret; (env as any).OIDC_PROVIDER_NAME = origProviderName; await oidcApp.cleanup(); }, 10_000); it("config returns OIDC fields when enabled", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/v1/config/auth", }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.oidcEnabled).toBe(true); expect(body.oidcProviderName).toBe("TestProvider"); expect(body.oidcLoginUrl).toBe("/api/auth/oidc/login"); }); it("config does NOT leak OIDC secrets", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/v1/config/auth", }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body).not.toHaveProperty("clientSecret"); expect(body).not.toHaveProperty("oidcClientSecret"); expect(body).not.toHaveProperty("issuerUrl"); expect(body).not.toHaveProperty("oidcIssuerUrl"); }); }); // ===================================================================== // CONFIG ENDPOINT (OIDC disabled -- default) // ===================================================================== describe("Config endpoint with OIDC disabled", () => { it("config omits OIDC fields when disabled", async () => { // The default test app has OIDC_ENABLED=false const res = await testApp.app.inject({ method: "GET", url: "/api/v1/config/auth", }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body).not.toHaveProperty("oidcEnabled"); expect(body).not.toHaveProperty("oidcProviderName"); expect(body).not.toHaveProperty("oidcLoginUrl"); expect(body.authEnabled).toBe(true); }); }); // ===================================================================== // LOGIN REDIRECT (requires OIDC routes + mock OIDC discovery) // ===================================================================== describe("OIDC login redirect", () => { let oidcApp: TestApp; let mockServer: Server; let mockPort: number; // Save original env values const origOidcEnabled = env.OIDC_ENABLED; const origExternalUrl = env.EXTERNAL_URL; const origIssuerUrl = env.OIDC_ISSUER_URL; const origClientId = env.OIDC_CLIENT_ID; const origClientSecret = env.OIDC_CLIENT_SECRET; beforeAll(async () => { // Start a minimal mock OIDC provider that serves discovery only mockServer = createServer((req, res) => { if (req.url === "/.well-known/openid-configuration") { const discovery = { issuer: `http://localhost:${mockPort}`, authorization_endpoint: `http://localhost:${mockPort}/authorize`, token_endpoint: `http://localhost:${mockPort}/token`, jwks_uri: `http://localhost:${mockPort}/jwks`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], code_challenge_methods_supported: ["S256"], }; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(discovery)); return; } if (req.url === "/jwks") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ keys: [] })); return; } res.writeHead(404); res.end(); }); // Bind to random port await new Promise((resolve) => { mockServer.listen(0, "127.0.0.1", () => { const addr = mockServer.address(); mockPort = typeof addr === "object" && addr ? addr.port : 0; resolve(); }); }); // Mutate the cached env to enable OIDC (env as any).OIDC_ENABLED = true; (env as any).EXTERNAL_URL = "http://localhost:9999"; (env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`; (env as any).OIDC_CLIENT_ID = "test-client-id"; (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; // Clear the cached OIDC config so discovery hits our mock const _oidcModule = await import("../../apps/api/src/plugins/oidc.js"); // The module caches config in a module-level variable; rebuild app to get fresh routes oidcApp = await buildTestApp(); }, 30_000); afterAll(async () => { // Restore original env values (env as any).OIDC_ENABLED = origOidcEnabled; (env as any).EXTERNAL_URL = origExternalUrl; (env as any).OIDC_ISSUER_URL = origIssuerUrl; (env as any).OIDC_CLIENT_ID = origClientId; (env as any).OIDC_CLIENT_SECRET = origClientSecret; await oidcApp.cleanup(); await new Promise((resolve) => mockServer.close(() => resolve())); }, 10_000); it("GET /api/auth/oidc/login returns 302 redirect to IdP", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/auth/oidc/login", }); // Should redirect to the mock IdP's authorization endpoint expect(res.statusCode).toBe(302); const location = res.headers.location as string; expect(location).toBeTruthy(); const redirectUrl = new URL(location); expect(redirectUrl.origin).toBe(`http://localhost:${mockPort}`); expect(redirectUrl.pathname).toBe("/authorize"); // Verify required OIDC params expect(redirectUrl.searchParams.get("client_id")).toBe("test-client-id"); expect(redirectUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:9999/api/auth/oidc/callback", ); expect(redirectUrl.searchParams.get("response_type")).toBe("code"); expect(redirectUrl.searchParams.get("scope")).toContain("openid"); expect(redirectUrl.searchParams.get("state")).toBeTruthy(); expect(redirectUrl.searchParams.get("nonce")).toBeTruthy(); expect(redirectUrl.searchParams.get("code_challenge")).toBeTruthy(); expect(redirectUrl.searchParams.get("code_challenge_method")).toBe("S256"); }); it("login redirect sets oidc-state cookie", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/auth/oidc/login", }); expect(res.statusCode).toBe(302); // Check for oidc-state cookie in Set-Cookie header const cookies = res.headers["set-cookie"]; const cookieStr = Array.isArray(cookies) ? cookies.join("; ") : cookies || ""; expect(cookieStr).toContain("oidc-state="); expect(cookieStr).toContain("HttpOnly"); expect(cookieStr).toContain("SameSite=Lax"); }); }); // ===================================================================== // OIDC CALLBACK EDGE CASES (without a full mock provider) // ===================================================================== describe("OIDC callback edge cases", () => { let oidcApp: TestApp; let mockServer: Server; let mockPort: number; // Save original env values const origOidcEnabled = env.OIDC_ENABLED; const origExternalUrl = env.EXTERNAL_URL; const origIssuerUrl = env.OIDC_ISSUER_URL; const origClientId = env.OIDC_CLIENT_ID; const origClientSecret = env.OIDC_CLIENT_SECRET; beforeAll(async () => { // Minimal mock server for discovery mockServer = createServer((req, res) => { if (req.url === "/.well-known/openid-configuration") { const discovery = { issuer: `http://localhost:${mockPort}`, authorization_endpoint: `http://localhost:${mockPort}/authorize`, token_endpoint: `http://localhost:${mockPort}/token`, jwks_uri: `http://localhost:${mockPort}/jwks`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], code_challenge_methods_supported: ["S256"], }; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(discovery)); return; } if (req.url === "/jwks") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ keys: [] })); return; } res.writeHead(404); res.end(); }); await new Promise((resolve) => { mockServer.listen(0, "127.0.0.1", () => { const addr = mockServer.address(); mockPort = typeof addr === "object" && addr ? addr.port : 0; resolve(); }); }); (env as any).OIDC_ENABLED = true; (env as any).EXTERNAL_URL = "http://localhost:9999"; (env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`; (env as any).OIDC_CLIENT_ID = "test-client-id"; (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; oidcApp = await buildTestApp(); }, 30_000); afterAll(async () => { (env as any).OIDC_ENABLED = origOidcEnabled; (env as any).EXTERNAL_URL = origExternalUrl; (env as any).OIDC_ISSUER_URL = origIssuerUrl; (env as any).OIDC_CLIENT_ID = origClientId; (env as any).OIDC_CLIENT_SECRET = origClientSecret; await oidcApp.cleanup(); await new Promise((resolve) => mockServer.close(() => resolve())); }, 10_000); it("callback without state cookie redirects to login with error", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/auth/oidc/callback?code=abc&state=xyz", }); // Should redirect to /login?error=oidc_session_expired expect(res.statusCode).toBe(302); const location = res.headers.location as string; expect(location).toContain("/login?error=oidc_session_expired"); }); it("callback with invalid cookie signature redirects to login with error", async () => { const res = await oidcApp.app.inject({ method: "GET", url: "/api/auth/oidc/callback?code=abc&state=xyz", cookies: { "oidc-state": "tampered-garbage-value" }, }); expect(res.statusCode).toBe(302); const location = res.headers.location as string; expect(location).toContain("/login?error=oidc_session_expired"); }); it("callback with IdP error redirects to login with oidc_auth_failed", async () => { // First, do a login to get a valid state cookie const loginRes = await oidcApp.app.inject({ method: "GET", url: "/api/auth/oidc/login", }); expect(loginRes.statusCode).toBe(302); // Extract the state cookie and state param from redirect const rawCookies = loginRes.headers["set-cookie"]; const cookieStr = Array.isArray(rawCookies) ? rawCookies[0] : rawCookies || ""; const cookieMatch = cookieStr.match(/oidc-state=([^;]+)/); expect(cookieMatch).toBeTruthy(); // The cookie value may be URL-encoded; decode for inject() const cookieValue = decodeURIComponent(cookieMatch?.[1]); const redirectUrl = new URL(loginRes.headers.location as string); const state = redirectUrl.searchParams.get("state"); // Simulate IdP returning an error const callbackRes = await oidcApp.app.inject({ method: "GET", url: `/api/auth/oidc/callback?error=access_denied&error_description=User+denied&state=${state}`, cookies: { "oidc-state": cookieValue }, }); expect(callbackRes.statusCode).toBe(302); const location = callbackRes.headers.location as string; expect(location).toContain("/login?error=oidc_auth_failed"); }); }); // ===================================================================== // ADMIN OPERATIONS ON OIDC USERS // ===================================================================== describe("Admin operations on OIDC users", () => { it("admin can delete an OIDC user", async () => { const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "DELETE", url: `/api/auth/users/${userId}`, headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); // Verify user is gone const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); expect(user).toBeUndefined(); }); it("admin can update role of an OIDC user", async () => { const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "PUT", url: `/api/auth/users/${userId}`, headers: { authorization: `Bearer ${adminToken}` }, payload: { role: "editor" }, }); expect(res.statusCode).toBe(200); const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); expect(user?.role).toBe("editor"); }); }); // ===================================================================== // COOKIE-BASED SESSION AUTH // ===================================================================== describe("Cookie-based session auth", () => { it("OIDC session cookie works for authenticated requests", async () => { const { sessionToken, username } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", cookies: { "snapotter-session": sessionToken }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.username).toBe(username); expect(body.user.authProvider).toBe("oidc"); }); it("Both cookie and Bearer work simultaneously", async () => { const { sessionToken: oidcToken, username: oidcUsername } = await createOidcUser(); // Bearer token for local user (admin) const bearerRes = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${adminToken}` }, }); expect(bearerRes.statusCode).toBe(200); const bearerBody = JSON.parse(bearerRes.body); expect(bearerBody.user.username).toBe("admin"); expect(bearerBody.user.authProvider).toBe("local"); // Cookie for OIDC user const cookieRes = await testApp.app.inject({ method: "GET", url: "/api/auth/session", cookies: { "snapotter-session": oidcToken }, }); expect(cookieRes.statusCode).toBe(200); const cookieBody = JSON.parse(cookieRes.body); expect(cookieBody.user.username).toBe(oidcUsername); expect(cookieBody.user.authProvider).toBe("oidc"); }); it("request with neither Bearer nor cookie returns 401", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", }); expect(res.statusCode).toBe(401); }); }); // ===================================================================== // SESSION EXPIRY // ===================================================================== describe("Session expiry", () => { it("expired OIDC session returns 401", async () => { const { userId } = await createOidcUser(); // Create a session that expired 10 minutes ago const expiredToken = await createOidcSession(userId, { expiresAt: new Date(Date.now() - 600_000), }); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", cookies: { "snapotter-session": expiredToken }, }); expect(res.statusCode).toBe(401); // Verify the expired session was cleaned up from DB const [session] = await db .select() .from(schema.sessions) .where(eq(schema.sessions.id, expiredToken)); expect(session).toBeUndefined(); }); }); // ===================================================================== // API KEYS FOR OIDC USERS // ===================================================================== describe("API keys for OIDC users", () => { it("OIDC user can create an API key", async () => { const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: "/api/v1/api-keys", cookies: { "snapotter-session": sessionToken }, payload: { name: "test-key" }, }); expect(res.statusCode).toBe(201); const body = JSON.parse(res.body); expect(body.key).toMatch(/^si_/); expect(body.name).toBe("test-key"); }); it("API key works for auth after creation", async () => { const { sessionToken } = await createOidcUser(); // Create the API key via cookie auth const createRes = await testApp.app.inject({ method: "POST", url: "/api/v1/api-keys", cookies: { "snapotter-session": sessionToken }, payload: { name: "auth-test-key" }, }); expect(createRes.statusCode).toBe(201); const { key: rawKey } = JSON.parse(createRes.body); // Use the raw API key as Bearer token const _sessionRes = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${rawKey}` }, }); // API key auth on GET /api/auth/session is a public route, but // the middleware still attaches the user if a valid token is found. // The session endpoint itself checks for session, not API key, // so it returns 401 since the si_ token is not a session ID. // Instead, test with a non-public route that requires auth. const healthRes = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${rawKey}` }, }); expect(healthRes.statusCode).toBe(200); const body = JSON.parse(healthRes.body); expect(body.apiKeys).toBeDefined(); expect(Array.isArray(body.apiKeys)).toBe(true); }); }); // ===================================================================== // LOGOUT // ===================================================================== describe("Logout", () => { it("logout clears the snapotter-session cookie", async () => { const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", url: "/api/auth/logout", cookies: { "snapotter-session": sessionToken }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.ok).toBe(true); // Check that response includes a Set-Cookie header clearing the session const setCookieHeader = res.headers["set-cookie"]; const cookieStr = Array.isArray(setCookieHeader) ? setCookieHeader.join("; ") : setCookieHeader || ""; expect(cookieStr).toContain("snapotter-session="); // The cookie should be expired (Expires in the past or Max-Age=0) const hasExpiry = cookieStr.toLowerCase().includes("max-age=0") || cookieStr.toLowerCase().includes("expires=thu, 01 jan 1970"); expect(hasExpiry).toBe(true); }); it("session is deleted from DB after logout", async () => { const { sessionToken } = await createOidcUser(); // Verify session exists before logout const [before] = await db .select() .from(schema.sessions) .where(eq(schema.sessions.id, sessionToken)); expect(before).toBeDefined(); await testApp.app.inject({ method: "POST", url: "/api/auth/logout", cookies: { "snapotter-session": sessionToken }, }); // Verify session is gone const [after] = await db .select() .from(schema.sessions) .where(eq(schema.sessions.id, sessionToken)); expect(after).toBeUndefined(); }); it("logout returns logoutUrl when session has idToken and OIDC discovery is cached", async () => { const { sessionToken } = await createOidcUser(); // The default createOidcUser sets idToken to "mock-id-token-jwt". // Without a running OIDC provider and cached discovery, the logout // route's try/catch will swallow the error and return no logoutUrl. // We still verify the response shape is correct. const res = await testApp.app.inject({ method: "POST", url: "/api/auth/logout", cookies: { "snapotter-session": sessionToken }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.ok).toBe(true); // logoutUrl is only present when OIDC discovery has been cached with // an end_session_endpoint. In test env without mock provider, it is // absent. We verify it's either undefined or a string URL. if (body.logoutUrl !== undefined) { expect(typeof body.logoutUrl).toBe("string"); expect(body.logoutUrl).toContain("id_token_hint="); } }); });