Files
psmyrdek 8c90cac07f fix(api-client): render API errors with friendly messages
The API ships {error: "<code>"} envelopes (e.g. "course_not_found"); we
were displaying that raw, or worse, "[object Object]" when callers tried
to print the payload. Map the 16 known codes (catalogued from the
delivery API routes) to human strings, prefer the optional `message`
field on auth routes, and fall back to a mentor-facing message for
unknown codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:23:11 +02:00

187 lines
6.8 KiB
TypeScript

/**
* Unit tests for `src/lib/api-client.ts` — specifically `resolveApiBase`,
* which is the env-var entrypoint that was flagged in the 2026-04-11
* security review (finding F3). The validation is a strict allowlist:
* - production: exact https://<prod-hostname>
* - dev: http://localhost or http://127.0.0.1 on any port
* Everything else throws. Any throw propagates to `src/index.ts` where
* the wrapper at lines 22-29 turns it into exit code 2 (USAGE).
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
DEFAULT_API_BASE,
messageForApiError,
resolveApiBase,
UNKNOWN_API_ERROR_MESSAGE,
} from "../src/lib/api-client";
let priorEnv: string | undefined;
beforeEach(() => {
priorEnv = process.env["API_BASE_URL"];
delete process.env["API_BASE_URL"];
});
afterEach(() => {
if (priorEnv === undefined) delete process.env["API_BASE_URL"];
else process.env["API_BASE_URL"] = priorEnv;
});
describe("resolveApiBase — no override", () => {
it("returns DEFAULT_API_BASE when API_BASE_URL is unset", () => {
expect(resolveApiBase()).toBe(DEFAULT_API_BASE);
});
it("returns DEFAULT_API_BASE when API_BASE_URL is an empty string", () => {
process.env["API_BASE_URL"] = "";
expect(resolveApiBase()).toBe(DEFAULT_API_BASE);
});
});
describe("resolveApiBase — accepted overrides", () => {
it("accepts the exact production URL", () => {
process.env["API_BASE_URL"] = DEFAULT_API_BASE;
expect(resolveApiBase()).toBe(DEFAULT_API_BASE);
});
it("accepts the production URL with a trailing slash (normalizes to no slash)", () => {
process.env["API_BASE_URL"] = `${DEFAULT_API_BASE}/`;
expect(resolveApiBase()).toBe(DEFAULT_API_BASE);
});
it("accepts http://localhost on any port", () => {
process.env["API_BASE_URL"] = "http://localhost:8787";
expect(resolveApiBase()).toBe("http://localhost:8787");
});
it("accepts http://localhost without a port", () => {
process.env["API_BASE_URL"] = "http://localhost";
expect(resolveApiBase()).toBe("http://localhost");
});
it("accepts http://127.0.0.1 on any port", () => {
process.env["API_BASE_URL"] = "http://127.0.0.1:8787";
expect(resolveApiBase()).toBe("http://127.0.0.1:8787");
});
});
describe("resolveApiBase — rejected overrides", () => {
it("rejects a non-URL string", () => {
process.env["API_BASE_URL"] = "not a url";
expect(() => resolveApiBase()).toThrow(/not a valid URL/);
});
it("rejects http:// against an external host", () => {
process.env["API_BASE_URL"] = "http://evil.example.com";
expect(() => resolveApiBase()).toThrow(/must be/);
});
it("rejects https:// against a non-allowlisted hostname", () => {
process.env["API_BASE_URL"] = "https://evil.example.com";
expect(() => resolveApiBase()).toThrow(/must be/);
});
it("rejects a neighbor subdomain on workers.dev", () => {
process.env["API_BASE_URL"] =
"https://attacker-toolkit-api.przeprogramowani.workers.dev";
expect(() => resolveApiBase()).toThrow(/must be/);
});
it("rejects the prod hostname over http:// (downgrade attempt)", () => {
process.env["API_BASE_URL"] = "http://10x-toolkit-api.przeprogramowani.workers.dev";
expect(() => resolveApiBase()).toThrow(/must be/);
});
it("rejects a base URL that includes a path prefix", () => {
process.env["API_BASE_URL"] = `${DEFAULT_API_BASE}/api/v1`;
expect(() => resolveApiBase()).toThrow(/must not include a path/);
});
it("rejects path-trick that embeds an attacker host in the path", () => {
// `new URL("https://prod-host/@attacker.com/")` parses with hostname =
// prod-host and pathname = "/@attacker.com/" — the strict path check
// closes this nested-path hole.
process.env["API_BASE_URL"] = `${DEFAULT_API_BASE}/@attacker.com/`;
expect(() => resolveApiBase()).toThrow(/must not include a path/);
});
it("rejects a base URL with a query string", () => {
process.env["API_BASE_URL"] = `${DEFAULT_API_BASE}?foo=bar`;
expect(() => resolveApiBase()).toThrow(/query string or fragment/);
});
it("rejects a base URL with a fragment", () => {
process.env["API_BASE_URL"] = `${DEFAULT_API_BASE}#frag`;
expect(() => resolveApiBase()).toThrow(/query string or fragment/);
});
it("rejects file:// (path check fires first, but result is still a throw)", () => {
process.env["API_BASE_URL"] = "file:///etc/passwd";
expect(() => resolveApiBase()).toThrow();
});
it("rejects ftp:// over the allowlisted hostname (non-http/https scheme)", () => {
process.env["API_BASE_URL"] = `ftp://${"10x-toolkit-api.przeprogramowani.workers.dev"}`;
expect(() => resolveApiBase()).toThrow(/must be/);
});
it("rejects IPv6 localhost literal (not in the dev allowlist)", () => {
process.env["API_BASE_URL"] = "http://[::1]:8787";
expect(() => resolveApiBase()).toThrow(/must be/);
});
});
describe("messageForApiError", () => {
it("returns undefined when payload is undefined", () => {
expect(messageForApiError(undefined)).toBeUndefined();
});
it("prefers an explicit human `message` when present", () => {
expect(
messageForApiError({
error: "no_membership",
message: "No active course membership found for this email.",
}),
).toBe("No active course membership found for this email.");
});
it("maps a known content error code to a human string", () => {
expect(messageForApiError({ error: "course_not_found" })).toBe("Course not found.");
expect(messageForApiError({ error: "lesson_not_found" })).toBe("Lesson not found.");
expect(messageForApiError({ error: "module_locked" })).toBe(
"This module is not available yet.",
);
});
it("maps known auth error codes", () => {
expect(messageForApiError({ error: "unauthorized" })).toBe(
"You are not signed in. Run `10x auth` first.",
);
expect(messageForApiError({ error: "invalid_refresh_token" })).toBe(
"Your session has expired. Run `10x auth` again.",
);
});
it("returns undefined for an unknown error code (caller falls back)", () => {
expect(messageForApiError({ error: "some_brand_new_code" })).toBeUndefined();
});
it("returns undefined for an empty `error` field with no `message`", () => {
expect(messageForApiError({ error: "" })).toBeUndefined();
});
it("returns undefined for a payload with no `error`/`message` keys", () => {
expect(messageForApiError({ module: 2, releaseAt: "2026-05-20T10:00:00Z" })).toBeUndefined();
});
it("ignores a non-string `error` field", () => {
expect(messageForApiError({ error: 123 as unknown as string })).toBeUndefined();
});
it("exports a non-empty mentor-facing fallback string", () => {
expect(UNKNOWN_API_ERROR_MESSAGE.length).toBeGreaterThan(0);
expect(UNKNOWN_API_ERROR_MESSAGE).toContain("10xDevs");
});
});