Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion web/src/auth/AuthContext.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AuthProvider } from "./AuthContext";
import { useAuth } from "./useAuth";
import { setStoredToken } from "../test/jwt";
import { clearAccessToken } from "./tokenStore";
import { login as apiLogin, logout as apiLogout, setOnTokensChanged, setOnUnauthenticated } from "../api/client";
import { login as apiLogin, logout as apiLogout, restoreSession, setOnTokensChanged, setOnUnauthenticated } from "../api/client";

// Mock the transport: AuthProvider drives session STATE, the client drives the
// network. We simulate the server side (login stores a token, logout clears it)
Expand All @@ -22,6 +22,7 @@ const mockApiLogin = vi.mocked(apiLogin);
const mockApiLogout = vi.mocked(apiLogout);
const mockSetOnTokensChanged = vi.mocked(setOnTokensChanged);
const mockSetOnUnauthenticated = vi.mocked(setOnUnauthenticated);
const mockRestoreSession = vi.mocked(restoreSession);

function Probe() {
const { role, isAdmin, isAuthenticated, login, logout } = useAuth();
Expand Down Expand Up @@ -76,6 +77,31 @@ describe("AuthProvider lifecycle", () => {
expect(screen.getByTestId("auth")).toHaveTextContent("true");
});

it("adopts the session the load-time silent refresh restores", async () => {
// The success half of the #145 bootstrap. Every other case here starts
// either already-authenticated or with restoreSession resolving false, so
// the branch that actually ADOPTS a restored session was never executed --
// which is what put src/auth under its coverage floor and turned main red.
//
// The real client stores the rotated access token before resolving, so the
// mock does the same: the provider must derive the role from the TOKEN, not
// from the boolean. A Manager token proves that — hard-coding Admin or
// Worker on this path fails.
mockRestoreSession.mockImplementationOnce(async () => {
setStoredToken({ sub: "u1", role: "Manager" });
return true;
});

await act(async () => {
renderAuth();
});

expect(mockRestoreSession).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("auth")).toHaveTextContent("true");
expect(screen.getByTestId("role")).toHaveTextContent("Manager");
expect(screen.getByTestId("admin")).toHaveTextContent("true");
});

it("logout clears auth state back to Worker", async () => {
setStoredToken({ sub: "u1", role: "Admin" }); // start logged in
mockApiLogout.mockImplementation(async () => clearAccessToken());
Expand Down
20 changes: 19 additions & 1 deletion web/src/auth/tokenStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { getAccessToken, setAccessToken, clearAccessToken, purgeLegacyTokens } from "./tokenStore";

// The in-memory access token is reset after every test in src/test/setup.ts.
Expand Down Expand Up @@ -36,6 +36,24 @@ describe("tokenStore (in-memory access token, #145)", () => {
expect(localStorage.getItem("cluckwork.tokens")).toBeNull();
});

it("purgeLegacyTokens survives storage being unavailable", () => {
// Safari private mode and hardened browser profiles throw from the storage
// API rather than returning null. The purge runs at startup, so an
// uncaught throw here would take the whole app down before first paint.
const removeItem = vi
.spyOn(Storage.prototype, "removeItem")
.mockImplementation(() => {
throw new DOMException("The operation is insecure.", "SecurityError");
});

try {
expect(() => purgeLegacyTokens()).not.toThrow();
expect(removeItem).toHaveBeenCalled();
} finally {
removeItem.mockRestore();
}
});

it("purgeLegacyTokens is a no-op when there is nothing to purge", () => {
expect(() => purgeLegacyTokens()).not.toThrow();
expect(getAccessToken()).toBeNull();
Expand Down
Loading