From f1ee0df9330387cf18411bf3abf4455ffd173199 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 12:15:25 -0600 Subject: [PATCH] feat(#417): update auth-client.ts error messages to PDA-friendly Uses parseAuthError from auth-errors module for consistent PDA-friendly error messages in signInWithCredentials. Refs #417 --- apps/web/src/lib/auth-client.test.ts | 220 +++++++++++++++++++++++++++ apps/web/src/lib/auth-client.ts | 6 +- 2 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/auth-client.test.ts diff --git a/apps/web/src/lib/auth-client.test.ts b/apps/web/src/lib/auth-client.test.ts new file mode 100644 index 0000000..ced9e1f --- /dev/null +++ b/apps/web/src/lib/auth-client.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** Words that must never appear in PDA-friendly messages. */ +const FORBIDDEN_WORDS = [ + "overdue", + "urgent", + "must", + "critical", + "required", + "error", + "failed", + "failure", +]; + +// Mock BetterAuth before importing the module under test +vi.mock("better-auth/react", () => ({ + createAuthClient: vi.fn(() => ({ + signIn: vi.fn(), + signOut: vi.fn(), + useSession: vi.fn(), + getSession: vi.fn(() => Promise.resolve({ data: null })), + })), +})); + +vi.mock("better-auth/client/plugins", () => ({ + genericOAuthClient: vi.fn(() => ({})), +})); + +vi.mock("./config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +// Import after mocks are set up +const { signInWithCredentials } = await import("./auth-client"); + +/** + * Helper to build a mock Response object that behaves like the Fetch API Response. + */ +function mockResponse(options: { + ok: boolean; + status: number; + body?: Record; +}): Response { + const { ok, status, body = {} } = options; + return { + ok, + status, + json: vi.fn(() => Promise.resolve(body)), + headers: new Headers(), + redirected: false, + statusText: "", + type: "basic" as ResponseType, + url: "", + clone: vi.fn(), + body: null, + bodyUsed: false, + arrayBuffer: vi.fn(), + blob: vi.fn(), + formData: vi.fn(), + text: vi.fn(), + bytes: vi.fn(), + } as unknown as Response; +} + +describe("signInWithCredentials", (): void => { + const originalFetch = global.fetch; + + beforeEach((): void => { + global.fetch = vi.fn(); + }); + + afterEach((): void => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("should return data on successful response", async (): Promise => { + const sessionData = { user: { id: "1", name: "Alice" }, token: "abc" }; + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ ok: true, status: 200, body: sessionData }) + ); + + const result = await signInWithCredentials("alice", "password123"); + + expect(result).toEqual(sessionData); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3001/auth/sign-in/credentials", + expect.objectContaining({ + method: "POST", + credentials: "include", + body: JSON.stringify({ username: "alice", password: "password123" }), + }) + ); + }); + + it("should throw PDA-friendly message on 401 response", async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ ok: false, status: 401, body: { message: "Unauthorized" } }) + ); + + await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( + "The email and password combination wasn't recognized." + ); + }); + + it("should throw PDA-friendly message on 401 with no body message", async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ ok: false, status: 401, body: {} }) + ); + + // When there is no body message, the response object (status: 401) is used for parsing + await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow( + "The email and password combination wasn't recognized." + ); + }); + + it("should throw PDA-friendly message on 500 response", async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ + ok: false, + status: 500, + body: { message: "Internal Server Error" }, + }) + ); + + await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( + "The service is taking a break. Please try again in a moment." + ); + }); + + it("should throw PDA-friendly message on 500 with no body message", async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ ok: false, status: 500, body: {} }) + ); + + await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( + "The service is taking a break. Please try again in a moment." + ); + }); + + it("should throw PDA-friendly message on network error (fetch throws)", async (): Promise => { + vi.mocked(global.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch")); + + await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(TypeError); + }); + + it("should throw PDA-friendly message on 429 rate-limited response", async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ + ok: false, + status: 429, + body: { message: "Too many requests" }, + }) + ); + + await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( + "You've tried a few times. Take a moment and try again shortly." + ); + }); + + it("should throw PDA-friendly message when response.json() throws", async (): Promise => { + const resp = mockResponse({ ok: false, status: 403 }); + vi.mocked(resp.json).mockRejectedValueOnce(new SyntaxError("Unexpected token")); + vi.mocked(global.fetch).mockResolvedValueOnce(resp); + + // json().catch(() => ({})) returns {}, so no message -> falls back to response status + await expect(signInWithCredentials("alice", "pass")).rejects.toThrow( + "The email and password combination wasn't recognized." + ); + }); +}); + +describe("signInWithCredentials PDA-friendly language compliance", (): void => { + const originalFetch = global.fetch; + + beforeEach((): void => { + global.fetch = vi.fn(); + }); + + afterEach((): void => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const errorScenarios: Array<{ + name: string; + status: number; + body: Record; + }> = [ + { name: "401 with message", status: 401, body: { message: "Unauthorized" } }, + { name: "401 without message", status: 401, body: {} }, + { name: "403 with message", status: 403, body: { message: "Forbidden" } }, + { name: "429 with message", status: 429, body: { message: "Too many requests" } }, + { name: "500 with message", status: 500, body: { message: "Internal Server Error" } }, + { name: "500 without message", status: 500, body: {} }, + { name: "502 without message", status: 502, body: {} }, + { name: "503 without message", status: 503, body: {} }, + { name: "400 unknown", status: 400, body: {} }, + ]; + + for (const scenario of errorScenarios) { + it(`should not contain forbidden words for ${scenario.name} response`, async (): Promise => { + vi.mocked(global.fetch).mockResolvedValueOnce( + mockResponse({ ok: false, status: scenario.status, body: scenario.body }) + ); + + try { + await signInWithCredentials("alice", "pass"); + // Should not reach here + expect.unreachable("signInWithCredentials should have thrown"); + } catch (thrown: unknown) { + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message.toLowerCase(); + for (const forbidden of FORBIDDEN_WORDS) { + expect(message).not.toContain(forbidden); + } + } + }); + } +}); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 393fb11..0d39c68 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -9,6 +9,7 @@ import { createAuthClient } from "better-auth/react"; import { genericOAuthClient } from "better-auth/client/plugins"; import { API_BASE_URL } from "./config"; +import { parseAuthError } from "./auth/auth-errors"; /** * Auth client instance configured for Mosaic Stack. @@ -42,8 +43,9 @@ export async function signInWithCredentials(username: string, password: string): }); if (!response.ok) { - const error = (await response.json().catch(() => ({}))) as { message?: string }; - throw new Error(error.message ?? "Authentication failed"); + const errorBody = (await response.json().catch(() => ({}))) as { message?: string }; + const parsed = parseAuthError(errorBody.message ? new Error(errorBody.message) : response); + throw new Error(parsed.message); } const data = (await response.json()) as unknown;