All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
923 lines
28 KiB
TypeScript
923 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";
|
|
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 <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: WorkspaceMemberRole.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: WorkspaceMemberRole.OWNER,
|
|
createdAt: "2026-01-01",
|
|
},
|
|
{
|
|
id: "ws-def-456",
|
|
name: "Second Workspace",
|
|
ownerId: "other",
|
|
role: WorkspaceMemberRole.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: WorkspaceMemberRole.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");
|
|
});
|
|
});
|
|
});
|