import React, { act } from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "./auth-context"; import type { AuthUser } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared"; // Mock the API client vi.mock("../api/client", () => ({ apiGet: vi.fn(), apiPost: vi.fn(), })); // Mock the workspaces API client vi.mock("../api/workspaces", () => ({ fetchUserWorkspaces: vi.fn(), })); const { apiGet, apiPost } = await import("../api/client"); const { fetchUserWorkspaces } = await import("../api/workspaces"); /** Helper: returns a date far in the future (1 hour from now) for session mocks */ function futureExpiry(): string { return new Date(Date.now() + 60 * 60 * 1000).toISOString(); } // Test component that uses the auth context function TestComponent(): React.JSX.Element { const { user, isLoading, isAuthenticated, authError, sessionExpiring, sessionMinutesRemaining, signOut, refreshSession, } = useAuth(); if (isLoading) { return
Loading...
; } return (
{isAuthenticated ? "Authenticated" : "Not Authenticated"}
{authError ?? "none"}
{sessionExpiring ? "true" : "false"}
{sessionMinutesRemaining}
{user && (
{user.email}
{user.name}
)}
); } describe("AuthContext", (): void => { beforeEach((): void => { vi.clearAllMocks(); }); it("should provide loading state initially", (): void => { vi.mocked(apiGet).mockImplementation( () => new Promise(() => { // Never resolves - intentionally empty for testing loading state }) ); render( ); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); it("should provide authenticated user when session exists", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); expect(screen.getByTestId("user-email")).toHaveTextContent("test@example.com"); expect(screen.getByTestId("user-name")).toHaveTextContent("Test User"); }); it("should handle unauthenticated state when session check fails", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - suppressing log output in tests }); vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(screen.queryByTestId("user-email")).not.toBeInTheDocument(); consoleErrorSpy.mockRestore(); }); it("should clear user on sign out", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(apiPost).mockResolvedValueOnce({ success: true }); render( ); // Wait for authenticated state await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); // Click sign out const signOutButton = screen.getByRole("button", { name: "Sign Out" }); signOutButton.click(); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(apiPost).toHaveBeenCalledWith("/auth/sign-out"); }); it("should clear user and set authError to 'network' when signOut fails with a network error", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - suppressing log output in tests }); const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; // First: user is logged in vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); // signOut request fails with a network error (TypeError with "fetch") vi.mocked(apiPost).mockRejectedValueOnce(new TypeError("Failed to fetch")); render( ); // Wait for authenticated state await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); // Click sign out — the apiPost will reject const signOutButton = screen.getByRole("button", { name: "Sign Out" }); signOutButton.click(); // User should be cleared (finally block runs even on error) await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); // authError should be set to "network" via classifyAuthError expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); // Verify the sign-out endpoint was still called expect(apiPost).toHaveBeenCalledWith("/auth/sign-out"); consoleErrorSpy.mockRestore(); }); it("should clear user and set authError to 'backend' when signOut fails with a server error", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - suppressing log output in tests }); const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; // First: user is logged in vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); // signOut request fails with a 500 Internal Server Error vi.mocked(apiPost).mockRejectedValueOnce(new Error("Internal Server Error")); render( ); // Wait for authenticated state await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); // Click sign out — the apiPost will reject with server error const signOutButton = screen.getByRole("button", { name: "Sign Out" }); signOutButton.click(); // User should be cleared (finally block runs even on error) await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); // authError should be set to "backend" via classifyAuthError expect(screen.getByTestId("auth-error")).toHaveTextContent("backend"); // Verify the sign-out endpoint was still called expect(apiPost).toHaveBeenCalledWith("/auth/sign-out"); consoleErrorSpy.mockRestore(); }); it("should throw error when useAuth is used outside AuthProvider", (): void => { // Suppress console.error for this test const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - suppressing React error boundary output }); expect(() => { render(); }).toThrow("useAuth must be used within AuthProvider"); consoleErrorSpy.mockRestore(); }); describe("auth error handling", (): void => { it("should set authError to null for normal 401 Unauthorized (not logged in)", async (): Promise => { // 401 Unauthorized is a normal condition (user not logged in), not an error. // classifyAuthError should return null so no "having trouble" banner appears. vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); // authError should be null (displayed as "none" by TestComponent) expect(screen.getByTestId("auth-error")).toHaveTextContent("none"); }); it("should set authError to null for 403 Forbidden", async (): Promise => { // 403 Forbidden is also classified as invalid_credentials by parseAuthError vi.mocked(apiGet).mockRejectedValueOnce(new Error("Forbidden")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(screen.getByTestId("auth-error")).toHaveTextContent("none"); }); it("should set authError to null for session expired errors", async (): Promise => { // "session expired" is a normal auth lifecycle event, not a backend error vi.mocked(apiGet).mockRejectedValueOnce(new Error("Session expired")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(screen.getByTestId("auth-error")).toHaveTextContent("none"); }); it("should set authError to 'backend' for truly unrecognised Error instances", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); // An Error that doesn't match any known pattern (parseAuthError returns "unknown") // should fall through to the instanceof Error catch-all and return "backend" vi.mocked(apiGet).mockRejectedValueOnce( new Error("Something completely unexpected happened") ); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(screen.getByTestId("auth-error")).toHaveTextContent("backend"); consoleErrorSpy.mockRestore(); }); it("should set authError to 'network' for fetch failures", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); // Network error - backend is unreachable vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); // Should have a network error expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); consoleErrorSpy.mockRestore(); }); it("should always log auth errors (including production)", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty - we're testing that errors are logged }); // Network error - backend is unreachable vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); }); // Should log error regardless of NODE_ENV expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("[Auth]"), expect.any(Error) ); consoleErrorSpy.mockRestore(); }); it("should set authError to 'network' for connection refused", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); // "Connection refused" includes "connection" which parseAuthError maps to network_error vi.mocked(apiGet).mockRejectedValueOnce(new Error("Connection refused")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); }); consoleErrorSpy.mockRestore(); }); it("should set authError to 'backend' for server errors", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); // Backend error - 500 Internal Server Error vi.mocked(apiGet).mockRejectedValueOnce(new Error("Internal Server Error")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); // Should have a backend error expect(screen.getByTestId("auth-error")).toHaveTextContent("backend"); consoleErrorSpy.mockRestore(); }); it("should set authError to 'backend' for service unavailable", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); vi.mocked(apiGet).mockRejectedValueOnce(new Error("Service Unavailable")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-error")).toHaveTextContent("backend"); }); consoleErrorSpy.mockRestore(); }); it("should persist authError across re-renders when no new session check occurs", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { // Intentionally empty }); // First call fails with network error vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch")); const { rerender } = render( ); await waitFor(() => { expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); }); // Re-render does NOT trigger a new session check, so authError persists rerender( ); // authError should still be "network" — re-render alone does not clear it expect(screen.getByTestId("auth-error")).toHaveTextContent("network"); consoleErrorSpy.mockRestore(); }); }); describe("session expiry detection", (): void => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; beforeEach((): void => { // Reset all mocks to clear any unconsumed mockResolvedValueOnce queues // from previous tests (vi.clearAllMocks only clears calls/results, not implementations) vi.resetAllMocks(); }); afterEach((): void => { // Ensure no stale intervals leak between tests vi.clearAllTimers(); }); it("should set sessionExpiring to false when session has plenty of time remaining", async (): Promise => { const farFuture = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 60 minutes vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: farFuture }, }); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); }); it("should set sessionExpiring to true when session is within 5 minutes of expiry", async (): Promise => { const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, }); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); await waitFor(() => { expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); }); }); it("should calculate sessionMinutesRemaining correctly", async (): Promise => { const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, }); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); await waitFor(() => { expect(screen.getByTestId("session-minutes-remaining")).toHaveTextContent("3"); }); }); it("should transition from not-expiring to expiring after interval fires", async (): Promise => { vi.useFakeTimers(); // Session expires 6 minutes from now - just outside the warning window const expiresAt = new Date(Date.now() + 6 * 60 * 1000).toISOString(); vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt }, }); await act(async () => { render( ); // Flush the resolved mock promise so checkSession completes await Promise.resolve(); await Promise.resolve(); }); // Initially not expiring (6 minutes remaining) expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); // Advance 2 minutes - should now be within the 5-minute window (4 min remaining) await act(async () => { vi.advanceTimersByTime(2 * 60 * 1000); await Promise.resolve(); }); expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); vi.useRealTimers(); }); it("should log out user and set session_expired when session expires via interval", async (): Promise => { vi.useFakeTimers(); // Session expires 30 seconds from now const almostExpired = new Date(Date.now() + 30 * 1000).toISOString(); vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: almostExpired }, }); await act(async () => { render( ); await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); // Advance past the expiry time (triggers the 60s interval) await act(async () => { vi.advanceTimersByTime(60 * 1000); await Promise.resolve(); }); expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); // Session expiry now sets explicit session_expired error state expect(screen.getByTestId("auth-error")).toHaveTextContent("session_expired"); vi.useRealTimers(); }); it("should reset sessionExpiring after successful refreshSession", async (): Promise => { // Session near expiry (3 minutes remaining) const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, }); render( ); await waitFor(() => { expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); }); // Set up a refreshed session response with a far-future expiry const refreshedExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-2", token: "token456", expiresAt: refreshedExpiry }, }); // Click refresh button const refreshButton = screen.getByRole("button", { name: "Refresh" }); refreshButton.click(); await waitFor(() => { expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); }); }); }); describe("workspace ID persistence", (): void => { // --------------------------------------------------------------------------- // localStorage mock for workspace persistence tests // --------------------------------------------------------------------------- interface MockLocalStorage { getItem: ReturnType; setItem: ReturnType; removeItem: ReturnType; clear: ReturnType; readonly length: number; key: ReturnType; } let localStorageMock: MockLocalStorage; beforeEach((): void => { let store: Record = {}; localStorageMock = { getItem: vi.fn((key: string): string | null => store[key] ?? null), setItem: vi.fn((key: string, value: string): void => { store[key] = value; }), removeItem: vi.fn((key: string): void => { store = Object.fromEntries(Object.entries(store).filter(([k]) => k !== key)); }), clear: vi.fn((): void => { store = {}; }), get length(): number { return Object.keys(store).length; }, key: vi.fn((_index: number): string | null => null), }; Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true, }); vi.resetAllMocks(); }); afterEach((): void => { vi.restoreAllMocks(); }); it("should call fetchUserWorkspaces after successful session check", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([ { id: "ws-1", name: "My Workspace", ownerId: "user-1", role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, ]); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1); }); it("should persist the first workspace ID to localStorage", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([ { id: "ws-abc-123", name: "My Workspace", ownerId: "user-1", role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, { id: "ws-def-456", name: "Second Workspace", ownerId: "other", role: WorkspaceMemberRole.MEMBER, createdAt: "2026-02-01", }, ]); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); expect(localStorageMock.setItem).toHaveBeenCalledWith("mosaic-workspace-id", "ws-abc-123"); }); it("should not write localStorage when fetchUserWorkspaces returns empty array", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([]); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); expect(localStorageMock.setItem).not.toHaveBeenCalledWith( "mosaic-workspace-id", expect.anything() ); }); it("should handle fetchUserWorkspaces failure gracefully — auth still succeeds", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(fetchUserWorkspaces).mockRejectedValueOnce(new Error("Network error")); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); // Auth succeeded despite workspace fetch failure expect(screen.getByTestId("auth-error")).toHaveTextContent("none"); }); it("should remove workspace ID from localStorage on sign-out", async (): Promise => { const mockUser: AuthUser = { id: "user-1", email: "test@example.com", name: "Test User", }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([ { id: "ws-1", name: "My Workspace", ownerId: "user-1", role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, ]); vi.mocked(apiPost).mockResolvedValueOnce({ success: true }); render( ); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); }); const signOutButton = screen.getByRole("button", { name: "Sign Out" }); signOutButton.click(); await waitFor(() => { expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); }); expect(localStorageMock.removeItem).toHaveBeenCalledWith("mosaic-workspace-id"); }); }); });