FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
});
|
|
|
|
// Fresh module import per test so the internal `refreshPromise` module-level
|
|
// state (shared across concurrent 401s) never leaks between tests.
|
|
async function loadApiClient() {
|
|
return import("@/lib/api-client");
|
|
}
|
|
|
|
describe("api client automatic token refresh", () => {
|
|
it("transparently retries once after a 401 when the refresh succeeds", async () => {
|
|
const { api, setTokens, getAccessToken } = await loadApiClient();
|
|
setTokens("expired-access", "valid-refresh");
|
|
|
|
const fetchMock = vi
|
|
.fn()
|
|
// The actual request, with the now-expired access token -> 401.
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({ detail: "Invalid or expired token" }),
|
|
})
|
|
// POST /auth/refresh -> a fresh token pair.
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
access_token: "new-access",
|
|
refresh_token: "new-refresh",
|
|
token_type: "bearer",
|
|
expires_in_minutes: 15,
|
|
}),
|
|
})
|
|
// The retried original request, now with the new access token.
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ id: "1", email: "[email protected]" }),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const result = await api.me();
|
|
|
|
expect(result).toEqual({ id: "1", email: "[email protected]" });
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(getAccessToken()).toBe("new-access");
|
|
|
|
const retryCall = fetchMock.mock.calls[2];
|
|
const retryHeaders = retryCall?.[1]?.headers as Headers;
|
|
expect(retryHeaders.get("Authorization")).toBe("Bearer new-access");
|
|
});
|
|
|
|
it("clears tokens and reports the original 401 when the refresh itself also fails", async () => {
|
|
const { api, setTokens, getAccessToken, getRefreshToken, ApiError } = await loadApiClient();
|
|
setTokens("expired-access", "expired-refresh");
|
|
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({ detail: "Invalid or expired token" }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({ detail: "Invalid or expired refresh token" }),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(api.me()).rejects.toBeInstanceOf(ApiError);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2); // original request + failed refresh, no retry
|
|
expect(getAccessToken()).toBeNull();
|
|
expect(getRefreshToken()).toBeNull();
|
|
});
|
|
|
|
it("does not attempt a refresh when there is no refresh token at all", async () => {
|
|
const { api, clearTokens } = await loadApiClient();
|
|
clearTokens();
|
|
|
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({ detail: "Not authenticated" }),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(api.me()).rejects.toThrow();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1); // no refresh attempt made
|
|
});
|
|
});
|