import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /** Mock session shape returned by getSession in tests. */ interface MockSessionData { data: { user: Record; } | null; } /** Words that must never appear in PDA-friendly messages. */ const FORBIDDEN_WORDS = [ "overdue", "urgent", "must", "critical", "required", "error", "failed", "failure", ]; // Mock BetterAuth before importing the module under test vi.mock("better-auth/react", () => ({ createAuthClient: vi.fn(() => ({ signIn: vi.fn(), signOut: vi.fn(), useSession: vi.fn(), getSession: vi.fn(() => Promise.resolve({ data: null })), })), })); vi.mock("better-auth/client/plugins", () => ({ genericOAuthClient: vi.fn(() => ({})), })); vi.mock("./config", () => ({ API_BASE_URL: "http://localhost:3001", })); // Import after mocks are set up const { signInWithCredentials, getAccessToken, isAdmin, getSession } = await import("./auth-client"); /** * Helper to build a mock Response object that behaves like the Fetch API Response. */ function mockResponse(options: { ok: boolean; status: number; body?: Record; }): Response { const { ok, status, body = {} } = options; return { ok, status, json: vi.fn(() => Promise.resolve(body)), headers: new Headers(), redirected: false, statusText: "", type: "basic" as ResponseType, url: "", clone: vi.fn(), body: null, bodyUsed: false, arrayBuffer: vi.fn(), blob: vi.fn(), formData: vi.fn(), text: vi.fn(), bytes: vi.fn(), } as unknown as Response; } describe("signInWithCredentials", (): void => { const originalFetch = global.fetch; beforeEach((): void => { global.fetch = vi.fn(); }); afterEach((): void => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it("should return data on successful response", async (): Promise => { const sessionData = { user: { id: "1", name: "Alice" }, token: "abc" }; vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: true, status: 200, body: sessionData }) ); const result = await signInWithCredentials("alice", "password123"); expect(result).toEqual(sessionData); expect(global.fetch).toHaveBeenCalledWith( "http://localhost:3001/auth/sign-in/credentials", expect.objectContaining({ method: "POST", credentials: "include", body: JSON.stringify({ email: "alice", password: "password123" }), }) ); }); it("should throw PDA-friendly message on 401 response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 401, body: { message: "Unauthorized" } }) ); await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( "The email and password combination wasn't recognized." ); }); it("should throw PDA-friendly message on 401 with no body message", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 401, body: {} }) ); // When there is no body message, the response object (status: 401) is used for parsing await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( "The email and password combination wasn't recognized." ); }); it("should throw PDA-friendly message on 500 response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 500, body: { message: "Internal Server Error" }, }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The service is taking a break. Please try again in a moment." ); }); it("should throw PDA-friendly message on 500 with no body message", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 500, body: {} }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The service is taking a break. Please try again in a moment." ); }); it("should throw PDA-friendly message on network error (fetch throws)", async (): Promise => { vi.mocked(global.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(TypeError); }); it("should throw PDA-friendly message on 429 rate-limited response", async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: 429, body: { message: "Too many requests" }, }) ); await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "You've tried a few times. Take a moment and try again shortly." ); }); it("should throw PDA-friendly message when response.json() throws", async (): Promise => { const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); const resp = mockResponse({ ok: false, status: 403 }); const jsonError = new SyntaxError("Unexpected token"); (resp.json as ReturnType).mockRejectedValueOnce(jsonError); vi.mocked(global.fetch).mockResolvedValueOnce(resp); // JSON parse fails -> logs error -> falls back to response status await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( "The email and password combination wasn't recognized." ); expect(errorSpy).toHaveBeenCalledWith( "[Auth] Failed to parse error response body (HTTP 403):", jsonError ); errorSpy.mockRestore(); }); }); describe("signInWithCredentials PDA-friendly language compliance", (): void => { const originalFetch = global.fetch; beforeEach((): void => { global.fetch = vi.fn(); }); afterEach((): void => { global.fetch = originalFetch; vi.restoreAllMocks(); }); const errorScenarios: { name: string; status: number; body: Record; }[] = [ { name: "401 with message", status: 401, body: { message: "Unauthorized" } }, { name: "401 without message", status: 401, body: {} }, { name: "403 with message", status: 403, body: { message: "Forbidden" } }, { name: "429 with message", status: 429, body: { message: "Too many requests" } }, { name: "500 with message", status: 500, body: { message: "Internal Server Error" } }, { name: "500 without message", status: 500, body: {} }, { name: "502 without message", status: 502, body: {} }, { name: "503 without message", status: 503, body: {} }, { name: "400 unknown", status: 400, body: {} }, ]; for (const scenario of errorScenarios) { it(`should not contain forbidden words for ${scenario.name} response`, async (): Promise => { vi.mocked(global.fetch).mockResolvedValueOnce( mockResponse({ ok: false, status: scenario.status, body: scenario.body }) ); try { await signInWithCredentials("alice", "pass"); // Should not reach here expect.unreachable("signInWithCredentials should have thrown"); } catch (thrown: unknown) { expect(thrown).toBeInstanceOf(Error); const message = (thrown as Error).message.toLowerCase(); for (const forbidden of FORBIDDEN_WORDS) { expect(message).not.toContain(forbidden); } } }); } }); // ──────────────────────────────────────────────────────────────────────────── // AUTH-030: getAccessToken tests // ──────────────────────────────────────────────────────────────────────────── describe("getAccessToken", (): void => { afterEach((): void => { vi.restoreAllMocks(); }); it("should return null when no session exists (session.data is null)", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); }); it("should return accessToken when session has valid, non-expired token", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", accessToken: "valid-token-abc", tokenExpiresAt: Date.now() + 300_000, // 5 minutes from now }, }, } as MockSessionData); const result = await getAccessToken(); expect(result).toBe("valid-token-abc"); }); it("should return null when token is expired (tokenExpiresAt in the past)", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", accessToken: "expired-token", tokenExpiresAt: Date.now() - 120_000, // 2 minutes ago }, }, } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); }); it("should return null when token expires within 60-second buffer window", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", accessToken: "almost-expired-token", tokenExpiresAt: Date.now() + 30_000, // 30 seconds from now (within 60s buffer) }, }, } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); }); it("should return null and warn when accessToken is undefined on user object", async (): Promise => { const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined); vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", // no accessToken property }, }, } as MockSessionData); const result = await getAccessToken(); expect(result).toBeNull(); expect(warnSpy).toHaveBeenCalledWith("[Auth] Session exists but no accessToken found"); warnSpy.mockRestore(); }); it("should return null and log error when getSession throws", async (): Promise => { const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); const sessionError = new Error("Network failure"); vi.mocked(getSession).mockRejectedValueOnce(sessionError); const result = await getAccessToken(); expect(result).toBeNull(); expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to get access token:", sessionError); errorSpy.mockRestore(); }); }); // ──────────────────────────────────────────────────────────────────────────── // AUTH-030: isAdmin tests // ──────────────────────────────────────────────────────────────────────────── describe("isAdmin", (): void => { afterEach((): void => { vi.restoreAllMocks(); }); it("should return false when no session exists", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData); const result = await isAdmin(); expect(result).toBe(false); }); it("should return true when user.isAdmin is true", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "admin-1", isAdmin: true, }, }, } as MockSessionData); const result = await isAdmin(); expect(result).toBe(true); }); it("should return false when user.isAdmin is false", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", isAdmin: false, }, }, } as MockSessionData); const result = await isAdmin(); expect(result).toBe(false); }); it("should return false when user.isAdmin is undefined", async (): Promise => { vi.mocked(getSession).mockResolvedValueOnce({ data: { user: { id: "user-1", // no isAdmin property }, }, } as MockSessionData); const result = await isAdmin(); expect(result).toBe(false); }); it("should return false and log error when getSession throws", async (): Promise => { const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined); const sessionError = new Error("Network failure"); vi.mocked(getSession).mockRejectedValueOnce(sessionError); const result = await isAdmin(); expect(result).toBe(false); expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to check admin status:", sessionError); errorSpy.mockRestore(); }); });