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:
parent
bc6e68db84
commit
9420209a2d
22 changed files with 812 additions and 447 deletions
15
.changeset/afraid-poets-visit.md
Normal file
15
.changeset/afraid-poets-visit.md
Normal 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
|
|
@ -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": {
|
||||||
|
|
|
@ -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
305
docs/saleor-webhook.md
Normal 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
|
||||||
|
});
|
||||||
|
```
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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({
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(() =>
|
||||||
|
processSaleorWebhook({
|
||||||
req: mockRequest,
|
req: mockRequest,
|
||||||
apl: mockAPL,
|
apl: mockAPL,
|
||||||
allowedEvent: "PRODUCT_UPDATED",
|
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",
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.
|
30
src/handlers/next/saleor-webhooks/saleor-async-webhook.ts
Normal file
30
src/handlers/next/saleor-webhooks/saleor-async-webhook.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
39
src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts
Normal file
39
src/handlers/next/saleor-webhooks/saleor-sync-webhook.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
@ -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;
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
70
src/test-utils/mock-apl.ts
Normal file
70
src/test-utils/mock-apl.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue