Files
stack/apps/web/src/lib/auth/auth-context.test.tsx
Jason Woltje 023949f1e0
Some checks failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/web Pipeline failed
fix(api,web): separate workspace context from auth session (#534)
BetterAuth session responses contain only identity fields — workspace
context (workspaceId, currentWorkspaceId) was never returned, causing
"Workspace ID is required" on every guarded endpoint after login.

Add GET /api/workspaces endpoint (AuthGuard only, no WorkspaceGuard)
that returns user workspace memberships with auto-provisioning for
new users. Frontend auth-context now fetches workspaces after session
check and persists the default to localStorage. Race condition in
auto-provisioning is guarded by re-querying inside the transaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:04:15 -06:00

922 lines
28 KiB
TypeScript

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";
// 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 <div>Loading...</div>;
}
return (
<div>
<div data-testid="auth-status">{isAuthenticated ? "Authenticated" : "Not Authenticated"}</div>
<div data-testid="auth-error">{authError ?? "none"}</div>
<div data-testid="session-expiring">{sessionExpiring ? "true" : "false"}</div>
<div data-testid="session-minutes-remaining">{sessionMinutesRemaining}</div>
{user && (
<div>
<div data-testid="user-email">{user.email}</div>
<div data-testid="user-name">{user.name}</div>
</div>
)}
<button onClick={signOut}>Sign Out</button>
<button onClick={refreshSession}>Refresh</button>
</div>
);
}
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("should provide authenticated user when session exists", async (): Promise<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty - suppressing log output in tests
});
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// 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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// 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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// 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(<TestComponent />);
}).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<void> => {
// 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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
// 403 Forbidden is also classified as invalid_credentials by parseAuthError
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Forbidden"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
// "session expired" is a normal auth lifecycle event, not a backend error
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Session expired"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty
});
// Network error - backend is unreachable
vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
});
consoleErrorSpy.mockRestore();
});
it("should set authError to 'backend' for server errors", async (): Promise<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty
});
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Service Unavailable"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
});
// Re-render does NOT trigger a new session check, so authError persists
rerender(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// 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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// 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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
// 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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<typeof vi.fn>;
setItem: ReturnType<typeof vi.fn>;
removeItem: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
readonly length: number;
key: ReturnType<typeof vi.fn>;
}
let localStorageMock: MockLocalStorage;
beforeEach((): void => {
let store: Record<string, string> = {};
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<void> => {
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: "OWNER",
createdAt: "2026-01-01",
},
]);
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1);
});
it("should persist the first workspace ID to localStorage", async (): Promise<void> => {
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: "OWNER",
createdAt: "2026-01-01",
},
{
id: "ws-def-456",
name: "Second Workspace",
ownerId: "other",
role: "MEMBER",
createdAt: "2026-02-01",
},
]);
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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<void> => {
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: "OWNER",
createdAt: "2026-01-01",
},
]);
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
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");
});
});
});