Skip to content

Commit

Permalink
[MS-670] feat: Improve app extensibility (#45)
Browse files Browse the repository at this point in the history
* [MS-670] feat: Improve apps extensibility

* [MS-670] feat: Fix tests

* [MS-670] feat: Clean templates props types

* [MS-670] feat: Type fix
  • Loading branch information
piotrgrundas committed Nov 6, 2024
1 parent 2147558 commit 1a54d2e
Show file tree
Hide file tree
Showing 30 changed files with 300 additions and 142 deletions.
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Saleor domain when you want to install the app. It will be used to verify JWT tokens.
# Saleor domain where do you want to install the app. It will be used to verify JWT tokens.
SALEOR_URL=https://your.eu.saleor.cloud
# Where your email assets are stored
STATIC_URL=https://your.cdn/
Expand All @@ -8,7 +8,7 @@ STOREFRONT_URL=https://your.storefront.com
# Email from which email will be sent
FROM_EMAIL=[email protected]
# Name from which email will be sent
FROM_NAME=
FROM_NAME=Mirumee

# AWS SQS queue url
SQS_QUEUE_URL=http://host.docker.internal:4566/000000000000/nimara-mailer-queue
Expand All @@ -21,8 +21,8 @@ AWS_REGION=ap-southeast-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# One of: NODE_MAILER, AWS_SES
EMAIL_PROVIDER=AWS_SES
# One of: AWS_SES, NODE_MAILER
EMAIL_PROVIDER=NODE_MAILER

# Localstack only. When using AWS please comment this out
FROM_DOMAIN=mirumee.com
Expand Down
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ AWS_SESSION_TOKEN=mock
SECRET_MANAGER_APP_CONFIG_PATH=mock
SQS_QUEUE_URL=https://sqs.queue.url
STATIC_URL=https://static.url
AUTHORIZATION_TOKEN=
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ coverage
# out files
.next/
out/
build/*
package*
build/
dist/
vite.config.js.timestamp*
*.zip
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"dev": "concurrently -k -p \"[{name}]\" -n \"TSC,BUILD,RECEIVER,SENDER\" -c \"blue.bold,red.bold,cyan.bold,green.bold\" \"pnpm tsc:watch\" \"pnpm dev:build\" \"pnpm dev:run:events-receiver\" \"pnpm dev:run:emails-sender-proxy\"",
"dev:build": "dotenv -v NODE_ENV=development -- node ./etc/scripts/dev.mjs",
"dev:emails": "dotenv -- email dev -d ./src/emails -p 3002",
"dev:emails": "dotenv -- email dev -d ./src/emails/templates -p 3002",
"dev:run:events-receiver": "dotenv -v NODE_ENV=development -- nodemon --inspect=0.0.0.0:9229 --watch build/events-receiver.js build/events-receiver.js",
"dev:run:emails-sender-proxy": "dotenv -v NODE_ENV=development -- nodemon --inspect=0.0.0.0:9230 --watch build/emails-sender-proxy.js build/emails-sender-proxy.js",
"build": "dotenv -v NODE_ENV=production node ./etc/scripts/build.mjs",
Expand Down
36 changes: 33 additions & 3 deletions src/api/rest/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { CONFIG } from "@/config";
import { EMAIL_EVENTS } from "@/const";
import * as validate from "@/lib/graphql/validate";
import * as auth from "@/lib/saleor/auth";
import { createServer } from "@/server";

import { EVENT_HANDLERS } from "./saleor/webhooks";

describe("apiRoutes", () => {
describe("/api/healthcheck", () => {
it("200", async () => {
Expand Down Expand Up @@ -56,6 +56,36 @@ describe("apiRoutes", () => {
expect(response.statusCode).toStrictEqual(expectedStatusCode);
});

it("Should return 401 with invalid config authorization token.", async () => {
// given
const expectedStatusCode = 401;
const expectedJson = {
error: "UNAUTHORIZED",
code: "UNAUTHORIZED_ERROR",
requestId: expect.any(String),
errors: [{ message: "Invalid authorization token." }],
};
const event = EMAIL_EVENTS[0];

vi.spyOn(CONFIG, "AUTHORIZATION_TOKEN", "get").mockReturnValue(
"mocked-token"
);

// when
const response = await app.inject({
method: "POST",
url,
headers: {
Authorization: "Bearer wrong",
},
body: { data: {}, event },
});

// then
expect(response.json()).toStrictEqual(expectedJson);
expect(response.statusCode).toStrictEqual(expectedStatusCode);
});

it("Should return 400 passed when event is not supported.", async () => {
// given
const expectedStatusCode = 400;
Expand Down Expand Up @@ -92,9 +122,9 @@ describe("apiRoutes", () => {
const validateSpy = vi.spyOn(validate, "validateDocumentAgainstData");
const expectedJson = { status: "ok" };
const expectedStatusCode = 200;
const event = EMAIL_EVENTS[0];

// when
const event = EVENT_HANDLERS[0].event.toLowerCase();
jwtVerifySpy.mockImplementation(async () => undefined);
validateSpy.mockImplementation(() => ({ isValid: true, error: null }));

Expand Down
67 changes: 44 additions & 23 deletions src/api/rest/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import { type ZodTypeProvider } from "fastify-type-provider-zod";
import { z } from "zod";

import { CONFIG } from "@/config";
import {
CUSTOM_EVENTS_SCHEMA,
type CustomEventType,
EMAIL_EVENTS,
} from "@/const";
import { UnauthorizedError } from "@/lib/errors/api";
import { validateDocumentAgainstData } from "@/lib/graphql/validate";
import { serializePayload } from "@/lib/payload";
import { verifyJWTSignature } from "@/lib/saleor/auth";
import { saleorBearerHeader } from "@/lib/saleor/schema";
import { getJWKSProvider } from "@/providers/jwks";

import { saleorRoutes } from "./saleor";
import { EVENT_HANDLERS } from "./saleor/webhooks";
import { SALEOR_EVENTS_MAP } from "./saleor/webhooks";

export const restRoutes: FastifyPluginAsync = async (fastify) => {
await fastify.register(saleorRoutes, { prefix: "/saleor" });
Expand All @@ -26,40 +32,55 @@ export const restRoutes: FastifyPluginAsync = async (fastify) => {
headers: saleorBearerHeader,
body: z.object({
data: z.any(),
event: z.enum(
EVENT_HANDLERS.map(({ event }) => event.toLowerCase()) as any
),
event: z.enum(EMAIL_EVENTS),
}),
},
},

async (request, response) => {
await verifyJWTSignature({
jwksProvider: getJWKSProvider(),
jwt: request.headers.authorization,
});
if (CONFIG.AUTHORIZATION_TOKEN) {
if (CONFIG.AUTHORIZATION_TOKEN !== request.headers.authorization) {
throw new UnauthorizedError({
message: "Invalid authorization token.",
});
}
} else {
await verifyJWTSignature({
jwksProvider: getJWKSProvider(),
jwt: request.headers.authorization,
});
}

const document = EVENT_HANDLERS.find(
const saleorEvent = SALEOR_EVENTS_MAP.find(
({ event }) => event.toLowerCase() === request.body.event
)!.query;
);
let data: unknown;

const { isValid, error } = validateDocumentAgainstData({
data: request.body.data,
document,
});
if (saleorEvent) {
const { isValid, error } = validateDocumentAgainstData({
data: request.body.data,
document: saleorEvent.query,
});
data = request.body.data;

if (!isValid) {
throw new z.ZodError([
{
path: ["body > data"],
message: error ?? "",
code: "custom",
},
]);
}
} else {
const schema =
CUSTOM_EVENTS_SCHEMA[request.body.event as CustomEventType];

if (!isValid) {
throw new z.ZodError([
{
path: ["body > data"],
message: error ?? "",
code: "custom",
},
]);
data = schema.parse(request.body.data, { path: ["body > data"] });
}

const payload = serializePayload({
data: request.body.data,
data,
event: request.body.event,
});

Expand Down
14 changes: 9 additions & 5 deletions src/api/rest/saleor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import { type FastifyRequest } from "fastify";
import type { FastifyPluginAsync } from "fastify/types/plugin";

import { CONFIG } from "@/config";
import { type AppManifestWebhook } from "@/graphql/schema";
import {
type AppManifestWebhook,
type WebhookEventTypeAsyncEnum,
} from "@/graphql/schema";
import { saleorAppRouter } from "@/lib/saleor/apps/router";
import { getConfigProvider } from "@/providers/config";
import { getJWKSProvider } from "@/providers/jwks";
import { getSaleorClient } from "@/providers/saleorClient";

import { saleorRestRoutes } from "./saleor";
import { EVENT_HANDLERS, saleorWebhooksRoutes } from "./webhooks";
import { SALEOR_EVENTS_MAP, saleorWebhooksRoutes } from "./webhooks";

const getManifestWebhooks = (request: FastifyRequest): AppManifestWebhook[] =>
EVENT_HANDLERS.map(({ event, query }) => {
const name = event.toLocaleLowerCase().replaceAll("_", "-");
SALEOR_EVENTS_MAP.map(({ event, query }) => {
const name = event.replaceAll("_", "-");

return {
asyncEvents: [event],
asyncEvents: [event.toUpperCase() as WebhookEventTypeAsyncEnum],
name,
query,
syncEvents: [],
Expand Down
4 changes: 2 additions & 2 deletions src/api/rest/saleor/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { serializePayload } from "@/lib/payload";
import * as auth from "@/lib/saleor/auth";
import { createServer } from "@/server";

import { EVENT_HANDLERS } from "./webhooks";
import { SALEOR_EVENTS_MAP } from "./webhooks";

describe("saleorWebhooksRoutes", () => {
const sendSpy = vi.fn();
Expand All @@ -21,7 +21,7 @@ describe("saleorWebhooksRoutes", () => {
});

describe("/api/saleor/webhooks/email/*", () => {
it.each(EVENT_HANDLERS)(
it.each(SALEOR_EVENTS_MAP)(
"should register route for $event event",
({ event }) => {
// Given
Expand Down
36 changes: 18 additions & 18 deletions src/api/rest/saleor/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import rawBody from "fastify-raw-body";
import type { ZodTypeProvider } from "fastify-type-provider-zod";

import { CONFIG } from "@/config";
import { type SaleorEventType } from "@/const";
import {
AccountChangeEmailRequestedSubscriptionDocument,
AccountConfirmationRequestedSubscriptionDocument,
Expand All @@ -19,75 +20,74 @@ import {
OrderCreatedSubscriptionDocument,
OrderRefundedSubscriptionDocument,
} from "@/graphql/operations/subscriptions/generated";
import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema";
import { serializePayload } from "@/lib/payload";
import { verifyWebhookSignature } from "@/lib/saleor/auth";
import { saleorWebhookHeaders } from "@/lib/saleor/schema";
import { getJWKSProvider } from "@/providers/jwks";

export const EVENT_HANDLERS: {
event: WebhookEventTypeAsyncEnum;
export const SALEOR_EVENTS_MAP: {
event: SaleorEventType;
query: string;
}[] = [
{
event: "ACCOUNT_CONFIRMATION_REQUESTED",
event: "account_confirmation_requested",
query: AccountConfirmationRequestedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_CONFIRMED",
event: "account_confirmed",
query: AccountConfirmedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_SET_PASSWORD_REQUESTED",
event: "account_set_password_requested",
query: AccountSetPasswordRequestedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_DELETE_REQUESTED",
event: "account_delete_requested",
query: AccountDeleteRequestedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_DELETED",
event: "account_deleted",
query: AccountDeletedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_CHANGE_EMAIL_REQUESTED",
event: "account_change_email_requested",
query: AccountChangeEmailRequestedSubscriptionDocument.toString(),
},
{
event: "ACCOUNT_EMAIL_CHANGED",
event: "account_email_changed",
query: AccountEmailChangedSubscriptionDocument.toString(),
},
{
event: "ORDER_CREATED",
event: "order_created",
query: OrderCreatedSubscriptionDocument.toString(),
},
{
event: "ORDER_CANCELLED",
event: "order_cancelled",
query: OrderCancelledSubscriptionDocument.toString(),
},
{
event: "ORDER_REFUNDED",
event: "order_refunded",
query: OrderRefundedSubscriptionDocument.toString(),
},
{
event: "FULFILLMENT_TRACKING_NUMBER_UPDATED",
event: "fulfillment_tracking_number_updated",
query: FulfillmentTrackingNumberUpdatedSubscriptionDocument.toString(),
},
{
event: "FULFILLMENT_CREATED",
event: "fulfillment_created",
query: FulfillmentCreatedSubscriptionDocument.toString(),
},
{
event: "GIFT_CARD_SENT",
event: "gift_card_sent",
query: GiftCardSentSubscriptionDocument.toString(),
},
];

export const saleorWebhooksRoutes: FastifyPluginAsync = async (fastify) => {
await fastify.register(rawBody);

EVENT_HANDLERS.forEach(({ event }) => {
const name = event.toLowerCase().replaceAll("_", "-");
SALEOR_EVENTS_MAP.forEach(({ event }) => {
const name = event.replaceAll("_", "-");

fastify.withTypeProvider<ZodTypeProvider>().post(
`/email/${name}`,
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const configSchema = z
EMAIL_PROVIDER: z.enum(["NODE_MAILER", "AWS_SES"]).default("AWS_SES"),
BASE_PATH: z.string().default("").optional(),
WHITELISTED_DOMAINS: z.array(z.string()).optional(),
// Used in `send-notification` endpoint for authentication if you do not want to use Saleor JWT verification.
AUTHORIZATION_TOKEN: z.string().optional(),
})
.and(commonConfigSchema)
.and(appConfigSchema)
Expand Down
Loading

0 comments on commit 1a54d2e

Please sign in to comment.