Add sync webhook (#202)

* Change process-async-saleor-webhook to process-saleor-webhook and add better debug logs

* Change processWebhook name

* wip

* Add base class structure

* refactor

* add sync payload builder

* wip

* wip

* Add some missing webhook responses

* Fix tests for Async version

* Fix tests

* Add MockAPL test util

* Refactor tests to use MockAPL

* Add test to sync webhook

* Restore legacy subscriptionQueryAst field for compatibility

* CR Fixes - TS type & event -> eventType

* Changelog, docs and remove old payment webhooks

* Apply suggestions from code review

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

* Update src/handlers/next/saleor-webhooks/process-saleor-webhook.ts

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

---------

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
Lukasz Ostrowski 2023-03-06 15:01:22 +01:00 committed by GitHub
parent bc6e68db84
commit 9420209a2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 812 additions and 447 deletions

View file

@ -0,0 +1,15 @@
---
"@saleor/app-sdk": minor
---
Add SaleorSyncWebhook class
Now app-sdk support both `new SaleorAsyncWebhook()` and `new SaleorSyncWebhook()`.
Changes:
Constructor field `subscriptionQueryAst?: ASTNode` in `SaleorAsyncWebhook` has been deprecated.
Use `query` field instead (`query: ASTNode | string`)
Constructor field `asyncEvent` has been deprecated. Use `event` instead

View file

@ -58,7 +58,8 @@
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
"@typescript-eslint/no-misused-promises": ["error"], "@typescript-eslint/no-misused-promises": ["error"],
"@typescript-eslint/no-floating-promises": ["error"], "@typescript-eslint/no-floating-promises": ["error"],
"class-methods-use-this": "off" "class-methods-use-this": "off",
"no-new": "off"
}, },
"settings": { "settings": {
"import/parsers": { "import/parsers": {

View file

@ -1,182 +0,0 @@
# Saleor Async Webhook
Apps are usually connected via webhooks - one App sends a HTTP request to another App, informing about some event or requesting some action to be performed.
To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request.
To avoid boilerplate, App SDK provides utility that abstracts connection details, allowing developers to focus on business logic.
Note - this utility works for Saleor Async Webhooks only. Support for Sync webhooks are not yet supported in SDK, but you can write your sync webhook handler
from scratch.
## Creating async webhook with SaleorAsyncWebhook
### Creating webhook handler configuration
To use SaleorAsyncWebhook utility, first create a new instance. It can be created in your API handler file
```typescript
// pages/api/webhooks/order-created.ts
/**
* To be type safe, define payload from API. This should be imported from generated graphQL code
*/
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
/**
* Name of the webhook, not required
*/
name: "Order Created",
/**
* Relative path to the webhook, required
*/
webhookPath: "api/webhooks/order-created",
/**
* Event type, required
*/
asyncEvent: "ORDER_CREATED",
/**
* Decide if webhook created during app installation should be active or not
*/
isActive: true,
/**
* Provide APL, read more below
*/
apl: require("../lib/apl"),
/**
* Subscription query, telling Saleor what payload app expects
*/
query: `
subscription {
event {
... on OrderCreated {
order {
id
}
}
}
}
`,
/**
* Optional
*
* Read internal errors
*/
onError(error: WebhookError | Error) {
// Can be used to e.g. trace errors
sentry.captureError(error);
},
/**
* Optional
* Allows to set custom error response. If not provided, default mapping and message will be responsed
* if Webhook validation fails
*/
async formatErrorResponse(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
) {
return {
code: 400,
body: "My custom response",
};
},
});
```
- Check available events [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events)
- [Read more about APLs](./apl.md)
- [Subscription query documentation](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads)
You can consider created `orderCreatedWebhook` a center point of your webhook configuration. Now, you need to create a handler and add it to manifest.
### Extending app manifest
Webhooks are created in Saleor when the App is installed. Saleor uses [AppManifest](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) to get information about webhooks to create.
`SaleorAsyncWebhook` utility can generate this manifest:
```typescript
// pages/api/manifest
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { orderCreatedWebhook } from "./order-created.ts";
export default createManifestHandler({
manifestFactory({ appBaseUrl }) {
return {
/**
* Add one or more webhook manifests.
*/
webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)],
// ...rest of your App's manifest
};
},
});
```
Now, try to read your manifest, in default Next.js config it will be `GET localhost:3000/api/manifest`. You should see webhook configuration as part of manifest response.
### Creating webhook domain logic
Now, let's create a handler that will process webhook data. Let's back to handler file `pages/api/webhooks/order-created.ts`.
```typescript
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
// ... your configuration
});
export default orderCreatedWebhook.createHandler((req, res, context) => {
const { baseUrl, event, payload, authData } = context;
console.log(payload.id); // type is inferred
// Perform some domain logic
// End with status 200
return res.status(200).end();
});
```
### query vs subscriptionQueryAst
Subscription query can be specified using plain string or as `ASTNode` object created by `gql` tag.
If your project does not use any code generation for GraphQL operations, use the string. In case you are using [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which we highly recommend, you should pass a subscription as GraphQL ASTNode:
```typescript
/**
* Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file.
*/
export const ExampleProductUpdatedSubscription = gql`
${ProductUpdatedWebhookPayload}
subscription ExampleProductUpdated {
event {
fragment
ProductUpdatedWebhookPayload
on
ProductUpdated {
product {
id
name
}
}
}
}
`;
export const productUpdatedWebhook = new SaleorAsyncWebhook<ProductUpdatedWebhookPayloadFragment>({
name: "Example product updated webhook",
webhookPath: "api/webhooks/saleor/product-updated",
asyncEvent: "PRODUCT_UPDATED",
apl: saleorApp.apl,
subscriptionQueryAst: ExampleProductUpdatedSubscription,
});
```

305
docs/saleor-webhook.md Normal file
View file

@ -0,0 +1,305 @@
# Saleor Webhook utilities
Apps are usually connected via webhooks - one App sends an HTTP request to another App, informing about some event or requesting some action to be performed.
To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request.
The App SDK provides a utility that abstracts connection details and auth, allowing developers to focus on business logic.
Depending on the type of the webhook, you can choose one of the classes:
- `SaleorAsyncWebhook`
- `SaleorSyncWebhook`
## Common configuration
Both `SaleorSyncWebhook` and `SaleorAsyncWebhook` contain similar API with little differences.
### Constructing Webhook instance
In Next.js pages create a page, e.g. `pages/api/webhooks/order-created.ts`. We recommend to keep webhook type in file name, which will be resolved by Next.js to route path.
```typescript
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>(options);
```
For `SaleorSyncWebhook` it will be similar. Create e.g. `order-calculate-taxes.ts` page and create a new instance:
```typescript
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type CalculateTaxedPayload = {
// taxes payload from subscription
};
export const orderCalculateTaxesWebhook = new SaleorSyncWebhook<OrderPayload>(options);
```
### Configuring Webhook instance
`options` in constructor must be specified. Here are all options:
```typescript
type Options = {
/**
* Additional webhook name, optional.
*/
name?: string;
/**
* Path to webhook. Should represent relative path from base app URL. In Next.js it will start with `api/`, e.g. `api/webhooks/order-created`.
*/
webhookPath: string;
/**
* Valid Async or Sync webhook. Constructor is statically typed so only valid Sync/Async webhooks will be allowed
*/
event: Event;
/**
* Should event be active after installation, enabled by default
*/
isActive?: boolean;
/**
* APL instance - see docs/apl/md
*/
apl: APL;
/**
* Optional callback that allows to inject custom error handling and take control of the response
*/
onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void;
/**
* Optional callback that allows to format error message. Useful to control how much details should be returned in the response
*/
formatErrorResponse?(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
): Promise<{
code: number;
body: object | string;
}>;
/**
* Required subscription query. Can be raw GraphQL string or the instance of query wrapped in `gql` tags
*/
query: string | ASTNode;
};
```
#### Configuration examples
##### SaleorAsyncWebhook configuration example
```typescript
// pages/api/webhooks/order-created.ts
/**
* To be type-safe, define payload from API. This should be imported from generated GraphQL code
*/
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
name: "Order Created",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
isActive: true,
apl: require("../lib/apl"),
query: `
subscription {
event {
... on OrderCreated {
order {
id
}
}
}
}
`,
onError(error: WebhookError | Error) {
// Can be used to e.g. trace errors
sentry.captureError(error);
},
async formatErrorResponse(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
) {
return {
code: 400,
body: "My custom response",
};
},
});
```
##### SyncAsyncWebhook configuration example
```typescript
// pages/api/webhooks/order-created.ts
/**
* To be type safe, define payload from API. This should be imported from generated graphQL code
*/
type Payload = {
taxBase: {
currency: string;
};
};
export const orderCalculateTaxesWebhook = new SaleorAsyncWebhook<Payload>({
name: "Order Calculate Taxes",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CALCULATE_TAXES",
isActive: true,
apl: require("../lib/apl"),
query: `
subscription {
event {
... on CalculateTaxes {
taxBase {
currency
}
}
}
}
`,
onError(error: WebhookError | Error) {
sentry.captureError(error);
},
async formatErrorResponse(
error: WebhookError | Error,
req: NextApiRequest,
res: NextApiResponse
) {
return {
code: 400,
body: "My custom response",
};
},
});
```
### Resources
- Check available events [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events)
- [Read more about APLs](./apl.md)
- [Subscription query documentation](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads)
### Extending app manifest
Webhooks are created in Saleor when the App is installed. Saleor uses [AppManifest](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) to get information about webhooks to create.
`SaleorSyncWebhook` & `SaleorAsyncWebhook` utility can generate this manifest:
```typescript
// pages/api/manifest
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { orderCreatedWebhook } from "./order-created.ts";
export default createManifestHandler({
manifestFactory({ appBaseUrl }) {
return {
/**
* Add one or more webhook manifests.
*/
webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)],
// ...rest of your App's manifest
};
},
});
```
Now, try to read your manifest, in default Next.js config it will be `GET localhost:3000/api/manifest`. You should see webhook configuration as part of manifest response.
### Creating webhook domain logic
Now, let's create a handler that will process webhook data. Let's back to handler file `pages/api/webhooks/order-created.ts`.
```typescript
type OrderPayload = {
id: string;
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderPayload>({
// ... your configuration
});
/**
* Handler has to be a default export so the Next.js will be able to use it
*/
export default orderCreatedWebhook.createHandler((req, res, context) => {
const { baseUrl, event, payload, authData } = context;
console.log(payload.id); // type is inferred
// Perform some domain logic
// End with status 200
return res.status(200).end();
});
```
### Typed sync webhook response
Sync webhooks need to return response to Saleor, so operation can be completed. To achieve that, `SaleorAsyncWebhook` injects additional context field `buildResponse`.
It infers even from constructor and provides typed factory:
```typescript
const webhook = new SaleorAsyncWebhook({ event: "ORDER_CALCULATE_TAXES" /* ... rest of config */ });
orderCreatedWebhook.createHandler((req, res, context) => {
return res.status(200).send(
context.buildResponse({
// Fields are typed here
})
);
});
```
### query vs subscriptionQueryAst
Subscription query can be specified using plain string or as `ASTNode` object created by `gql` tag.
If your project does not use any code generation for GraphQL operations, use the string. In case you are using [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which we highly recommend, you should pass a subscription as GraphQL ASTNode:
```typescript
/**
* Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file.
*/
export const ExampleProductUpdatedSubscription = gql`
${ProductUpdatedWebhookPayload}
subscription ExampleProductUpdated {
event {
fragment
ProductUpdatedWebhookPayload
on
ProductUpdated {
product {
id
name
}
}
}
}
`;
export const productUpdatedWebhook = new SaleorAsyncWebhook<ProductUpdatedWebhookPayloadFragment>({
name: "Example product updated webhook",
webhookPath: "api/webhooks/saleor/product-updated",
event: "PRODUCT_UPDATED",
apl: saleorApp.apl,
query: ExampleProductUpdatedSubscription, // Or use plain string
});
```

View file

@ -67,7 +67,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"tsm": "^2.2.2", "tsm": "^2.2.2",
"tsup": "^6.2.3", "tsup": "^6.2.3",
"typescript": "^4.8.2", "typescript": "^4.9.5",
"vi-fetch": "^0.8.0", "vi-fetch": "^0.8.0",
"vite": "^4.0.4", "vite": "^4.0.4",
"vitest": "^0.28.1" "vitest": "^0.28.1"

View file

@ -40,7 +40,7 @@ specifiers:
retes: ^0.33.0 retes: ^0.33.0
tsm: ^2.2.2 tsm: ^2.2.2
tsup: ^6.2.3 tsup: ^6.2.3
typescript: ^4.8.2 typescript: ^4.9.5
uuid: ^8.3.2 uuid: ^8.3.2
vi-fetch: ^0.8.0 vi-fetch: ^0.8.0
vite: ^4.0.4 vite: ^4.0.4
@ -65,8 +65,8 @@ devDependencies:
'@types/react': 18.0.21 '@types/react': 18.0.21
'@types/react-dom': 18.0.6 '@types/react-dom': 18.0.6
'@types/uuid': 8.3.4 '@types/uuid': 8.3.4
'@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera '@typescript-eslint/eslint-plugin': 5.36.1_fl7tjqljox3oqt6nqaer2gdi5a
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
'@vitejs/plugin-react': 3.0.1_vite@4.0.4 '@vitejs/plugin-react': 3.0.1_vite@4.0.4
'@vitest/coverage-c8': 0.27.2_jsdom@20.0.3 '@vitest/coverage-c8': 0.27.2_jsdom@20.0.3
clean-publish: 4.0.1 clean-publish: 4.0.1
@ -88,8 +88,8 @@ devDependencies:
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0_react@18.2.0 react-dom: 18.2.0_react@18.2.0
tsm: 2.2.2 tsm: 2.2.2
tsup: 6.2.3_typescript@4.8.2 tsup: 6.2.3_typescript@4.9.5
typescript: 4.8.2 typescript: 4.9.5
vi-fetch: 0.8.0 vi-fetch: 0.8.0
vite: 4.0.4_@types+node@18.7.15 vite: 4.0.4_@types+node@18.7.15
vitest: 0.28.1_jsdom@20.0.3 vitest: 0.28.1_jsdom@20.0.3
@ -1145,7 +1145,7 @@ packages:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
dev: true dev: true
/@typescript-eslint/eslint-plugin/5.36.1_lbwfnm54o3pmr3ypeqp3btnera: /@typescript-eslint/eslint-plugin/5.36.1_fl7tjqljox3oqt6nqaer2gdi5a:
resolution: {integrity: sha512-iC40UK8q1tMepSDwiLbTbMXKDxzNy+4TfPWgIL661Ym0sD42vRcQU93IsZIrmi+x292DBr60UI/gSwfdVYexCA==} resolution: {integrity: sha512-iC40UK8q1tMepSDwiLbTbMXKDxzNy+4TfPWgIL661Ym0sD42vRcQU93IsZIrmi+x292DBr60UI/gSwfdVYexCA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
@ -1156,23 +1156,23 @@ packages:
typescript: typescript:
optional: true optional: true
dependencies: dependencies:
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
'@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/scope-manager': 5.36.1
'@typescript-eslint/type-utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/type-utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
'@typescript-eslint/utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
debug: 4.3.4 debug: 4.3.4
eslint: 8.23.0 eslint: 8.23.0
functional-red-black-tree: 1.0.1 functional-red-black-tree: 1.0.1
ignore: 5.2.0 ignore: 5.2.0
regexpp: 3.2.0 regexpp: 3.2.0
semver: 7.3.7 semver: 7.3.7
tsutils: 3.21.0_typescript@4.8.2 tsutils: 3.21.0_typescript@4.9.5
typescript: 4.8.2 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
/@typescript-eslint/parser/5.36.1_yqf6kl63nyoq5megxukfnom5rm: /@typescript-eslint/parser/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e:
resolution: {integrity: sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==} resolution: {integrity: sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
@ -1184,10 +1184,10 @@ packages:
dependencies: dependencies:
'@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/scope-manager': 5.36.1
'@typescript-eslint/types': 5.36.1 '@typescript-eslint/types': 5.36.1
'@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5
debug: 4.3.4 debug: 4.3.4
eslint: 8.23.0 eslint: 8.23.0
typescript: 4.8.2 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
@ -1200,7 +1200,7 @@ packages:
'@typescript-eslint/visitor-keys': 5.36.1 '@typescript-eslint/visitor-keys': 5.36.1
dev: true dev: true
/@typescript-eslint/type-utils/5.36.1_yqf6kl63nyoq5megxukfnom5rm: /@typescript-eslint/type-utils/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e:
resolution: {integrity: sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q==} resolution: {integrity: sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
@ -1210,12 +1210,12 @@ packages:
typescript: typescript:
optional: true optional: true
dependencies: dependencies:
'@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5
'@typescript-eslint/utils': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/utils': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
debug: 4.3.4 debug: 4.3.4
eslint: 8.23.0 eslint: 8.23.0
tsutils: 3.21.0_typescript@4.8.2 tsutils: 3.21.0_typescript@4.9.5
typescript: 4.8.2 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
@ -1225,7 +1225,7 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true dev: true
/@typescript-eslint/typescript-estree/5.36.1_typescript@4.8.2: /@typescript-eslint/typescript-estree/5.36.1_typescript@4.9.5:
resolution: {integrity: sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g==} resolution: {integrity: sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
@ -1240,13 +1240,13 @@ packages:
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
semver: 7.3.7 semver: 7.3.7
tsutils: 3.21.0_typescript@4.8.2 tsutils: 3.21.0_typescript@4.9.5
typescript: 4.8.2 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
/@typescript-eslint/utils/5.36.1_yqf6kl63nyoq5megxukfnom5rm: /@typescript-eslint/utils/5.36.1_yjwicu3lrm7zijfb2ermzlxu3e:
resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==} resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
@ -1255,7 +1255,7 @@ packages:
'@types/json-schema': 7.0.11 '@types/json-schema': 7.0.11
'@typescript-eslint/scope-manager': 5.36.1 '@typescript-eslint/scope-manager': 5.36.1
'@typescript-eslint/types': 5.36.1 '@typescript-eslint/types': 5.36.1
'@typescript-eslint/typescript-estree': 5.36.1_typescript@4.8.2 '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.9.5
eslint: 8.23.0 eslint: 8.23.0
eslint-scope: 5.1.1 eslint-scope: 5.1.1
eslint-utils: 3.0.0_eslint@8.23.0 eslint-utils: 3.0.0_eslint@8.23.0
@ -2720,7 +2720,7 @@ packages:
eslint-import-resolver-webpack: eslint-import-resolver-webpack:
optional: true optional: true
dependencies: dependencies:
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
debug: 3.2.7 debug: 3.2.7
eslint: 8.23.0 eslint: 8.23.0
eslint-import-resolver-node: 0.3.6 eslint-import-resolver-node: 0.3.6
@ -2739,7 +2739,7 @@ packages:
'@typescript-eslint/parser': '@typescript-eslint/parser':
optional: true optional: true
dependencies: dependencies:
'@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm '@typescript-eslint/parser': 5.36.1_yjwicu3lrm7zijfb2ermzlxu3e
array-includes: 3.1.5 array-includes: 3.1.5
array.prototype.flat: 1.3.0 array.prototype.flat: 1.3.0
debug: 2.6.9 debug: 2.6.9
@ -5306,7 +5306,7 @@ packages:
esbuild: 0.14.54 esbuild: 0.14.54
dev: true dev: true
/tsup/6.2.3_typescript@4.8.2: /tsup/6.2.3_typescript@4.9.5:
resolution: {integrity: sha512-J5Pu2Dx0E1wlpIEsVFv9ryzP1pZ1OYsJ2cBHZ7GrKteytNdzaSz5hmLX7/nAxtypq+jVkVvA79d7S83ETgHQ5w==} resolution: {integrity: sha512-J5Pu2Dx0E1wlpIEsVFv9ryzP1pZ1OYsJ2cBHZ7GrKteytNdzaSz5hmLX7/nAxtypq+jVkVvA79d7S83ETgHQ5w==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@ -5336,20 +5336,20 @@ packages:
source-map: 0.8.0-beta.0 source-map: 0.8.0-beta.0
sucrase: 3.25.0 sucrase: 3.25.0
tree-kill: 1.2.2 tree-kill: 1.2.2
typescript: 4.8.2 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- ts-node - ts-node
dev: true dev: true
/tsutils/3.21.0_typescript@4.8.2: /tsutils/3.21.0_typescript@4.9.5:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
peerDependencies: peerDependencies:
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
dependencies: dependencies:
tslib: 1.14.1 tslib: 1.14.1
typescript: 4.8.2 typescript: 4.9.5
dev: true dev: true
/tty-table/4.1.6: /tty-table/4.1.6:
@ -5418,8 +5418,8 @@ packages:
mime-types: 2.1.35 mime-types: 2.1.35
dev: true dev: true
/typescript/4.8.2: /typescript/4.9.5:
resolution: {integrity: sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==} resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
engines: {node: '>=4.2.0'} engines: {node: '>=4.2.0'}
hasBin: true hasBin: true
dev: true dev: true

View file

@ -1,7 +1,8 @@
import { createMocks } from "node-mocks-http"; import { createMocks } from "node-mocks-http";
import { describe, expect, it, Mock, vi } from "vitest"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { APL, AuthData } from "../../APL"; import { APL, AuthData } from "../../APL";
import { MockAPL } from "../../test-utils/mock-apl";
import { createAppRegisterHandler } from "./create-app-register-handler"; import { createAppRegisterHandler } from "./create-app-register-handler";
const mockJwksValue = "{}"; const mockJwksValue = "{}";
@ -15,20 +16,13 @@ vi.mock("../../fetch-remote-jwks", () => ({
fetchRemoteJwks: vi.fn().mockResolvedValue("{}"), // can't use var reference, due to hoisting fetchRemoteJwks: vi.fn().mockResolvedValue("{}"), // can't use var reference, due to hoisting
})); }));
const mockApl: APL = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn().mockImplementation(async () => ({
ready: true,
})),
isConfigured: vi.fn().mockImplementation(async () => ({
configured: true,
})),
};
describe("create-app-register-handler", () => { describe("create-app-register-handler", () => {
let mockApl: APL;
beforeEach(() => {
mockApl = new MockAPL();
});
it("Sets auth data for correct request", async () => { it("Sets auth data for correct request", async () => {
const { res, req } = createMocks({ const { res, req } = createMocks({
/** /**

View file

@ -3,12 +3,12 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { APL } from "../../APL"; import { APL } from "../../APL";
import { createDebug } from "../../debug"; import { createDebug } from "../../debug";
import { AppPermission } from "../../types"; import { AppPermission } from "../../types";
import { ProtectedHandlerContext } from "./process-async-saleor-webhook";
import { import {
processSaleorProtectedHandler, processSaleorProtectedHandler,
ProtectedHandlerError, ProtectedHandlerError,
SaleorProtectedHandlerError, SaleorProtectedHandlerError,
} from "./process-protected-handler"; } from "./process-protected-handler";
import { ProtectedHandlerContext } from "./saleor-webhooks/process-saleor-webhook";
const debug = createDebug("ProtectedHandler"); const debug = createDebug("ProtectedHandler");

View file

@ -2,4 +2,6 @@ export * from "./create-app-register-handler";
export * from "./create-manifest-handler"; export * from "./create-manifest-handler";
export * from "./create-protected-handler"; export * from "./create-protected-handler";
export * from "./process-protected-handler"; export * from "./process-protected-handler";
export * from "./saleor-async-webhook"; export * from "./saleor-webhooks/saleor-async-webhook";
export * from "./saleor-webhooks/saleor-sync-webhook";
export * from "./saleor-webhooks/sync-webhook-response-builder";

View file

@ -2,8 +2,8 @@ import { NextApiRequest } from "next/types";
import { createMocks } from "node-mocks-http"; import { createMocks } from "node-mocks-http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { APL } from "../../APL";
import { getAppId } from "../../get-app-id"; import { getAppId } from "../../get-app-id";
import { MockAPL } from "../../test-utils/mock-apl";
import { verifyJWT } from "../../verify-jwt"; import { verifyJWT } from "../../verify-jwt";
import { processSaleorProtectedHandler } from "./process-protected-handler"; import { processSaleorProtectedHandler } from "./process-protected-handler";
@ -25,32 +25,18 @@ vi.mock("./../../verify-jwt", () => ({
describe("processSaleorProtectedHandler", () => { describe("processSaleorProtectedHandler", () => {
let mockRequest: NextApiRequest; let mockRequest: NextApiRequest;
const mockAPL: APL = { let mockAPL: MockAPL;
get: async (saleorApiUrl: string) =>
saleorApiUrl === "https://example.com/graphql/"
? {
domain: "example.com",
token: "mock-token",
saleorApiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn(),
isConfigured: vi.fn(),
};
beforeEach(() => { beforeEach(() => {
mockAPL = new MockAPL();
// Create request method which passes all the tests // Create request method which passes all the tests
const { req } = createMocks({ const { req } = createMocks({
headers: { headers: {
host: "some-saleor-host.cloud", host: "some-saleor-host.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
"saleor-domain": "example.com", "saleor-domain": mockAPL.workingSaleorDomain,
"saleor-api-url": "https://example.com/graphql/", "saleor-api-url": mockAPL.workingSaleorApiUrl,
"saleor-event": "product_updated", "saleor-event": "product_updated",
"saleor-signature": "mocked_signature", "saleor-signature": "mocked_signature",
"authorization-bearer": validToken, "authorization-bearer": validToken,
@ -70,11 +56,11 @@ describe("processSaleorProtectedHandler", () => {
expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({ expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({
authData: { authData: {
domain: "example.com", domain: mockAPL.workingSaleorDomain,
token: "mock-token", token: mockAPL.mockToken,
saleorApiUrl: "https://example.com/graphql/", saleorApiUrl: mockAPL.workingSaleorApiUrl,
appId: "42", appId: mockAPL.mockAppId,
jwks: "{}", jwks: mockAPL.mockJwks,
}, },
baseUrl: "https://some-saleor-host.cloud", baseUrl: "https://some-saleor-host.cloud",
}); });

View file

@ -3,10 +3,10 @@ import { createMocks } from "node-mocks-http";
import rawBody from "raw-body"; import rawBody from "raw-body";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { APL } from "../../APL"; import { MockAPL } from "../../../test-utils/mock-apl";
import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook"; import { processSaleorWebhook } from "./process-saleor-webhook";
vi.mock("./../../verify-signature", () => ({ vi.mock("../../../verify-signature", () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
verifySignature: vi.fn((domain, signature) => { verifySignature: vi.fn((domain, signature) => {
if (signature !== "mocked_signature") { if (signature !== "mocked_signature") {
@ -32,23 +32,7 @@ vi.mock("raw-body", () => ({
describe("processAsyncSaleorWebhook", () => { describe("processAsyncSaleorWebhook", () => {
let mockRequest: NextApiRequest; let mockRequest: NextApiRequest;
const mockAPL: APL = { const mockAPL = new MockAPL();
get: async (saleorApiUrl: string) =>
saleorApiUrl === "https://example.com/graphql/"
? {
domain: "example.com",
token: "mock-token",
saleorApiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn(),
isConfigured: vi.fn(),
};
beforeEach(() => { beforeEach(() => {
// Create request method which passes all the tests // Create request method which passes all the tests
@ -56,8 +40,8 @@ describe("processAsyncSaleorWebhook", () => {
headers: { headers: {
host: "some-saleor-host.cloud", host: "some-saleor-host.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
"saleor-domain": "example.com", "saleor-domain": mockAPL.workingSaleorDomain,
"saleor-api-url": "https://example.com/graphql/", "saleor-api-url": mockAPL.workingSaleorApiUrl,
"saleor-event": "product_updated", "saleor-event": "product_updated",
"saleor-signature": "mocked_signature", "saleor-signature": "mocked_signature",
"content-length": "0", // is ignored by mocked raw-body "content-length": "0", // is ignored by mocked raw-body
@ -68,19 +52,20 @@ describe("processAsyncSaleorWebhook", () => {
mockRequest = req; mockRequest = req;
}); });
it("Process valid request", async () => { it("Process valid request", async () =>
await processAsyncSaleorWebhook({ expect(() =>
req: mockRequest, processSaleorWebhook({
apl: mockAPL, req: mockRequest,
allowedEvent: "PRODUCT_UPDATED", apl: mockAPL,
}); allowedEvent: "PRODUCT_UPDATED",
}); })
).not.toThrow());
it("Throw error on non-POST request method", async () => { it("Throw error on non-POST request method", async () => {
mockRequest.method = "GET"; mockRequest.method = "GET";
await expect( await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Wrong request method"); ).rejects.toThrow("Wrong request method");
}); });
@ -88,7 +73,7 @@ describe("processAsyncSaleorWebhook", () => {
delete mockRequest.headers["saleor-api-url"]; delete mockRequest.headers["saleor-api-url"];
await expect( await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Missing saleor-api-url header"); ).rejects.toThrow("Missing saleor-api-url header");
}); });
@ -96,7 +81,7 @@ describe("processAsyncSaleorWebhook", () => {
delete mockRequest.headers["saleor-event"]; delete mockRequest.headers["saleor-event"];
await expect( await expect(
processAsyncSaleorWebhook({ processSaleorWebhook({
req: mockRequest, req: mockRequest,
apl: mockAPL, apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",
@ -107,14 +92,14 @@ describe("processAsyncSaleorWebhook", () => {
it("Throw error on mismatched event header", async () => { it("Throw error on mismatched event header", async () => {
mockRequest.headers["saleor-event"] = "different_event"; mockRequest.headers["saleor-event"] = "different_event";
await expect( await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) processSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated"); ).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated");
}); });
it("Throw error on missing signature header", async () => { it("Throw error on missing signature header", async () => {
delete mockRequest.headers["saleor-signature"]; delete mockRequest.headers["saleor-signature"];
await expect( await expect(
processAsyncSaleorWebhook({ processSaleorWebhook({
req: mockRequest, req: mockRequest,
apl: mockAPL, apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",
@ -128,7 +113,7 @@ describe("processAsyncSaleorWebhook", () => {
}); });
await expect( await expect(
processAsyncSaleorWebhook({ processSaleorWebhook({
req: mockRequest, req: mockRequest,
apl: mockAPL, apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",
@ -139,7 +124,7 @@ describe("processAsyncSaleorWebhook", () => {
it("Throw error on not registered app", async () => { it("Throw error on not registered app", async () => {
mockRequest.headers["saleor-api-url"] = "https://not-registered.example.com/graphql/"; mockRequest.headers["saleor-api-url"] = "https://not-registered.example.com/graphql/";
await expect( await expect(
processAsyncSaleorWebhook({ processSaleorWebhook({
req: mockRequest, req: mockRequest,
apl: mockAPL, apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",
@ -151,11 +136,13 @@ describe("processAsyncSaleorWebhook", () => {
it("Throw error on wrong signature", async () => { it("Throw error on wrong signature", async () => {
mockRequest.headers["saleor-signature"] = "wrong_signature"; mockRequest.headers["saleor-signature"] = "wrong_signature";
vi.mock("./../../fetch-remote-jwks", () => ({ vi.mock("./../../fetch-remote-jwks", () => ({
fetchRemoteJwks: vi.fn(() => "wrong_signature"), fetchRemoteJwks: vi.fn(async () => "wrong_signature"),
})); }));
await expect(
processAsyncSaleorWebhook({ return expect(
processSaleorWebhook({
req: mockRequest, req: mockRequest,
apl: mockAPL, apl: mockAPL,
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",

View file

@ -1,14 +1,14 @@
import { NextApiRequest } from "next"; import { NextApiRequest } from "next";
import getRawBody from "raw-body"; import getRawBody from "raw-body";
import { APL } from "../../APL"; import { APL } from "../../../APL";
import { AuthData } from "../../APL/apl"; import { AuthData } from "../../../APL/apl";
import { createDebug } from "../../debug"; import { createDebug } from "../../../debug";
import { fetchRemoteJwks } from "../../fetch-remote-jwks"; import { fetchRemoteJwks } from "../../../fetch-remote-jwks";
import { getBaseUrl, getSaleorHeaders } from "../../headers"; import { getBaseUrl, getSaleorHeaders } from "../../../headers";
import { verifySignatureWithJwks } from "../../verify-signature"; import { verifySignatureWithJwks } from "../../../verify-signature";
const debug = createDebug("processAsyncWebhook"); const debug = createDebug("processSaleorWebhook");
export type SaleorWebhookError = export type SaleorWebhookError =
| "OTHER" | "OTHER"
@ -56,7 +56,7 @@ interface ProcessSaleorWebhookArgs {
allowedEvent: string; allowedEvent: string;
} }
type ProcessAsyncSaleorWebhook = <T = unknown>( type ProcessSaleorWebhook = <T = unknown>(
props: ProcessSaleorWebhookArgs props: ProcessSaleorWebhookArgs
) => Promise<WebhookContext<T>>; ) => Promise<WebhookContext<T>>;
@ -66,16 +66,18 @@ type ProcessAsyncSaleorWebhook = <T = unknown>(
* *
* @returns WebhookContext * @returns WebhookContext
*/ */
export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({ export const processSaleorWebhook: ProcessSaleorWebhook = async <T>({
req, req,
apl, apl,
allowedEvent, allowedEvent,
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => { }: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
debug("Request processing started"); debug("Request processing started");
if (req.method !== "POST") { if (req.method !== "POST") {
debug("Wrong HTTP method"); debug("Wrong HTTP method");
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD");
} }
const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers);
const baseUrl = getBaseUrl(req.headers); const baseUrl = getBaseUrl(req.headers);
@ -95,8 +97,10 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
} }
const expected = allowedEvent.toLowerCase(); const expected = allowedEvent.toLowerCase();
if (event !== expected) { if (event !== expected) {
debug(`Wrong incoming request event: ${event}. Expected: ${expected}`); debug(`Wrong incoming request event: ${event}. Expected: ${expected}`);
throw new WebhookError( throw new WebhookError(
`Wrong incoming request event: ${event}. Expected: ${expected}`, `Wrong incoming request event: ${event}. Expected: ${expected}`,
"WRONG_EVENT" "WRONG_EVENT"
@ -105,6 +109,7 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
if (!signature) { if (!signature) {
debug("No signature"); debug("No signature");
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
} }
@ -116,41 +121,65 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
).toString(); ).toString();
if (!rawBody) { if (!rawBody) {
debug("Missing request body"); debug("Missing request body");
throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
} }
let parsedBody: unknown; let parsedBody: unknown;
try { try {
parsedBody = JSON.parse(rawBody); parsedBody = JSON.parse(rawBody);
} catch { } catch {
debug("Request body cannot be parsed"); debug("Request body cannot be parsed");
throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED");
} }
// Check if domain is installed in the app /**
* Verify if the app is properly installed for given Saleor API URL
*/
const authData = await apl.get(saleorApiUrl); const authData = await apl.get(saleorApiUrl);
if (!authData) { if (!authData) {
debug("APL didn't found auth data for %s", saleorApiUrl); debug("APL didn't found auth data for %s", saleorApiUrl);
throw new WebhookError( throw new WebhookError(
`Can't find auth data for ${saleorApiUrl}. Please register the application`, `Can't find auth data for ${saleorApiUrl}. Please register the application`,
"NOT_REGISTERED" "NOT_REGISTERED"
); );
} }
// Payload signature check /**
* Verify payload signature
*
* TODO: Add test for repeat verification scenario
*/
try { try {
debug("Will verify signature with JWKS saved in AuthData");
await verifySignatureWithJwks(authData.jwks, signature, rawBody); await verifySignatureWithJwks(authData.jwks, signature, rawBody);
} catch { } catch {
debug("Request signature check failed. Refresh the JWKS cache and check again"); debug("Request signature check failed. Refresh the JWKS cache and check again");
const newJwks = await fetchRemoteJwks(authData.saleorApiUrl);
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 { try {
debug("Second attempt to validate the signature JWKS, using fresh tokens from the API"); debug("Second attempt to validate the signature JWKS, using fresh tokens from the API");
await verifySignatureWithJwks(newJwks, signature, rawBody); await verifySignatureWithJwks(newJwks, signature, rawBody);
debug("Verification successful - update JWKS in the AuthData"); debug("Verification successful - update JWKS in the AuthData");
await apl.set({ ...authData, jwks: newJwks }); await apl.set({ ...authData, jwks: newJwks });
} catch { } catch {
debug("Second attempt also ended with validation error. Reject the webhook"); debug("Second attempt also ended with validation error. Reject the webhook");
throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED");
} }
} }

View file

@ -1,71 +1,47 @@
import { ASTNode } from "graphql";
import { createMocks } from "node-mocks-http"; import { createMocks } from "node-mocks-http";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { APL } from "../../APL"; import { MockAPL } from "../../../test-utils/mock-apl";
import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook"; import { AsyncWebhookEventType } from "../../../types";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "./saleor-async-webhook"; import { processSaleorWebhook } from "./process-saleor-webhook";
import { SaleorAsyncWebhook } from "./saleor-async-webhook";
import { NextWebhookApiHandler, WebhookConfig } from "./saleor-webhook";
const webhookPath = "api/webhooks/product-updated"; const webhookPath = "api/webhooks/product-updated";
const baseUrl = "http://example.com"; const baseUrl = "http://example.com";
describe("SaleorAsyncWebhook", () => { describe("SaleorAsyncWebhook", () => {
const mockAPL: APL = { const mockAPL = new MockAPL();
get: async (domain: string) =>
domain === "example.com"
? {
domain: "example.com",
token: "mock-token",
jwks: "",
saleorApiUrl: "https://example.com/graphql/",
appId: "12345",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
isReady: vi.fn(),
isConfigured: vi.fn(),
};
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
const validAsyncWebhookConfiguration = { const validAsyncWebhookConfiguration: WebhookConfig<AsyncWebhookEventType> = {
apl: mockAPL, apl: mockAPL,
asyncEvent: "PRODUCT_UPDATED", event: "PRODUCT_UPDATED",
webhookPath, webhookPath,
query: "subscription { event { ... on ProductUpdated { product { id }}}}", query: "subscription { event { ... on ProductUpdated { product { id }}}}",
} as const; } as const;
const saleorAsyncWebhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration); const saleorAsyncWebhook = new SaleorAsyncWebhook(validAsyncWebhookConfiguration);
it("throw CONFIGURATION_ERROR if query and subscriptionQueryAst are both absent", async () => { it("constructor passes if query is provided", async () => {
expect(() => { expect(() => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new SaleorAsyncWebhook({ new SaleorAsyncWebhook({
...validAsyncWebhookConfiguration, ...validAsyncWebhookConfiguration,
// @ts-ignore: We make type error for test purpose query: "subscription { event { ... on ProductUpdated { product { id }}}}",
query: undefined,
subscriptionQueryAst: undefined,
});
}).toThrowError();
});
it("constructor passes if subscriptionQueryAst is provided", async () => {
expect(() => {
// eslint-disable-next-line no-new
new SaleorAsyncWebhook({
...validAsyncWebhookConfiguration,
query: undefined,
subscriptionQueryAst: {} as ASTNode,
}); });
}).not.toThrowError(); }).not.toThrowError();
}); });
it("targetUrl should return full path to the webhook route based on given baseUrl", async () => { it("targetUrl should return full path to the webhook route based on given baseUrl", async () => {
expect(saleorAsyncWebhook.getTargetUrl(baseUrl)).toBe(`${baseUrl}/${webhookPath}`); expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toEqual(
expect.objectContaining({
targetUrl: `${baseUrl}/${webhookPath}`,
})
);
}); });
it("getWebhookManifest should return a valid manifest", async () => { it("getWebhookManifest should return a valid manifest", async () => {
@ -80,9 +56,9 @@ describe("SaleorAsyncWebhook", () => {
it("Test createHandler which return success", async () => { it("Test createHandler which return success", async () => {
// prepare mocked context returned by mocked process function // prepare mocked context returned by mocked process function
vi.mock("./process-async-saleor-webhook"); vi.mock("./process-saleor-webhook");
vi.mocked(processAsyncSaleorWebhook).mockImplementationOnce(async () => ({ vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => ({
baseUrl: "example.com", baseUrl: "example.com",
event: "product_updated", event: "product_updated",
payload: { data: "test_payload" }, payload: { data: "test_payload" },
@ -109,6 +85,7 @@ describe("SaleorAsyncWebhook", () => {
const { req, res } = createMocks(); const { req, res } = createMocks();
const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler); const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler);
await wrappedHandler(req, res); await wrappedHandler(req, res);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
// Check if test handler was used by the wrapper // Check if test handler was used by the wrapper
@ -129,9 +106,9 @@ describe("SaleorAsyncWebhook", () => {
}); });
// prepare mocked context returned by mocked process function // prepare mocked context returned by mocked process function
vi.mock("./process-async-saleor-webhook"); vi.mock("./process-saleor-webhook");
vi.mocked(processAsyncSaleorWebhook).mockImplementationOnce(async () => { vi.mocked(processSaleorWebhook).mockImplementationOnce(async () => {
/** /**
* This mock should throw WebhookError, but there was TypeError related to constructor of extended class. * This mock should throw WebhookError, but there was TypeError related to constructor of extended class.
* Try "throw new WebhookError()" to check it. * Try "throw new WebhookError()" to check it.

View file

@ -0,0 +1,30 @@
import { NextApiHandler } from "next";
import { AsyncWebhookEventType } from "../../../types";
import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook";
export class SaleorAsyncWebhook<TPayload = unknown> extends SaleorWebhook<TPayload> {
readonly event: AsyncWebhookEventType;
protected readonly eventType = "async" as const;
constructor(
configuration: WebhookConfig<AsyncWebhookEventType> & {
/**
* @deprecated - use `event` instead. Will be removed in 0.35.0
*/
asyncEvent?: AsyncWebhookEventType;
}
) {
super({
...configuration,
event: configuration.event ?? configuration.asyncEvent,
});
this.event = configuration.event ?? configuration.asyncEvent;
}
createHandler(handlerFn: NextWebhookApiHandler<TPayload>): NextApiHandler {
return super.createHandler(handlerFn);
}
}

View file

@ -0,0 +1,80 @@
import { createMocks } from "node-mocks-http";
import { describe, expect, it, vi } from "vitest";
import { MockAPL } from "../../../test-utils/mock-apl";
import { processSaleorWebhook } from "./process-saleor-webhook";
import { SaleorSyncWebhook } from "./saleor-sync-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" },
authData: {
domain: mockApl.workingSaleorDomain,
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/",
},
});
const webhook = new SaleorSyncWebhook({
apl: mockApl,
webhookPath: "/test",
event: "CHECKOUT_CALCULATE_TAXES",
query: "",
name: "Webhook test name",
isActive: true,
});
const handler = webhook.createHandler((_req, _res, ctx) => {
_res.send(
ctx.buildResponse({
lines: [
{
tax_rate: "VAT8",
total_net_amount: 10,
total_gross_amount: 1.08,
},
],
shipping_price_gross_amount: 2,
shipping_tax_rate: "VAT8",
shipping_price_net_amount: 1,
})
);
});
await handler(req, res);
expect(res._getData()).toEqual(
expect.objectContaining({
lines: [
{
tax_rate: "VAT8",
total_net_amount: 10,
total_gross_amount: 1.08,
},
],
shipping_price_gross_amount: 2,
shipping_tax_rate: "VAT8",
shipping_price_net_amount: 1,
})
);
});
});

View file

@ -0,0 +1,39 @@
import { NextApiHandler } from "next";
import { SyncWebhookEventType } from "../../../types";
import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook";
import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder";
type InjectedContext<TEvent extends SyncWebhookEventType> = {
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
};
export class SaleorSyncWebhook<
TPayload = unknown,
TEvent extends SyncWebhookEventType = SyncWebhookEventType
> extends SaleorWebhook<TPayload, InjectedContext<TEvent>> {
readonly event: TEvent;
protected readonly eventType = "sync" as const;
protected extraContext = {
buildResponse: buildSyncWebhookResponsePayload,
};
constructor(configuration: WebhookConfig<TEvent>) {
super(configuration);
this.event = configuration.event;
}
createHandler(
handlerFn: NextWebhookApiHandler<
TPayload,
{
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
}
>
): NextApiHandler {
return super.createHandler(handlerFn);
}
}

View file

@ -1,23 +1,23 @@
import { ASTNode } from "graphql"; import { ASTNode } from "graphql";
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { APL } from "../../APL"; import { APL } from "../../../APL";
import { createDebug } from "../../debug"; import { createDebug } from "../../../debug";
import { gqlAstToString } from "../../gql-ast-to-string"; import { gqlAstToString } from "../../../gql-ast-to-string";
import { AsyncWebhookEventType, WebhookManifest } from "../../types"; import { AsyncWebhookEventType, SyncWebhookEventType, WebhookManifest } from "../../../types";
import { import {
processAsyncSaleorWebhook, processSaleorWebhook,
SaleorWebhookError, SaleorWebhookError,
WebhookContext, WebhookContext,
WebhookError, WebhookError,
} from "./process-async-saleor-webhook"; } from "./process-saleor-webhook";
const debug = createDebug("SaleorAsyncWebhook"); const debug = createDebug("SaleorAsyncWebhook");
interface WebhookManifestConfigurationBase { export interface WebhookConfig<Event = AsyncWebhookEventType | SyncWebhookEventType> {
name?: string; name?: string;
webhookPath: string; webhookPath: string;
asyncEvent: AsyncWebhookEventType; event: Event;
isActive?: boolean; isActive?: boolean;
apl: APL; apl: APL;
onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void;
@ -29,21 +29,14 @@ interface WebhookManifestConfigurationBase {
code: number; code: number;
body: object | string; body: object | string;
}>; }>;
query: string | ASTNode;
/**
* @deprecated will be removed in 0.35.0, use query field instead
*/
subscriptionQueryAst?: ASTNode;
} }
interface WebhookManifestConfigurationWithAst extends WebhookManifestConfigurationBase { export const WebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
subscriptionQueryAst: ASTNode;
}
interface WebhookManifestConfigurationWithQuery extends WebhookManifestConfigurationBase {
query: string;
}
type WebhookManifestConfiguration =
| WebhookManifestConfigurationWithAst
| WebhookManifestConfigurationWithQuery;
export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
OTHER: 500, OTHER: 500,
MISSING_HOST_HEADER: 400, MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400, MISSING_DOMAIN_HEADER: 400,
@ -60,63 +53,63 @@ export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
CONFIGURATION_ERROR: 500, CONFIGURATION_ERROR: 500,
}; };
export type NextWebhookApiHandler<TPayload = unknown, TResp = unknown> = ( export type NextWebhookApiHandler<TPayload = unknown, TExtras = {}> = (
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<TResp>, res: NextApiResponse,
ctx: WebhookContext<TPayload> ctx: WebhookContext<TPayload> & TExtras
) => unknown | Promise<unknown>; ) => unknown | Promise<unknown>;
export class SaleorAsyncWebhook<TPayload = unknown> { export abstract class SaleorWebhook<
TPayload = unknown,
TExtras extends Record<string, unknown> = {}
> {
protected abstract eventType: "async" | "sync";
protected extraContext?: TExtras;
name: string; name: string;
webhookPath: string; webhookPath: string;
subscriptionQueryAst?: ASTNode; query: string | ASTNode;
query?: string; event: AsyncWebhookEventType | SyncWebhookEventType;
asyncEvent: AsyncWebhookEventType;
isActive?: boolean; isActive?: boolean;
apl: APL; apl: APL;
onError: WebhookManifestConfigurationBase["onError"]; onError: WebhookConfig["onError"];
formatErrorResponse: WebhookManifestConfigurationBase["formatErrorResponse"]; formatErrorResponse: WebhookConfig["formatErrorResponse"];
constructor(configuration: WebhookManifestConfiguration) { protected constructor(configuration: WebhookConfig) {
const { name, webhookPath, asyncEvent, apl, isActive = true } = configuration; const {
this.name = name || `${asyncEvent} webhook`; name,
if ("query" in configuration) { webhookPath,
this.query = configuration.query; event,
} query,
if ("subscriptionQueryAst" in configuration) { apl,
this.subscriptionQueryAst = configuration.subscriptionQueryAst; isActive = true,
} subscriptionQueryAst,
if (!this.subscriptionQueryAst && !this.query) { } = configuration;
throw new WebhookError(
"Need to specify `subscriptionQueryAst` or `query` to create webhook subscription",
"CONFIGURATION_ERROR"
);
}
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.webhookPath = webhookPath;
this.asyncEvent = asyncEvent; this.event = event;
this.isActive = isActive; this.isActive = isActive;
this.apl = apl; this.apl = apl;
this.onError = configuration.onError; this.onError = configuration.onError;
this.formatErrorResponse = configuration.formatErrorResponse; this.formatErrorResponse = configuration.formatErrorResponse;
} }
/** private getTargetUrl(baseUrl: string) {
* Returns full URL to the webhook, based on provided baseUrl.
*
* TODO: Shouldn't it be private?
*
* @param baseUrl Base URL used by your application
*/
getTargetUrl(baseUrl: string) {
return new URL(this.webhookPath, baseUrl).href; return new URL(this.webhookPath, baseUrl).href;
} }
@ -127,34 +120,47 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
* @returns WebhookManifest * @returns WebhookManifest
*/ */
getWebhookManifest(baseUrl: string): WebhookManifest { getWebhookManifest(baseUrl: string): WebhookManifest {
return { const manifestBase: Omit<WebhookManifest, "asyncEvents" | "syncEvents"> = {
query: typeof this.query === "string" ? this.query : gqlAstToString(this.query),
name: this.name, name: this.name,
targetUrl: this.getTargetUrl(baseUrl), targetUrl: this.getTargetUrl(baseUrl),
asyncEvents: [this.asyncEvent],
isActive: this.isActive, isActive: this.isActive,
// Query can be provided as plaintext..
...(this.query && { query: this.query }),
// ...GQL AST which has to be stringified..
...(this.subscriptionQueryAst && { query: gqlAstToString(this.subscriptionQueryAst) }),
// or no query at all. In such case default webhook payload will be sent
}; };
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");
}
}
} }
/** /**
* Wraps provided function, to ensure incoming request comes from registered Saleor instance. * Wraps provided function, to ensure incoming request comes from registered Saleor instance.
* Also provides additional `context` object containing typed payload and request properties. * Also provides additional `context` object containing typed payload and request properties.
*/ */
createHandler(handlerFn: NextWebhookApiHandler<TPayload>): NextApiHandler { createHandler(handlerFn: NextWebhookApiHandler<TPayload, TExtras>): NextApiHandler {
return async (req, res) => { return async (req, res) => {
debug(`Handler for webhook ${this.name} called`); debug(`Handler for webhook ${this.name} called`);
await processAsyncSaleorWebhook<TPayload>({
await processSaleorWebhook<TPayload>({
req, req,
apl: this.apl, apl: this.apl,
allowedEvent: this.asyncEvent, allowedEvent: this.event,
}) })
.then(async (context) => { .then(async (context) => {
debug("Incoming request validated. Call handlerFn"); debug("Incoming request validated. Call handlerFn");
return handlerFn(req, res, context);
return handlerFn(req, res, { ...(this.extraContext ?? ({} as TExtras)), ...context });
}) })
.catch(async (e) => { .catch(async (e) => {
debug(`Unexpected error during processing the webhook ${this.name}`); debug(`Unexpected error during processing the webhook ${this.name}`);
@ -174,7 +180,7 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
return; return;
} }
res.status(AsyncWebhookErrorCodeMap[e.errorType] || 400).send({ res.status(WebhookErrorCodeMap[e.errorType] || 400).send({
error: { error: {
type: e.errorType, type: e.errorType,
message: e.message, message: e.message,

View file

@ -0,0 +1,39 @@
import { SyncWebhookEventType } from "../../../types";
/**
* TODO Confirm with Saleor Core source (not the docs) to check if its 100% accurate
*/
type SyncWebhookResponsesMap = {
CHECKOUT_CALCULATE_TAXES: {
shipping_price_gross_amount: number;
shipping_price_net_amount: number;
shipping_tax_rate: string;
lines: Array<{
total_gross_amount: number;
total_net_amount: number;
tax_rate: string;
}>;
};
CHECKOUT_FILTER_SHIPPING_METHODS: {
excluded_methods: Array<{
id: string;
reason: string;
}>;
};
ORDER_CALCULATE_TAXES: SyncWebhookResponsesMap["CHECKOUT_CALCULATE_TAXES"];
ORDER_FILTER_SHIPPING_METHODS: SyncWebhookResponsesMap["CHECKOUT_FILTER_SHIPPING_METHODS"];
SHIPPING_LIST_METHODS_FOR_CHECKOUT: Array<{
id: string;
name: string;
amount: number;
currency: string; // or enum?
maximum_delivery_days?: number;
}>;
};
/**
* Identity function, but it works on Typescript level to pick right payload based on first param
*/
export const buildSyncWebhookResponsePayload = <E extends SyncWebhookEventType>(
payload: SyncWebhookResponsesMap[E]
): SyncWebhookResponsesMap[E] => payload;

View file

@ -2,9 +2,9 @@ import { Handler, Request } from "retes";
import { Response } from "retes/response"; import { Response } from "retes/response";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { APL } from "../APL"; import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../const";
import { SALEOR_DOMAIN_HEADER } from "../const";
import { SaleorApp } from "../saleor-app"; import { SaleorApp } from "../saleor-app";
import { MockAPL } from "../test-utils/mock-apl";
import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header"; import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header";
import { withSaleorApp } from "./with-saleor-app"; import { withSaleorApp } from "./with-saleor-app";
@ -14,18 +14,7 @@ describe("middleware", () => {
describe("withRegisteredSaleorDomainHeader", () => { describe("withRegisteredSaleorDomainHeader", () => {
let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse);
const mockAPL: APL = { const mockAPL = new MockAPL();
get: async (domain: string) =>
domain === "example.com"
? {
domain: "example.com",
token: "mock-token",
}
: undefined,
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(),
};
beforeEach(() => { beforeEach(() => {
mockHandlerFn = vi.fn(getMockSuccessResponse); mockHandlerFn = vi.fn(getMockSuccessResponse);
@ -37,7 +26,8 @@ describe("middleware", () => {
headers: { headers: {
host: "my-saleor-env.saleor.cloud", host: "my-saleor-env.saleor.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
[SALEOR_DOMAIN_HEADER]: "example.com", [SALEOR_DOMAIN_HEADER]: mockAPL.workingSaleorDomain,
[SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl,
}, },
} as unknown as Request; } as unknown as Request;
@ -60,6 +50,7 @@ describe("middleware", () => {
host: "my-saleor-env.saleor.cloud", host: "my-saleor-env.saleor.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
[SALEOR_DOMAIN_HEADER]: "not-registered.example.com", [SALEOR_DOMAIN_HEADER]: "not-registered.example.com",
[SALEOR_API_URL_HEADER]: "https://not-registered.example.com/graphql/",
}, },
} as unknown as Request; } as unknown as Request;
@ -80,7 +71,8 @@ describe("middleware", () => {
headers: { headers: {
host: "my-saleor-env.saleor.cloud", host: "my-saleor-env.saleor.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
[SALEOR_DOMAIN_HEADER]: "example.com", [SALEOR_DOMAIN_HEADER]: mockAPL.workingSaleorDomain,
[SALEOR_API_URL_HEADER]: mockAPL.workingSaleorApiUrl,
}, },
} as unknown as Request; } as unknown as Request;

View file

@ -8,16 +8,16 @@ import { getSaleorAppFromRequest } from "./with-saleor-app";
const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader"); const debug = createMiddlewareDebug("withRegisteredSaleorDomainHeader");
export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => { export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (request) => {
const { domain: saleorDomain } = getSaleorHeaders(request.headers); const { saleorApiUrl } = getSaleorHeaders(request.headers);
if (!saleorDomain) { if (!saleorApiUrl) {
return Response.BadRequest({ return Response.BadRequest({
success: false, success: false,
message: "Domain header missing.", message: "saleorApiUrl header missing.",
}); });
} }
debug("Middleware called with domain: \"%s\"", saleorDomain); debug("Middleware called with saleorApiUrl: \"%s\"", saleorApiUrl);
const saleorApp = getSaleorAppFromRequest(request); const saleorApp = getSaleorAppFromRequest(request);
@ -32,14 +32,14 @@ export const withRegisteredSaleorDomainHeader: Middleware = (handler) => async (
}); });
} }
const authData = await saleorApp?.apl.get(saleorDomain); const authData = await saleorApp?.apl.get(saleorApiUrl);
if (!authData) { if (!authData) {
debug("Auth was not found in APL, will respond with Forbidden status"); debug("Auth was not found in APL, will respond with Forbidden status");
return Response.Forbidden({ return Response.Forbidden({
success: false, success: false,
message: `Domain ${saleorDomain} not registered.`, message: `Saleor: ${saleorApiUrl} not registered.`,
}); });
} }

View file

@ -0,0 +1,70 @@
import { vi } from "vitest";
import { APL, AuthData } from "../APL";
type Options = {
workForApiUrl?: string;
savedAllAuthData?: AuthData[];
};
/**
* Test utility used across scenarios to simulate various APL behaviors
*/
export class MockAPL implements APL {
private readonly options: Options = {
workForApiUrl: "https://example.com/graphql/",
savedAllAuthData: [],
};
constructor(opts?: Options) {
this.options = {
...this.options,
...(opts ?? {}),
};
this.workingSaleorApiUrl = this.options.workForApiUrl ?? this.workingSaleorApiUrl;
}
mockJwks = "{}";
mockToken = "mock-token";
mockAppId = "mock-app-id";
workingSaleorApiUrl = "https://example.com/graphql/";
resolveDomainFromApiUrl = (apiUrl: string) =>
apiUrl.replace("/graphql/", "").replace("https://", "");
get workingSaleorDomain() {
return this.resolveDomainFromApiUrl(this.workingSaleorApiUrl);
}
async get(saleorApiUrl: string) {
if (saleorApiUrl === this.workingSaleorApiUrl) {
return {
domain: this.resolveDomainFromApiUrl(saleorApiUrl),
token: this.mockToken,
saleorApiUrl,
appId: this.mockAppId,
jwks: this.mockJwks,
};
}
return undefined;
}
set = vi.fn();
delete = vi.fn();
getAll = vi.fn().mockImplementation(async () => this.options.savedAllAuthData);
isReady = vi.fn().mockImplementation(async () => ({
ready: true,
}));
isConfigured = vi.fn().mockImplementation(async () => ({
configured: true,
}));
}

View file

@ -163,15 +163,10 @@ export type AsyncWebhookEventType =
/** /**
* @see https://github.com/saleor/saleor/blob/main/saleor/graphql/schema.graphql#L1995 * @see https://github.com/saleor/saleor/blob/main/saleor/graphql/schema.graphql#L1995
*
* TODO Add Payment events after new API in 3.13
*/ */
export type SyncWebhookEventType = export type SyncWebhookEventType =
| "PAYMENT_LIST_GATEWAYS"
| "PAYMENT_AUTHORIZE"
| "PAYMENT_CAPTURE"
| "PAYMENT_REFUND"
| "PAYMENT_VOID"
| "PAYMENT_CONFIRM"
| "PAYMENT_PROCESS"
| "CHECKOUT_CALCULATE_TAXES" | "CHECKOUT_CALCULATE_TAXES"
| "ORDER_CALCULATE_TAXES" | "ORDER_CALCULATE_TAXES"
| "SHIPPING_LIST_METHODS_FOR_CHECKOUT" | "SHIPPING_LIST_METHODS_FOR_CHECKOUT"