fix(#411): classifyAuthError — return null for normal 401/session-expired instead of 'backend'

Normal authentication failures (401 Unauthorized, 403 Forbidden, session
expired) are not backend errors — they simply mean the user isn't logged in.
Previously these fell through to the `instanceof Error` catch-all and returned
"backend", causing a misleading "having trouble connecting" banner.

Now classifyAuthError explicitly checks for invalid_credentials and
session_expired codes from parseAuthError and returns null, so the UI shows
the logged-out state cleanly without an error banner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 15:42:44 -06:00
parent 399d5a31c8
commit d7de20e586
2 changed files with 67 additions and 9 deletions

View File

@@ -270,13 +270,9 @@ describe("AuthContext", (): void => {
});
describe("auth error handling", (): void => {
it("should set authError to 'backend' for unrecognised auth errors (e.g. 401/403)", async (): Promise<void> => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty
});
// Error instances that don't match network/server keywords default to "backend"
// so they surface a banner rather than silently logging the user out
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(
@@ -289,7 +285,63 @@ describe("AuthContext", (): void => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
});
// With classifyAuthError, unrecognised Error instances default to "backend"
// 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();