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