diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 4509f15..dc75f8b 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -44,9 +44,10 @@ vi.mock("@/lib/auth/fetch-with-retry", () => ({ fetchWithRetry: mockFetchWithRetry, })); -// Mock parseAuthError to use the real implementation -vi.mock("@/lib/auth/auth-errors", async (importOriginal) => { - return importOriginal(); +// Use real parseAuthError implementation — vi.mock required for module resolution in vitest +vi.mock("@/lib/auth/auth-errors", async () => { + const actual = await import("../../../lib/auth/auth-errors"); + return { ...actual }; }); /* ------------------------------------------------------------------ */ @@ -317,7 +318,7 @@ describe("LoginPage", (): void => { }); }); - it("shows error banner on sign-in failure", async (): Promise => { + it("sanitizes BetterAuth error messages through parseAuthError", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ error: { message: "Invalid credentials" }, @@ -334,8 +335,39 @@ describe("LoginPage", (): void => { await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); + // Raw "Invalid credentials" is mapped through parseAuthError to a PDA-friendly message await waitFor((): void => { - expect(screen.getByText("Invalid credentials")).toBeInTheDocument(); + expect( + screen.getByText("Authentication didn't complete. Please try again when ready.") + ).toBeInTheDocument(); + }); + + expect(mockPush).not.toHaveBeenCalled(); + }); + + it("maps raw DB/server errors to PDA-friendly messages instead of leaking details", async (): Promise => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + // Simulate a leaked internal server error from BetterAuth + mockSignInEmail.mockResolvedValueOnce({ + error: { message: "Internal server error: connection to DB pool exhausted" }, + }); + const user = userEvent.setup(); + + render(); + + await waitFor((): void => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "wrong"); + await user.click(screen.getByRole("button", { name: /continue/i })); + + // parseAuthError maps "internal server" keyword to server_error PDA-friendly message + await waitFor((): void => { + expect( + screen.getByText("The service is taking a break. Please try again in a moment.") + ).toBeInTheDocument(); }); expect(mockPush).not.toHaveBeenCalled(); @@ -359,9 +391,11 @@ describe("LoginPage", (): void => { await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); + // When error.message is falsy, parseAuthError receives the raw error object + // which falls through to the "unknown" code PDA-friendly message await waitFor((): void => { expect( - screen.getByText("Unable to sign in. Please check your credentials and try again.") + screen.getByText("Authentication didn't complete. Please try again when ready.") ).toBeInTheDocument(); }); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 4726ddc..22f1b5f 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -102,11 +102,10 @@ export default function LoginPage(): ReactElement { const result = await signIn.email({ email, password }); if (result.error) { - setError( - typeof result.error.message === "string" - ? result.error.message - : "Unable to sign in. Please check your credentials and try again." + const parsed = parseAuthError( + result.error.message ? new Error(String(result.error.message)) : result.error ); + setError(parsed.message); } else { router.push("/tasks"); }