import { beforeEach, describe, expect, it, vi } from "vitest"; const rows: unknown[][] = []; const values = new Map(); const set = vi.fn(async (key: string, value: string) => { values.set(key, value); }); const removeSession = vi.fn(async () => undefined); const updateUser = vi.fn(async () => undefined); const redis = { get: vi.fn(async (key: string) => values.get(key) ?? null), set }; vi.mock("server-only", () => ({})); vi.mock("@/db", () => ({ getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit: async () => rows.shift() ?? [] }) }) }), update: () => ({ set: () => ({ where: updateUser }) }), delete: () => ({ where: removeSession }), }) })); vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => redis, redisCachePrefix: () => "test", })); const { bindSudlohSession, refreshLinkedSudlohProfile, validateSudlohSession } = await import("./sudloh"); const expiresAt = new Date(Date.now() + 60 * 60_000); const account = { id: "account-1", accountId: "sub-1", accessToken: "access-1", accessTokenExpiresAt: expiresAt }; beforeEach(() => { rows.length = 0; values.clear(); vi.clearAllMocks(); process.env.SUDLOH_OIDC_ISSUER = "https://account.test/api/auth"; process.env.SUDLOH_OIDC_CLIENT_ID = "client"; process.env.SUDLOH_OIDC_CLIENT_SECRET = "secret"; }); function provider(active: boolean, profileSub = "sub-1") { const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { const url = String(input); if (url.endsWith("openid-configuration")) return Response.json({ issuer: "https://account.test/api/auth", introspection_endpoint: "https://account.test/api/auth/oauth2/introspect", userinfo_endpoint: "https://account.test/api/auth/oauth2/userinfo", }); if (url.endsWith("introspect")) return Response.json({ active, sub: "sub-1", exp: Math.floor(Date.now() / 1000) + 3600 }); if (url.endsWith("userinfo")) return Response.json({ sub: profileSub, name: "New Name", email: "new@test.invalid", email_verified: true, picture: "https://account.test/avatar.png" }); throw new Error(`unexpected URL: ${url}`); }); return fetchMock; } describe("Sudloh session validation", () => { it("rejects a new Guide session when the callback did not bind it", async () => { rows.push([account]); expect(await validateSudlohSession("user-1", "session-1")).toBe(false); expect(removeSession).toHaveBeenCalledOnce(); }); it("binds the callback token to its Guide session and caches a verified check", async () => { rows.push([account]); await bindSudlohSession("user-1", "session-1"); const fetchMock = provider(true); rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); expect(await validateSudlohSession("user-1", "session-1")).toBe(true); expect(updateUser).toHaveBeenCalledOnce(); const fetchCount = fetchMock.mock.calls.length; rows.push([account]); expect(await validateSudlohSession("user-1", "session-1")).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(fetchCount); fetchMock.mockRestore(); }); it("ends the Guide session when Sudloh reports the bound token inactive", async () => { rows.push([account]); await bindSudlohSession("user-1", "session-1"); const fetchMock = provider(false); rows.push([account]); expect(await validateSudlohSession("user-1", "session-1")).toBe(false); expect(removeSession).toHaveBeenCalledOnce(); fetchMock.mockRestore(); }); it("fails closed when UserInfo returns another subject", async () => { rows.push([account]); await bindSudlohSession("user-1", "session-1"); const fetchMock = provider(true, "someone-else"); rows.push([account]); await expect(validateSudlohSession("user-1", "session-1")) .rejects.toMatchObject({ status: 503 }); expect(updateUser).not.toHaveBeenCalled(); fetchMock.mockRestore(); }); it("reports a verified Sudloh email that conflicts with another Guide account", async () => { rows.push([account]); await bindSudlohSession("user-1", "session-1"); const fetchMock = provider(true); rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); updateUser.mockRejectedValueOnce({ cause: { code: "23505" } }); await expect(validateSudlohSession("user-1", "session-1")) .rejects.toMatchObject({ status: 409, message: "sudloh-email-conflict" }); fetchMock.mockRestore(); }); it("refreshes a linked profile during the legacy sign-in transition", async () => { const fetchMock = provider(true); rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); await refreshLinkedSudlohProfile("user-1"); expect(updateUser).toHaveBeenCalledOnce(); fetchMock.mockRestore(); }); });