Skip to content

Commit 0870394

Browse files
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>
1 parent 6affe2d commit 0870394

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// The 401-retry decorator calls forceRefreshCredentials() and the factory
4+
// resolves base URL / auth headers from auth.js. Mock the module so the
5+
// tests control the token lifecycle without touching the real credential
6+
// store on disk.
7+
vi.mock("./auth.js", () => ({
8+
forceRefreshCredentials: vi.fn(),
9+
resolveCloudAuthHeaders: vi.fn(),
10+
resolveCloudBaseUrl: vi.fn(() => "https://cloud.test"),
11+
}));
12+
13+
import { forceRefreshCredentials, resolveCloudAuthHeaders } from "./auth.js";
14+
import { createCloudClient } from "./index.js";
15+
import { HyperframesApiError } from "./_gen/client.js";
16+
17+
const jsonResponse = (status: number, body: unknown): Response =>
18+
new Response(JSON.stringify(body), {
19+
status,
20+
headers: { "content-type": "application/json" },
21+
});
22+
23+
const ok = (body: unknown): Response => jsonResponse(200, body);
24+
const unauthorized = (): Response =>
25+
jsonResponse(401, { error: { message: "token revoked", code: "unauthorized" } });
26+
27+
// Narrow a recorded fetch call to the headers of its RequestInit without
28+
// casting; throws (failing the test) if the call shape is unexpected.
29+
const headersOf = (call: readonly unknown[] | undefined): unknown => {
30+
const init = call?.[1];
31+
if (init === null || init === undefined || typeof init !== "object" || !("headers" in init)) {
32+
throw new Error("expected fetch to be called with a RequestInit carrying headers");
33+
}
34+
return init.headers;
35+
};
36+
37+
describe("createCloudClient 401-retry decorator", () => {
38+
// The generated client falls back to global fetch when no fetchImpl is
39+
// injected, and createCloudClient doesn't expose that knob — stub the
40+
// global so the decorator under test wraps the same client the cloud
41+
// commands get.
42+
let fetchMock: ReturnType<typeof vi.fn>;
43+
let token: string;
44+
45+
beforeEach(() => {
46+
token = "tok-old";
47+
fetchMock = vi.fn();
48+
vi.stubGlobal("fetch", fetchMock);
49+
vi.mocked(resolveCloudAuthHeaders).mockImplementation(async () => ({
50+
authorization: `Bearer ${token}`,
51+
}));
52+
vi.mocked(forceRefreshCredentials).mockReset();
53+
vi.mocked(forceRefreshCredentials).mockImplementation(async () => {
54+
token = "tok-new";
55+
});
56+
});
57+
58+
afterEach(() => {
59+
vi.unstubAllGlobals();
60+
});
61+
62+
it("passes a successful call through without refreshing", async () => {
63+
fetchMock.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
64+
65+
const client = await createCloudClient();
66+
const render = await client.getRender({ render_id: "hfr_1" });
67+
68+
expect(render).toEqual({ id: "hfr_1", status: "complete" });
69+
expect(fetchMock).toHaveBeenCalledTimes(1);
70+
expect(forceRefreshCredentials).not.toHaveBeenCalled();
71+
});
72+
73+
it("refreshes once on 401 and retries with the new token", async () => {
74+
fetchMock
75+
.mockResolvedValueOnce(unauthorized())
76+
.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
77+
78+
const client = await createCloudClient();
79+
const render = await client.getRender({ render_id: "hfr_1" });
80+
81+
expect(render).toEqual({ id: "hfr_1", status: "complete" });
82+
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
83+
expect(fetchMock).toHaveBeenCalledTimes(2);
84+
85+
// The retry must re-resolve credentials, not replay the stale header:
86+
// a refresh that isn't picked up would 401 forever.
87+
expect(headersOf(fetchMock.mock.calls[0])).toMatchObject({
88+
authorization: "Bearer tok-old",
89+
});
90+
expect(headersOf(fetchMock.mock.calls[1])).toMatchObject({
91+
authorization: "Bearer tok-new",
92+
});
93+
});
94+
95+
it("surfaces the original 401 when the refresh itself fails", async () => {
96+
fetchMock.mockResolvedValueOnce(unauthorized());
97+
vi.mocked(forceRefreshCredentials).mockRejectedValueOnce(new Error("refresh_token expired"));
98+
99+
const client = await createCloudClient();
100+
const call = client.getRender({ render_id: "hfr_1" });
101+
102+
// The decorator promises to surface the 401, not the refresh error —
103+
// the 401 carries the API's message/code, which reportApiError needs.
104+
await expect(call).rejects.toMatchObject({
105+
name: "HyperframesApiError",
106+
status: 401,
107+
message: "token revoked",
108+
});
109+
expect(fetchMock).toHaveBeenCalledTimes(1);
110+
});
111+
112+
it("retries exactly once: a second 401 propagates", async () => {
113+
fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(unauthorized());
114+
115+
const client = await createCloudClient();
116+
const call = client.getRender({ render_id: "hfr_1" });
117+
118+
await expect(call).rejects.toMatchObject({ status: 401 });
119+
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
120+
expect(fetchMock).toHaveBeenCalledTimes(2);
121+
});
122+
123+
it("does not refresh on non-401 API errors", async () => {
124+
fetchMock.mockResolvedValueOnce(
125+
jsonResponse(500, { error: { message: "internal", code: "internal_error" } }),
126+
);
127+
128+
const client = await createCloudClient();
129+
const call = client.listRenders({});
130+
131+
await expect(call).rejects.toBeInstanceOf(HyperframesApiError);
132+
await expect(call).rejects.toMatchObject({ status: 500 });
133+
expect(forceRefreshCredentials).not.toHaveBeenCalled();
134+
expect(fetchMock).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it("does not refresh on transport errors that aren't HyperframesApiError", async () => {
138+
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
139+
140+
const client = await createCloudClient();
141+
const call = client.getRender({ render_id: "hfr_1" });
142+
143+
await expect(call).rejects.toThrow("fetch failed");
144+
expect(forceRefreshCredentials).not.toHaveBeenCalled();
145+
expect(fetchMock).toHaveBeenCalledTimes(1);
146+
});
147+
});

0 commit comments

Comments
 (0)