mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
test(cli): cover the cloud client 401-refresh-retry decorator (#1202)
createCloudClient wraps the generated client in a Proxy that catches HyperframesApiError(401), force-refreshes credentials, and retries once. That auth recovery path had no tests; a regression would only surface as cloud commands failing outright on server-side token revocation or clock-skew rejections. Covers: passthrough, refresh-and-retry with the new token actually re-resolved (not a stale header replay), refresh failure surfacing the original 401, single-retry on repeated 401, and no refresh on non-401 or transport errors. Zero source changes. Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
co-authored by
Carlos Alcaraz
parent
6affe2d212
commit
0870394d20
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The 401-retry decorator calls forceRefreshCredentials() and the factory
|
||||
// resolves base URL / auth headers from auth.js. Mock the module so the
|
||||
// tests control the token lifecycle without touching the real credential
|
||||
// store on disk.
|
||||
vi.mock("./auth.js", () => ({
|
||||
forceRefreshCredentials: vi.fn(),
|
||||
resolveCloudAuthHeaders: vi.fn(),
|
||||
resolveCloudBaseUrl: vi.fn(() => "https://cloud.test"),
|
||||
}));
|
||||
|
||||
import { forceRefreshCredentials, resolveCloudAuthHeaders } from "./auth.js";
|
||||
import { createCloudClient } from "./index.js";
|
||||
import { HyperframesApiError } from "./_gen/client.js";
|
||||
|
||||
const jsonResponse = (status: number, body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const ok = (body: unknown): Response => jsonResponse(200, body);
|
||||
const unauthorized = (): Response =>
|
||||
jsonResponse(401, { error: { message: "token revoked", code: "unauthorized" } });
|
||||
|
||||
// Narrow a recorded fetch call to the headers of its RequestInit without
|
||||
// casting; throws (failing the test) if the call shape is unexpected.
|
||||
const headersOf = (call: readonly unknown[] | undefined): unknown => {
|
||||
const init = call?.[1];
|
||||
if (init === null || init === undefined || typeof init !== "object" || !("headers" in init)) {
|
||||
throw new Error("expected fetch to be called with a RequestInit carrying headers");
|
||||
}
|
||||
return init.headers;
|
||||
};
|
||||
|
||||
describe("createCloudClient 401-retry decorator", () => {
|
||||
// The generated client falls back to global fetch when no fetchImpl is
|
||||
// injected, and createCloudClient doesn't expose that knob — stub the
|
||||
// global so the decorator under test wraps the same client the cloud
|
||||
// commands get.
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let token: string;
|
||||
|
||||
beforeEach(() => {
|
||||
token = "tok-old";
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.mocked(resolveCloudAuthHeaders).mockImplementation(async () => ({
|
||||
authorization: `Bearer ${token}`,
|
||||
}));
|
||||
vi.mocked(forceRefreshCredentials).mockReset();
|
||||
vi.mocked(forceRefreshCredentials).mockImplementation(async () => {
|
||||
token = "tok-new";
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("passes a successful call through without refreshing", async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
|
||||
|
||||
const client = await createCloudClient();
|
||||
const render = await client.getRender({ render_id: "hfr_1" });
|
||||
|
||||
expect(render).toEqual({ id: "hfr_1", status: "complete" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(forceRefreshCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes once on 401 and retries with the new token", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(unauthorized())
|
||||
.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
|
||||
|
||||
const client = await createCloudClient();
|
||||
const render = await client.getRender({ render_id: "hfr_1" });
|
||||
|
||||
expect(render).toEqual({ id: "hfr_1", status: "complete" });
|
||||
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The retry must re-resolve credentials, not replay the stale header:
|
||||
// a refresh that isn't picked up would 401 forever.
|
||||
expect(headersOf(fetchMock.mock.calls[0])).toMatchObject({
|
||||
authorization: "Bearer tok-old",
|
||||
});
|
||||
expect(headersOf(fetchMock.mock.calls[1])).toMatchObject({
|
||||
authorization: "Bearer tok-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the original 401 when the refresh itself fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce(unauthorized());
|
||||
vi.mocked(forceRefreshCredentials).mockRejectedValueOnce(new Error("refresh_token expired"));
|
||||
|
||||
const client = await createCloudClient();
|
||||
const call = client.getRender({ render_id: "hfr_1" });
|
||||
|
||||
// The decorator promises to surface the 401, not the refresh error —
|
||||
// the 401 carries the API's message/code, which reportApiError needs.
|
||||
await expect(call).rejects.toMatchObject({
|
||||
name: "HyperframesApiError",
|
||||
status: 401,
|
||||
message: "token revoked",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries exactly once: a second 401 propagates", async () => {
|
||||
fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(unauthorized());
|
||||
|
||||
const client = await createCloudClient();
|
||||
const call = client.getRender({ render_id: "hfr_1" });
|
||||
|
||||
await expect(call).rejects.toMatchObject({ status: 401 });
|
||||
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refresh on non-401 API errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(500, { error: { message: "internal", code: "internal_error" } }),
|
||||
);
|
||||
|
||||
const client = await createCloudClient();
|
||||
const call = client.listRenders({});
|
||||
|
||||
await expect(call).rejects.toBeInstanceOf(HyperframesApiError);
|
||||
await expect(call).rejects.toMatchObject({ status: 500 });
|
||||
expect(forceRefreshCredentials).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refresh on transport errors that aren't HyperframesApiError", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
|
||||
|
||||
const client = await createCloudClient();
|
||||
const call = client.getRender({ render_id: "hfr_1" });
|
||||
|
||||
await expect(call).rejects.toThrow("fetch failed");
|
||||
expect(forceRefreshCredentials).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user