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
This commit is contained in:
220
apps/web/src/lib/auth-client.test.ts
Normal file
220
apps/web/src/lib/auth-client.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
}): 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<string, unknown>;
|
||||
}> = [
|
||||
{ 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<void> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user