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
This commit is contained in:
294
apps/web/src/lib/auth/auth-errors.test.ts
Normal file
294
apps/web/src/lib/auth/auth-errors.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
173
apps/web/src/lib/auth/auth-errors.ts
Normal file
173
apps/web/src/lib/auth/auth-errors.ts
Normal file
@@ -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<AuthErrorCode, string> = {
|
||||
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<AuthErrorCode> = new Set<AuthErrorCode>([
|
||||
"network_error",
|
||||
"server_error",
|
||||
]);
|
||||
|
||||
/** Set of recognised error code strings for fast membership testing. */
|
||||
const KNOWN_CODES: ReadonlySet<string> = new Set<string>([
|
||||
"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;
|
||||
}
|
||||
Reference in New Issue
Block a user