feat(auth): fetch wrapper with 401 redirect + authApi
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
describe("auth/api fetch wrapper", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
originalLocation = window.location;
|
||||
delete (window as any).__navCalls;
|
||||
(window as any).__navCalls = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
window.location = originalLocation as any;
|
||||
});
|
||||
|
||||
it("redirects to /login on 401 response", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: new Headers(),
|
||||
json: async () => ({ error: "session_expired" }),
|
||||
} as Response);
|
||||
|
||||
// Stub window.location.href setter to capture navigation without
|
||||
// actually navigating (jsdom does not implement location.href assignment).
|
||||
let href = originalLocation.href;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
...originalLocation,
|
||||
get href() {
|
||||
return href;
|
||||
},
|
||||
set href(v: string) {
|
||||
(window as any).__navCalls.push(v);
|
||||
href = v;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { authedFetch } = await import("./api");
|
||||
await expect(authedFetch("/api/anything")).rejects.toThrow();
|
||||
expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login"));
|
||||
});
|
||||
|
||||
it("does NOT redirect on 403", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
headers: new Headers(),
|
||||
json: async () => ({ error: "forbidden" }),
|
||||
} as Response);
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
...originalLocation,
|
||||
set href(_: string) {
|
||||
throw new Error("should not nav");
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { authedFetch } = await import("./api");
|
||||
await expect(authedFetch("/api/anything")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns parsed JSON on 200", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ hello: "world" }),
|
||||
} as Response);
|
||||
const { authedFetch } = await import("./api");
|
||||
const data = await authedFetch("/api/anything");
|
||||
expect(data).toEqual({ hello: "world" });
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Auth-aware fetch wrapper.
|
||||
*
|
||||
* Every authenticated backend call in the SPA goes through `authedFetch`.
|
||||
* Responsibilities:
|
||||
*
|
||||
* 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly
|
||||
* cookie rides along on cross-origin XHR calls.
|
||||
* 2. Tag every request with `Accept: application/json` by default so
|
||||
* the FastAPI backend knows to return its structured error shape
|
||||
* (`{"error": "...", "detail": "..."}`) on failures.
|
||||
* 3. On a 401 from anything OTHER than the auth endpoints themselves,
|
||||
* redirect to `/login?next=<current>` so the operator sees a real
|
||||
* sign-in screen instead of an infinite stream of failing queries.
|
||||
* 401 from `/api/auth/*` is the normal "bad password" path — let
|
||||
* the caller handle it.
|
||||
* 4. On any other non-2xx, parse the JSON body and throw an `ApiError`
|
||||
* carrying `status`, the backend's `error` code, and the `detail`
|
||||
* string. The Login page and hooks both branch on `.code`.
|
||||
* 5. On 204, return `undefined` (so callers can `await` without
|
||||
* blowing up on `res.json()` of an empty body).
|
||||
* 6. Otherwise return the parsed JSON.
|
||||
*
|
||||
* `authApi` is the typed wrapper around the three auth endpoints
|
||||
* (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`).
|
||||
*/
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
function joinUrl(path: string): string {
|
||||
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown for any non-2xx `authedFetch` response. Carries the HTTP
|
||||
* `status`, the backend's `error` code (so callers can branch on
|
||||
* `err.code === "invalid_credentials"`, etc.), and the optional `detail`
|
||||
* string for surfacing in toasts.
|
||||
*
|
||||
* Distinct from the `ApiError` in `src/lib/api.ts` — that one only
|
||||
* carries `status` and is used by the existing pages; this one is the
|
||||
* richer auth-aware variant that the Login page + hooks depend on.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
detail?: string;
|
||||
constructor(status: number, code: string, detail?: string) {
|
||||
super(detail ?? code);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/login?next=${next}`;
|
||||
}
|
||||
|
||||
export async function authedFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(joinUrl(path), {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||
redirectToLogin();
|
||||
throw new ApiError(401, "session_expired");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
/* no body */
|
||||
}
|
||||
const code = body?.error ?? "error";
|
||||
const detail = body?.detail ?? res.statusText;
|
||||
throw new ApiError(res.status, code, detail);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
|
||||
// they bypass the 401-redirect behavior on purpose — a 401 from
|
||||
// /api/auth/login is "wrong password", not "session expired".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const authApi = {
|
||||
async login(username: string, password: string) {
|
||||
const res = await fetch(joinUrl("/api/auth/login"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body.error ?? "error",
|
||||
body.detail ?? res.statusText
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
async me() {
|
||||
return authedFetch("/api/auth/me");
|
||||
},
|
||||
async logout() {
|
||||
// Fire-and-forget. A network error here is fine — the server has
|
||||
// already cleared the cookie or the operator is signing out because
|
||||
// they're about to be disconnected. Either way, the client should
|
||||
// drop the user into the unauthenticated state.
|
||||
await fetch(joinUrl("/api/auth/logout"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
}).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user