Skip to content

Commit 922e3e8

Browse files
committedFeb 3, 2025
Add tests for re-fetching jwks
1 parent 602971b commit 922e3e8

File tree

2 files changed

+160
-22
lines changed

2 files changed

+160
-22
lines changed
 

‎src/handlers/shared/saleor-webhook-validator.test.ts

+159-21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3+
import { AuthData } from "@/APL";
4+
import * as fetchRemoteJwksModule from "@/fetch-remote-jwks";
35
import { MockAdapter } from "@/test-utils/mock-adapter";
46
import { MockAPL } from "@/test-utils/mock-apl";
57
import * as verifySignatureModule from "@/verify-signature";
@@ -42,27 +44,6 @@ describe("SaleorWebhookValidator", () => {
4244
middleware = new PlatformAdapterMiddleware(adapter);
4345
});
4446

45-
it("Processes valid request", async () => {
46-
vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders);
47-
48-
const result = await validator.validateRequest({
49-
allowedEvent: "PRODUCT_UPDATED",
50-
apl: mockAPL,
51-
adapter,
52-
adapterMiddleware: middleware,
53-
});
54-
55-
expect(result).toMatchObject({
56-
result: "ok",
57-
context: expect.objectContaining({
58-
baseUrl: "https://example-app.com/api",
59-
event: "product_updated",
60-
payload: {},
61-
schemaVersion: null,
62-
}),
63-
});
64-
});
65-
6647
it("Throws error on non-POST request method", async () => {
6748
vi.spyOn(adapter, "method", "get").mockReturnValue("GET");
6849

@@ -211,6 +192,7 @@ describe("SaleorWebhookValidator", () => {
211192
});
212193
});
213194

