Skip to content

Commit 0a9f9d1

Browse files
committed
Handle IAM auth better
1 parent 574e140 commit 0a9f9d1

File tree

3 files changed

+566
-55
lines changed

3 files changed

+566
-55
lines changed
Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
import { signRequest } from "./authentication.js";
2+
import aws4 from "aws4";
3+
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
4+
5+
// Mock the AWS SDK credential provider
6+
vi.mock("@aws-sdk/credential-providers", () => ({
7+
fromNodeProviderChain: vi.fn(),
8+
}));
9+
10+
// Mock aws4
11+
vi.mock("aws4", () => ({
12+
default: {
13+
sign: vi.fn(),
14+
},
15+
}));
16+
17+
const mockCredentialProvider = vi.mocked(fromNodeProviderChain);
18+
const mockAws4Sign = vi.mocked(aws4.sign);
19+
20+
describe("signRequest", () => {
21+
const mockCredentials = {
22+
accessKeyId: "test-access-key",
23+
secretAccessKey: "test-secret-key",
24+
sessionToken: "test-session-token",
25+
};
26+
27+
const testUrl = new URL("https://example.com/path?query=value");
28+
const testRequest = {
29+
method: "POST",
30+
headers: { "Content-Type": "application/json" },
31+
body: JSON.stringify({ test: "data" }),
32+
};
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
});
37+
38+
describe("when IAM options are not provided", () => {
39+
it("should return the original request unchanged", async () => {
40+
const result = await signRequest(testUrl, testRequest);
41+
42+
expect(result).toBe(testRequest);
43+
expect(mockCredentialProvider).not.toHaveBeenCalled();
44+
expect(mockAws4Sign).not.toHaveBeenCalled();
45+
});
46+
});
47+
48+
describe("when IAM options are provided", () => {
49+
const iamOptions = {
50+
service: "neptune-db",
51+
region: "us-east-1",
52+
};
53+
54+
beforeEach(() => {
55+
const mockProvider = vi.fn().mockResolvedValue(mockCredentials);
56+
mockCredentialProvider.mockReturnValue(mockProvider);
57+
58+
mockAws4Sign.mockReturnValue({
59+
headers: {
60+
Authorization: "AWS4-HMAC-SHA256 Credential=...",
61+
"X-Amz-Date": "20231201T120000Z",
62+
},
63+
});
64+
});
65+
66+
it("should sign the request with AWS credentials", async () => {
67+
mockAws4Sign.mockReturnValue({
68+
body: '{"test":"data"}', // Mock the transformed body
69+
headers: {
70+
Authorization: "AWS4-HMAC-SHA256 Credential=...",
71+
"X-Amz-Date": "20231201T120000Z",
72+
},
73+
});
74+
75+
const result = await signRequest(testUrl, testRequest, iamOptions);
76+
77+
expect(mockCredentialProvider).toHaveBeenCalled();
78+
expect(mockAws4Sign).toHaveBeenCalledWith(
79+
{
80+
host: "example.com",
81+
path: "/path?query=value",
82+
method: "POST",
83+
headers: { "Content-Type": "application/json" },
84+
body: '{"test":"data"}',
85+
service: "neptune-db",
86+
region: "us-east-1",
87+
},
88+
{
89+
accessKeyId: "test-access-key",
90+
secretAccessKey: "test-secret-key",
91+
sessionToken: "test-session-token",
92+
}
93+
);
94+
95+
expect(result).toEqual({
96+
...testRequest,
97+
body: '{"test":"data"}', // Should return the transformed body
98+
headers: {
99+
"Content-Type": "application/json",
100+
Authorization: "AWS4-HMAC-SHA256 Credential=...",
101+
"X-Amz-Date": "20231201T120000Z",
102+
},
103+
});
104+
});
105+
106+
it("should handle credentials without session token", async () => {
107+
const credsWithoutToken = {
108+
accessKeyId: "test-access-key",
109+
secretAccessKey: "test-secret-key",
110+
};
111+
112+
const mockProvider = vi.fn().mockResolvedValue(credsWithoutToken);
113+
mockCredentialProvider.mockReturnValue(mockProvider);
114+
115+
await signRequest(testUrl, testRequest, iamOptions);
116+
117+
expect(mockAws4Sign).toHaveBeenCalledWith(expect.any(Object), {
118+
accessKeyId: "test-access-key",
119+
secretAccessKey: "test-secret-key",
120+
});
121+
});
122+
123+
it("should handle GET requests without body", async () => {
124+
const getRequest = {
125+
method: "GET",
126+
headers: { Accept: "application/json" },
127+
};
128+
129+
await signRequest(testUrl, getRequest, iamOptions);
130+
131+
expect(mockAws4Sign).toHaveBeenCalledWith(
132+
expect.objectContaining({
133+
method: "GET",
134+
body: undefined,
135+
}),
136+
expect.any(Object)
137+
);
138+
});
139+
140+
it("should handle requests without headers", async () => {
141+
const requestWithoutHeaders = {
142+
method: "POST",
143+
body: "test body",
144+
};
145+
146+
await signRequest(testUrl, requestWithoutHeaders, iamOptions);
147+
148+
expect(mockAws4Sign).toHaveBeenCalledWith(
149+
expect.objectContaining({
150+
headers: undefined,
151+
}),
152+
expect.any(Object)
153+
);
154+
});
155+
156+
it("should throw error when credentials cannot be found", async () => {
157+
const mockProvider = vi.fn().mockResolvedValue(undefined);
158+
mockCredentialProvider.mockReturnValue(mockProvider);
159+
160+
await expect(
161+
signRequest(testUrl, testRequest, iamOptions)
162+
).rejects.toThrow(
163+
"IAM is enabled but credentials cannot be found on the credential provider chain."
164+
);
165+
});
166+
});
167+
168+
describe("body transformation and return", () => {
169+
const iamOptions = { service: "neptune-db", region: "us-east-1" };
170+
171+
beforeEach(() => {
172+
const mockProvider = vi.fn().mockResolvedValue(mockCredentials);
173+
mockCredentialProvider.mockReturnValue(mockProvider);
174+
});
175+
176+
it("should return transformed URLSearchParams body", async () => {
177+
const params = new URLSearchParams();
178+
params.append("key", "value");
179+
const request = { method: "POST", body: params };
180+
181+
mockAws4Sign.mockReturnValue({
182+
body: "key=value",
183+
headers: { Authorization: "test" },
184+
});
185+
186+
const result = await signRequest(testUrl, request, iamOptions);
187+
188+
expect(result.body).toBe("key=value");
189+
});
190+
191+
it("should return transformed FormData body", async () => {
192+
const formData = new FormData();
193+
formData.append("key1", "value1");
194+
formData.append("key2", "value2");
195+
const request = { method: "POST", body: formData };
196+
197+
mockAws4Sign.mockReturnValue({
198+
body: "key1=value1&key2=value2",
199+
headers: { Authorization: "test" },
200+
});
201+
202+
const result = await signRequest(testUrl, request, iamOptions);
203+
204+
expect(result.body).toBe("key1=value1&key2=value2");
205+
});
206+
207+
it("should return transformed Blob body", async () => {
208+
const blob = new Blob(["blob content"], { type: "text/plain" });
209+
const request = { method: "POST", body: blob };
210+
211+
mockAws4Sign.mockReturnValue({
212+
body: "blob content",
213+
headers: { Authorization: "test" },
214+
});
215+
216+
const result = await signRequest(testUrl, request, iamOptions);
217+
218+
expect(result.body).toBe("blob content");
219+
});
220+
});
221+
222+
describe("mapToCompatibleBody", () => {
223+
// We need to test the private function indirectly through signRequest
224+
const iamOptions = { service: "neptune-db", region: "us-east-1" };
225+
226+
beforeEach(() => {
227+
const mockProvider = vi.fn().mockResolvedValue(mockCredentials);
228+
mockCredentialProvider.mockReturnValue(mockProvider);
229+
mockAws4Sign.mockReturnValue({ headers: {} });
230+
});
231+
232+
it("should handle string body", async () => {
233+
const request = { method: "POST", body: "string body" };
234+
235+
await signRequest(testUrl, request, iamOptions);
236+
237+
expect(mockAws4Sign).toHaveBeenCalledWith(
238+
expect.objectContaining({ body: "string body" }),
239+
expect.any(Object)
240+
);
241+
});
242+
243+
it("should handle URLSearchParams body", async () => {
244+
const params = new URLSearchParams();
245+
params.append("key", "value");
246+
const request = { method: "POST", body: params };
247+
248+
await signRequest(testUrl, request, iamOptions);
249+
250+
expect(mockAws4Sign).toHaveBeenCalledWith(
251+
expect.objectContaining({ body: "key=value" }),
252+
expect.any(Object)
253+
);
254+
});
255+
256+
it("should handle Buffer body", async () => {
257+
const buffer = Buffer.from("buffer content");
258+
const request = { method: "POST", body: buffer };
259+
260+
await signRequest(testUrl, request, iamOptions);
261+
262+
expect(mockAws4Sign).toHaveBeenCalledWith(
263+
expect.objectContaining({ body: buffer }),
264+
expect.any(Object)
265+
);
266+
});
267+
268+
it("should handle Blob body", async () => {
269+
const blob = new Blob(["blob content"], { type: "text/plain" });
270+
const request = { method: "POST", body: blob };
271+
272+
await signRequest(testUrl, request, iamOptions);
273+
274+
expect(mockAws4Sign).toHaveBeenCalledWith(
275+
expect.objectContaining({ body: "blob content" }),
276+
expect.any(Object)
277+
);
278+
});
279+
280+
it("should handle null body", async () => {
281+
const request = { method: "POST", body: null };
282+
283+
await signRequest(testUrl, request, iamOptions);
284+
285+
expect(mockAws4Sign).toHaveBeenCalledWith(
286+
expect.objectContaining({ body: undefined }),
287+
expect.any(Object)
288+
);
289+
});
290+
291+
it("should handle undefined body", async () => {
292+
const request = { method: "GET" };
293+
294+
await signRequest(testUrl, request, iamOptions);
295+
296+
expect(mockAws4Sign).toHaveBeenCalledWith(
297+
expect.objectContaining({ body: undefined }),
298+
expect.any(Object)
299+
);
300+
});
301+
302+
it("should handle FormData body", async () => {
303+
const formData = new FormData();
304+
formData.append("key1", "value1");
305+
formData.append("key2", "value2");
306+
const request = { method: "POST", body: formData };
307+
308+
await signRequest(testUrl, request, iamOptions);
309+
310+
expect(mockAws4Sign).toHaveBeenCalledWith(
311+
expect.objectContaining({ body: "key1=value1&key2=value2" }),
312+
expect.any(Object)
313+
);
314+
});
315+
316+
it("should throw error for FormData with File", async () => {
317+
const formData = new FormData();
318+
const file = new File(["content"], "test.txt", { type: "text/plain" });
319+
formData.append("file", file);
320+
const request = { method: "POST", body: formData };
321+
322+
await expect(signRequest(testUrl, request, iamOptions)).rejects.toThrow(
323+
"File uploads are not supported."
324+
);
325+
});
326+
327+
it("should handle object body with JSON.stringify fallback", async () => {
328+
const objectBody = { key: "value", nested: { prop: 123 } };
329+
const request = { method: "POST", body: objectBody as any };
330+
331+
await signRequest(testUrl, request, iamOptions);
332+
333+
expect(mockAws4Sign).toHaveBeenCalledWith(
334+
expect.objectContaining({
335+
body: '{"key":"value","nested":{"prop":123}}',
336+
}),
337+
expect.any(Object)
338+
);
339+
});
340+
});
341+
342+
describe("URL handling", () => {
343+
const iamOptions = { service: "neptune-db", region: "us-east-1" };
344+
345+
beforeEach(() => {
346+
const mockProvider = vi.fn().mockResolvedValue(mockCredentials);
347+
mockCredentialProvider.mockReturnValue(mockProvider);
348+
mockAws4Sign.mockReturnValue({ headers: {} });
349+
});
350+
351+
it("should handle URL with query parameters", async () => {
352+
const urlWithQuery = new URL(
353+
"https://example.com/path?param1=value1&param2=value2"
354+
);
355+
356+
await signRequest(urlWithQuery, { method: "GET" }, iamOptions);
357+
358+
expect(mockAws4Sign).toHaveBeenCalledWith(
359+
expect.objectContaining({
360+
host: "example.com",
361+
path: "/path?param1=value1&param2=value2",
362+
}),
363+
expect.any(Object)
364+
);
365+
});
366+
367+
it("should handle URL without query parameters", async () => {
368+
const urlWithoutQuery = new URL("https://example.com/path");
369+
370+
await signRequest(urlWithoutQuery, { method: "GET" }, iamOptions);
371+
372+
expect(mockAws4Sign).toHaveBeenCalledWith(
373+
expect.objectContaining({
374+
host: "example.com",
375+
path: "/path",
376+
}),
377+
expect.any(Object)
378+
);
379+
});
380+
381+
it("should handle URL with port", async () => {
382+
const urlWithPort = new URL("https://example.com:8182/gremlin");
383+
384+
await signRequest(urlWithPort, { method: "POST" }, iamOptions);
385+
386+
expect(mockAws4Sign).toHaveBeenCalledWith(
387+
expect.objectContaining({
388+
host: "example.com:8182",
389+
path: "/gremlin",
390+
}),
391+
expect.any(Object)
392+
);
393+
});
394+
});
395+
});

0 commit comments

Comments
 (0)