From f500300b1f7a725d0b942d8a12df188e93c7b2a1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 12:02:57 -0600 Subject: [PATCH] feat(#417): create auth-errors.ts with PDA error parsing and mapping Adds AuthErrorCode type, ParsedAuthError interface, parseAuthError() classifier, and getErrorMessage() helper. All messages use PDA-friendly language. Refs #417 --- apps/web/src/lib/auth/auth-errors.test.ts | 294 ++++++++++++++++++++++ apps/web/src/lib/auth/auth-errors.ts | 173 +++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 apps/web/src/lib/auth/auth-errors.test.ts create mode 100644 apps/web/src/lib/auth/auth-errors.ts diff --git a/apps/web/src/lib/auth/auth-errors.test.ts b/apps/web/src/lib/auth/auth-errors.test.ts new file mode 100644 index 0000000..49e8a2f --- /dev/null +++ b/apps/web/src/lib/auth/auth-errors.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect } from "vitest"; +import { parseAuthError, getErrorMessage } from "./auth-errors"; +import type { AuthErrorCode, ParsedAuthError } from "./auth-errors"; + +/** Words that must never appear in PDA-friendly messages. */ +const FORBIDDEN_WORDS = [ + "overdue", + "urgent", + "must", + "critical", + "required", + "error", + "failed", + "failure", +]; + +describe("parseAuthError", (): void => { + it("should classify TypeError('Failed to fetch') as network_error", (): void => { + const result: ParsedAuthError = parseAuthError(new TypeError("Failed to fetch")); + expect(result.code).toBe("network_error"); + expect(result.retryable).toBe(true); + }); + + it("should classify TypeError with 'fetch' anywhere in message as network_error", (): void => { + const result: ParsedAuthError = parseAuthError(new TypeError("Could not fetch resource")); + expect(result.code).toBe("network_error"); + }); + + it("should classify Error('Unauthorized') as invalid_credentials", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Unauthorized")); + expect(result.code).toBe("invalid_credentials"); + expect(result.retryable).toBe(false); + }); + + it("should classify Error('Forbidden') as invalid_credentials", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Forbidden")); + expect(result.code).toBe("invalid_credentials"); + }); + + it("should classify HTTP 401 response as invalid_credentials", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 401 }); + expect(result.code).toBe("invalid_credentials"); + expect(result.retryable).toBe(false); + }); + + it("should classify HTTP 403 response as invalid_credentials", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 403 }); + expect(result.code).toBe("invalid_credentials"); + }); + + it("should classify HTTP 429 response as rate_limited", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 429 }); + expect(result.code).toBe("rate_limited"); + expect(result.retryable).toBe(false); + }); + + it("should classify HTTP 500 response as server_error", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 500 }); + expect(result.code).toBe("server_error"); + expect(result.retryable).toBe(true); + }); + + it("should classify HTTP 502 response as server_error", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 502 }); + expect(result.code).toBe("server_error"); + }); + + it("should classify HTTP 503 response as server_error", (): void => { + const result: ParsedAuthError = parseAuthError({ status: 503 }); + expect(result.code).toBe("server_error"); + }); + + it("should classify string 'access_denied' as access_denied", (): void => { + const result: ParsedAuthError = parseAuthError("access_denied"); + expect(result.code).toBe("access_denied"); + expect(result.retryable).toBe(false); + }); + + it("should classify string 'session_expired' as session_expired", (): void => { + const result: ParsedAuthError = parseAuthError("session_expired"); + expect(result.code).toBe("session_expired"); + expect(result.retryable).toBe(false); + }); + + it("should classify string 'rate_limited' as rate_limited", (): void => { + const result: ParsedAuthError = parseAuthError("rate_limited"); + expect(result.code).toBe("rate_limited"); + }); + + it("should classify string 'server_error' as server_error", (): void => { + const result: ParsedAuthError = parseAuthError("server_error"); + expect(result.code).toBe("server_error"); + expect(result.retryable).toBe(true); + }); + + it("should classify string 'network_error' as network_error", (): void => { + const result: ParsedAuthError = parseAuthError("network_error"); + expect(result.code).toBe("network_error"); + expect(result.retryable).toBe(true); + }); + + it("should classify unknown string as unknown", (): void => { + const result: ParsedAuthError = parseAuthError("something_weird"); + expect(result.code).toBe("unknown"); + expect(result.retryable).toBe(false); + }); + + it("should classify null as unknown", (): void => { + const result: ParsedAuthError = parseAuthError(null); + expect(result.code).toBe("unknown"); + }); + + it("should classify undefined as unknown", (): void => { + const result: ParsedAuthError = parseAuthError(undefined); + expect(result.code).toBe("unknown"); + }); + + it("should classify a number as unknown", (): void => { + const result: ParsedAuthError = parseAuthError(42); + expect(result.code).toBe("unknown"); + }); + + it("should classify an empty object as unknown", (): void => { + const result: ParsedAuthError = parseAuthError({}); + expect(result.code).toBe("unknown"); + }); + + it("should classify Error('Internal Server Error') as server_error", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Internal Server Error")); + expect(result.code).toBe("server_error"); + expect(result.retryable).toBe(true); + }); + + it("should classify Error('Service Unavailable') as server_error", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Service Unavailable")); + expect(result.code).toBe("server_error"); + }); + + it("should classify Error('Too many requests') as rate_limited", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Too many requests")); + expect(result.code).toBe("rate_limited"); + }); + + it("should classify Error('Session expired') as session_expired", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Session expired")); + expect(result.code).toBe("session_expired"); + }); + + it("should classify Error('Network issue') as network_error", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Network issue")); + expect(result.code).toBe("network_error"); + }); + + it("should classify Error with unknown message as unknown", (): void => { + const result: ParsedAuthError = parseAuthError(new Error("Something completely different")); + expect(result.code).toBe("unknown"); + }); +}); + +describe("parseAuthError retryable flag", (): void => { + it("should mark network_error as retryable", (): void => { + expect(parseAuthError(new TypeError("Failed to fetch")).retryable).toBe(true); + }); + + it("should mark server_error as retryable", (): void => { + expect(parseAuthError({ status: 500 }).retryable).toBe(true); + }); + + it("should mark invalid_credentials as not retryable", (): void => { + expect(parseAuthError(new Error("Unauthorized")).retryable).toBe(false); + }); + + it("should mark access_denied as not retryable", (): void => { + expect(parseAuthError("access_denied").retryable).toBe(false); + }); + + it("should mark rate_limited as not retryable", (): void => { + expect(parseAuthError("rate_limited").retryable).toBe(false); + }); + + it("should mark session_expired as not retryable", (): void => { + expect(parseAuthError("session_expired").retryable).toBe(false); + }); + + it("should mark unknown as not retryable", (): void => { + expect(parseAuthError(null).retryable).toBe(false); + }); +}); + +describe("getErrorMessage", (): void => { + const allCodes: AuthErrorCode[] = [ + "access_denied", + "invalid_credentials", + "server_error", + "network_error", + "rate_limited", + "session_expired", + "unknown", + ]; + + it("should return the correct message for access_denied", (): void => { + expect(getErrorMessage("access_denied")).toBe( + "Authentication paused. Please try again when ready." + ); + }); + + it("should return the correct message for invalid_credentials", (): void => { + expect(getErrorMessage("invalid_credentials")).toBe( + "The email and password combination wasn't recognized." + ); + }); + + it("should return the correct message for server_error", (): void => { + expect(getErrorMessage("server_error")).toBe( + "The service is taking a break. Please try again in a moment." + ); + }); + + it("should return the correct message for network_error", (): void => { + expect(getErrorMessage("network_error")).toBe( + "Unable to connect. Check your network and try again." + ); + }); + + it("should return the correct message for rate_limited", (): void => { + expect(getErrorMessage("rate_limited")).toBe( + "You've tried a few times. Take a moment and try again shortly." + ); + }); + + it("should return the correct message for session_expired", (): void => { + expect(getErrorMessage("session_expired")).toBe( + "Your session ended. Please sign in again when ready." + ); + }); + + it("should return the correct message for unknown", (): void => { + expect(getErrorMessage("unknown")).toBe( + "Authentication didn't complete. Please try again when ready." + ); + }); + + it("should return a non-empty string for every error code", (): void => { + for (const code of allCodes) { + const message = getErrorMessage(code); + expect(message).toBeTruthy(); + expect(message.length).toBeGreaterThan(0); + } + }); +}); + +describe("PDA-friendly language compliance", (): void => { + const allCodes: AuthErrorCode[] = [ + "access_denied", + "invalid_credentials", + "server_error", + "network_error", + "rate_limited", + "session_expired", + "unknown", + ]; + + it("should not contain any forbidden words in any message", (): void => { + for (const code of allCodes) { + const message = getErrorMessage(code).toLowerCase(); + for (const forbidden of FORBIDDEN_WORDS) { + expect(message).not.toContain(forbidden); + } + } + }); + + it("should not contain forbidden words in parseAuthError output messages", (): void => { + const testInputs: unknown[] = [ + new TypeError("Failed to fetch"), + new Error("Unauthorized"), + new Error("Internal Server Error"), + { status: 429 }, + { status: 500 }, + "access_denied", + "session_expired", + null, + undefined, + 42, + ]; + + for (const input of testInputs) { + const result = parseAuthError(input); + const message = result.message.toLowerCase(); + for (const forbidden of FORBIDDEN_WORDS) { + expect(message).not.toContain(forbidden); + } + } + }); +}); diff --git a/apps/web/src/lib/auth/auth-errors.ts b/apps/web/src/lib/auth/auth-errors.ts new file mode 100644 index 0000000..36378b3 --- /dev/null +++ b/apps/web/src/lib/auth/auth-errors.ts @@ -0,0 +1,173 @@ +/** + * Auth error codes, PDA-friendly message mapping, and error parsing utilities. + * + * All user-facing messages follow PDA-friendly language guidelines: + * no alarming words like OVERDUE, URGENT, MUST, CRITICAL, REQUIRED, ERROR, FAILED. + */ + +/** Union of all recognised auth error codes. */ +export type AuthErrorCode = + | "access_denied" + | "invalid_credentials" + | "server_error" + | "network_error" + | "rate_limited" + | "session_expired" + | "unknown"; + +/** A parsed, UI-ready representation of an auth error. */ +export interface ParsedAuthError { + code: AuthErrorCode; + /** PDA-friendly message suitable for display to the user. */ + message: string; + /** Whether the operation that caused this can be retried. */ + retryable: boolean; +} + +/** + * PDA-friendly error messages keyed by error code. + * Uses calm, informational language throughout. + */ +const ERROR_MESSAGES: Record = { + access_denied: "Authentication paused. Please try again when ready.", + invalid_credentials: "The email and password combination wasn't recognized.", + server_error: "The service is taking a break. Please try again in a moment.", + network_error: "Unable to connect. Check your network and try again.", + rate_limited: "You've tried a few times. Take a moment and try again shortly.", + session_expired: "Your session ended. Please sign in again when ready.", + unknown: "Authentication didn't complete. Please try again when ready.", +}; + +/** Error codes that are safe to retry automatically. */ +const RETRYABLE_CODES: ReadonlySet = new Set([ + "network_error", + "server_error", +]); + +/** Set of recognised error code strings for fast membership testing. */ +const KNOWN_CODES: ReadonlySet = new Set([ + "access_denied", + "invalid_credentials", + "server_error", + "network_error", + "rate_limited", + "session_expired", + "unknown", +]); + +/** + * Type-guard: checks whether a string value is a known {@link AuthErrorCode}. + */ +function isAuthErrorCode(value: string): value is AuthErrorCode { + return KNOWN_CODES.has(value); +} + +/** + * Type-guard: checks whether a value looks like an HTTP response object + * with a numeric `status` property. + */ +function isHttpResponseLike(value: unknown): value is { status: number } { + return ( + typeof value === "object" && + value !== null && + "status" in value && + typeof (value as { status: unknown }).status === "number" + ); +} + +/** + * Map an HTTP status code to an {@link AuthErrorCode}. + */ +function httpStatusToCode(status: number): AuthErrorCode { + if (status === 401 || status === 403) { + return "invalid_credentials"; + } + if (status === 429) { + return "rate_limited"; + } + if (status >= 500) { + return "server_error"; + } + return "unknown"; +} + +/** + * Build a {@link ParsedAuthError} for the given code. + */ +function buildParsedError(code: AuthErrorCode): ParsedAuthError { + return { + code, + message: ERROR_MESSAGES[code], + retryable: RETRYABLE_CODES.has(code), + }; +} + +/** + * Parse an unknown error value into a structured, PDA-friendly + * {@link ParsedAuthError}. + * + * Handles: + * - `TypeError` whose message contains "fetch" -> `network_error` + * - Generic `Error` objects with keyword-based message matching + * - HTTP-response-shaped objects with a numeric `status` field + * - Plain strings that match a known error code + * - Anything else falls back to `unknown` + */ +export function parseAuthError(error: unknown): ParsedAuthError { + // 1. TypeError with "fetch" in message -> network error + if (error instanceof TypeError && error.message.toLowerCase().includes("fetch")) { + return buildParsedError("network_error"); + } + + // 2. Generic Error objects — match on message keywords + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + + if (msg.includes("unauthorized") || msg.includes("forbidden")) { + return buildParsedError("invalid_credentials"); + } + if (msg.includes("rate limit") || msg.includes("too many")) { + return buildParsedError("rate_limited"); + } + if ( + msg.includes("internal server") || + msg.includes("service unavailable") || + msg.includes("bad gateway") || + msg.includes("gateway timeout") + ) { + return buildParsedError("server_error"); + } + if (msg.includes("session") && msg.includes("expired")) { + return buildParsedError("session_expired"); + } + if (msg.includes("network") || msg.includes("connection")) { + return buildParsedError("network_error"); + } + + return buildParsedError("unknown"); + } + + // 3. HTTP response-like objects (e.g. { status: 429 }) + if (isHttpResponseLike(error)) { + return buildParsedError(httpStatusToCode(error.status)); + } + + // 4. Plain string matching a known error code (e.g. from URL query params) + if (typeof error === "string") { + if (isAuthErrorCode(error)) { + return buildParsedError(error); + } + return buildParsedError("unknown"); + } + + // 5. Fallback + return buildParsedError("unknown"); +} + +/** + * Look up the PDA-friendly message for a given {@link AuthErrorCode}. + * Returns the `unknown` message for any unrecognised code. + */ +export function getErrorMessage(code: AuthErrorCode): string { + return ERROR_MESSAGES[code] ?? ERROR_MESSAGES.unknown; +}