Skip to content

Commit 93cbd2b

Browse files
committed
Add CoderApi tests
1 parent 6119d99 commit 93cbd2b

File tree

1 file changed

+382
-0
lines changed

1 file changed

+382
-0
lines changed

test/unit/api/coderApi.test.ts

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import axios, { AxiosError, AxiosHeaders } from "axios";
2+
import { type ProvisionerJobLog } from "coder/site/src/api/typesGenerated";
3+
import { EventSource } from "eventsource";
4+
import { ProxyAgent } from "proxy-agent";
5+
import { describe, it, expect, vi, beforeEach } from "vitest";
6+
import Ws from "ws";
7+
8+
import { CoderApi } from "@/api/coderApi";
9+
import { createHttpAgent } from "@/api/utils";
10+
import { CertificateError } from "@/error";
11+
import { getHeaders } from "@/headers";
12+
import { type RequestConfigWithMeta } from "@/logging/types";
13+
import { OneWayWebSocket } from "@/websocket/oneWayWebSocket";
14+
import { SseConnection } from "@/websocket/sseConnection";
15+
16+
import {
17+
createMockLogger,
18+
MockConfigurationProvider,
19+
} from "../../mocks/testHelpers";
20+
21+
vi.mock("ws");
22+
vi.mock("eventsource");
23+
vi.mock("proxy-agent");
24+
25+
const mockAdapterImpl = vi.hoisted(() => (config: Record<string, unknown>) => {
26+
return Promise.resolve({
27+
data: config.data || "{}",
28+
status: 200,
29+
statusText: "OK",
30+
headers: {},
31+
config,
32+
});
33+
});
34+
35+
vi.mock("axios", async () => {
36+
const actual = await vi.importActual<typeof import("axios")>("axios");
37+
38+
const mockAdapter = vi.fn(mockAdapterImpl);
39+
40+
const mockDefault = {
41+
...actual.default,
42+
create: vi.fn((config) => {
43+
const instance = actual.default.create({
44+
...config,
45+
adapter: mockAdapter,
46+
});
47+
return instance;
48+
}),
49+
__mockAdapter: mockAdapter,
50+
};
51+
52+
return {
53+
...actual,
54+
default: mockDefault,
55+
};
56+
});
57+
58+
vi.mock("@/headers", () => ({
59+
getHeaders: vi.fn().mockResolvedValue({}),
60+
getHeaderCommand: vi.fn(),
61+
}));
62+
63+
vi.mock("@/api/utils", () => ({
64+
createHttpAgent: vi.fn(),
65+
}));
66+
67+
vi.mock("@/api/streamingFetchAdapter", () => ({
68+
createStreamingFetchAdapter: vi.fn(() => fetch),
69+
}));
70+
71+
describe("CoderApi", () => {
72+
let mockLogger: ReturnType<typeof createMockLogger>;
73+
let mockConfig: MockConfigurationProvider;
74+
let mockAdapter: ReturnType<typeof vi.fn>;
75+
76+
beforeEach(() => {
77+
vi.resetAllMocks();
78+
79+
const axiosMock = axios as typeof axios & {
80+
__mockAdapter: ReturnType<typeof vi.fn>;
81+
};
82+
mockAdapter = axiosMock.__mockAdapter;
83+
mockAdapter.mockImplementation(mockAdapterImpl);
84+
85+
vi.mocked(getHeaders).mockResolvedValue({});
86+
mockLogger = createMockLogger();
87+
mockConfig = new MockConfigurationProvider();
88+
mockConfig.set("coder.httpClientLogLevel", "BASIC");
89+
});
90+
91+
describe("HTTP Interceptors", () => {
92+
it("adds custom headers and HTTP agent to requests", async () => {
93+
const mockAgent = new ProxyAgent();
94+
vi.mocked(createHttpAgent).mockResolvedValue(mockAgent);
95+
vi.mocked(getHeaders).mockResolvedValue({
96+
"X-Custom-Header": "custom-value",
97+
"X-Another-Header": "another-value",
98+
});
99+
100+
const api = CoderApi.create(
101+
"https://coder.example.com",
102+
"token",
103+
mockLogger,
104+
);
105+
106+
const response = await api.getAxiosInstance().get("/api/v2/users/me");
107+
108+
expect(response.config.headers["X-Custom-Header"]).toBe("custom-value");
109+
expect(response.config.headers["X-Another-Header"]).toBe("another-value");
110+
expect(response.config.httpsAgent).toBe(mockAgent);
111+
expect(response.config.httpAgent).toBe(mockAgent);
112+
expect(response.config.proxy).toBe(false);
113+
});
114+
115+
it("wraps certificate errors in response interceptor", async () => {
116+
const api = CoderApi.create(
117+
"https://coder.example.com",
118+
"token",
119+
mockLogger,
120+
);
121+
122+
const certError = new AxiosError(
123+
"self signed certificate",
124+
"DEPTH_ZERO_SELF_SIGNED_CERT",
125+
);
126+
mockAdapter.mockRejectedValueOnce(certError);
127+
128+
const thrownError = await api
129+
.getAxiosInstance()
130+
.get("/api/v2/users/me")
131+
.catch((e) => e);
132+
133+
expect(thrownError).toBeInstanceOf(CertificateError);
134+
expect(thrownError.message).toContain("Secure connection");
135+
expect(thrownError.x509Err).toBeDefined();
136+
});
137+
138+
it("applies headers in correct precedence order", async () => {
139+
vi.mocked(getHeaders).mockResolvedValue({
140+
"X-Custom-Header": "from-command",
141+
"Coder-Session-Token": "from-header-command",
142+
});
143+
144+
const api = CoderApi.create(
145+
"https://coder.example.com",
146+
"passed-token",
147+
mockLogger,
148+
);
149+
150+
const response = await api.getAxiosInstance().get("/api/v2/users/me", {
151+
headers: new AxiosHeaders({
152+
"X-Custom-Header": "from-config",
153+
"X-Extra": "extra-value",
154+
"Coder-Session-Token": "ignored-token",
155+
}),
156+
});
157+
158+
expect(response.config.headers["X-Custom-Header"]).toBe("from-command");
159+
expect(response.config.headers["X-Extra"]).toBe("extra-value");
160+
expect(response.config.headers["Coder-Session-Token"]).toBe(
161+
"from-header-command",
162+
);
163+
});
164+
165+
it("logs requests and responses", async () => {
166+
const api = CoderApi.create(
167+
"https://coder.example.com",
168+
"token",
169+
mockLogger,
170+
);
171+
172+
await api.getWorkspaces({});
173+
174+
expect(mockLogger.trace).toHaveBeenCalledWith(
175+
expect.stringContaining("/api/v2/workspaces"),
176+
);
177+
});
178+
179+
it("calculates request and response sizes in transforms", async () => {
180+
const api = CoderApi.create(
181+
"https://coder.example.com",
182+
"token",
183+
mockLogger,
184+
);
185+
186+
const response = await api
187+
.getAxiosInstance()
188+
.post("/api/v2/workspaces", { name: "test" });
189+
190+
expect((response.config as RequestConfigWithMeta).rawRequestSize).toBe(
191+
15,
192+
);
193+
// We return the same data we sent in the mock adapter.
194+
expect((response.config as RequestConfigWithMeta).rawResponseSize).toBe(
195+
15,
196+
);
197+
});
198+
});
199+
200+
describe("WebSocket Creation", () => {
201+
const buildId = "build-123";
202+
const wsUrl = `wss://coder.example.com/api/v2/workspacebuilds/${buildId}/logs?follow=true`;
203+
let api: CoderApi;
204+
205+
beforeEach(() => {
206+
api = CoderApi.create(
207+
"https://coder.example.com",
208+
"passed-token",
209+
mockLogger,
210+
);
211+
212+
// Mock all WS as "WatchBuildLogsByBuildId"
213+
const mockWs = {
214+
url: wsUrl,
215+
on: vi.fn(),
216+
off: vi.fn(),
217+
close: vi.fn(),
218+
} as Partial<Ws>;
219+
vi.mocked(Ws).mockImplementation(() => mockWs as Ws);
220+
});
221+
222+
it("creates WebSocket with proper headers and configuration", async () => {
223+
const mockAgent = new ProxyAgent();
224+
vi.mocked(getHeaders).mockResolvedValue({
225+
"X-Custom-Header": "custom-value",
226+
});
227+
vi.mocked(createHttpAgent).mockResolvedValue(mockAgent);
228+
229+
await api.watchBuildLogsByBuildId(buildId, []);
230+
231+
expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, {
232+
agent: mockAgent,
233+
followRedirects: true,
234+
headers: {
235+
"X-Custom-Header": "custom-value",
236+
"Coder-Session-Token": "passed-token",
237+
},
238+
});
239+
});
240+
241+
it("applies headers in correct precedence order", async () => {
242+
vi.mocked(getHeaders).mockResolvedValue({
243+
"X-Custom-Header": "from-command",
244+
"Coder-Session-Token": "from-header-command",
245+
});
246+
247+
await api.watchBuildLogsByBuildId(buildId, []);
248+
249+
expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, {
250+
agent: undefined,
251+
followRedirects: true,
252+
headers: {
253+
"X-Custom-Header": "from-command",
254+
"Coder-Session-Token": "passed-token",
255+
},
256+
});
257+
});
258+
259+
it("logs WebSocket connections", async () => {
260+
await api.watchBuildLogsByBuildId(buildId, []);
261+
262+
expect(mockLogger.trace).toHaveBeenCalledWith(
263+
expect.stringContaining(buildId),
264+
);
265+
});
266+
267+
it("'watchBuildLogsByBuildId' includes after parameter for existing logs", async () => {
268+
const jobLog: ProvisionerJobLog = {
269+
created_at: new Date().toISOString(),
270+
id: 1,
271+
output: "log1",
272+
log_source: "provisioner",
273+
log_level: "info",
274+
stage: "stage1",
275+
};
276+
const existingLogs = [jobLog, { ...jobLog, id: 20 }];
277+
278+
await api.watchBuildLogsByBuildId(buildId, existingLogs);
279+
280+
expect(Ws).toHaveBeenCalledWith(
281+
expect.stringContaining("after=20"),
282+
undefined,
283+
expect.any(Object),
284+
);
285+
});
286+
});
287+
288+
describe("SSE Fallback", () => {
289+
let api: CoderApi;
290+
291+
beforeEach(() => {
292+
api = CoderApi.create("https://coder.example.com", "token", mockLogger);
293+
294+
const mockEventSource = {
295+
url: "https://coder.example.com/api/v2/workspaces/123/watch",
296+
readyState: EventSource.CONNECTING,
297+
addEventListener: vi.fn((event, handler) => {
298+
if (event === "open") {
299+
setImmediate(() => handler(new Event("open")));
300+
}
301+
}),
302+
removeEventListener: vi.fn(),
303+
close: vi.fn(),
304+
};
305+
306+
vi.mocked(EventSource).mockImplementation(
307+
() => mockEventSource as unknown as EventSource,
308+
);
309+
});
310+
311+
it("uses WebSocket when no errors occur", async () => {
312+
const mockWs: Partial<Ws> = {
313+
url: "wss://coder.example.com/api/v2/workspaceagents/agent-123/watch-metadata",
314+
on: vi.fn((event, handler) => {
315+
if (event === "open") {
316+
setImmediate(() => handler());
317+
}
318+
return mockWs as Ws;
319+
}),
320+
off: vi.fn(),
321+
close: vi.fn(),
322+
};
323+
vi.mocked(Ws).mockImplementation(() => mockWs as Ws);
324+
325+
const connection = await api.watchAgentMetadata("agent-123");
326+
327+
expect(connection).toBeInstanceOf(OneWayWebSocket);
328+
expect(EventSource).not.toHaveBeenCalled();
329+
});
330+
331+
it("falls back to SSE when WebSocket creation fails", async () => {
332+
vi.mocked(Ws).mockImplementation(() => {
333+
throw new Error("WebSocket creation failed");
334+
});
335+
336+
const connection = await api.watchAgentMetadata("agent-123");
337+
expect(connection).toBeInstanceOf(SseConnection);
338+
expect(EventSource).toHaveBeenCalled();
339+
});
340+
341+
it("falls back to SSE on 404 error from WebSocket", async () => {
342+
const mockWs: Partial<Ws> = {
343+
url: "wss://coder.example.com/api/v2/test",
344+
on: vi.fn((event: string, handler: (e: unknown) => void) => {
345+
if (event === "error") {
346+
setImmediate(() => {
347+
handler({
348+
error: new Error("404 Not Found"),
349+
message: "404 Not Found",
350+
});
351+
});
352+
}
353+
return mockWs as Ws;
354+
}),
355+
off: vi.fn(),
356+
close: vi.fn(),
357+
};
358+
359+
vi.mocked(Ws).mockImplementation(() => mockWs as Ws);
360+
361+
const connection = await api.watchAgentMetadata("agent-123");
362+
expect(connection).toBeInstanceOf(SseConnection);
363+
expect(EventSource).toHaveBeenCalled();
364+
});
365+
});
366+
367+
describe("Error Handling", () => {
368+
it("throws error when no base URL is set", async () => {
369+
const api = CoderApi.create(
370+
"https://coder.example.com",
371+
"token",
372+
mockLogger,
373+
);
374+
375+
api.getAxiosInstance().defaults.baseURL = undefined;
376+
377+
await expect(
378+
api.watchBuildLogsByBuildId("build-123", []),
379+
).rejects.toThrow("No base URL set on REST client");
380+
});
381+
});
382+
});

0 commit comments

Comments
 (0)