feat(#417): add session expiry detection to AuthProvider

Adds sessionExpiring and sessionMinutesRemaining to auth context.
Checks session expiry every 60s, warns when within 5 minutes.

Refs #417
This commit is contained in:
Jason Woltje
2026-02-16 12:12:46 -06:00
parent f500300b1f
commit 07084208a7
2 changed files with 284 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
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";
@@ -11,9 +12,23 @@ vi.mock("../api/client", () => ({
const { apiGet, apiPost } = await import("../api/client");
/** 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, signOut } = useAuth();
const {
user,
isLoading,
isAuthenticated,
authError,
sessionExpiring,
sessionMinutesRemaining,
signOut,
refreshSession,
} = useAuth();
if (isLoading) {
return <div>Loading...</div>;
@@ -23,6 +38,8 @@ function TestComponent(): React.JSX.Element {
<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>
@@ -30,6 +47,7 @@ function TestComponent(): React.JSX.Element {
</div>
)}
<button onClick={signOut}>Sign Out</button>
<button onClick={refreshSession}>Refresh</button>
</div>
);
}
@@ -65,7 +83,7 @@ describe("AuthContext", (): void => {
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: new Date() },
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
render(
@@ -107,7 +125,7 @@ describe("AuthContext", (): void => {
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: new Date() },
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
@@ -305,7 +323,7 @@ describe("AuthContext", (): void => {
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: new Date() },
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
// Trigger a rerender (simulating refreshSession being called)
@@ -320,4 +338,195 @@ describe("AuthContext", (): void => {
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);
});
expect(screen.getByTestId("session-expiring")).toHaveTextContent("true");
vi.useRealTimers();
});
it("should log out user 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);
});
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
expect(screen.getByTestId("session-expiring")).toHaveTextContent("false");
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");
});
});
});
});