diff --git a/.changeset/hip-queens-roll.md b/.changeset/hip-queens-roll.md new file mode 100644 index 00000000..28d22aa3 --- /dev/null +++ b/.changeset/hip-queens-roll.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": patch +--- + +Removed `/middlewares`, you should use `/handlers` instead. diff --git a/.changeset/kind-zoos-raise.md b/.changeset/kind-zoos-raise.md new file mode 100644 index 00000000..09fa1e09 --- /dev/null +++ b/.changeset/kind-zoos-raise.md @@ -0,0 +1,9 @@ +--- +"@saleor/app-sdk": major +--- + +Removed deprecated fields fields and methods in `/handlers`: + +- `SaleorAsyncWebhook` and `SaleorSyncWebhook` - removed `asyncEvent` and `subscriptionQueryAst` +- Removed `processSaleorWebhook` and `processProtectedHandler` methods +- Some types were moved from `/next` to `/shared` diff --git a/.changeset/sixty-taxis-glow.md b/.changeset/sixty-taxis-glow.md new file mode 100644 index 00000000..0bcc9431 --- /dev/null +++ b/.changeset/sixty-taxis-glow.md @@ -0,0 +1,7 @@ +--- +"@saleor/app-sdk": patch +--- + +Added abstract `PlatformAdapterInterface` and `ActionHandlerInterface` to enable cross-framework handler implementations. + +Next.js handlers were rewritten to use the new interface. diff --git a/.changeset/tough-socks-tease.md b/.changeset/tough-socks-tease.md index 262c887f..7e846506 100644 --- a/.changeset/tough-socks-tease.md +++ b/.changeset/tough-socks-tease.md @@ -2,4 +2,4 @@ "@saleor/app-sdk": major --- -Breaking change: Remove checking "domain" header from Saleor requests. It should be replaced with the "saleor-api-url" header. +Breaking change: SDK will no longer check `saleor-domain` header when validating Saleor requests, instead it will check `saleor-api-url` header. diff --git a/package.json b/package.json index 6704a4ad..9f35f1ff 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "debug": "4.3.4", "jose": "4.14.4", "raw-body": "2.5.2", - "retes": "0.33.0", "uuid": "9.0.0" }, "devDependencies": { @@ -126,11 +125,6 @@ "import": "./settings-manager/index.mjs", "require": "./settings-manager/index.js" }, - "./middleware": { - "types": "./middleware/index.d.ts", - "import": "./middleware/index.mjs", - "require": "./middleware/index.js" - }, "./urls": { "types": "./urls.d.ts", "import": "./urls.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d904039f..94412890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: raw-body: specifier: 2.5.2 version: 2.5.2 - retes: - specifier: 0.33.0 - version: 0.33.0 uuid: specifier: 9.0.0 version: 9.0.0 @@ -1151,10 +1148,6 @@ packages: peerDependencies: esbuild: '>=0.13' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2974,9 +2967,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - retes@0.33.0: - resolution: {integrity: sha512-I6V1G2JkJ2JFIFSVuultNXepf7BW8SCaSUOq5IETM2fDjFim5Dg5F1zU/QbplNW0mqkk8QCw+I722v3nPkpRlA==} - reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3145,10 +3135,6 @@ packages: stream-transform@2.1.3: resolution: {integrity: sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -3673,9 +3659,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.19.1: - resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==} - publishDirectory: dist snapshots: @@ -4707,10 +4690,6 @@ snapshots: esbuild: 0.15.7 load-tsconfig: 0.2.3 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - bytes@3.1.2: {} cac@6.7.14: {} @@ -6545,11 +6524,6 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - retes@0.33.0: - dependencies: - busboy: 1.6.0 - zod: 3.19.1 - reusify@1.0.4: {} rfdc@1.3.0: {} @@ -6719,8 +6693,6 @@ snapshots: dependencies: mixme: 0.5.5 - streamsearch@1.1.0: {} - strict-uri-encode@2.0.0: {} string-argv@0.3.1: {} @@ -7222,5 +7194,3 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} - - zod@3.19.1: {} diff --git a/src/handlers/actions/manifest-action-handler.test.ts b/src/handlers/actions/manifest-action-handler.test.ts new file mode 100644 index 00000000..c13fa9d2 --- /dev/null +++ b/src/handlers/actions/manifest-action-handler.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SALEOR_SCHEMA_VERSION } from "@/const"; +import { MockAdapter } from "@/test-utils/mock-adapter"; +import { AppManifest } from "@/types"; + +import { ManifestActionHandler } from "./manifest-action-handler"; + +describe("ManifestActionHandler", () => { + const mockManifest: AppManifest = { + id: "test-app", + name: "Test Application", + version: "1.0.0", + appUrl: "http://example.com", + permissions: [], + tokenTargetUrl: "http://example.com/token", + }; + + let adapter: MockAdapter; + + beforeEach(() => { + adapter = new MockAdapter({ + mockHeaders: { + [SALEOR_SCHEMA_VERSION]: "3.20", + }, + baseUrl: "http://example.com", + }); + adapter.method = "GET"; + }); + + it("should call manifest factory and return 200 status when it resolves", async () => { + const handler = new ManifestActionHandler(adapter); + const manifestFactory = vi.fn().mockResolvedValue(mockManifest); + + const result = await handler.handleAction({ manifestFactory }); + + expect(result.status).toBe(200); + expect(result.body).toEqual(mockManifest); + expect(manifestFactory).toHaveBeenCalledWith({ + appBaseUrl: "http://example.com", + request: {}, + schemaVersion: 3.20, + }); + }); + + it("should call manifest factory and return 500 when it throws an error", async () => { + const handler = new ManifestActionHandler(adapter); + const manifestFactory = vi.fn().mockRejectedValue(new Error("Test error")); + + const result = await handler.handleAction({ manifestFactory }); + + expect(result.status).toBe(500); + expect(result.body).toBe("Error resolving manifest file."); + }); + + it("should return 405 when not called using HTTP GET method", async () => { + adapter.method = "POST"; + const handler = new ManifestActionHandler(adapter); + + const manifestFactory = vi.fn().mockResolvedValue(mockManifest); + + const result = await handler.handleAction({ manifestFactory }); + + expect(result.status).toBe(405); + expect(result.body).toBe("Method not allowed"); + expect(manifestFactory).not.toHaveBeenCalled(); + }) + + it("should return 400 when receives null schema version header from unsupported legacy Saleor version", async () => { + adapter.getHeader = vi.fn().mockReturnValue(null); + const handler = new ManifestActionHandler(adapter); + + const manifestFactory = vi.fn().mockResolvedValue(mockManifest); + + const result = await handler.handleAction({ manifestFactory }); + + expect(result.status).toBe(400); + expect(result.body).toBe("Missing schema version header"); + expect(manifestFactory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/actions/manifest-action-handler.ts b/src/handlers/actions/manifest-action-handler.ts new file mode 100644 index 00000000..78899693 --- /dev/null +++ b/src/handlers/actions/manifest-action-handler.ts @@ -0,0 +1,71 @@ +import { createDebug } from "@/debug"; +import { AppManifest } from "@/types"; + +import { + ActionHandlerInterface, + ActionHandlerResult, + PlatformAdapterInterface, +} from "../shared/generic-adapter-use-case-types"; +import { SaleorRequestProcessor } from "../shared/saleor-request-processor"; + +const debug = createDebug("create-manifest-handler"); + +export type CreateManifestHandlerOptions = { + manifestFactory(context: { + appBaseUrl: string; + request: T; + /** Added in Saleor 3.15 */ + schemaVersion: number; + }): AppManifest | Promise; +}; + +export class ManifestActionHandler implements ActionHandlerInterface { + constructor(private adapter: PlatformAdapterInterface) {} + + private requestProcessor = new SaleorRequestProcessor(this.adapter); + + async handleAction(options: CreateManifestHandlerOptions): Promise { + const { schemaVersion } = this.requestProcessor.getSaleorHeaders(); + const baseURL = this.adapter.getBaseUrl(); + + debug("Received request with schema version \"%s\" and base URL \"%s\"", schemaVersion, baseURL); + + const invalidMethodResponse = this.requestProcessor.withMethod(["GET"]); + + if (invalidMethodResponse) { + return invalidMethodResponse; + } + + if (!schemaVersion) { + return { + status: 400, + bodyType: "string", + body: "Missing schema version header", + }; + } + + try { + const manifest = await options.manifestFactory({ + appBaseUrl: baseURL, + request: this.adapter.request, + schemaVersion, + }); + + debug("Executed manifest file"); + + return { + status: 200, + bodyType: "json", + body: manifest, + }; + } catch (e) { + debug("Error while resolving manifest: %O", e); + + return { + status: 500, + bodyType: "string", + body: "Error resolving manifest file.", + }; + } + } +} diff --git a/src/handlers/actions/register-action-handler.test.ts b/src/handlers/actions/register-action-handler.test.ts new file mode 100644 index 00000000..70eec0d1 --- /dev/null +++ b/src/handlers/actions/register-action-handler.test.ts @@ -0,0 +1,453 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SALEOR_API_URL_HEADER } from "@/const"; +import * as fetchRemoteJwksModule from "@/fetch-remote-jwks"; +import * as getAppIdModule from "@/get-app-id"; +import { MockAdapter } from "@/test-utils/mock-adapter"; +import { MockAPL } from "@/test-utils/mock-apl"; + +import { RegisterActionHandler, RegisterHandlerResponseBody } from "./register-action-handler"; + +describe("RegisterActionHandler", () => { + let adapter: MockAdapter; + let mockApl: MockAPL; + + const mockAuthData = { + token: "mock-auth-token", + saleorApiUrl: "https://mock-saleor-domain.saleor.cloud/graphql", + jwks: "mock-jwks", + appId: "mock-app-id", + } as const; + + beforeEach(() => { + vi.clearAllMocks(); + mockApl = new MockAPL(); + adapter = new MockAdapter({ + mockHeaders: { + [SALEOR_API_URL_HEADER]: mockAuthData.saleorApiUrl, + }, + baseUrl: "http://example.com", + }); + + vi.spyOn(adapter, "getBody").mockResolvedValue({ auth_token: mockAuthData.token }); + vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue(mockAuthData.appId); + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue(mockAuthData.jwks); + }); + + describe("handleAction", () => { + it("should validate request method", async () => { + adapter.method = "GET"; + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(405); + }); + + it("should validate Saleor API URL presence", async () => { + // Set missing headers in request + adapter = new MockAdapter({ mockHeaders: {}, baseUrl: "http://example.com" }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(400); + }); + + it("should validate auth token presence", async () => { + vi.spyOn(adapter, "getBody").mockResolvedValue({}); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(400); + expect(result.body).toBe("Missing auth token."); + }); + + it("should validate allowed Saleor URLs", async () => { + vi.spyOn(adapter, "getBody").mockResolvedValue({ auth_token: mockAuthData.token }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + allowedSaleorUrls: ["different-domain.saleor.cloud"], + }); + + expect(result.status).toBe(403); + const body = result.body as RegisterHandlerResponseBody; + expect(body.error?.code).toBe("SALEOR_URL_PROHIBITED"); + }); + + it("should return error when APL is not configured", async () => { + mockApl.isConfigured.mockResolvedValue({ configured: false }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(503); + const body = result.body as RegisterHandlerResponseBody; + expect(body.success).toBe(false); + expect(body.error).toEqual({ + code: "APL_NOT_CONFIGURED", + message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", + }); + }); + + it("should return error when app ID cannot be fetched", async () => { + vi.spyOn(getAppIdModule, "getAppId").mockResolvedValue(undefined); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(401); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "UNKNOWN_APP_ID", + message: `The auth data given during registration request could not be used to fetch app ID. + This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${mockAuthData.saleorApiUrl}`, + }, + }); + }); + + it("should return error when JWKS cannot be fetched", async () => { + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockRejectedValue( + new Error("Network error"), + ); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(401); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }, + }); + }); + + it("should return error when JWKS is empty", async () => { + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue(""); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(401); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }, + }); + }); + + it("should return response when app is successfully registered", async () => { + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(200); + expect(mockApl.set).toHaveBeenCalledWith(mockAuthData); + const body = result.body as RegisterHandlerResponseBody; + expect(body.success).toBe(true); + }); + + it("should handle APL save failure when no onAplSetFailed callback is provided", async () => { + mockApl.set.mockRejectedValue(new Error("APL save failed")); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl }); + + expect(result.status).toBe(500); + const body = result.body as RegisterHandlerResponseBody; + expect(body.success).toBe(false); + }); + + it("should execute lifecycle hooks in correct order", async () => { + const hookOrder: string[] = []; + const config = { + apl: mockApl, + onRequestStart: vi.fn().mockImplementation(() => { + hookOrder.push("onRequestStart"); + }), + onRequestVerified: vi.fn().mockImplementation(() => { + hookOrder.push("onRequestVerified"); + }), + onAuthAplSaved: vi.fn().mockImplementation(() => { + hookOrder.push("onAuthAplSaved"); + }), + }; + + vi.spyOn(adapter, "getBody").mockResolvedValue({ auth_token: mockAuthData.token }); + + const handler = new RegisterActionHandler(adapter); + await handler.handleAction(config); + + expect(hookOrder).toEqual(["onRequestStart", "onRequestVerified", "onAuthAplSaved"]); + expect(mockApl.set).toHaveBeenCalledWith(mockAuthData); + }); + + describe("onRequestStart callback", () => { + it("should proceed with execution and call onRequestStart when provided", async () => { + const onRequestStart = vi.fn(); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl, onRequestStart }); + + // Verify onRequestStart was called with correct parameters + expect(onRequestStart).toHaveBeenCalledWith( + adapter.request, + expect.objectContaining({ + authToken: mockAuthData.token, + saleorApiUrl: mockAuthData.saleorApiUrl, + respondWithError: expect.any(Function), + }), + ); + + // Verify the registration flow completed successfully + expect(result.status).toBe(200); + const body = result.body as RegisterHandlerResponseBody; + expect(body.success).toBe(true); + expect(mockApl.set).toHaveBeenCalledWith(mockAuthData); + }); + + it("should map error correctly when onRequestStart calls respondWithError", async () => { + const errorMessage = "Custom validation error"; + const onRequestStart = vi.fn().mockImplementation((_req, { respondWithError }) => { + respondWithError({ + message: errorMessage, + status: 422, + }); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl, onRequestStart }); + + expect(onRequestStart).toHaveBeenCalled(); + expect(result.status).toBe(422); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: errorMessage, + }, + }); + }); + + it("should handle generic errors from onRequestStart", async () => { + const onRequestStart = vi.fn().mockImplementation(() => { + throw new Error("Unexpected error"); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ apl: mockApl, onRequestStart }); + + expect(onRequestStart).toHaveBeenCalled(); + expect(result.status).toBe(500); + expect(result.body).toBe("Error during app installation"); + }); + }); + + describe("onRequestVerified callback", () => { + it("should proceed with execution and call onRequestVerified if provided", async () => { + const onRequestVerified = vi.fn(); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onRequestVerified, + }); + + expect(result.status).toBe(200); + expect(onRequestVerified).toHaveBeenCalledWith( + adapter.request, + expect.objectContaining({ + authData: mockAuthData, + respondWithError: expect.any(Function), + }), + ); + expect(mockApl.set).toHaveBeenCalledWith(mockAuthData); + }); + + it("should map and return error when onRequestVerified calls respondWithError", async () => { + const errorMessage = "Verification failed"; + const onRequestVerified = vi.fn().mockImplementation((_req, { respondWithError }) => { + respondWithError({ + message: errorMessage, + status: 422, + }); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onRequestVerified, + }); + + expect(result.status).toBe(422); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: errorMessage, + }, + }); + expect(mockApl.set).not.toHaveBeenCalled(); + }); + + it("should handle generic errors thrown from onRequestVerified callback", async () => { + const onRequestVerified = vi.fn().mockImplementation(() => { + throw new Error("Unexpected error"); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onRequestVerified, + }); + + expect(result.status).toBe(500); + expect(result.body).toBe("Error during app installation"); + expect(mockApl.set).not.toHaveBeenCalled(); + }); + }); + + describe("onAuthAplSaved callback", () => { + it("should proceed with execution and call if onAuthAplSaved is provided", async () => { + const onAuthAplSaved = vi.fn(); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAuthAplSaved, + }); + + expect(result.status).toBe(200); + expect(onAuthAplSaved).toHaveBeenCalledWith( + adapter.request, + expect.objectContaining({ + authData: mockAuthData, + respondWithError: expect.any(Function), + }), + ); + }); + + it("should map and return error when onAuthAplSaved calls respondWithError", async () => { + const errorMessage = "Post-save validation failed"; + const onAuthAplSaved = vi.fn().mockImplementation((_req, { respondWithError }) => { + respondWithError({ + message: errorMessage, + status: 422, + }); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAuthAplSaved, + }); + + expect(result.status).toBe(422); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: errorMessage, + }, + }); + }); + + it("should map thrown error from onAuthAplSaved callback", async () => { + const onAuthAplSaved = vi.fn().mockImplementation(() => { + throw new Error("Unexpected error"); + }); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAuthAplSaved, + }); + + expect(result.status).toBe(500); + expect(result.body).toBe("Error during app installation"); + }); + }); + + describe("onAplSetFailed callback", () => { + it("should call onAplSetFailed is provided and when APL save fails", async () => { + const onAplSetFailed = vi.fn(); + const aplError = new Error("APL save error"); + mockApl.set.mockRejectedValue(aplError); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAplSetFailed, + }); + + expect(result.status).toBe(500); + expect(onAplSetFailed).toHaveBeenCalledWith( + adapter.request, + expect.objectContaining({ + authData: mockAuthData, + error: aplError, + respondWithError: expect.any(Function), + }), + ); + const body = result.body as RegisterHandlerResponseBody; + expect(body.success).toBe(false); + }); + + it("should map and return error when onAplSetFailed calls respondWithError", async () => { + const errorMessage = "Custom error handling"; + const onAplSetFailed = vi.fn().mockImplementation((_req, { respondWithError }) => { + respondWithError({ + message: errorMessage, + status: 503, + }); + }); + mockApl.set.mockRejectedValue(new Error("APL save error")); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAplSetFailed, + }); + + expect(result.status).toBe(503); + const body = result.body as RegisterHandlerResponseBody; + expect(body).toEqual({ + success: false, + error: { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: errorMessage, + }, + }); + }); + + it("should map thrown error from onAplSetFailed callback", async () => { + const onAplSetFailed = vi.fn().mockImplementation(() => { + throw new Error("Unexpected error"); + }); + mockApl.set.mockRejectedValue(new Error("APL save error")); + + const handler = new RegisterActionHandler(adapter); + const result = await handler.handleAction({ + apl: mockApl, + onAplSetFailed, + }); + + expect(result.status).toBe(500); + expect(result.body).toBe("Error during app installation"); + }); + }); + }); +}); diff --git a/src/handlers/actions/register-action-handler.ts b/src/handlers/actions/register-action-handler.ts new file mode 100644 index 00000000..cfb69cce --- /dev/null +++ b/src/handlers/actions/register-action-handler.ts @@ -0,0 +1,513 @@ +/* eslint-disable max-classes-per-file */ +import { APL, AuthData } from "@/APL"; +import { SALEOR_API_URL_HEADER } from "@/const"; +import { createDebug } from "@/debug"; +import { fetchRemoteJwks } from "@/fetch-remote-jwks"; +import { getAppId } from "@/get-app-id"; +import { HasAPL } from "@/saleor-app"; + +import { + ActionHandlerInterface, + ActionHandlerResult, + PlatformAdapterInterface, + ResultStatusCodes, +} from "../shared/generic-adapter-use-case-types"; +import { SaleorRequestProcessor } from "../shared/saleor-request-processor"; +import { validateAllowSaleorUrls } from "../shared/validate-allow-saleor-urls"; + +const debug = createDebug("createAppRegisterHandler"); + +/** Error raised by async handlers passed by + * users in config to Register handler */ +class RegisterCallbackError extends Error { + public status: ResultStatusCodes = 500; + + constructor(errorParams: HookCallbackErrorParams) { + super(errorParams.message); + + if (errorParams.status) { + this.status = errorParams.status; + } + } +} + +export type RegisterErrorCode = + | "SALEOR_URL_PROHIBITED" + | "APL_NOT_CONFIGURED" + | "UNKNOWN_APP_ID" + | "JWKS_NOT_AVAILABLE" + | "REGISTER_HANDLER_HOOK_ERROR"; + +export type RegisterHandlerResponseBody = { + success: boolean; + error?: { + code?: RegisterErrorCode; + message?: string; + }; +}; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"], + statusCode?: ResultStatusCodes +): ActionHandlerResult => ({ + status: statusCode ?? (success ? 200 : 500), + body: { + success, + error, + }, + bodyType: "json", +}); + +export type HookCallbackErrorParams = { + status?: ResultStatusCodes; + message?: string; +}; + +export type CallbackErrorHandler = (params: HookCallbackErrorParams) => never; + +export type AppRegisterHandlerOptions = HasAPL & { + /** + * Protect app from being registered in Saleor other than specific. + * By default, allow everything. + * + * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) + * or a function that receives a full Saleor API URL ad returns true/false. + */ + allowedSaleorUrls?: Array boolean)>; + /** + * Run right after Saleor calls this endpoint + */ + onRequestStart?( + request: Request, + context: { + authToken?: string; + saleorApiUrl?: string; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after all security checks + */ + onRequestVerified?( + request: Request, + context: { + authData: AuthData; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error + */ + onAuthAplSaved?( + request: Request, + context: { + authData: AuthData; + respondWithError: CallbackErrorHandler; + } + ): Promise; + /** + * Run after APL fails to set AuthData + */ + onAplSetFailed?( + request: Request, + context: { + authData: AuthData; + error: unknown; + respondWithError: CallbackErrorHandler; + } + ): Promise; +}; + +export class RegisterActionHandler + implements ActionHandlerInterface +{ + constructor(private adapter: PlatformAdapterInterface) {} + + private requestProcessor = new SaleorRequestProcessor(this.adapter); + + private runPreChecks(): ActionHandlerResult | null { + const checksToRun = [ + this.requestProcessor.withMethod(["POST"]), + this.requestProcessor.withSaleorApiUrlPresent(), + ]; + + for (const check of checksToRun) { + if (check) { + return check; + } + } + + return null; + } + + async handleAction( + config: AppRegisterHandlerOptions + ): Promise> { + debug("Request received"); + + const precheckResult = this.runPreChecks(); + if (precheckResult) { + return precheckResult; + } + + const saleorApiUrl = this.adapter.getHeader(SALEOR_API_URL_HEADER) as string; + + const authTokenResult = await this.parseRequestBody(); + + if (!authTokenResult.success) { + return authTokenResult.response; + } + + const { authToken } = authTokenResult; + + const handleOnRequestResult = await this.handleOnRequestStartCallback(config.onRequestStart, { + authToken, + saleorApiUrl, + }); + + if (handleOnRequestResult) { + return handleOnRequestResult; + } + + const saleorApiUrlValidationResult = this.handleSaleorApiUrlValidation({ + saleorApiUrl, + allowedSaleorUrls: config.allowedSaleorUrls, + }); + + if (saleorApiUrlValidationResult) { + return saleorApiUrlValidationResult; + } + + const aplCheckResult = await this.checkAplIsConfigured(config.apl); + + if (aplCheckResult) { + return aplCheckResult; + } + + const getAppIdResult = await this.getAppIdAndHandleMissingAppId({ + saleorApiUrl, + token: authToken, + }); + + if (!getAppIdResult.success) { + return getAppIdResult.responseBody; + } + + const { appId } = getAppIdResult; + + const getJwksResult = await this.getJwksAndHandleMissingJwks({ saleorApiUrl }); + + if (!getJwksResult.success) { + return getJwksResult.responseBody; + } + + const { jwks } = getJwksResult; + + const authData = { + token: authToken, + saleorApiUrl, + appId, + jwks, + }; + + const onRequestVerifiedErrorResponse = await this.handleOnRequestVerifiedCallback( + config.onRequestVerified, + authData + ); + + if (onRequestVerifiedErrorResponse) { + return onRequestVerifiedErrorResponse; + } + + const aplSaveResponse = await this.saveAplAuthData({ + apl: config.apl, + authData, + onAplSetFailed: config.onAplSetFailed, + onAuthAplSaved: config.onAuthAplSaved, + }); + + return aplSaveResponse; + } + + private async parseRequestBody(): Promise< + | { + success: false; + response: ActionHandlerResult; + authToken?: never; + } + | { + success: true; + authToken: string; + response?: never; + } + > { + let body: { auth_token: string }; + try { + body = (await this.adapter.getBody()) as { auth_token: string }; + } catch (err) { + return { + success: false, + response: { + status: 400, + body: "Invalid request json.", + bodyType: "string", + }, + }; + } + + const authToken = body?.auth_token; + + if (!authToken) { + debug("Found missing authToken param"); + + return { + success: false, + response: { + status: 400, + body: "Missing auth token.", + bodyType: "string", + }, + }; + } + + return { + success: true, + authToken, + }; + } + + private async handleOnRequestStartCallback( + onRequestStart: AppRegisterHandlerOptions["onRequestStart"], + { authToken, saleorApiUrl }: { authToken: string; saleorApiUrl: string } + ) { + if (onRequestStart) { + debug("Calling \"onRequestStart\" hook"); + + try { + await onRequestStart(this.adapter.request, { + authToken, + saleorApiUrl, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestStart\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + + return null; + } + + private handleSaleorApiUrlValidation({ + saleorApiUrl, + allowedSaleorUrls, + }: { + saleorApiUrl: string; + allowedSaleorUrls: AppRegisterHandlerOptions["allowedSaleorUrls"]; + }) { + if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { + debug( + "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", + saleorApiUrl + ); + + return createRegisterHandlerResponseBody( + false, + { + code: "SALEOR_URL_PROHIBITED", + message: "This app expects to be installed only in allowed Saleor instances", + }, + 403 + ); + } + + return null; + } + + private async checkAplIsConfigured(apl: AppRegisterHandlerOptions["apl"]) { + const { configured: aplConfigured } = await apl.isConfigured(); + + if (!aplConfigured) { + debug("The APL has not been configured"); + + return createRegisterHandlerResponseBody( + false, + { + code: "APL_NOT_CONFIGURED", + message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", + }, + 503 + ); + } + + return null; + } + + private async getAppIdAndHandleMissingAppId({ + saleorApiUrl, + token, + }: { + saleorApiUrl: string; + token: string; + }): Promise< + | { + success: false; + responseBody: ActionHandlerResult; + } + | { success: true; appId: string } + > { + // Try to get App ID from the API, to confirm that communication can be established + const appId = await getAppId({ saleorApiUrl, token }); + + if (!appId) { + const responseBody = createRegisterHandlerResponseBody( + false, + { + code: "UNKNOWN_APP_ID", + message: `The auth data given during registration request could not be used to fetch app ID. + This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${saleorApiUrl}`, + }, + 401 + ); + + return { success: false, responseBody }; + } + + return { success: true, appId }; + } + + private async getJwksAndHandleMissingJwks({ saleorApiUrl }: { saleorApiUrl: string }): Promise< + | { + success: false; + responseBody: ActionHandlerResult; + } + | { success: true; jwks: string } + > { + // Fetch the JWKS which will be used during webhook validation + try { + const jwks = await fetchRemoteJwks(saleorApiUrl); + if (jwks) { + return { success: true, jwks }; + } + } catch (err) { + // no-op - will return result below + } + + const responseBody = createRegisterHandlerResponseBody( + false, + { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }, + 401 + ); + + return { success: false, responseBody }; + } + + private async handleOnRequestVerifiedCallback( + onRequestVerified: AppRegisterHandlerOptions["onRequestVerified"], + authData: AuthData + ) { + if (onRequestVerified) { + debug("Calling \"onRequestVerified\" hook"); + + try { + await onRequestVerified(this.adapter.request, { + authData, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestVerified\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + + return null; + } + + private async saveAplAuthData({ + apl, + onAplSetFailed, + onAuthAplSaved, + authData, + }: { + apl: APL; + onAplSetFailed: AppRegisterHandlerOptions["onAplSetFailed"]; + onAuthAplSaved: AppRegisterHandlerOptions["onAuthAplSaved"]; + authData: AuthData; + }) { + try { + await apl.set(authData); + + if (onAuthAplSaved) { + debug("Calling \"onAuthAplSaved\" hook"); + + try { + await onAuthAplSaved(this.adapter.request, { + authData, + respondWithError: this.createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onAuthAplSaved\" hook thrown error: %o", e); + + return this.handleHookError(e); + } + } + } catch (aplError: unknown) { + debug("There was an error during saving the auth data"); + + if (onAplSetFailed) { + debug("Calling \"onAuthAplFailed\" hook"); + + try { + await onAplSetFailed(this.adapter.request, { + authData, + error: aplError, + respondWithError: this.createCallbackError, + }); + } catch (hookError: RegisterCallbackError | unknown) { + debug("\"onAuthAplFailed\" hook thrown error: %o", hookError); + + return this.handleHookError(hookError); + } + } + + return createRegisterHandlerResponseBody(false, { + message: "Registration failed: could not save the auth data.", + }); + } + + debug("Register complete"); + return createRegisterHandlerResponseBody(true); + } + + /** Callbacks declared by users in configuration can throw an error + * It is caught here and converted into a response */ + private handleHookError( + e: RegisterCallbackError | unknown + ): ActionHandlerResult { + if (e instanceof RegisterCallbackError) { + return createRegisterHandlerResponseBody( + false, + { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: e.message, + }, + e.status + ); + } + return { + status: 500, + body: "Error during app installation", + bodyType: "string", + }; + } + + private createCallbackError: CallbackErrorHandler = (params: HookCallbackErrorParams) => { + throw new RegisterCallbackError(params); + }; +} diff --git a/src/handlers/platforms/next/create-app-register-handler.test.ts b/src/handlers/platforms/next/create-app-register-handler.test.ts index 582cfc03..5b62bddd 100644 --- a/src/handlers/platforms/next/create-app-register-handler.test.ts +++ b/src/handlers/platforms/next/create-app-register-handler.test.ts @@ -36,7 +36,6 @@ describe("create-app-register-handler", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -71,7 +70,6 @@ describe("create-app-register-handler", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-api-url": "https://wrong-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://wrong-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -84,7 +82,7 @@ describe("create-app-register-handler", () => { await handler(req, res); expect(res._getStatusCode()).toBe(403); - expect(res._getData().success).toBe(false); + expect(res._getJSONData().success).toBe(false); }); describe("Callback hooks", () => { @@ -106,7 +104,6 @@ describe("create-app-register-handler", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -170,7 +167,6 @@ describe("create-app-register-handler", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -229,7 +225,6 @@ describe("create-app-register-handler", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -242,7 +237,7 @@ describe("create-app-register-handler", () => { await handler(req, res); expect(res._getStatusCode()).toBe(401); - expect(res._getData()).toEqual({ + expect(res._getJSONData()).toEqual({ success: false, error: { code: "REGISTER_HANDLER_HOOK_ERROR", diff --git a/src/handlers/platforms/next/create-app-register-handler.ts b/src/handlers/platforms/next/create-app-register-handler.ts index 70f88c33..425ad58c 100644 --- a/src/handlers/platforms/next/create-app-register-handler.ts +++ b/src/handlers/platforms/next/create-app-register-handler.ts @@ -1,285 +1,31 @@ -import type { Handler, Request } from "retes"; -import { toNextHandler } from "retes/adapter"; -import { withMethod } from "retes/middleware"; -import { Response } from "retes/response"; +import { + RegisterActionHandler, +} from "@/handlers/actions/register-action-handler"; +import { GenericCreateAppRegisterHandlerOptions } from "@/handlers/shared/create-app-register-handler-types"; -import { AuthData } from "@/APL"; -import { SALEOR_API_URL_HEADER } from "@/const"; -import { createDebug } from "@/debug"; -import { fetchRemoteJwks } from "@/fetch-remote-jwks"; -import { getAppId } from "@/get-app-id"; -import { HookCallbackErrorParams } from "@/handlers/shared/create-app-register-handler-types"; -import { validateAllowSaleorUrls } from "@/handlers/shared/validate-allow-saleor-urls"; -import { withAuthTokenRequired } from "@/middleware"; -import { HasAPL } from "@/saleor-app"; +import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; -const debug = createDebug("createAppRegisterHandler"); - -class RegisterCallbackError extends Error { - public status = 500; - - constructor(errorParams: HookCallbackErrorParams) { - super(errorParams.message); - - if (errorParams.status) { - this.status = errorParams.status; - } - } -} - -const createCallbackError = (params: HookCallbackErrorParams) => { - throw new RegisterCallbackError(params); -}; - -export type RegisterHandlerResponseBody = { - success: boolean; - error?: { - code?: string; - message?: string; - }; -}; -export const createRegisterHandlerResponseBody = ( - success: boolean, - error?: RegisterHandlerResponseBody["error"] -): RegisterHandlerResponseBody => ({ - success, - error, -}); - -const handleHookError = (e: RegisterCallbackError | unknown) => { - if (e instanceof RegisterCallbackError) { - return new Response( - createRegisterHandlerResponseBody(false, { - code: "REGISTER_HANDLER_HOOK_ERROR", - message: e.message, - }), - { status: e.status } - ); - } - return Response.InternalServerError("Error during app installation"); -}; - -export type CreateAppRegisterHandlerOptions = HasAPL & { - /** - * Protect app from being registered in Saleor other than specific. - * By default, allow everything. - * - * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) - * or a function that receives a full Saleor API URL ad returns true/false. - */ - allowedSaleorUrls?: Array boolean)>; - /** - * Run right after Saleor calls this endpoint - */ - onRequestStart?( - request: Request, - context: { - authToken?: string; - saleorDomain?: string; - saleorApiUrl?: string; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after all security checks - */ - onRequestVerified?( - request: Request, - context: { - authData: AuthData; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error - */ - onAuthAplSaved?( - request: Request, - context: { - authData: AuthData; - respondWithError: typeof createCallbackError; - } - ): Promise; - /** - * Run after APL fails to set AuthData - */ - onAplSetFailed?( - request: Request, - context: { - authData: AuthData; - error: unknown; - respondWithError: typeof createCallbackError; - } - ): Promise; -}; +export type CreateAppRegisterHandlerOptions = + GenericCreateAppRegisterHandlerOptions; /** - * Creates API handler for Next.js. Creates handler called by Saleor that registers app. - * Hides implementation details if possible - * In the future this will be extracted to separate sdk/next package - */ -export const createAppRegisterHandler = ({ - apl, - allowedSaleorUrls, - onAplSetFailed, - onAuthAplSaved, - onRequestVerified, - onRequestStart, -}: CreateAppRegisterHandlerOptions) => { - const baseHandler: Handler = async (request) => { - debug("Request received"); - - const authToken = request.params.auth_token; - const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER] as string; - - if (onRequestStart) { - debug("Calling \"onRequestStart\" hook"); - - try { - await onRequestStart(request, { - authToken, - saleorApiUrl, - respondWithError: createCallbackError, - }); - } catch (e: RegisterCallbackError | unknown) { - debug("\"onRequestStart\" hook thrown error: %o", e); - - return handleHookError(e); - } - } - - if (!saleorApiUrl) { - debug("saleorApiUrl doesnt exist in headers"); - } - - if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { - debug( - "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", - saleorApiUrl - ); - - return Response.Forbidden( - createRegisterHandlerResponseBody(false, { - code: "SALEOR_URL_PROHIBITED", - message: "This app expects to be installed only in allowed Saleor instances", - }) - ); - } - - const { configured: aplConfigured } = await apl.isConfigured(); - - if (!aplConfigured) { - debug("The APL has not been configured"); - - return new Response( - createRegisterHandlerResponseBody(false, { - code: "APL_NOT_CONFIGURED", - message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", - }), - { - status: 503, - } - ); - } - - // Try to get App ID from the API, to confirm that communication can be established - const appId = await getAppId({ saleorApiUrl, token: authToken }); - if (!appId) { - return new Response( - createRegisterHandlerResponseBody(false, { - code: "UNKNOWN_APP_ID", - message: `The auth data given during registration request could not be used to fetch app ID. - This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${saleorApiUrl}`, - }), - { - status: 401, - } - ); - } - - // Fetch the JWKS which will be used during webhook validation - const jwks = await fetchRemoteJwks(saleorApiUrl); - if (!jwks) { - return new Response( - createRegisterHandlerResponseBody(false, { - code: "JWKS_NOT_AVAILABLE", - message: "Can't fetch the remote JWKS.", - }), - { - status: 401, - } - ); - } - - const authData = { - token: authToken, - saleorApiUrl, - appId, - jwks, + * Returns API route handler for **Next.js pages router** + * for register endpoint that is called by Saleor when installing the app + * + * It verifies the request and stores `app_token` from Saleor + * in APL and along with all required AuthData fields (jwks, saleorApiUrl, ...) + * + * **Recommended path**: `/api/register` + * (configured in manifest handler) + * + * To learn more check Saleor docs + * @see {@link https://docs.saleor.io/developer/extending/apps/architecture/app-requirements#register-url} + * */ +export const createAppRegisterHandler = + (config: CreateAppRegisterHandlerOptions): NextJsHandler => + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionHandler = new RegisterActionHandler(adapter); + const result = await actionHandler.handleAction(config); + return adapter.send(result); }; - - if (onRequestVerified) { - debug("Calling \"onRequestVerified\" hook"); - - try { - await onRequestVerified(request, { - authData, - respondWithError: createCallbackError, - }); - } catch (e: RegisterCallbackError | unknown) { - debug("\"onRequestVerified\" hook thrown error: %o", e); - - return handleHookError(e); - } - } - - try { - await apl.set(authData); - - if (onAuthAplSaved) { - debug("Calling \"onAuthAplSaved\" hook"); - - try { - await onAuthAplSaved(request, { - authData, - respondWithError: createCallbackError, - }); - } catch (e: RegisterCallbackError | unknown) { - debug("\"onAuthAplSaved\" hook thrown error: %o", e); - - return handleHookError(e); - } - } - } catch (aplError: unknown) { - debug("There was an error during saving the auth data"); - - if (onAplSetFailed) { - debug("Calling \"onAuthAplFailed\" hook"); - - try { - await onAplSetFailed(request, { - authData, - error: aplError, - respondWithError: createCallbackError, - }); - } catch (hookError: RegisterCallbackError | unknown) { - debug("\"onAuthAplFailed\" hook thrown error: %o", hookError); - - return handleHookError(hookError); - } - } - - return Response.InternalServerError( - createRegisterHandlerResponseBody(false, { - message: "Registration failed: could not save the auth data.", - }) - ); - } - - debug("Register complete"); - - return Response.OK(createRegisterHandlerResponseBody(true)); - }; - - return toNextHandler([withMethod("POST"), withAuthTokenRequired, baseHandler]); -}; diff --git a/src/handlers/platforms/next/create-manifest-handler.test.ts b/src/handlers/platforms/next/create-manifest-handler.test.ts index 5fe18733..7924feaf 100644 --- a/src/handlers/platforms/next/create-manifest-handler.test.ts +++ b/src/handlers/platforms/next/create-manifest-handler.test.ts @@ -1,18 +1,20 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, it } from "vitest"; +import { SALEOR_SCHEMA_VERSION } from "@/const"; import { AppManifest } from "@/types"; import { createManifestHandler } from "./create-manifest-handler"; describe("createManifestHandler", () => { it("Creates a handler that responds with Manifest. Includes request in context", async () => { - expect.assertions(3); + expect.assertions(4); const { res, req } = createMocks({ headers: { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", + [SALEOR_SCHEMA_VERSION]: "3.20", }, method: "GET", }); @@ -35,6 +37,8 @@ describe("createManifestHandler", () => { await handler(req, res); + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toEqual({ appUrl: "https://some-saleor-host.cloud", id: "app-id", diff --git a/src/handlers/platforms/next/create-manifest-handler.ts b/src/handlers/platforms/next/create-manifest-handler.ts index fcca4c7a..cce8341e 100644 --- a/src/handlers/platforms/next/create-manifest-handler.ts +++ b/src/handlers/platforms/next/create-manifest-handler.ts @@ -1,48 +1,23 @@ -import { NextApiHandler, NextApiRequest } from "next"; +import { + CreateManifestHandlerOptions as GenericCreateManifestHandlerOptions, + ManifestActionHandler, +} from "@/handlers/actions/manifest-action-handler"; -import { createDebug } from "@/debug"; -import { getBaseUrl, getSaleorHeaders } from "@/headers"; -import { AppManifest } from "@/types"; +import { NextJsAdapter, NextJsHandler, NextJsHandlerInput } from "./platform-adapter"; -export type CreateManifestHandlerOptions = { - manifestFactory(context: { - appBaseUrl: string; - request: NextApiRequest; - /** For Saleor < 3.15 it will be null. */ - schemaVersion: number | null; - }): AppManifest | Promise; -}; - -const debug = createDebug("create-manifest-handler"); +export type CreateManifestHandlerOptions = GenericCreateManifestHandlerOptions; /** - * Creates API handler for Next.js. Helps with Manifest creation, hides + * Creates API handler for Next.js page router. + * + * elps with Manifest creation, hides * implementation details if possible - * In the future this will be extracted to separate sdk/next package */ export const createManifestHandler = - (options: CreateManifestHandlerOptions): NextApiHandler => - async (request, response) => { - const { schemaVersion } = getSaleorHeaders(request.headers); - const baseURL = getBaseUrl(request.headers); - - debug("Received request with schema version \"%s\" and base URL \"%s\"", schemaVersion, baseURL); - - try { - const manifest = await options.manifestFactory({ - appBaseUrl: baseURL, - request, - schemaVersion, - }); - - debug("Executed manifest file"); - - return response.status(200).json(manifest); - } catch (e) { - debug("Error while resolving manifest: %O", e); - - return response.status(500).json({ - message: "Error resolving manifest file", - }); - } + (options: CreateManifestHandlerOptions): NextJsHandler => + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionHandler = new ManifestActionHandler(adapter); + const result = await actionHandler.handleAction(options); + return adapter.send(result); }; diff --git a/src/handlers/platforms/next/create-protected-handler.test.ts b/src/handlers/platforms/next/create-protected-handler.test.ts new file mode 100644 index 00000000..98071e9d --- /dev/null +++ b/src/handlers/platforms/next/create-protected-handler.test.ts @@ -0,0 +1,97 @@ +import { createMocks } from "node-mocks-http"; +import { describe, expect, it, vi } from "vitest"; + +import { ProtectedActionValidator, ProtectedHandlerContext } from "@/handlers/shared/protected-action-validator"; +import { MockAPL } from "@/test-utils/mock-apl"; +import { Permission } from "@/types"; + +import { createProtectedHandler } from "./create-protected-handler"; + +describe("createProtectedHandler", () => { + const mockAPL = new MockAPL(); + const mockHandlerFn = vi.fn(); + const { req, res } = createMocks({ + headers: { + host: "some-saleor-host.cloud", + "x-forwarded-proto": "https", + }, + method: "GET", + }); + const mockHandlerContext: ProtectedHandlerContext = { + baseUrl: "https://example.com", + authData: { + token: mockAPL.mockToken, + saleorApiUrl: "https://example.saleor.cloud/graphql/", + appId: mockAPL.mockAppId, + jwks: mockAPL.mockJwks, + }, + user: { + email: "test@example.com", + userPermissions: [], + }, + }; + + describe("validation", () => { + it("sends error when request validation fails", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "failure", + value: { + status: 401, + body: "Unauthorized", + bodyType: "string", + }, + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + await handler(req, res); + + expect(mockHandlerFn).not.toHaveBeenCalled(); + expect(res._getStatusCode()).toBe(401); + }); + + it("calls handler function when validation succeeds", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "ok", + value: mockHandlerContext, + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + await handler(req, res); + + expect(mockHandlerFn).toHaveBeenCalledWith(req, res, mockHandlerContext); + }); + }); + + describe("permissions handling", () => { + it("passes required permissions from factory input to validator", async () => { + const validateRequestSpy = vi.spyOn(ProtectedActionValidator.prototype, "validateRequest"); + const requiredPermissions: Permission[] = ["MANAGE_APPS"]; + + const handler = createProtectedHandler(mockHandlerFn, mockAPL, requiredPermissions); + await handler(req, res); + + expect(validateRequestSpy).toHaveBeenCalledWith({ + apl: mockAPL, + requiredPermissions, + }); + }); + }); + + describe("error handling", () => { + it("returns 500 status when user handler function throws error", async () => { + vi.spyOn(ProtectedActionValidator.prototype, "validateRequest").mockResolvedValueOnce({ + result: "ok", + value: mockHandlerContext, + }); + + mockHandlerFn.mockImplementationOnce(() => { + throw new Error("Test error"); + }); + + const handler = createProtectedHandler(mockHandlerFn, mockAPL); + await handler(req, res); + + expect(res._getStatusCode()).toBe(500); + }); + }); +}); diff --git a/src/handlers/platforms/next/create-protected-handler.ts b/src/handlers/platforms/next/create-protected-handler.ts index 6407c878..c2a5920c 100644 --- a/src/handlers/platforms/next/create-protected-handler.ts +++ b/src/handlers/platforms/next/create-protected-handler.ts @@ -1,19 +1,15 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { APL } from "@/APL"; -import { createDebug } from "@/debug"; -import { ProtectedHandlerErrorCodeMap } from "@/handlers/shared/protected-handler"; -import { Permission } from "@/types"; - import { - processSaleorProtectedHandler, - ProtectedHandlerError, -} from "./process-protected-handler"; -import { ProtectedHandlerContext } from "./protected-handler-context"; + ProtectedActionValidator, + ProtectedHandlerContext, +} from "@/handlers/shared/protected-action-validator"; +import { Permission } from "@/types"; -const debug = createDebug("ProtectedHandler"); +import { NextJsAdapter } from "./platform-adapter"; -export type NextProtectedApiHandler = ( +export type NextJsProtectedApiHandler = ( req: NextApiRequest, res: NextApiResponse, ctx: ProtectedHandlerContext @@ -25,30 +21,26 @@ export type NextProtectedApiHandler = ( */ export const createProtectedHandler = ( - handlerFn: NextProtectedApiHandler, + handlerFn: NextJsProtectedApiHandler, apl: APL, requiredPermissions?: Permission[] ): NextApiHandler => - (req, res) => { - debug("Protected handler called"); - processSaleorProtectedHandler({ - req, + async (req, res) => { + const adapter = new NextJsAdapter(req, res); + const actionValidator = new ProtectedActionValidator(adapter); + const validationResult = await actionValidator.validateRequest({ apl, requiredPermissions, - }) - .then(async (context) => { - debug("Incoming request validated. Call handlerFn"); - return handlerFn(req, res, context); - }) - .catch((e) => { - debug("Unexpected error during processing the request"); + }); + + if (validationResult.result === "failure") { + return adapter.send(validationResult.value); + } - if (e instanceof ProtectedHandlerError) { - debug(`Validation error: ${e.message}`); - res.status(ProtectedHandlerErrorCodeMap[e.errorType] || 400).end(); - return; - } - debug("Unexpected error: %O", e); - res.status(500).end(); - }); + const context = validationResult.value; + try { + return handlerFn(req, res, context); + } catch (err) { + return res.status(500).end(); + } }; diff --git a/src/handlers/platforms/next/index.ts b/src/handlers/platforms/next/index.ts index ae894354..fafdaf8a 100644 --- a/src/handlers/platforms/next/index.ts +++ b/src/handlers/platforms/next/index.ts @@ -1,11 +1,7 @@ export * from "./create-app-register-handler"; export * from "./create-manifest-handler"; export * from "./create-protected-handler"; -export * from "./process-protected-handler"; -export * from "./protected-handler-context"; +export * from "./platform-adapter"; export * from "./saleor-webhooks/saleor-async-webhook"; export * from "./saleor-webhooks/saleor-sync-webhook"; -export { NextWebhookApiHandler } from "./saleor-webhooks/saleor-webhook"; - -// Left for compatibility -export { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; +export { NextJsWebhookHandler } from "./saleor-webhooks/saleor-webhook"; diff --git a/src/handlers/platforms/next/platform-adapter.test.ts b/src/handlers/platforms/next/platform-adapter.test.ts new file mode 100644 index 00000000..32b99469 --- /dev/null +++ b/src/handlers/platforms/next/platform-adapter.test.ts @@ -0,0 +1,132 @@ +import { createMocks } from "node-mocks-http"; +import getRawBody from "raw-body"; +import { describe, expect, it, vi } from "vitest"; + +import { NextJsAdapter } from "./platform-adapter"; + +vi.mock("raw-body"); + +describe("NextJsAdapter", () => { + describe("getHeader", () => { + it("should return single header value", () => { + const { req, res } = createMocks({ + headers: { + "content-type": "application/json" + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getHeader("content-type")).toBe("application/json"); + }); + + it("should join multiple header values", () => { + const { req, res } = createMocks({ + headers: { + // @ts-expect-error node-mocks-http types != real NextJsRequest + "accept": ["text/html", "application/json"] + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getHeader("accept")).toBe("text/html, application/json"); + }); + + it("should return null for non-existent header", () => { + const { req, res } = createMocks(); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getHeader("non-existent")).toBeNull(); + }); + }); + + describe("getBody", () => { + it("should return request body", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { data: "test" } + }); + const adapter = new NextJsAdapter(req, res); + const body = await adapter.getBody(); + expect(body).toEqual({ data: "test" }); + }); + }); + + describe("getRawBody", () => { + it("should return raw body string", async () => { + const { req, res } = createMocks({ + headers: { + "content-length": "10" + } + }); + const adapter = new NextJsAdapter(req, res); + + vi.mocked(getRawBody).mockResolvedValueOnce(Buffer.from("test body")); + + const result = await adapter.getRawBody(); + expect(result).toBe("test body"); + expect(getRawBody).toHaveBeenCalledWith(req, { length: "10" }); + }); + }); + + describe("getBaseUrl", () => { + it("should use x-forwarded-proto header for protocol", () => { + const { req, res } = createMocks({ + headers: { + host: "example.com", + "x-forwarded-proto": "https" + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer https when x-forwarded-proto has multiple values", () => { + const { req, res } = createMocks({ + headers: { + host: "example.com", + "x-forwarded-proto": "http,https,wss" + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getBaseUrl()).toBe("https://example.com"); + }); + + it("should prefer http when x-forwarded-proto has multiple values and https is not present", () => { + const { req, res } = createMocks({ + headers: { + host: "example.com", + "x-forwarded-proto": "wss,http" + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getBaseUrl()).toBe("http://example.com"); + }); + + it("should use first protocol when https is not present in x-forwarded-proto", () => { + const { req, res } = createMocks({ + headers: { + host: "example.com", + "x-forwarded-proto": "wss,ftp" + } + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.getBaseUrl()).toBe("wss://example.com"); + }); + }); + + describe("method", () => { + it("should return POST method when used in request", () => { + const { req, res } = createMocks({ + method: "POST" + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.method).toBe("POST"); + }); + + it("should return GET method when used in request", () => { + const { req, res } = createMocks({ + method: "GET" + }); + const adapter = new NextJsAdapter(req, res); + expect(adapter.method).toBe("GET"); + }); + }); +}); + diff --git a/src/handlers/platforms/next/platform-adapter.ts b/src/handlers/platforms/next/platform-adapter.ts new file mode 100644 index 00000000..7b76fb96 --- /dev/null +++ b/src/handlers/platforms/next/platform-adapter.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import getRawBody from "raw-body"; + +import { + ActionHandlerResult, + PlatformAdapterInterface, +} from "@/handlers/shared/generic-adapter-use-case-types"; + +export type NextJsHandlerInput = NextApiRequest; +export type NextJsHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; + +export class NextJsAdapter implements PlatformAdapterInterface { + readonly type = "next" as const; + + constructor(public request: NextApiRequest, private res: NextApiResponse) { } + + getHeader(name: string) { + const header = this.request.headers[name]; + return Array.isArray(header) ? header.join(", ") : header ?? null; + } + + getBody(): Promise { + return Promise.resolve(this.request.body); + } + + async getRawBody(): Promise { + return ( + await getRawBody(this.request, { + length: this.request.headers["content-length"], + }) + ).toString(); + } + + getBaseUrl(): string { + const { host, "x-forwarded-proto": xForwardedProto = "http" } = this.request.headers; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols.find((el => el === "http")) || protocols[0]; + + return `${protocol}://${host}`; + } + + get method() { + return this.request.method as "POST" | "GET"; + } + + async send(result: ActionHandlerResult): Promise { + if (result.bodyType === "json") { + this.res.status(result.status).json(result.body); + } else { + this.res.status(result.status).send(result.body); + } + } +} diff --git a/src/handlers/platforms/next/process-protected-handler.test.ts b/src/handlers/platforms/next/process-protected-handler.test.ts deleted file mode 100644 index 21856c33..00000000 --- a/src/handlers/platforms/next/process-protected-handler.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { NextApiRequest } from "next/types"; -import { createMocks } from "node-mocks-http"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as getAppIdModule from "@/get-app-id"; -import { getAppId } from "@/get-app-id"; -import { MockAPL } from "@/test-utils/mock-apl"; -import * as verifyJWTModule from "@/verify-jwt"; -import { verifyJWT } from "@/verify-jwt"; - -import { processSaleorProtectedHandler } from "./process-protected-handler"; - -const validToken = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; - -const validAppId = "QXBwOjI3NQ=="; - -vi.spyOn(getAppIdModule, "getAppId"); -vi.spyOn(verifyJWTModule, "verifyJWT"); - -describe("processSaleorProtectedHandler", () => { - let mockRequest: NextApiRequest; - - let mockAPL: MockAPL; - - beforeEach(() => { - mockAPL = new MockAPL(); - - // Create request method which passes all the tests - const { req } = createMocks({ - headers: { - host: "some-saleor-host.cloud", - "x-forwarded-proto": "https", - "saleor-domain": mockAPL.workingSaleorDomain, - "saleor-api-url": mockAPL.workingSaleorApiUrl, - "saleor-event": "product_updated", - "saleor-signature": "mocked_signature", - "authorization-bearer": validToken, - }, - method: "POST", - }); - mockRequest = req; - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it("Process valid request", async () => { - vi.mocked(getAppId).mockResolvedValue(validAppId); - vi.mocked(verifyJWT).mockResolvedValue(); - - expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({ - authData: { - token: mockAPL.mockToken, - saleorApiUrl: mockAPL.workingSaleorApiUrl, - appId: mockAPL.mockAppId, - jwks: mockAPL.mockJwks, - }, - baseUrl: "https://some-saleor-host.cloud", - user: expect.objectContaining({ - email: expect.any(String), - userPermissions: expect.any(Array), - }), - }); - }); - - it("Throw error when api url header is missing", async () => { - vi.mocked(getAppId).mockResolvedValue(validAppId); - vi.mocked(verifyJWT).mockResolvedValue(); - - delete mockRequest.headers["saleor-api-url"]; - - await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Missing saleor-api-url header" - ); - }); - - it("Throw error when token header is missing", async () => { - vi.mocked(getAppId).mockResolvedValue(validAppId); - vi.mocked(verifyJWT).mockResolvedValue(); - - delete mockRequest.headers["authorization-bearer"]; - - await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Missing authorization-bearer header" - ); - }); - - it("Throw error when APL has no auth data for the given domain", async () => { - vi.mocked(getAppId).mockResolvedValue(validAppId); - vi.mocked(verifyJWT).mockResolvedValue(); - - mockRequest.headers["saleor-api-url"] = "https://wrong.example.com/graphql/"; - - await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Can't find auth data for saleorApiUrl https://wrong.example.com/graphql/. Please register the application" - ); - }); - - it("Throw error when token verification fails", async () => { - vi.mocked(getAppId).mockResolvedValue(validAppId); - vi.mocked(verifyJWT).mockRejectedValue("Verification error"); - - await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "JWT verification failed: " - ); - }); -}); diff --git a/src/handlers/platforms/next/process-protected-handler.ts b/src/handlers/platforms/next/process-protected-handler.ts deleted file mode 100644 index 56138b6f..00000000 --- a/src/handlers/platforms/next/process-protected-handler.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; -import { NextApiRequest } from "next"; - -import { APL } from "@/APL"; -import { createDebug } from "@/debug"; -import { SaleorProtectedHandlerError } from "@/handlers/shared/protected-handler"; -import { getBaseUrl, getSaleorHeaders } from "@/headers"; -import { getOtelTracer } from "@/open-telemetry"; -import { Permission } from "@/types"; -import { extractUserFromJwt } from "@/util/extract-user-from-jwt"; -import { verifyJWT } from "@/verify-jwt"; - -import { ProtectedHandlerContext } from "./protected-handler-context"; - -const debug = createDebug("processProtectedHandler"); - -export class ProtectedHandlerError extends Error { - errorType: SaleorProtectedHandlerError = "OTHER"; - - constructor(message: string, errorType: SaleorProtectedHandlerError) { - super(message); - if (errorType) { - this.errorType = errorType; - } - Object.setPrototypeOf(this, ProtectedHandlerError.prototype); - } -} - -interface ProcessSaleorProtectedHandlerArgs { - req: Pick; - apl: APL; - requiredPermissions?: Permission[]; -} - -type ProcessAsyncSaleorProtectedHandler = ( - props: ProcessSaleorProtectedHandlerArgs -) => Promise; - -/** - * Perform security checks on given request and return ProtectedHandlerContext object. - * In case of validation issues, instance of the ProtectedHandlerError will be thrown. - * - * Can pass entire next request or Headers with saleorApiUrl and token - */ -export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ - req, - apl, - requiredPermissions, -}: ProcessSaleorProtectedHandlerArgs): Promise => { - const tracer = getOtelTracer(); - - return tracer.startActiveSpan( - "processSaleorProtectedHandler", - { - kind: SpanKind.INTERNAL, - attributes: { - requiredPermissions, - }, - }, - async (span) => { - debug("Request processing started"); - - const { saleorApiUrl, authorizationBearer: token } = getSaleorHeaders(req.headers); - - const baseUrl = getBaseUrl(req.headers); - - span.setAttribute("saleorApiUrl", saleorApiUrl ?? ""); - - if (!baseUrl) { - span - .setStatus({ - code: SpanStatusCode.ERROR, - message: "Missing host header", - }) - .end(); - - debug("Missing host header"); - - throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); - } - - if (!saleorApiUrl) { - span - .setStatus({ - code: SpanStatusCode.ERROR, - message: "Missing saleor-api-url header", - }) - .end(); - - debug("Missing saleor-api-url header"); - - throw new ProtectedHandlerError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); - } - - if (!token) { - span - .setStatus({ - code: SpanStatusCode.ERROR, - message: "Missing authorization-bearer header", - }) - .end(); - - debug("Missing authorization-bearer header"); - - throw new ProtectedHandlerError( - "Missing authorization-bearer header", - "MISSING_AUTHORIZATION_BEARER_HEADER" - ); - } - - // Check if API URL has been registered in the APL - const authData = await apl.get(saleorApiUrl); - - if (!authData) { - span - .setStatus({ - code: SpanStatusCode.ERROR, - message: "APL didn't found auth data for API URL", - }) - .end(); - - debug("APL didn't found auth data for API URL %s", saleorApiUrl); - - throw new ProtectedHandlerError( - `Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`, - "NOT_REGISTERED" - ); - } - - try { - await verifyJWT({ appId: authData.appId, token, saleorApiUrl, requiredPermissions }); - } catch (e) { - span - .setStatus({ - code: SpanStatusCode.ERROR, - message: "JWT verification failed", - }) - .end(); - - throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); - } - - const userJwtPayload = extractUserFromJwt(token); - - span.end(); - - return { - baseUrl, - authData, - user: userJwtPayload, - }; - } - ); -}; diff --git a/src/handlers/platforms/next/protected-handler-context.ts b/src/handlers/platforms/next/protected-handler-context.ts deleted file mode 100644 index 38031889..00000000 --- a/src/handlers/platforms/next/protected-handler-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AuthData } from "@/APL"; -import { TokenUserPayload } from "@/util/extract-user-from-jwt"; - -export type ProtectedHandlerContext = { - baseUrl: string; - authData: AuthData; - user: TokenUserPayload; -}; diff --git a/src/handlers/platforms/next/readme.md b/src/handlers/platforms/next/readme.md deleted file mode 100644 index e6cfad4f..00000000 --- a/src/handlers/platforms/next/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -Handlers with adapters to Next.js - -TODO Extract to separate package diff --git a/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts deleted file mode 100644 index 4ca484b4..00000000 --- a/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { NextApiRequest } from "next/types"; -import { createMocks } from "node-mocks-http"; -import rawBody from "raw-body"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { MockAPL } from "@/test-utils/mock-apl"; - -import { processSaleorWebhook } from "./process-saleor-webhook"; - -vi.mock("@/verify-signature", () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - verifySignature: vi.fn((domain, signature) => { - if (signature !== "mocked_signature") { - throw new Error("Wrong signature"); - } - }), - verifySignatureFromApiUrl: vi.fn((domain, signature) => { - if (signature !== "mocked_signature") { - throw new Error("Wrong signature"); - } - }), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - verifySignatureWithJwks: vi.fn((jwks, signature, body) => { - if (signature !== "mocked_signature") { - throw new Error("Wrong signature"); - } - }), -})); - -vi.mock("raw-body", () => ({ - default: vi.fn().mockResolvedValue("{}"), -})); -describe("processAsyncSaleorWebhook", () => { - let mockRequest: NextApiRequest; - - const mockAPL = new MockAPL(); - - beforeEach(() => { - // Create request method which passes all the tests - const { req } = createMocks({ - headers: { - host: "some-saleor-host.cloud", - "x-forwarded-proto": "https", - "saleor-domain": mockAPL.workingSaleorDomain, - "saleor-api-url": mockAPL.workingSaleorApiUrl, - "saleor-event": "product_updated", - "saleor-signature": "mocked_signature", - "content-length": "0", // is ignored by mocked raw-body. - }, - method: "POST", - // body can be skipped because we mock it with raw-body - }); - mockRequest = req; - }); - - it("Process valid request", async () => - expect(() => - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).not.toThrow()); - - it("Throw error on non-POST request method", async () => { - mockRequest.method = "GET"; - - await expect( - processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) - ).rejects.toThrow("Wrong request method"); - }); - - it("Throw error on missing api url header", async () => { - delete mockRequest.headers["saleor-api-url"]; - - await expect( - processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) - ).rejects.toThrow("Missing saleor-api-url header"); - }); - - it("Throw error on missing event header", async () => { - delete mockRequest.headers["saleor-event"]; - - await expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).rejects.toThrow("Missing saleor-event header"); - }); - - it("Throw error on mismatched event header", async () => { - mockRequest.headers["saleor-event"] = "different_event"; - await expect( - processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) - ).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated"); - }); - - it("Throw error on missing signature header", async () => { - delete mockRequest.headers["saleor-signature"]; - await expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).rejects.toThrow("Missing saleor-signature header"); - }); - - it("Throw error on missing request body", async () => { - vi.mocked(rawBody).mockImplementationOnce(async () => { - throw new Error("Missing request body"); - }); - - await expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).rejects.toThrow("Missing request body"); - }); - - it("Throw error on not registered app", async () => { - mockRequest.headers["saleor-api-url"] = "https://not-registered.example.com/graphql/"; - await expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).rejects.toThrow( - "Can't find auth data for https://not-registered.example.com/graphql/. Please register the application" - ); - }); - - it("Throw error on wrong signature", async () => { - mockRequest.headers["saleor-signature"] = "wrong_signature"; - - vi.mock("@/fetch-remote-jwks", () => ({ - fetchRemoteJwks: vi.fn(async () => "wrong_signature"), - })); - - return expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).rejects.toThrow("Request signature check failed"); - }); - - it("Fallback to null if version is missing on payload", async () => { - await expect( - processSaleorWebhook({ - req: mockRequest, - apl: mockAPL, - allowedEvent: "PRODUCT_UPDATED", - }) - ).resolves.toStrictEqual({ - authData: { - appId: "mock-app-id", - jwks: "{}", - saleorApiUrl: "https://example.com/graphql/", - token: "mock-token", - }, - baseUrl: "https://some-saleor-host.cloud", - event: "product_updated", - payload: {}, - schemaVersion: null, - }); - }); -}); diff --git a/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts deleted file mode 100644 index 80a91289..00000000 --- a/src/handlers/platforms/next/saleor-webhooks/process-saleor-webhook.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; -import { NextApiRequest } from "next"; -import getRawBody from "raw-body"; - -import { APL } from "@/APL"; -import { createDebug } from "@/debug"; -import { fetchRemoteJwks } from "@/fetch-remote-jwks"; -import { WebhookContext, WebhookError } from "@/handlers/shared/saleor-webhook"; -import { getBaseUrl, getSaleorHeaders } from "@/headers"; -import { getOtelTracer } from "@/open-telemetry"; -import { parseSchemaVersion } from "@/util"; -import { verifySignatureWithJwks } from "@/verify-signature"; - -const debug = createDebug("processSaleorWebhook"); - -interface ProcessSaleorWebhookArgs { - req: NextApiRequest; - apl: APL; - allowedEvent: string; -} - -type ProcessSaleorWebhook = ( - props: ProcessSaleorWebhookArgs -) => Promise>; - -/** - * Perform security checks on given request and return WebhookContext object. - * In case of validation issues, instance of the WebhookError will be thrown. - * - * @returns WebhookContext - */ -export const processSaleorWebhook: ProcessSaleorWebhook = async ({ - req, - apl, - allowedEvent, -}: ProcessSaleorWebhookArgs): Promise> => { - const tracer = getOtelTracer(); - - return tracer.startActiveSpan( - "processSaleorWebhook", - { - kind: SpanKind.INTERNAL, - attributes: { - allowedEvent, - }, - }, - async (span) => { - try { - debug("Request processing started"); - - if (req.method !== "POST") { - debug("Wrong HTTP method"); - throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); - } - - const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); - const baseUrl = getBaseUrl(req.headers); - - if (!baseUrl) { - debug("Missing host header"); - throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); - } - - if (!saleorApiUrl) { - debug("Missing saleor-api-url header"); - throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); - } - - if (!event) { - debug("Missing saleor-event header"); - throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); - } - - const expected = allowedEvent.toLowerCase(); - - if (event !== expected) { - debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); - - throw new WebhookError( - `Wrong incoming request event: ${event}. Expected: ${expected}`, - "WRONG_EVENT" - ); - } - - if (!signature) { - debug("No signature"); - - throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); - } - - const rawBody = ( - await getRawBody(req, { - length: req.headers["content-length"], - }) - ).toString(); - if (!rawBody) { - debug("Missing request body"); - - throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); - } - - let parsedBody: unknown & { version?: string | null }; - - try { - parsedBody = JSON.parse(rawBody); - } catch { - debug("Request body cannot be parsed"); - - throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); - } - - let parsedSchemaVersion: number | null = null; - - try { - parsedSchemaVersion = parseSchemaVersion(parsedBody.version); - } catch { - debug("Schema version cannot be parsed"); - } - - /** - * Verify if the app is properly installed for given Saleor API URL - */ - const authData = await apl.get(saleorApiUrl); - - if (!authData) { - debug("APL didn't found auth data for %s", saleorApiUrl); - - throw new WebhookError( - `Can't find auth data for ${saleorApiUrl}. Please register the application`, - "NOT_REGISTERED" - ); - } - - /** - * Verify payload signature - * - * TODO: Add test for repeat verification scenario - */ - try { - debug("Will verify signature with JWKS saved in AuthData"); - - if (!authData.jwks) { - throw new Error("JWKS not found in AuthData"); - } - - await verifySignatureWithJwks(authData.jwks, signature, rawBody); - } catch { - debug("Request signature check failed. Refresh the JWKS cache and check again"); - - const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { - debug(e); - - throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED"); - }); - - debug("Fetched refreshed JWKS"); - - try { - debug("Second attempt to validate the signature JWKS, using fresh tokens from the API"); - - await verifySignatureWithJwks(newJwks, signature, rawBody); - - debug("Verification successful - update JWKS in the AuthData"); - - await apl.set({ ...authData, jwks: newJwks }); - } catch { - debug("Second attempt also ended with validation error. Reject the webhook"); - - throw new WebhookError( - "Request signature check failed", - "SIGNATURE_VERIFICATION_FAILED" - ); - } - } - - span.setStatus({ - code: SpanStatusCode.OK, - }); - - return { - baseUrl, - event, - payload: parsedBody as T, - authData, - schemaVersion: parsedSchemaVersion, - }; - } catch (err) { - const message = (err as Error)?.message ?? "Unknown error"; - - span.setStatus({ - code: SpanStatusCode.ERROR, - message, - }); - - throw err; - } finally { - span.end(); - } - } - ); -}; diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts index 0e1d785e..763ac6b6 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.test.ts @@ -1,18 +1,18 @@ -import { ASTNode } from "graphql"; import { createMocks } from "node-mocks-http"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebhookError } from "@/handlers/shared"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; import { MockAPL } from "@/test-utils/mock-apl"; import { AsyncWebhookEventType } from "@/types"; -import { processSaleorWebhook } from "./process-saleor-webhook"; import { SaleorAsyncWebhook } from "./saleor-async-webhook"; -import { NextWebhookApiHandler, WebhookConfig } from "./saleor-webhook"; +import { NextJsWebhookHandler, WebhookConfig } from "./saleor-webhook"; const webhookPath = "api/webhooks/product-updated"; const baseUrl = "http://example.com"; -describe("SaleorAsyncWebhook", () => { +describe("Next.js SaleorAsyncWebhook", () => { const mockAPL = new MockAPL(); afterEach(async () => { @@ -28,161 +28,253 @@ describe("SaleorAsyncWebhook", () => { const saleorAsyncWebhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); - it("constructor passes if query is provided", async () => { - expect(() => { - // eslint-disable-next-line no-new - new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, + describe("getWebhookManifest", () => { + it("should return full path to the webhook route based on given baseUrl", async () => { + expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toEqual( + expect.objectContaining({ + targetUrl: `${baseUrl}/${webhookPath}`, + }) + ); + }); + + it("should return a valid manifest", async () => { + expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toStrictEqual({ + asyncEvents: ["PRODUCT_UPDATED"], + isActive: true, + name: "PRODUCT_UPDATED webhook", + targetUrl: "http://example.com/api/webhooks/product-updated", query: "subscription { event { ... on ProductUpdated { product { id }}}}", }); - }).not.toThrowError(); + }); }); - it("targetUrl should return full path to the webhook route based on given baseUrl", async () => { - expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toEqual( - expect.objectContaining({ - targetUrl: `${baseUrl}/${webhookPath}`, - }) - ); - }); + describe("createHandler", () => { + it("validates request before passing it to provided handler function with context", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "product_updated", + payload: { data: "test_payload" }, + schemaVersion: 3.19, + authData: { + token: "token", + jwks: "", + saleorApiUrl: "https://example.com/graphql/", + appId: "12345", + }, + }, + }); - it("getWebhookManifest should return a valid manifest", async () => { - expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toStrictEqual({ - asyncEvents: ["PRODUCT_UPDATED"], - isActive: true, - name: "PRODUCT_UPDATED webhook", - targetUrl: "http://example.com/api/webhooks/product-updated", - query: "subscription { event { ... on ProductUpdated { product { id }}}}", - }); - }); + const testHandler: NextJsWebhookHandler = vi.fn().mockImplementation((_req, res, context) => { + if (context.payload.data === "test_payload") { + res.status(200).end(); + return; + } + throw new Error("Test payload has not been passed to handler function"); + }); + + const { req, res } = createMocks(); + const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler); + await wrappedHandler(req, res); - it("Test createHandler which return success", async () => { - // prepare mocked context returned by mocked process function - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ - baseUrl: "example.com", - event: "product_updated", - payload: { data: "test_payload" }, - schemaVersion: 3.19, - authData: { - token: "token", - jwks: "", - saleorApiUrl: "https://example.com/graphql/", - appId: "12345", - }, - })); - - // Test handler - will throw error if mocked context is not passed to it - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const testHandler: NextWebhookApiHandler = vi.fn().mockImplementation((req, res, context) => { - if (context.payload.data === "test_payload") { - res.status(200).end(); - return; - } - throw new Error("Test payload has not been passed to handler function"); + expect(res.statusCode).toBe(200); + expect(testHandler).toBeCalledTimes(1); }); - // We are mocking validation method, so empty mock requests will pass - const { req, res } = createMocks(); - const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler); - await wrappedHandler(req, res); + it("prevents handler execution when validation fails", async () => { + const handler = vi.fn(); + const webhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); - expect(res.statusCode).toBe(200); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); - // Check if test handler was used by the wrapper - expect(testHandler).toBeCalledTimes(1); - }); + const { req, res } = createMocks(); + await webhook.createHandler(handler)(req, res); - it("Calls callbacks for error handling", async () => { - const onErrorCallback = vi.fn(); - const formatErrorCallback = vi.fn().mockImplementation(async () => ({ - code: 401, - body: "My Body", - })); - - const webhook = new SaleorAsyncWebhook({ - ...validAsyncWebhookConfiguration, - onError: onErrorCallback, - formatErrorResponse: formatErrorCallback, + expect(handler).not.toHaveBeenCalled(); }); - // prepare mocked context returned by mocked process function - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => { - /** - * This mock should throw WebhookError, but there was TypeError related to constructor of extended class. - * Try "throw new WebhookError()" to check it. - * - * For test suite it doesn't matter, because errors thrown from source code are valid - */ - throw new Error("Test error message"); - }); + describe("when validation throws WebhookError", () => { + it("calls onError and uses formatErrorResponse when provided", async () => { + const webhookError = new WebhookError("Test error", "OTHER"); + const formatErrorResponse = vi.fn().mockResolvedValue({ + code: 418, + body: "Custom response", + }); + + const webhook = new SaleorAsyncWebhook({ + ...validAsyncWebhookConfiguration, + onError: vi.fn(), + formatErrorResponse, + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(webhook.onError).toHaveBeenCalledWith(webhookError, req); + expect(formatErrorResponse).toHaveBeenCalledWith(webhookError, req); + expect(res.statusCode).toBe(418); + expect(res._getData()).toBe("Custom response"); + }); + + it("calls onError and uses default JSON response when formatErrorResponse not provided", async () => { + const webhookError = new WebhookError("Test error", "OTHER"); + const webhook = new SaleorAsyncWebhook({ + ...validAsyncWebhookConfiguration, + onError: vi.fn(), + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(webhook.onError).toHaveBeenCalledWith(webhookError, req); + expect(res.statusCode).toBe(500); // OTHER error is mapped to 500 + expect(res._getJSONData()).toEqual({ + error: { type: "OTHER", message: "Test error" }, + }); + }); + + describe("WebhookError code mapping", () => { + const webhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); + + it("should map OTHER error to 500 status code", async () => { + const webhookError = new WebhookError("Internal server error", "OTHER"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const testHandler: NextWebhookApiHandler = vi.fn().mockImplementation((req, res, context) => { - if (context.payload.data === "test_payload") { - res.status(200).end(); - return; - } - throw new Error("Test payload has not been passed to handler function"); + expect(res.statusCode).toBe(500); + expect(res._getJSONData()).toEqual({ + error: { + type: "OTHER", + message: "Internal server error", + }, + }); + }); + + it("should map MISSING_HOST_HEADER error to 400 status code", async () => { + const webhookError = new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(400); + expect(res._getJSONData()).toEqual({ + error: { + type: "MISSING_HOST_HEADER", + message: "Missing host header", + }, + }); + }); + + it("should map NOT_REGISTERED error to 401 status code", async () => { + const webhookError = new WebhookError("Not registered", "NOT_REGISTERED"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(401); + expect(res._getJSONData()).toEqual({ + error: { + type: "NOT_REGISTERED", + message: "Not registered", + }, + }); + }); + + it("should map WRONG_METHOD error to 405 status code", async () => { + const webhookError = new WebhookError("Wrong HTTP method", "WRONG_METHOD"); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(405); + expect(res._getJSONData()).toEqual({ + error: { + type: "WRONG_METHOD", + message: "Wrong HTTP method", + }, + }); + }); + }); }); - const { req, res } = createMocks(); - const wrappedHandler = webhook.createHandler(testHandler); - - await wrappedHandler(req, res); - - /** - * Response should match formatErrorCallback - */ - expect(res.statusCode).toBe(401); - expect(res._getData()).toBe("My Body"); - expect(onErrorCallback).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Test error message", - }), - req, - res - ); - - /** - * Handler should not be called, since it thrown before - */ - expect(testHandler).not.toHaveBeenCalled(); - }); + describe("when validation throws generic Error", () => { + const genericError = new Error("Unexpected error"); - /** - * Pre 0.35.0 - then remove - */ - it("Allows legacy asyncEvent and subscriptionQueryAst fields, but fails if none provided", () => { - expect( - () => - new SaleorAsyncWebhook({ - asyncEvent: "ADDRESS_CREATED", - subscriptionQueryAst: {} as unknown as ASTNode, - apl: mockAPL, - webhookPath: "", - }) - ).not.toThrowError(); - - expect( - () => - new SaleorAsyncWebhook({ - subscriptionQueryAst: {} as unknown as ASTNode, - apl: mockAPL, - webhookPath: "", - }) - ).toThrowError(); - - expect( - () => - new SaleorAsyncWebhook({ - asyncEvent: "ADDRESS_CREATED", - apl: mockAPL, - webhookPath: "", - }) - ).toThrowError(); + it("calls onError and uses formatErrorResponse when provided", async () => { + const formatErrorResponse = vi.fn().mockResolvedValue({ + code: 500, + body: "Server error", + }); + + const webhook = new SaleorAsyncWebhook({ + ...validAsyncWebhookConfiguration, + onError: vi.fn(), + formatErrorResponse, + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: genericError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(webhook.onError).toHaveBeenCalledWith(genericError, req); + expect(formatErrorResponse).toHaveBeenCalledWith(genericError, req); + expect(res.statusCode).toBe(500); + expect(res._getData()).toBe("Server error"); + }); + + it("calls onError and uses default text response when formatErrorResponse not provided", async () => { + const webhook = new SaleorAsyncWebhook({ + ...validAsyncWebhookConfiguration, + onError: vi.fn(), + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: genericError, + }); + + const { req, res } = createMocks(); + await webhook.createHandler(() => {})(req, res); + + expect(webhook.onError).toHaveBeenCalledWith(genericError, req); + expect(res.statusCode).toBe(500); + expect(res._getData()).toBe("Unexpected error while handling request"); + }); + }); }); }); diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts index 327a87ce..61074303 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-async-webhook.ts @@ -1,50 +1,21 @@ -import { ASTNode } from "graphql/index"; import { NextApiHandler } from "next"; import { AsyncWebhookEventType } from "@/types"; -import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; +import { NextJsWebhookHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; export class SaleorAsyncWebhook extends SaleorWebhook { readonly event: AsyncWebhookEventType; protected readonly eventType = "async" as const; - constructor( - /** - * Omit new required fields and make them optional. Validate in constructor. - * In 0.35.0 remove old fields - */ - configuration: Omit, "event" | "query"> & { - /** - * @deprecated - use `event` instead. Will be removed in 0.35.0 - */ - asyncEvent?: AsyncWebhookEventType; - event?: AsyncWebhookEventType; - query?: string | ASTNode; - } - ) { - if (!configuration.event && !configuration.asyncEvent) { - throw new Error("event or asyncEvent must be provided. asyncEvent is deprecated"); - } + constructor(configuration: WebhookConfig) { + super(configuration); - if (!configuration.query && !configuration.subscriptionQueryAst) { - throw new Error( - "query or subscriptionQueryAst must be provided. subscriptionQueryAst is deprecated" - ); - } - - super({ - ...configuration, - event: configuration.event! ?? configuration.asyncEvent!, - query: configuration.query! ?? configuration.subscriptionQueryAst!, - }); - - this.event = configuration.event! ?? configuration.asyncEvent!; - this.query = configuration.query! ?? configuration.subscriptionQueryAst!; + this.event = configuration.event; } - createHandler(handlerFn: NextWebhookApiHandler): NextApiHandler { + createHandler(handlerFn: NextJsWebhookHandler): NextApiHandler { return super.createHandler(handlerFn); } } diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts index d80f36ec..f0c95ea5 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.test.ts @@ -1,59 +1,90 @@ import { createMocks } from "node-mocks-http"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebhookError } from "@/handlers/shared"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; import { MockAPL } from "@/test-utils/mock-apl"; -import { processSaleorWebhook } from "./process-saleor-webhook"; import { SaleorSyncWebhook } from "./saleor-sync-webhook"; +import { NextJsWebhookHandler } from "./saleor-webhook"; -describe("SaleorSyncWebhook", () => { - const mockApl = new MockAPL(); - - it("Provides type-safe response builder in the context", async () => { - vi.mock("./process-saleor-webhook"); - - vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({ - baseUrl: "example.com", - event: "CHECKOUT_CALCULATE_TAXES", - payload: { data: "test_payload" }, - schemaVersion: 3.19, - authData: { - token: mockApl.mockToken, - jwks: mockApl.mockJwks, - saleorApiUrl: mockApl.workingSaleorApiUrl, - appId: mockApl.mockAppId, - }, - })); - - const { req, res } = createMocks({ - method: "POST", - headers: { - host: "some-saleor-host.cloud", - "x-forwarded-proto": "https", - "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", - }, +describe("Next.js SaleorSyncWebhook", () => { + const mockAPL = new MockAPL(); + const baseUrl = "http://saleor-app.com"; + const validSyncWebhookConfiguration = { + apl: mockAPL, + webhookPath: "api/webhooks/checkout-calculate-taxes", + event: "CHECKOUT_CALCULATE_TAXES", + query: "subscription { event { ... on CheckoutCalculateTaxes { payload } } }", + name: "Webhook test name", + isActive: true, + } as const; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getWebhookManifest", () => { + it("should return full path to the webhook route based on given baseUrl", () => { + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + const manifest = saleorSyncWebhook.getWebhookManifest(baseUrl); + expect(manifest).toEqual( + expect.objectContaining({ + targetUrl: `${baseUrl}/${validSyncWebhookConfiguration.webhookPath}`, + }) + ); }); - const webhook = new SaleorSyncWebhook({ - apl: mockApl, - webhookPath: "/test", - event: "CHECKOUT_CALCULATE_TAXES", - query: "", - name: "Webhook test name", - isActive: true, + it("should return a valid manifest", () => { + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + expect(saleorSyncWebhook.getWebhookManifest(baseUrl)).toStrictEqual({ + syncEvents: ["CHECKOUT_CALCULATE_TAXES"], + isActive: validSyncWebhookConfiguration.isActive, + name: validSyncWebhookConfiguration.name, + targetUrl: `${baseUrl}/${validSyncWebhookConfiguration.webhookPath}`, + query: validSyncWebhookConfiguration.query, + }); }); + }); + + describe("createHandler", () => { + it("validates request before passing it to provided handler function with context including buildResponse", async () => { + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "ok", + context: { + baseUrl: "example.com", + event: "checkout_calculate_taxes", + payload: { data: "test_payload" }, + schemaVersion: 3.19, + authData: { + token: validSyncWebhookConfiguration.apl.mockToken, + jwks: validSyncWebhookConfiguration.apl.mockJwks, + saleorApiUrl: validSyncWebhookConfiguration.apl.workingSaleorApiUrl, + appId: validSyncWebhookConfiguration.apl.mockAppId, + }, + }, + }); - const handler = webhook.createHandler((_req, _res, ctx) => { - _res.send( - ctx.buildResponse({ - lines: [ - { - tax_rate: 8, - total_net_amount: 10, - total_gross_amount: 1.08, - }, - ], + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + const testHandler: NextJsWebhookHandler = vi.fn().mockImplementation((_req, res, ctx) => { + const responsePayload = ctx.buildResponse({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 1.08 }], + shipping_price_gross_amount: 2, + shipping_tax_rate: 8, + shipping_price_net_amount: 1, + }); + res.status(200).send(responsePayload); + }); + + const { req, res } = createMocks({ method: "POST" }); + const wrappedHandler = saleorSyncWebhook.createHandler(testHandler); + await wrappedHandler(req, res); + + expect(res.statusCode).toBe(200); + expect(testHandler).toBeCalledTimes(1); + expect(res._getData()).toEqual( + expect.objectContaining({ + lines: [{ tax_rate: 8, total_net_amount: 10, total_gross_amount: 1.08 }], shipping_price_gross_amount: 2, shipping_tax_rate: 8, shipping_price_net_amount: 1, @@ -61,21 +92,193 @@ describe("SaleorSyncWebhook", () => { ); }); - await handler(req, res); + it("prevents handler execution when validation fails", async () => { + const handler = vi.fn(); + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); - expect(res._getData()).toEqual( - expect.objectContaining({ - lines: [ - { - tax_rate: 8, - total_net_amount: 10, - total_gross_amount: 1.08, - }, - ], - shipping_price_gross_amount: 2, - shipping_tax_rate: 8, - shipping_price_net_amount: 1, - }) - ); + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: new Error("Test error"), + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(handler)(req, res); + + expect(handler).not.toHaveBeenCalled(); + }); + + describe("when validation throws WebhookError", () => { + it("calls onError and uses formatErrorResponse when provided", async () => { + const webhookError = new WebhookError("Test error", "OTHER"); + const formatErrorResponse = vi.fn().mockResolvedValue({ + code: 418, + body: "Custom response", + }); + + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...validSyncWebhookConfiguration, + onError: vi.fn(), + formatErrorResponse, + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(saleorSyncWebhook.onError).toHaveBeenCalledWith(webhookError, req); + expect(formatErrorResponse).toHaveBeenCalledWith(webhookError, req); + expect(res.statusCode).toBe(418); + expect(res._getData()).toBe("Custom response"); + }); + + it("calls onError and uses default JSON response when formatErrorResponse is not provided", async () => { + const webhookError = new WebhookError("Test error", "OTHER"); + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...validSyncWebhookConfiguration, + onError: vi.fn(), + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(saleorSyncWebhook.onError).toHaveBeenCalledWith(webhookError, req); + expect(res.statusCode).toBe(500); + expect(res._getJSONData()).toEqual({ + error: { type: "OTHER", message: "Test error" }, + }); + }); + + describe("WebhookError code mapping", () => { + it("should map OTHER error to 500 status code", async () => { + const webhookError = new WebhookError("Internal server error", "OTHER"); + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(500); + expect(res._getJSONData()).toEqual({ + error: { type: "OTHER", message: "Internal server error" }, + }); + }); + + it("should map MISSING_HOST_HEADER error to 400 status code", async () => { + const webhookError = new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(400); + expect(res._getJSONData()).toEqual({ + error: { type: "MISSING_HOST_HEADER", message: "Missing host header" }, + }); + }); + + it("should map NOT_REGISTERED error to 401 status code", async () => { + const webhookError = new WebhookError("Not registered", "NOT_REGISTERED"); + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(401); + expect(res._getJSONData()).toEqual({ + error: { type: "NOT_REGISTERED", message: "Not registered" }, + }); + }); + + it("should map WRONG_METHOD error to 405 status code", async () => { + const webhookError = new WebhookError("Wrong HTTP method", "WRONG_METHOD"); + const saleorSyncWebhook = new SaleorSyncWebhook(validSyncWebhookConfiguration); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: webhookError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(res.statusCode).toBe(405); + expect(res._getJSONData()).toEqual({ + error: { type: "WRONG_METHOD", message: "Wrong HTTP method" }, + }); + }); + }); + }); + + describe("when validation throws a generic Error", () => { + const genericError = new Error("Unexpected error"); + + it("calls onError and uses formatErrorResponse when provided", async () => { + const formatErrorResponse = vi.fn().mockResolvedValue({ + code: 500, + body: "Server error", + }); + + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...validSyncWebhookConfiguration, + onError: vi.fn(), + formatErrorResponse, + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: genericError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(saleorSyncWebhook.onError).toHaveBeenCalledWith(genericError, req); + expect(formatErrorResponse).toHaveBeenCalledWith(genericError, req); + expect(res.statusCode).toBe(500); + expect(res._getData()).toBe("Server error"); + }); + + it("calls onError and uses default text response when formatErrorResponse is not provided", async () => { + const saleorSyncWebhook = new SaleorSyncWebhook({ + ...validSyncWebhookConfiguration, + onError: vi.fn(), + }); + + vi.spyOn(SaleorWebhookValidator.prototype, "validateRequest").mockResolvedValue({ + result: "failure", + error: genericError, + }); + + const { req, res } = createMocks({ method: "POST" }); + await saleorSyncWebhook.createHandler(() => {})(req, res); + + expect(saleorSyncWebhook.onError).toHaveBeenCalledWith(genericError, req); + expect(res.statusCode).toBe(500); + expect(res._getData()).toBe("Unexpected error while handling request"); + }); + }); }); }); diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts index 9aaa216b..6ac8b003 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-sync-webhook.ts @@ -3,7 +3,7 @@ import { NextApiHandler } from "next"; import { buildSyncWebhookResponsePayload } from "@/handlers/shared/sync-webhook-response-builder"; import { SyncWebhookEventType } from "@/types"; -import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; +import { NextJsWebhookHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; type InjectedContext = { buildResponse: typeof buildSyncWebhookResponsePayload; @@ -28,7 +28,7 @@ export class SaleorSyncWebhook< } createHandler( - handlerFn: NextWebhookApiHandler< + handlerFn: NextJsWebhookHandler< TPayload, { buildResponse: typeof buildSyncWebhookResponsePayload; diff --git a/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts b/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts index 5a6a6fda..075ec113 100644 --- a/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts +++ b/src/handlers/platforms/next/saleor-webhooks/saleor-webhook.ts @@ -1,41 +1,21 @@ -import { ASTNode } from "graphql"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; -import { APL } from "@/APL"; import { createDebug } from "@/debug"; -import { gqlAstToString } from "@/gql-ast-to-string"; -import { WebhookContext, WebhookError, WebhookErrorCodeMap } from "@/handlers/shared/saleor-webhook"; -import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "@/types"; - import { - processSaleorWebhook, -} from "./process-saleor-webhook"; + GenericSaleorWebhook, + GenericWebhookConfig, +} from "@/handlers/shared/generic-saleor-webhook"; +import { WebhookContext } from "@/handlers/shared/saleor-webhook"; +import { AsyncWebhookEventType, SyncWebhookEventType } from "@/types"; + +import { NextJsAdapter } from "../platform-adapter"; const debug = createDebug("SaleorWebhook"); -export interface WebhookConfig { - name?: string; - webhookPath: string; - event: Event; - isActive?: boolean; - apl: APL; - onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; - formatErrorResponse?( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ): Promise<{ - code: number; - body: object | string; - }>; - query: string | ASTNode; - /** - * @deprecated will be removed in 0.35.0, use query field instead - */ - subscriptionQueryAst?: ASTNode; -} +export type WebhookConfig = + GenericWebhookConfig; -export type NextWebhookApiHandler = ( +export type NextJsWebhookHandler = ( req: NextApiRequest, res: NextApiResponse, ctx: WebhookContext & TExtras @@ -44,148 +24,25 @@ export type NextWebhookApiHandler = ( export abstract class SaleorWebhook< TPayload = unknown, TExtras extends Record = {} -> { - protected abstract eventType: "async" | "sync"; - - protected extraContext?: TExtras; - - name: string; - - webhookPath: string; - - query: string | ASTNode; - - event: AsyncWebhookEventType | SyncWebhookEventType; - - isActive?: boolean; - - apl: APL; - - onError: WebhookConfig["onError"]; - - formatErrorResponse: WebhookConfig["formatErrorResponse"]; - - protected constructor(configuration: WebhookConfig) { - const { - name, - webhookPath, - event, - query, - apl, - isActive = true, - subscriptionQueryAst, - } = configuration; - - this.name = name || `${event} webhook`; - /** - * Fallback subscriptionQueryAst to avoid breaking changes - * - * TODO Remove in 0.35.0 - */ - this.query = query ?? subscriptionQueryAst; - this.webhookPath = webhookPath; - this.event = event; - this.isActive = isActive; - this.apl = apl; - this.onError = configuration.onError; - this.formatErrorResponse = configuration.formatErrorResponse; - } - - private getTargetUrl(baseUrl: string) { - return new URL(this.webhookPath, baseUrl).href; - } - - /** - * Returns synchronous event manifest for this webhook. - * - * @param baseUrl Base URL used by your application - * @returns WebhookManifest - */ - getWebhookManifest(baseUrl: string): WebhookManifest { - const manifestBase: Omit = { - query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), - name: this.name, - targetUrl: this.getTargetUrl(baseUrl), - isActive: this.isActive, - }; - - switch (this.eventType) { - case "async": - return { - ...manifestBase, - asyncEvents: [this.event as AsyncWebhookEventType], - }; - case "sync": - return { - ...manifestBase, - syncEvents: [this.event as SyncWebhookEventType], - }; - default: { - throw new Error("Class extended incorrectly"); - } - } - } - +> extends GenericSaleorWebhook { /** * Wraps provided function, to ensure incoming request comes from registered Saleor instance. * Also provides additional `context` object containing typed payload and request properties. */ - createHandler(handlerFn: NextWebhookApiHandler): NextApiHandler { + createHandler(handlerFn: NextJsWebhookHandler): NextApiHandler { return async (req, res) => { - debug(`Handler for webhook ${this.name} called`); - - await processSaleorWebhook({ - req, - apl: this.apl, - allowedEvent: this.event, - }) - .then(async (context) => { - debug("Incoming request validated. Call handlerFn"); - - return handlerFn(req, res, { ...(this.extraContext ?? ({} as TExtras)), ...context }); - }) - .catch(async (e) => { - debug(`Unexpected error during processing the webhook ${this.name}`); - - if (e instanceof WebhookError) { - debug(`Validation error: ${e.message}`); + const adapter = new NextJsAdapter(req, res); + const prepareRequestResult = await super.prepareRequest(adapter); - if (this.onError) { - this.onError(e, req, res); - } - - if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); - - res.status(code).send(body); - - return; - } - - res.status(WebhookErrorCodeMap[e.errorType] || 400).send({ - error: { - type: e.errorType, - message: e.message, - }, - }); - return; - } - debug("Unexpected error: %O", e); - - if (this.onError) { - this.onError(e, req, res); - } - - if (this.formatErrorResponse) { - const { code, body } = await this.formatErrorResponse(e, req, res); - - res.status(code).send(body); - - return; - } + if (prepareRequestResult.result === "sendResponse") { + return prepareRequestResult.response; + } - res.status(500).end(); - }); + debug("Incoming request validated. Call handlerFn"); + return handlerFn(req, res, { + ...(this.extraContext ?? ({} as TExtras)), + ...prepareRequestResult.context, + }); }; } } diff --git a/src/handlers/shared/generic-adapter-use-case-types.ts b/src/handlers/shared/generic-adapter-use-case-types.ts new file mode 100644 index 00000000..26371b1f --- /dev/null +++ b/src/handlers/shared/generic-adapter-use-case-types.ts @@ -0,0 +1,48 @@ +export const HTTPMethod = { + GET: "GET", + POST: "POST", + PUT: "PUT", + PATH: "PATCH", + HEAD: "HEAD", + OPTIONS: "OPTIONS", + DELETE: "DELETE", +} as const; +export type HTTPMethod = typeof HTTPMethod[keyof typeof HTTPMethod]; + +/** Status code of the result, for most platforms it's mapped to HTTP status code + * however when request is not HTTP it can be mapped to something else */ +export type ResultStatusCodes = number; + +/** Shape of result that should be returned from use case + * that is then translated by adapter to a valid platform response */ +export type ActionHandlerResult = + | { + status: ResultStatusCodes; + body: Body; + bodyType: "json"; + } + | { + status: ResultStatusCodes; + body: string; + bodyType: "string"; + }; + +/** + * Interface for adapters that translate specific platform objects (e.g. Web API, Next.js) + * into a common interface that can be used in each handler use case + * */ +export interface PlatformAdapterInterface { + send(result: ActionHandlerResult): unknown; + getHeader(name: string): string | null; + getBody(): Promise; + getRawBody(): Promise; + getBaseUrl(): string; + method: HTTPMethod; + request: Request; +} + +/** Interfaces for use case handlers that encapsulate business logic + * (e.g. validating headers, checking HTTP method, etc. ) */ +export interface ActionHandlerInterface { + handleAction(...params: [unknown]): Promise>; +} diff --git a/src/handlers/shared/generic-saleor-webhook.ts b/src/handlers/shared/generic-saleor-webhook.ts new file mode 100644 index 00000000..82f15312 --- /dev/null +++ b/src/handlers/shared/generic-saleor-webhook.ts @@ -0,0 +1,200 @@ +import { ASTNode } from "graphql"; + +import { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { gqlAstToString } from "@/gql-ast-to-string"; +import { + WebhookContext, + WebhookError, + WebhookErrorCodeMap, +} from "@/handlers/shared/saleor-webhook"; +import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator"; +import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "@/types"; + +import { PlatformAdapterInterface } from "./generic-adapter-use-case-types"; +import { SaleorRequestProcessor } from "./saleor-request-processor"; + +const debug = createDebug("SaleorWebhook"); + +export interface GenericWebhookConfig< + RequestType, + Event = AsyncWebhookEventType | SyncWebhookEventType +> { + name?: string; + webhookPath: string; + event: Event; + isActive?: boolean; + apl: APL; + onError?(error: WebhookError | Error, request: RequestType): void; + formatErrorResponse?( + error: WebhookError | Error, + request: RequestType + ): Promise<{ + code: number; + body: string; + }>; + query: string | ASTNode; +} + +export abstract class GenericSaleorWebhook< + TRequestType, + TPayload = unknown, + TExtras extends Record = {} +> { + private webhookValidator = new SaleorWebhookValidator(); + + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + + name: string; + + webhookPath: string; + + query: string | ASTNode; + + event: AsyncWebhookEventType | SyncWebhookEventType; + + isActive?: boolean; + + apl: APL; + + onError: GenericWebhookConfig["onError"]; + + formatErrorResponse: GenericWebhookConfig["formatErrorResponse"]; + + protected constructor(configuration: GenericWebhookConfig) { + const { name, webhookPath, event, query, apl, isActive = true } = configuration; + + this.name = name || `${event} webhook`; + this.query = query; + this.webhookPath = webhookPath; + this.event = event; + this.isActive = isActive; + this.apl = apl; + this.onError = configuration.onError; + this.formatErrorResponse = configuration.formatErrorResponse; + } + + private getTargetUrl(baseUrl: string) { + return new URL(this.webhookPath, baseUrl).href; + } + + /** + * Returns synchronous event manifest for this webhook. + * + * @param baseUrl Base URL used by your application + * @returns WebhookManifest + */ + getWebhookManifest(baseUrl: string): WebhookManifest { + const manifestBase: Omit = { + query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), + name: this.name, + targetUrl: this.getTargetUrl(baseUrl), + isActive: this.isActive, + }; + + switch (this.eventType) { + case "async": + return { + ...manifestBase, + asyncEvents: [this.event as AsyncWebhookEventType], + }; + case "sync": + return { + ...manifestBase, + syncEvents: [this.event as SyncWebhookEventType], + }; + default: { + throw new Error("Class extended incorrectly"); + } + } + } + + protected async prepareRequest>( + adapter: Adapter + ): Promise< + | { result: "callHandler"; context: WebhookContext } + | { result: "sendResponse"; response: ReturnType } + > { + const requestProcessor = new SaleorRequestProcessor(adapter); + const validationResult = await this.webhookValidator.validateRequest({ + allowedEvent: this.event, + apl: this.apl, + adapter, + requestProcessor, + }); + + if (validationResult.result === "ok") { + return { result: "callHandler", context: validationResult.context }; + } + + const { error } = validationResult; + + debug(`Unexpected error during processing the webhook ${this.name}`); + + if (error instanceof WebhookError) { + debug(`Validation error: ${error.message}`); + + if (this.onError) { + this.onError(error, adapter.request); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(error, adapter.request); + + return { + result: "sendResponse", + response: adapter.send({ + status: code, + body, + bodyType: "string", + }) as ReturnType, + }; + } + + return { + result: "sendResponse", + response: adapter.send({ + bodyType: "json", + body: { + error: { + type: error.errorType, + message: error.message, + }, + }, + status: WebhookErrorCodeMap[error.errorType] || 400, + }) as ReturnType, + }; + } + debug("Unexpected error: %O", error); + + if (this.onError) { + this.onError(error, adapter.request); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(error, adapter.request); + + return { + result: "sendResponse", + response: adapter.send({ + status: code, + body, + bodyType: "string", + }) as ReturnType, + }; + } + + return { + result: "sendResponse", + response: adapter.send({ + status: 500, + body: "Unexpected error while handling request", + bodyType: "string", + }) as ReturnType, + }; + } + + abstract createHandler(handlerFn: unknown): unknown; +} diff --git a/src/handlers/shared/index.ts b/src/handlers/shared/index.ts index 35973296..1904f954 100644 --- a/src/handlers/shared/index.ts +++ b/src/handlers/shared/index.ts @@ -1,5 +1,5 @@ export * from "./create-app-register-handler-types"; +export * from "./generic-adapter-use-case-types"; export * from "./protected-handler"; export * from "./saleor-webhook"; export * from "./sync-webhook-response-builder"; -export * from "./validate-allow-saleor-urls" diff --git a/src/handlers/shared/protected-action-validator.test.ts b/src/handlers/shared/protected-action-validator.test.ts new file mode 100644 index 00000000..7b917c0b --- /dev/null +++ b/src/handlers/shared/protected-action-validator.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@/const"; +import { MockAdapter } from "@/test-utils/mock-adapter"; +import { MockAPL } from "@/test-utils/mock-apl"; +import * as extractUserModule from "@/util/extract-user-from-jwt"; +import * as verifyJWTModule from "@/verify-jwt"; + +import { ProtectedActionValidator } from "./protected-action-validator"; + +describe("ProtectedActionValidator", () => { + let mockAPL: MockAPL; + let mockAdapter: MockAdapter; + + beforeEach(() => { + mockAPL = new MockAPL(); + mockAdapter = new MockAdapter({ + baseUrl: "https://example.com", + mockHeaders: { + [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, + [SALEOR_AUTHORIZATION_BEARER_HEADER]: mockAPL.mockToken + } + }); + + + vi.spyOn(verifyJWTModule, "verifyJWT").mockResolvedValue(undefined); + vi.spyOn(extractUserModule, "extractUserFromJwt").mockReturnValue({ + email: "user@domain.com", + userPermissions: [] + }); + }); + + describe("validateRequest", () => { + it("should validate request successfully when all parameters are valid", async () => { + const validator = new ProtectedActionValidator(mockAdapter); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result.result).toBe("ok"); + expect(result.value).toEqual({ + baseUrl: "https://example.com", + authData: { + token: mockAPL.mockToken, + saleorApiUrl: mockAPL.workingSaleorApiUrl, + appId: mockAPL.mockAppId, + jwks: mockAPL.mockJwks, + }, + user: { email: "user@domain.com", userPermissions: [] }, + }); + }); + + it("should fail validation when baseUrl is missing", async () => { + mockAdapter.config.baseUrl = ""; + const validator = new ProtectedActionValidator(mockAdapter); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing host header", + }, + }); + }); + + it("should fail validation when saleor-api-url header is missing", async () => { + const adapter = new MockAdapter({ + baseUrl: "https://example.com", + mockHeaders: { + // SALEOR_API_URL_HEADER is missing + [SALEOR_AUTHORIZATION_BEARER_HEADER]: mockAPL.mockToken + } + }); + const validator = new ProtectedActionValidator(adapter); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing saleor-api-url header", + }, + }); + }); + + it("should fail validation when authorization-bearer header is missing", async () => { + const adapter = new MockAdapter({ + baseUrl: "https://example.com", + mockHeaders: { + [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, + // SALEOR_AUTHORIZATION_BEARER_HEADER is missing + } + }); + const validator = new ProtectedActionValidator(adapter); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing authorization-bearer header", + }, + }); + }); + + it("should fail validation when APL has no auth data for the API URL", async () => { + mockAPL.workingSaleorApiUrl = ""; + const validator = new ProtectedActionValidator(mockAdapter); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 401, + body: "Validation error: Can't find auth data for saleorApiUrl https://example.com/graphql/. Please register the application", + }, + }); + }); + + it("should fail validation when JWT verification fails", async () => { + const validator = new ProtectedActionValidator(mockAdapter); + + vi.spyOn(verifyJWTModule, "verifyJWT").mockRejectedValue(new Error("JWT verification failed")); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 401, + body: "Validation error: JWT verification failed", + }, + }); + }); + + it("should fail validation when user extraction from JWT fails", async () => { + const validator = new ProtectedActionValidator(mockAdapter); + + vi.spyOn(extractUserModule, "extractUserFromJwt").mockImplementation(() => { + throw new Error("Failed to extract user"); + }); + + const result = await validator.validateRequest({ + apl: mockAPL, + requiredPermissions: ["MANAGE_APPS"], + }); + + expect(result).toEqual({ + result: "failure", + value: { + bodyType: "string", + status: 500, + body: "Unexpected error: parsing user from JWT", + }, + }); + }); + }); +}); diff --git a/src/handlers/shared/protected-action-validator.ts b/src/handlers/shared/protected-action-validator.ts new file mode 100644 index 00000000..856f0321 --- /dev/null +++ b/src/handlers/shared/protected-action-validator.ts @@ -0,0 +1,197 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { APL, AuthData } from "@/APL"; +import { createDebug } from "@/debug"; +import { getOtelTracer } from "@/open-telemetry"; +import { Permission } from "@/types"; +import { extractUserFromJwt, TokenUserPayload } from "@/util/extract-user-from-jwt"; +import { verifyJWT } from "@/verify-jwt"; + +import { ActionHandlerResult, PlatformAdapterInterface } from "./generic-adapter-use-case-types"; +import { SaleorRequestProcessor } from "./saleor-request-processor"; + +export type ProtectedHandlerConfig = { + apl: APL; + requiredPermissions?: Permission[]; +}; + +export type ProtectedHandlerContext = { + baseUrl: string; + authData: AuthData; + user: TokenUserPayload; +}; + +export type ValidationResult = + | { result: "failure"; value: ActionHandlerResult } + | { result: "ok"; value: ProtectedHandlerContext }; + +export class ProtectedActionValidator { + private debug = createDebug("ProtectedActionValidator"); + + private tracer = getOtelTracer(); + + constructor(private adapter: PlatformAdapterInterface) {} + + private requestProcessor = new SaleorRequestProcessor(this.adapter); + + /** Validates received request if it's legitimate webhook request from Saleor + * returns ActionHandlerResult if request is invalid and must be terminated early + * */ + async validateRequest(config: ProtectedHandlerConfig): Promise { + return this.tracer.startActiveSpan( + "processSaleorProtectedHandler", + { + kind: SpanKind.INTERNAL, + attributes: { + requiredPermissions: config.requiredPermissions, + }, + }, + async (span): Promise => { + this.debug("Request processing started"); + + const { saleorApiUrl, authorizationBearer: token } = + this.requestProcessor.getSaleorHeaders(); + + const baseUrl = this.adapter.getBaseUrl(); + + span.setAttribute("saleorApiUrl", saleorApiUrl ?? ""); + + if (!baseUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing host header", + }) + .end(); + + this.debug("Missing host header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing host header", + }, + }; + } + + if (!saleorApiUrl) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing saleor-api-url header", + }) + .end(); + + this.debug("Missing saleor-api-url header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing saleor-api-url header", + }, + }; + } + + if (!token) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Missing authorization-bearer header", + }) + .end(); + + this.debug("Missing authorization-bearer header"); + + return { + result: "failure", + value: { + bodyType: "string", + status: 400, + body: "Validation error: Missing authorization-bearer header", + }, + }; + } + + // Check if API URL has been registered in the APL + const authData = await config.apl.get(saleorApiUrl); + + if (!authData) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "APL didn't found auth data for API URL", + }) + .end(); + + this.debug("APL didn't found auth data for API URL %s", saleorApiUrl); + + return { + result: "failure", + value: { + bodyType: "string", + status: 401, + body: `Validation error: Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`, + }, + }; + } + + try { + await verifyJWT({ + appId: authData.appId, + token, + saleorApiUrl, + requiredPermissions: config.requiredPermissions, + }); + } catch (e) { + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "JWT verification failed", + }) + .end(); + + return { + result: "failure", + value: { + bodyType: "string", + status: 401, + body: "Validation error: JWT verification failed", + }, + }; + } + + try { + const userJwtPayload = extractUserFromJwt(token); + + span.end(); + return { + result: "ok", + value: { + baseUrl, + authData, + user: userJwtPayload, + }, + }; + } catch (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "Error parsing user from JWT", + }); + span.end(); + return { + result: "failure", + value: { + bodyType: "string", + status: 500, + body: "Unexpected error: parsing user from JWT", + }, + }; + } + } + ); + } +} diff --git a/src/handlers/shared/saleor-request-processor.test.ts b/src/handlers/shared/saleor-request-processor.test.ts new file mode 100644 index 00000000..49c35fc4 --- /dev/null +++ b/src/handlers/shared/saleor-request-processor.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + SALEOR_API_URL_HEADER, + SALEOR_AUTHORIZATION_BEARER_HEADER, + SALEOR_EVENT_HEADER, + SALEOR_SCHEMA_VERSION, + SALEOR_SIGNATURE_HEADER, +} from "@/const"; +import { MockAdapter } from "@/test-utils/mock-adapter"; + +import { SaleorRequestProcessor } from "./saleor-request-processor"; + +describe("SaleorRequestProcessor", () => { + let mockAdapter: MockAdapter; + + beforeEach(() => { + mockAdapter = new MockAdapter({}); + }); + + describe("withMethod", () => { + it("returns null when method is allowed", () => { + mockAdapter.method = "POST"; + const middleware = new SaleorRequestProcessor(mockAdapter); + + const result = middleware.withMethod(["POST", "GET"]); + + expect(result).toBeNull(); + }); + + it("returns 405 error when method is not allowed", () => { + mockAdapter.method = "POST"; + const middleware = new SaleorRequestProcessor(mockAdapter); + + const result = middleware.withMethod(["GET"]); + + expect(result).toEqual({ + body: "Method not allowed", + bodyType: "string", + status: 405, + }); + }); + }); + + describe("withSaleorApiUrlPresent", () => { + it("returns null when saleor-api-url header is present", () => { + const adapter = new MockAdapter({ + mockHeaders: { + [SALEOR_API_URL_HEADER]: "https://api.saleor.io", + }, + }); + const middleware = new SaleorRequestProcessor(adapter); + + const result = middleware.withSaleorApiUrlPresent(); + + expect(result).toBeNull(); + }); + + it("returns 400 error when saleor api url is missing", () => { + const middleware = new SaleorRequestProcessor(mockAdapter); + + const result = middleware.withSaleorApiUrlPresent(); + + expect(result).toEqual({ + body: "Missing saleor-api-url header", + bodyType: "string", + status: 400, + }); + }); + }); + + describe("getSaleorHeaders", () => { + it("correctly transforms header values", () => { + const adapter = new MockAdapter({ + mockHeaders: { + [SALEOR_AUTHORIZATION_BEARER_HEADER]: "bearer-token", + [SALEOR_SIGNATURE_HEADER]: "signature-value", + [SALEOR_EVENT_HEADER]: "event-name", + [SALEOR_API_URL_HEADER]: "https://api.saleor.io", + [SALEOR_SCHEMA_VERSION]: "3.20", + }, + }); + const middleware = new SaleorRequestProcessor(adapter); + + const result = middleware.getSaleorHeaders(); + + expect(result).toEqual({ + authorizationBearer: "bearer-token", + signature: "signature-value", + event: "event-name", + saleorApiUrl: "https://api.saleor.io", + schemaVersion: 3.2, + }); + }); + + it("handles missing values correctly - returns undefined", () => { + const middleware = new SaleorRequestProcessor(mockAdapter); + + const result = middleware.getSaleorHeaders(); + + expect(result).toEqual({ + authorizationBearer: undefined, + signature: undefined, + event: undefined, + saleorApiUrl: undefined, + schemaVersion: undefined, + }); + }); + + it("handlers partially missing headers", () => { + const adapter = new MockAdapter({ + mockHeaders: { + // SALEOR_AUTHORIZATION_BEARER_HEADER missing + [SALEOR_SIGNATURE_HEADER]: "signature-value", + [SALEOR_EVENT_HEADER]: "event-name", + [SALEOR_API_URL_HEADER]: "https://api.saleor.io", + // SALEOR_SCHEMA_VERSION missing + }, + }); + const middleware = new SaleorRequestProcessor(adapter); + + const result = middleware.getSaleorHeaders(); + + expect(result).toEqual({ + authorizationBearer: undefined, + signature: "signature-value", + event: "event-name", + saleorApiUrl: "https://api.saleor.io", + schemaVersion: undefined, + }); + }); + }); +}); diff --git a/src/handlers/shared/saleor-request-processor.ts b/src/handlers/shared/saleor-request-processor.ts new file mode 100644 index 00000000..e945df95 --- /dev/null +++ b/src/handlers/shared/saleor-request-processor.ts @@ -0,0 +1,57 @@ +import { + SALEOR_API_URL_HEADER, + SALEOR_AUTHORIZATION_BEARER_HEADER, + SALEOR_EVENT_HEADER, + SALEOR_SCHEMA_VERSION, + SALEOR_SIGNATURE_HEADER, +} from "@/const"; + +import { HTTPMethod, PlatformAdapterInterface } from "./generic-adapter-use-case-types"; + +export class SaleorRequestProcessor { + constructor(private adapter: PlatformAdapterInterface) {} + + withMethod(methods: HTTPMethod[]) { + if (!methods.includes(this.adapter.method)) { + return { + body: "Method not allowed", + bodyType: "string", + status: 405, + } as const; + } + + return null; + } + + withSaleorApiUrlPresent() { + const { saleorApiUrl } = this.getSaleorHeaders(); + + if (!saleorApiUrl) { + return { + body: "Missing saleor-api-url header", + bodyType: "string", + status: 400, + } as const; + } + + return null; + } + + private toStringOrUndefined = (value: string | string[] | undefined | null) => + value ? value.toString() : undefined; + + private toFloatOrNull = (value: string | string[] | undefined | null) => + value ? parseFloat(value.toString()) : undefined; + + getSaleorHeaders() { + return { + authorizationBearer: this.toStringOrUndefined( + this.adapter.getHeader(SALEOR_AUTHORIZATION_BEARER_HEADER) + ), + signature: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_SIGNATURE_HEADER)), + event: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_EVENT_HEADER)), + saleorApiUrl: this.toStringOrUndefined(this.adapter.getHeader(SALEOR_API_URL_HEADER)), + schemaVersion: this.toFloatOrNull(this.adapter.getHeader(SALEOR_SCHEMA_VERSION)), + }; + } +} diff --git a/src/handlers/shared/saleor-webhook-validator.test.ts b/src/handlers/shared/saleor-webhook-validator.test.ts new file mode 100644 index 00000000..2025fc40 --- /dev/null +++ b/src/handlers/shared/saleor-webhook-validator.test.ts @@ -0,0 +1,415 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AuthData } from "@/APL"; +import * as fetchRemoteJwksModule from "@/fetch-remote-jwks"; +import { MockAdapter } from "@/test-utils/mock-adapter"; +import { MockAPL } from "@/test-utils/mock-apl"; +import * as verifySignatureModule from "@/verify-signature"; + +import { SaleorRequestProcessor } from "./saleor-request-processor"; +import { SaleorWebhookValidator } from "./saleor-webhook-validator"; + +vi.spyOn(verifySignatureModule, "verifySignatureFromApiUrl").mockImplementation( + async (domain, signature) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + } +); +vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockImplementation( + async (domain, signature) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + } +); + +describe("SaleorWebhookValidator", () => { + const mockAPL = new MockAPL(); + const validator = new SaleorWebhookValidator(); + let adapter: MockAdapter; + let requestProcessor: SaleorRequestProcessor; + + const validHeaders = { + saleorApiUrl: mockAPL.workingSaleorApiUrl, + event: "product_updated", + schemaVersion: 3.2, + signature: "mocked_signature", + authorizationBearer: "mocked_bearer", + domain: "example.com", + }; + + beforeEach(() => { + adapter = new MockAdapter({ baseUrl: "https://example-app.com/api" }); + requestProcessor = new SaleorRequestProcessor(adapter); + }); + + it("Throws error on non-POST request method", async () => { + vi.spyOn(adapter, "method", "get").mockReturnValue("GET"); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Wrong request method, only POST allowed", + errorType: "WRONG_METHOD", + }, + }); + }); + + it("Throws error on missing base URL", async () => { + vi.spyOn(adapter, "getBaseUrl").mockReturnValue(""); + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Missing host header", + errorType: "MISSING_HOST_HEADER", + }, + }); + }); + + it("Throws error on missing api url header", async () => { + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + // @ts-expect-error testing missing saleorApiUrl + saleorApiUrl: null, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Missing saleor-api-url header", + errorType: "MISSING_API_URL_HEADER", + }, + }); + }); + + it("Throws error on missing event header", async () => { + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({ + // @ts-expect-error testing missing event + event: null, + signature: "mocked_signature", + saleorApiUrl: mockAPL.workingSaleorApiUrl, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Missing saleor-event header", + errorType: "MISSING_EVENT_HEADER", + }, + }); + }); + + it("Throws error on mismatched event header", async () => { + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + event: "different_event", + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Wrong incoming request event: different_event. Expected: product_updated", + errorType: "WRONG_EVENT", + }, + }); + }); + + it("Throws error on missing signature header", async () => { + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + // @ts-expect-error testing missing signature + signature: null, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Missing saleor-signature header", + errorType: "MISSING_SIGNATURE_HEADER", + }, + }); + }); + + it("Throws error on missing request body", async () => { + vi.spyOn(adapter, "getRawBody").mockResolvedValue(""); + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Missing request body", + errorType: "MISSING_REQUEST_BODY", + }, + }); + }); + + it("Throws error on unparsable request body", async () => { + vi.spyOn(adapter, "getRawBody").mockResolvedValue("{ "); // broken JSON + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: "Request body can't be parsed", + errorType: "CANT_BE_PARSED", + }, + }); + }); + + it("Throws error on unregistered app", async () => { + const unregisteredApiUrl = "https://not-registered.example.com/graphql/"; + + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({ + ...validHeaders, + saleorApiUrl: unregisteredApiUrl, + }); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + message: `Can't find auth data for ${unregisteredApiUrl}. Please register the application`, + errorType: "NOT_REGISTERED", + }, + }); + }); + + // TODO: This should be required + it("Fallbacks to null if version is missing in payload", async () => { + vi.spyOn(adapter, "getRawBody").mockResolvedValue(JSON.stringify({})); + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + schemaVersion: null, + }), + }); + }); + + it("Returns success on valid request with signature passing validation against jwks in auth data", async () => { + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + baseUrl: "https://example-app.com/api", + event: "product_updated", + payload: {}, + schemaVersion: null, + }), + }); + }); + + describe("JWKS re-try validation", () => { + const authDataNoJwks = { + token: mockAPL.mockToken, + saleorApiUrl: mockAPL.workingSaleorApiUrl, + appId: mockAPL.mockAppId, + jwks: null, // Simulate missing JWKS in initial auth data + } as unknown as AuthData; // We're testing missing jwks, so this is fine + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders); + }); + + it("Triggers JWKS refresh when initial auth data contains empty JWKS", async () => { + vi.spyOn(mockAPL, "get").mockResolvedValue(authDataNoJwks); + vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValueOnce(undefined); + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks"); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + baseUrl: "https://example-app.com/api", + event: "product_updated", + payload: {}, + schemaVersion: null, + }), + }); + + expect(mockAPL.set).toHaveBeenCalledWith( + expect.objectContaining({ + jwks: "new-jwks", + }) + ); + expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith( + authDataNoJwks.saleorApiUrl + ); + // it's called only once because jwks was missing initially, so we skipped first validation + expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(1); + }); + + it("Triggers JWKS refresh when token signature doesn't match JWKS from existing auth data", async () => { + vi.spyOn(verifySignatureModule, "verifySignatureWithJwks") + .mockRejectedValueOnce(new Error("Signature verification failed")) // First: reject validation due to stale jwks + .mockResolvedValueOnce(undefined); // Second: resolve validation because jwks is now correct + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks"); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "ok", + context: expect.objectContaining({ + baseUrl: "https://example-app.com/api", + event: "product_updated", + payload: {}, + schemaVersion: null, + }), + }); + + expect(mockAPL.set).toHaveBeenCalledWith( + expect.objectContaining({ + jwks: "new-jwks", + }) + ); + expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith( + authDataNoJwks.saleorApiUrl + ); + expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(2); + }); + + it("Returns an error when new JWKS cannot be fetched", async () => { + vi.spyOn(mockAPL, "get").mockResolvedValue(authDataNoJwks); + vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockRejectedValue( + new Error("Initial verification failed") + ); + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockRejectedValue( + new Error("JWKS fetch failed") + ); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + errorType: "SIGNATURE_VERIFICATION_FAILED", + message: "Fetching remote JWKS failed", + }, + }); + expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledTimes(1); + }); + + it("Returns an error when signature doesn't match JWKS after re-fetching it", async () => { + vi.spyOn(verifySignatureModule, "verifySignatureWithJwks") + .mockRejectedValueOnce(new Error("Stale JWKS")) // First attempt fails + .mockRejectedValueOnce(new Error("Fresh JWKS mismatch")); // Second attempt fails + vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("{}"); + + const result = await validator.validateRequest({ + allowedEvent: "PRODUCT_UPDATED", + apl: mockAPL, + adapter, + requestProcessor, + }); + + expect(result).toMatchObject({ + result: "failure", + error: { + errorType: "SIGNATURE_VERIFICATION_FAILED", + message: "Request signature check failed", + }, + }); + + expect(verifySignatureModule.verifySignatureWithJwks).toHaveBeenCalledTimes(2); + expect(fetchRemoteJwksModule.fetchRemoteJwks).toHaveBeenCalledWith( + authDataNoJwks.saleorApiUrl + ); + }); + }); +}); diff --git a/src/handlers/shared/saleor-webhook-validator.ts b/src/handlers/shared/saleor-webhook-validator.ts new file mode 100644 index 00000000..e223f6e1 --- /dev/null +++ b/src/handlers/shared/saleor-webhook-validator.ts @@ -0,0 +1,217 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { APL } from "@/APL"; +import { createDebug } from "@/debug"; +import { fetchRemoteJwks } from "@/fetch-remote-jwks"; +import { getOtelTracer } from "@/open-telemetry"; +import { parseSchemaVersion } from "@/util"; +import { verifySignatureWithJwks } from "@/verify-signature"; + +import { PlatformAdapterInterface } from "./generic-adapter-use-case-types"; +import { SaleorRequestProcessor } from "./saleor-request-processor"; +import { WebhookContext, WebhookError } from "./saleor-webhook"; + +type WebhookValidationResult = + | { result: "ok"; context: WebhookContext } + | { result: "failure"; error: WebhookError | Error }; + +export class SaleorWebhookValidator { + private debug = createDebug("processProtectedHandler"); + + private tracer = getOtelTracer(); + + async validateRequest(config: { + allowedEvent: string; + apl: APL; + adapter: PlatformAdapterInterface; + requestProcessor: SaleorRequestProcessor; + }): Promise> { + try { + const context = await this.validateRequestOrThrowError(config); + + return { + result: "ok", + context, + }; + } catch (err) { + return { + result: "failure", + error: err as WebhookError | Error, + }; + } + } + + private async validateRequestOrThrowError({ + allowedEvent, + apl, + adapter, + requestProcessor, + }: { + allowedEvent: string; + apl: APL; + adapter: PlatformAdapterInterface; + requestProcessor: SaleorRequestProcessor; + }): Promise> { + return this.tracer.startActiveSpan( + "processSaleorWebhook", + { + kind: SpanKind.INTERNAL, + attributes: { + allowedEvent, + }, + }, + async (span) => { + try { + this.debug("Request processing started"); + + if (adapter.method !== "POST") { + this.debug("Wrong HTTP method"); + throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); + } + + const { event, signature, saleorApiUrl } = requestProcessor.getSaleorHeaders(); + const baseUrl = adapter.getBaseUrl(); + + if (!baseUrl) { + this.debug("Missing host header"); + throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + this.debug("Missing saleor-api-url header"); + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!event) { + this.debug("Missing saleor-event header"); + throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); + } + + const expected = allowedEvent.toLowerCase(); + + if (event !== expected) { + this.debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); + + throw new WebhookError( + `Wrong incoming request event: ${event}. Expected: ${expected}`, + "WRONG_EVENT" + ); + } + + if (!signature) { + this.debug("No signature"); + + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); + } + + const rawBody = await adapter.getRawBody(); + if (!rawBody) { + this.debug("Missing request body"); + + throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); + } + + let parsedBody: unknown & { version?: string | null }; + + try { + parsedBody = JSON.parse(rawBody); + } catch { + this.debug("Request body cannot be parsed"); + + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); + } + + let parsedSchemaVersion: number | null = null; + + try { + parsedSchemaVersion = parseSchemaVersion(parsedBody.version); + } catch { + this.debug("Schema version cannot be parsed"); + } + + /** + * Verify if the app is properly installed for given Saleor API URL + */ + const authData = await apl.get(saleorApiUrl); + + if (!authData) { + this.debug("APL didn't found auth data for %s", saleorApiUrl); + + throw new WebhookError( + `Can't find auth data for ${saleorApiUrl}. Please register the application`, + "NOT_REGISTERED" + ); + } + + /** + * Verify payload signature + */ + try { + this.debug("Will verify signature with JWKS saved in AuthData"); + + if (!authData.jwks) { + throw new Error("JWKS not found in AuthData"); + } + + await verifySignatureWithJwks(authData.jwks, signature, rawBody); + } catch { + this.debug("Request signature check failed. Refresh the JWKS cache and check again"); + + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + this.debug(e); + + throw new WebhookError( + "Fetching remote JWKS failed", + "SIGNATURE_VERIFICATION_FAILED" + ); + }); + + this.debug("Fetched refreshed JWKS"); + + try { + this.debug( + "Second attempt to validate the signature JWKS, using fresh tokens from the API" + ); + + await verifySignatureWithJwks(newJwks, signature, rawBody); + + this.debug("Verification successful - update JWKS in the AuthData"); + + await apl.set({ ...authData, jwks: newJwks }); + } catch { + this.debug("Second attempt also ended with validation error. Reject the webhook"); + + throw new WebhookError( + "Request signature check failed", + "SIGNATURE_VERIFICATION_FAILED" + ); + } + } + + span.setStatus({ + code: SpanStatusCode.OK, + }); + + return { + baseUrl, + event, + payload: parsedBody as TPayload, + authData, + schemaVersion: parsedSchemaVersion, + }; + } catch (err) { + const message = (err as Error)?.message ?? "Unknown error"; + + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + throw err; + } finally { + span.end(); + } + } + ); + } +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts deleted file mode 100644 index 561669bd..00000000 --- a/src/middleware/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { withReqResDebugging } from "./middleware-debug"; -export * from "./with-auth-token-required"; -export * from "./with-base-url"; -export * from "./with-jwt-verified"; -export * from "./with-registered-saleor-domain-header"; -export * from "./with-saleor-app"; -export * from "./with-saleor-event-match"; -export * from "./with-webhook-signature-verified"; diff --git a/src/middleware/middleware-debug.test.ts b/src/middleware/middleware-debug.test.ts deleted file mode 100644 index 463c75eb..00000000 --- a/src/middleware/middleware-debug.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Handler, Request } from "retes"; -import { Response } from "retes/response"; -import { describe, expect, it, vi } from "vitest"; - -import { withReqResDebugging } from "./middleware-debug"; - -describe("withReqResDebugging", () => { - it("Logs request and response to debug", async () => { - const mockDebug = vi.fn(); - const handler: Handler = async () => Response.OK("Tested handler is ok"); - const wrappedHandler = withReqResDebugging(() => mockDebug)(handler); - - const mockReqBody = JSON.stringify({ foo: "bar" }); - - await wrappedHandler({ rawBody: mockReqBody } as Request); - - expect(mockDebug).toHaveBeenNthCalledWith(1, "Called with request %j", { - rawBody: mockReqBody, - }); - - expect(mockDebug).toHaveBeenNthCalledWith(2, "Responded with response %j", { - body: "Tested handler is ok", - headers: {}, - status: 200, - }); - }); -}); diff --git a/src/middleware/middleware-debug.ts b/src/middleware/middleware-debug.ts deleted file mode 100644 index a44ea0ed..00000000 --- a/src/middleware/middleware-debug.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Middleware } from "retes"; - -import { createDebug } from "../debug"; - -export const createMiddlewareDebug = (middleware: string) => - createDebug(`Middleware:${middleware}`); - -type DebugFactory = (handlerName: string) => (msg: string, ...args: unknown[]) => void; - -/** - * Experimental. Needs to be tested and evaluated on security - */ -export const withReqResDebugging = - (debugFactory: DebugFactory = createMiddlewareDebug): Middleware => - (handler) => - async (request) => { - const debug = debugFactory(handler.name); - - debug("Called with request %j", request); - - const response = await handler(request); - - debug("Responded with response %j", response); - - return response; - }; diff --git a/src/middleware/with-auth-token-required.test.ts b/src/middleware/with-auth-token-required.test.ts deleted file mode 100644 index 881c8f52..00000000 --- a/src/middleware/with-auth-token-required.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Handler, Request } from "retes"; -import { Response } from "retes/response"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { withAuthTokenRequired } from "./with-auth-token-required"; - -const getMockSuccessResponse = async () => Response.OK({}); - -describe("middleware", () => { - describe("withAuthTokenRequired", () => { - let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); - - beforeEach(() => { - mockHandlerFn = vi.fn(getMockSuccessResponse); - }); - - it("Pass request when request has token prop", async () => { - const mockRequest = { - context: {}, - headers: {}, - params: { - auth_token: "token", - }, - } as unknown as Request; - - const response = await withAuthTokenRequired(mockHandlerFn)(mockRequest); - - expect(response.status).toBe(200); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - - it("Reject request without auth token", async () => { - const mockRequest = { - context: {}, - headers: {}, - params: {}, - } as unknown as Request; - - const response = await withAuthTokenRequired(mockHandlerFn)(mockRequest); - expect(response.status).eq(400); - expect(mockHandlerFn).toBeCalledTimes(0); - }); - }); -}); diff --git a/src/middleware/with-auth-token-required.ts b/src/middleware/with-auth-token-required.ts deleted file mode 100644 index c54e9a28..00000000 --- a/src/middleware/with-auth-token-required.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Middleware } from "retes"; -import { Response } from "retes/response"; - -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withAuthTokenRequired"); - -export const withAuthTokenRequired: Middleware = (handler) => async (request) => { - debug("Middleware called"); - - const authToken = request.params.auth_token; - - if (!authToken) { - debug("Found missing authToken param"); - - return Response.BadRequest({ - success: false, - message: "Missing auth token.", - }); - } - - return handler(request); -}; diff --git a/src/middleware/with-base-url.test.ts b/src/middleware/with-base-url.test.ts deleted file mode 100644 index a0f4ea4b..00000000 --- a/src/middleware/with-base-url.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Handler, Request } from "retes"; -import { Response } from "retes/response"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { withBaseURL } from "./with-base-url"; - -const getMockEmptyResponse = async () => ({} as Response); - -describe("middleware", () => { - describe("withBaseURL", () => { - let mockHandlerFn: Handler = vi.fn(getMockEmptyResponse); - - beforeEach(() => { - mockHandlerFn = vi.fn(); - }); - - it("Adds base URL from request header to context and calls handler", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https", - }, - } as unknown as Request; - - await withBaseURL(mockHandlerFn)(mockRequest); - - expect(mockRequest.context.baseURL).toBe("https://my-saleor-env.saleor.cloud"); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - - it("supports multiple comma-delimited values in x-forwarded-proto", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https,http", - }, - } as unknown as Request; - - await withBaseURL(mockHandlerFn)(mockRequest); - - expect(mockRequest.context.baseURL).toBe("https://my-saleor-env.saleor.cloud"); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - - it("supports multiple x-forwarded-proto headers", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": ["http", "ftp,https"], - }, - } as unknown as Request; - - await withBaseURL(mockHandlerFn)(mockRequest); - - expect(mockRequest.context.baseURL).toBe("https://my-saleor-env.saleor.cloud"); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/middleware/with-base-url.ts b/src/middleware/with-base-url.ts deleted file mode 100644 index 93f93d7a..00000000 --- a/src/middleware/with-base-url.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Middleware } from "retes"; - -import { getBaseUrl } from "../headers"; -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withBaseURL"); - -export const withBaseURL: Middleware = (handler) => async (request) => { - const { host, "x-forwarded-proto": protocol = "http" } = request.headers; - - debug("Middleware called with host: %s, protocol %s", host, protocol); - - request.context ??= {}; - request.context.baseURL = getBaseUrl(request.headers); - - debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL); - - return handler(request); -}; diff --git a/src/middleware/with-jwt-verified.ts b/src/middleware/with-jwt-verified.ts deleted file mode 100644 index 16ff6e96..00000000 --- a/src/middleware/with-jwt-verified.ts +++ /dev/null @@ -1,112 +0,0 @@ -import * as jose from "jose"; -import type { Middleware, Request } from "retes"; -import { Response } from "retes/response"; - -import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "../const"; -import { getSaleorHeaders } from "../headers"; -import { getJwksUrlFromSaleorApiUrl } from "../urls"; -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withJWTVerified"); - -export interface DashboardTokenPayload extends jose.JWTPayload { - app: string; -} - -const ERROR_MESSAGE = "JWT verification failed:"; - -export const withJWTVerified = - (getAppId: (request: Request) => Promise): Middleware => - (handler) => - async (request) => { - const { authorizationBearer: token, saleorApiUrl } = getSaleorHeaders(request.headers); - - debug("Middleware called with apiUrl: \"%s\"", saleorApiUrl); - - if (typeof token !== "string") { - debug("Middleware with empty token, will response with Bad Request", token); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_AUTHORIZATION_BEARER_HEADER} header.`, - }); - } - - debug("Middleware called with token starting with: \"%s\"", token.substring(0, 4)); - - if (saleorApiUrl === undefined) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_API_URL_HEADER} header.`, - }); - } - - let tokenClaims: DashboardTokenPayload; - - try { - tokenClaims = jose.decodeJwt(token as string) as DashboardTokenPayload; - debug("Token Claims decoded from jwt"); - } catch (e) { - debug("Token Claims could not be decoded from JWT, will respond with Bad Request"); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Could not decode authorization token.`, - }); - } - - let appId: string | undefined; - - try { - appId = await getAppId(request); - - debug("Resolved App ID from request to be: %s", appId); - } catch (error) { - debug("App ID could not be resolved from request, will respond with Internal Server Error"); - - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} Could not obtain the app ID.`, - }); - } - - if (!appId) { - debug("Resolved App ID to be empty value"); - - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} No value for app ID.`, - }); - } - - if (tokenClaims.app !== appId) { - debug( - "Resolved App ID value from token to be different than in request, will respond with Bad Request" - ); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Token's app property is different than app ID.`, - }); - } - - try { - debug("Trying to create JWKS"); - - const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrlFromSaleorApiUrl(saleorApiUrl))); - debug("Trying to compare JWKS with token"); - await jose.jwtVerify(token, JWKS); - } catch (e) { - debug("Failure: %s", e); - debug("Will return with Bad Request"); - - console.error(e); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} JWT signature verification failed.`, - }); - } - - return handler(request); - }; diff --git a/src/middleware/with-registered-saleor-domain-header.test.ts b/src/middleware/with-registered-saleor-domain-header.test.ts deleted file mode 100644 index 707d820c..00000000 --- a/src/middleware/with-registered-saleor-domain-header.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Handler, Request } from "retes"; -import { Response } from "retes/response"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { SALEOR_API_URL_HEADER } from "../const"; -import { SaleorApp } from "../saleor-app"; -import { MockAPL } from "../test-utils/mock-apl"; -import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header"; -import { withSaleorApp } from "./with-saleor-app"; - -const getMockSuccessResponse = async () => Response.OK({}); - -describe("middleware", () => { - describe("withRegisteredSaleorDomainHeader", () => { - let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); - - const mockAPL = new MockAPL(); - - beforeEach(() => { - mockHandlerFn = vi.fn(getMockSuccessResponse); - }); - - it("Pass request when auth data are available", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https", - [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, - }, - } as unknown as Request; - - const app = new SaleorApp({ - apl: mockAPL, - }); - - const response = await withSaleorApp(app)(withRegisteredSaleorDomainHeader(mockHandlerFn))( - mockRequest - ); - - expect(response.status).toBe(200); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - - it("Reject request when auth data are not available", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https", - [SALEOR_API_URL_HEADER]: "https://not-registered.example.com/graphql/", - }, - } as unknown as Request; - - const app = new SaleorApp({ - apl: mockAPL, - }); - - const response = await withSaleorApp(app)(withRegisteredSaleorDomainHeader(mockHandlerFn))( - mockRequest - ); - expect(response.status).eq(403); - expect(mockHandlerFn).toBeCalledTimes(0); - }); - - it("Throws if SaleorApp not found in context", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https", - [SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl, - }, - } as unknown as Request; - - const response = await withRegisteredSaleorDomainHeader(mockHandlerFn)(mockRequest); - - expect(response.status).eq(500); - }); - }); -}); diff --git a/src/middleware/with-registered-saleor-domain-header.ts b/src/middleware/with-registered-saleor-domain-header.ts deleted file mode 100644 index 7551e2c4..00000000 --- a/src/middleware/with-registered-saleor-domain-header.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Middleware } from "retes"; -import { Response } from "retes/response"; - -import { getSaleorHeaders } from "../headers"; -import { createMiddlewareDebug } from "./middleware-debug"; -import { getSaleorAppFromRequest } from "./with-saleor-app"; - -const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader"); - -export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => { - const { saleorApiUrl } = getSaleorHeaders(request.headers); - - if (!saleorApiUrl) { - return Response.BadRequest({ - success: false, - message: "saleorApiUrl header missing.", - }); - } - - debug("Middleware called with saleorApiUrl: \"%s\"", saleorApiUrl); - - const saleorApp = getSaleorAppFromRequest(request); - - if (!saleorApp) { - console.error( - "SaleorApp not found in request context. Ensure your API handler is wrapped with withSaleorApp middleware" - ); - - return Response.InternalServerError({ - success: false, - message: "SaleorApp is misconfigured", - }); - } - - const authData = await saleorApp?.apl.get(saleorApiUrl); - - if (!authData) { - debug("Auth was not found in APL, will respond with Forbidden status"); - - return Response.Forbidden({ - success: false, - message: `Saleor: ${saleorApiUrl} not registered.`, - }); - } - - return handler(request); -}; diff --git a/src/middleware/with-saleor-app.test.ts b/src/middleware/with-saleor-app.test.ts deleted file mode 100644 index c291f77e..00000000 --- a/src/middleware/with-saleor-app.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Request } from "retes"; -import { Response } from "retes/response"; -import { describe, expect, it } from "vitest"; - -import { FileAPL } from "../APL"; -import { SaleorApp } from "../saleor-app"; -import { withSaleorApp } from "./with-saleor-app"; - -describe("middleware", () => { - describe("withSaleorApp", () => { - it("Adds SaleorApp instance to request context", async () => { - const mockRequest = { - context: {}, - } as unknown as Request; - - await withSaleorApp(new SaleorApp({ apl: new FileAPL() }))((request) => { - expect(request.context.saleorApp).toBeDefined(); - - return Response.OK(""); - })(mockRequest); - }); - }); -}); diff --git a/src/middleware/with-saleor-app.ts b/src/middleware/with-saleor-app.ts deleted file mode 100644 index b7decc30..00000000 --- a/src/middleware/with-saleor-app.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Middleware, Request } from "retes"; - -import { SaleorApp } from "../saleor-app"; -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withSaleorApp"); - -export const withSaleorApp = - (saleorApp: SaleorApp): Middleware => - (handler) => - async (request) => { - debug("Middleware called"); - - request.context ??= {}; - request.context.saleorApp = saleorApp; - - return handler(request); - }; - -export const getSaleorAppFromRequest = (request: Request): SaleorApp | undefined => - request.context?.saleorApp; diff --git a/src/middleware/with-saleor-event-match.test.ts b/src/middleware/with-saleor-event-match.test.ts deleted file mode 100644 index 29eabb23..00000000 --- a/src/middleware/with-saleor-event-match.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Handler, Request } from "retes"; -import { Response } from "retes/response"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { SALEOR_EVENT_HEADER } from "../const"; -import { withSaleorEventMatch } from "./with-saleor-event-match"; - -const getMockSuccessResponse = async () => Response.OK({}); - -describe("middleware", () => { - describe("withSaleorEventMatch", () => { - let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); - - beforeEach(() => { - mockHandlerFn = vi.fn(getMockSuccessResponse); - }); - - it("Pass request when request has expected event header", async () => { - const eventName = "product-updated"; - const mockRequest = { - context: {}, - headers: { - [SALEOR_EVENT_HEADER]: eventName, - }, - } as unknown as Request; - - const response = await withSaleorEventMatch(eventName)(mockHandlerFn)(mockRequest); - - expect(response.status).toBe(200); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - - it("Reject request when event header is not present", async () => { - const mockRequest = { - context: {}, - headers: {}, - } as unknown as Request; - - const response = await withSaleorEventMatch("product-updated")(mockHandlerFn)(mockRequest); - expect(response.status).eq(400); - expect(mockHandlerFn).toBeCalledTimes(0); - }); - - it("Reject request when event header does not match", async () => { - const mockRequest = { - context: {}, - headers: { - [SALEOR_EVENT_HEADER]: "wrong-event", - }, - } as unknown as Request; - - const response = await withSaleorEventMatch("product-updated")(mockHandlerFn)(mockRequest); - expect(response.status).eq(400); - expect(mockHandlerFn).toBeCalledTimes(0); - }); - }); -}); diff --git a/src/middleware/with-saleor-event-match.ts b/src/middleware/with-saleor-event-match.ts deleted file mode 100644 index 6cdae58e..00000000 --- a/src/middleware/with-saleor-event-match.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Middleware } from "retes"; -import { Response } from "retes/response"; - -import { getSaleorHeaders } from "../headers"; -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withSaleorEventMatch"); - -export const withSaleorEventMatch = - (expectedEvent: `${Lowercase}`): Middleware => - (handler) => - async (request) => { - const { event } = getSaleorHeaders(request.headers); - - debug("Middleware called with even header: \"%s\"", event); - - if (event !== expectedEvent) { - debug( - "Event from header (%s) doesnt match expected (%s). Will respond with Bad Request", - event, - expectedEvent - ); - - return Response.BadRequest({ - success: false, - message: `Invalid Saleor event. Expecting ${expectedEvent}.`, - }); - } - - return handler(request); - }; diff --git a/src/middleware/with-webhook-signature-verified.ts b/src/middleware/with-webhook-signature-verified.ts deleted file mode 100644 index 516eea6e..00000000 --- a/src/middleware/with-webhook-signature-verified.ts +++ /dev/null @@ -1,80 +0,0 @@ -import crypto from "crypto"; -import { Middleware } from "retes"; -import { Response } from "retes/response"; - -import { SALEOR_API_URL_HEADER, SALEOR_SIGNATURE_HEADER } from "../const"; -import { getSaleorHeaders } from "../headers"; -import { verifySignatureFromApiUrl } from "../verify-signature"; -import { createMiddlewareDebug } from "./middleware-debug"; - -const debug = createMiddlewareDebug("withWebhookSignatureVerified"); - -const ERROR_MESSAGE = "Webhook signature verification failed:"; - -/** - * TODO: Add test - */ -export const withWebhookSignatureVerified = - (secretKey: string | undefined = undefined): Middleware => - (handler) => - async (request) => { - debug("Middleware executing start"); - - if (request.rawBody === undefined) { - debug("Request rawBody was not found, will return Internal Server Error"); - - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} Request payload already parsed.`, - }); - } - - const { signature: payloadSignature, saleorApiUrl } = getSaleorHeaders(request.headers); - - if (!payloadSignature) { - debug("Signature header was not found"); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_SIGNATURE_HEADER} header.`, - }); - } - - if (!saleorApiUrl) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_API_URL_HEADER} header.`, - }); - } - - if (secretKey !== undefined) { - const calculatedSignature = crypto - .createHmac("sha256", secretKey) - .update(request.rawBody) - .digest("hex"); - - debug("Signature was calculated"); - - if (calculatedSignature !== payloadSignature) { - debug("Calculated signature doesn't match payload signature, will return Bad Request"); - - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Verification using secret key has failed.`, - }); - } - } else { - try { - await verifySignatureFromApiUrl(saleorApiUrl, payloadSignature, request.rawBody); - debug("JWKS verified"); - } catch { - debug("JWKS verification failed"); - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Verification using public key has failed.`, - }); - } - } - - return handler(request); - }; diff --git a/src/test-utils/mock-adapter.ts b/src/test-utils/mock-adapter.ts new file mode 100644 index 00000000..725aca00 --- /dev/null +++ b/src/test-utils/mock-adapter.ts @@ -0,0 +1,36 @@ +import { HTTPMethod, PlatformAdapterInterface } from "@/handlers/shared/generic-adapter-use-case-types"; + +export class MockAdapter implements PlatformAdapterInterface { + constructor(public config: { mockHeaders?: Record; baseUrl?: string }) { + } + + send() { + throw new Error("Method not implemented."); + } + + getHeader(key: string) { + if (this.config.mockHeaders && key in this.config.mockHeaders) { + return this.config.mockHeaders[key]; + } + return null; + } + + async getBody(): Promise { + return null; + } + + async getRawBody(): Promise { + return "{}"; + } + + getBaseUrl() { + if (this.config.baseUrl) { + return this.config.baseUrl; + } + return ""; + } + + method: HTTPMethod = "POST"; + + request = {}; +} diff --git a/src/test-utils/mock-apl.ts b/src/test-utils/mock-apl.ts index 6cde6d5f..a60c3181 100644 --- a/src/test-utils/mock-apl.ts +++ b/src/test-utils/mock-apl.ts @@ -40,7 +40,7 @@ export class MockAPL implements APL { return this.resolveDomainFromApiUrl(this.workingSaleorApiUrl); } - async get(saleorApiUrl: string) { + async get(saleorApiUrl: string): Promise { if (saleorApiUrl === this.workingSaleorApiUrl) { return { token: this.mockToken, diff --git a/tsup.config.ts b/tsup.config.ts index 337cd7d8..fc399ee9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,9 +17,6 @@ export default defineConfig({ "settings-manager/index": "src/settings-manager/index.ts", "handlers/shared/index": "src/handlers/shared/index.ts", - // Deprecated - "middleware/index": "src/middleware/index.ts", - // Mapped exports "handlers/next/index": "src/handlers/platforms/next/index.ts", },