195+
// TODO: This should be required
214196
it("Fallbacks to null if version is missing in payload", async () => {
215197
vi.spyOn(adapter, "getRawBody").mockResolvedValue(JSON.stringify({}));
216198
vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders);
@@ -229,4 +211,160 @@ describe("SaleorWebhookValidator", () => {
229211
}),
230212
});
231213
});
214+
215+
it("Returns success on valid request with signature passing validation against jwks in auth data", async () => {
216+
vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders);
217+
218+
const result = await validator.validateRequest({
219+
allowedEvent: "PRODUCT_UPDATED",
220+
apl: mockAPL,
221+
adapter,
222+
adapterMiddleware: middleware,
223+
});
224+
225+
expect(result).toMatchObject({
226+
result: "ok",
227+
context: expect.objectContaining({
228+
baseUrl: "https://example-app.com/api",
229+
event: "product_updated",
230+
payload: {},
231+
schemaVersion: null,
232+
}),
233+
});
234+
});
235+
236+
describe("JWKS re-try validation", () => {
237+
const authDataNoJwks = {
238+
token: mockAPL.mockToken,
239+
saleorApiUrl: mockAPL.workingSaleorApiUrl,
240+
appId: mockAPL.mockAppId,
241+
jwks: null, // Simulate missing JWKS in initial auth data
242+
} as unknown as AuthData; // We're testing missing jwks, so this is fine
243+
244+
beforeEach(() => {
245+
vi.resetAllMocks();
246+
vi.spyOn(middleware, "getSaleorHeaders").mockReturnValue(validHeaders);
247+
});
248+
249+
it("Triggers JWKS refresh when initial auth data contains empty JWKS", async () => {
250+
vi.spyOn(mockAPL, "get").mockResolvedValue(authDataNoJwks);
251+
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValueOnce(undefined);
252+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks");
253+
254+
const result = await validator.validateRequest({
255+
allowedEvent: "PRODUCT_UPDATED",
256+
apl: mockAPL,
257+
adapter,
258+
adapterMiddleware: middleware,
259+
});
260+
261+
expect(result).toMatchObject({
262+
result: "ok",
263+
context: expect.objectContaining({
264+
baseUrl: "https://example-app.com/api",
265+
event: "product_updated",
266+
payload: {},
267+
schemaVersion: null,
268+
}),
269+
});
270+
271+
expect(mockAPL.set).toHaveBeenCalledWith(
272+
expect.objectContaining({
273+
jwks: "new-jwks",
274+
})
275+
);
276+
expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith(
277+
authDataNoJwks.saleorApiUrl
278+
);
279+
// it's called only once because jwks was missing initially, so we skipped first validation
280+
expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(1);
281+
});
282+
283+
it("Triggers JWKS refresh when token signature doesn't match JWKS from existing auth data", async () => {
284+
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks")
285+
.mockRejectedValueOnce(new Error("Signature verification failed")) // First: reject validation due to stale jwks
286+
.mockResolvedValueOnce(undefined); // Second: resolve validation because jwks is now correct
287+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks");
288+
289+
const result = await validator.validateRequest({
290+
allowedEvent: "PRODUCT_UPDATED",
291+
apl: mockAPL,
292+
adapter,
293+
adapterMiddleware: middleware,
294+
});
295+
296+
expect(result).toMatchObject({
297+
result: "ok",
298+
context: expect.objectContaining({
299+
baseUrl: "https://example-app.com/api",
300+
event: "product_updated",
301+
payload: {},
302+
schemaVersion: null,
303+
}),
304+
});
305+
306+
expect(mockAPL.set).toHaveBeenCalledWith(
307+
expect.objectContaining({
308+
jwks: "new-jwks",
309+
})
310+
);
311+
expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith(
312+
authDataNoJwks.saleorApiUrl
313+
);
314+
expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(2);
315+
});
316+
317+
it("Returns an error when new JWKS cannot be fetched", async () => {
318+
vi.spyOn(mockAPL, "get").mockResolvedValue(authDataNoJwks);
319+
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockRejectedValue(
320+
new Error("Initial verification failed")
321+
);
322+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockRejectedValue(
323+
new Error("JWKS fetch failed")
324+
);
325+
326+
const result = await validator.validateRequest({
327+
allowedEvent: "PRODUCT_UPDATED",
328+
apl: mockAPL,
329+
adapter,
330+
adapterMiddleware: middleware,
331+
});
332+
333+
expect(result).toMatchObject({
334+
result: "failure",
335+
error: expect.objectContaining({
336+
errorType: "SIGNATURE_VERIFICATION_FAILED",
337+
message: "Fetching remote JWKS failed",
338+
}),
339+
});
340+
expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledTimes(1);
341+
});
342+
343+
it("Returns an error when signature doesn't match JWKS after re-fetching it", async () => {
344+
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks")
345+
.mockRejectedValueOnce(new Error("Stale JWKS")) // First attempt fails
346+
.mockRejectedValueOnce(new Error("Fresh JWKS mismatch")); // Second attempt fails
347+
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("{}");
348+
349+
const result = await validator.validateRequest({
350+
allowedEvent: "PRODUCT_UPDATED",
351+
apl: mockAPL,
352+
adapter,
353+
adapterMiddleware: middleware,
354+
});
355+
356+
expect(result).toMatchObject({
357+
result: "failure",
358+
error: expect.objectContaining({
359+
errorType: "SIGNATURE_VERIFICATION_FAILED",
360+
message: "Request signature check failed",
361+
}),
362+
});
363+
364+
expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(2);
365+
expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith(
366+
authDataNoJwks.saleorApiUrl
367+
);
368+
});
369+
});
232370
});

‎src/test-utils/mock-apl.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class MockAPL implements APL {
4040
return this.resolveDomainFromApiUrl(this.workingSaleorApiUrl);
4141
}
4242

43-
async get(saleorApiUrl: string) {
43+
async get(saleorApiUrl: string): Promise<AuthData | null> {
4444
if (saleorApiUrl === this.workingSaleorApiUrl) {
4545
return {
4646
token: this.mockToken,

0 commit comments

Comments
 (0)
Failed to load comments.