fix(#411): sanitize login error messages through parseAuthError — prevent raw error leakage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,9 +44,10 @@ vi.mock("@/lib/auth/fetch-with-retry", () => ({
|
|||||||
fetchWithRetry: mockFetchWithRetry,
|
fetchWithRetry: mockFetchWithRetry,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock parseAuthError to use the real implementation
|
// Use real parseAuthError implementation — vi.mock required for module resolution in vitest
|
||||||
vi.mock("@/lib/auth/auth-errors", async (importOriginal) => {
|
vi.mock("@/lib/auth/auth-errors", async () => {
|
||||||
return importOriginal();
|
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<void> => {
|
it("sanitizes BetterAuth error messages through parseAuthError", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
mockSignInEmail.mockResolvedValueOnce({
|
mockSignInEmail.mockResolvedValueOnce({
|
||||||
error: { message: "Invalid credentials" },
|
error: { message: "Invalid credentials" },
|
||||||
@@ -334,8 +335,39 @@ describe("LoginPage", (): void => {
|
|||||||
await user.type(screen.getByLabelText(/password/i), "wrong");
|
await user.type(screen.getByLabelText(/password/i), "wrong");
|
||||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||||
|
|
||||||
|
// Raw "Invalid credentials" is mapped through parseAuthError to a PDA-friendly message
|
||||||
await waitFor((): void => {
|
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<void> => {
|
||||||
|
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(<LoginPage />);
|
||||||
|
|
||||||
|
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();
|
expect(mockPush).not.toHaveBeenCalled();
|
||||||
@@ -359,9 +391,11 @@ describe("LoginPage", (): void => {
|
|||||||
await user.type(screen.getByLabelText(/password/i), "wrong");
|
await user.type(screen.getByLabelText(/password/i), "wrong");
|
||||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
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 => {
|
await waitFor((): void => {
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -102,11 +102,10 @@ export default function LoginPage(): ReactElement {
|
|||||||
const result = await signIn.email({ email, password });
|
const result = await signIn.email({ email, password });
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(
|
const parsed = parseAuthError(
|
||||||
typeof result.error.message === "string"
|
result.error.message ? new Error(String(result.error.message)) : result.error
|
||||||
? result.error.message
|
|
||||||
: "Unable to sign in. Please check your credentials and try again."
|
|
||||||
);
|
);
|
||||||
|
setError(parsed.message);
|
||||||
} else {
|
} else {
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user