diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index ecbc6efbbc..f7647c4286 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -6,6 +6,7 @@ import { } from "@medusajs/modules-sdk" import PaymentModuleService from "@medusajs/payment/dist/services/payment-module" import { + IApiKeyModuleService, ICartModuleService, ICustomerModuleService, IFulfillmentModuleService, @@ -47,6 +48,7 @@ medusaIntegrationTestRunner({ let pricingModule: IPricingModuleService let remoteLink: RemoteLink let promotionModule: IPromotionModuleService + let apiKeyModule: IApiKeyModuleService let taxModule: ITaxModuleService let fulfillmentModule: IFulfillmentModuleService let remoteLinkService @@ -64,6 +66,7 @@ medusaIntegrationTestRunner({ customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER) productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT) pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING) + apiKeyModule = appContainer.resolve(ModuleRegistrationName.API_KEY) remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION) taxModule = appContainer.resolve(ModuleRegistrationName.TAX) @@ -346,6 +349,158 @@ medusaIntegrationTestRunner({ ) }) + it("throws if publishable key is not associated with sales channel", async () => { + const salesChannel = await scModule.create({ + name: "Retail Store", + }) + + const salesChannel2 = await scModule.create({ + name: "Webshop", + }) + + const pubKey = await apiKeyModule.create({ + title: "Test key", + type: "publishable", + created_by: "test", + }) + + await api.post( + `/admin/api-keys/${pubKey.id}/sales-channels`, + { + add: [salesChannel2.id], + }, + adminHeaders + ) + + const errorRes = await api + .post( + "/store/carts", + { + sales_channel_id: salesChannel.id, + }, + { + headers: { "x-publishable-api-key": pubKey.token }, + } + ) + .catch((e) => e) + + expect(errorRes.response.status).toEqual(400) + expect(errorRes.response.data).toEqual({ + errors: expect.arrayContaining([ + `Sales channel ID in payload ${salesChannel.id} is not associated with the Publishable API Key in the header.`, + ]), + message: + "Provided request body contains errors. Please check the data and retry the request", + }) + }) + + it("throws if publishable key has multiple associated sales channels", async () => { + const salesChannel = await scModule.create({ + name: "Retail Store", + }) + + const salesChannel2 = await scModule.create({ + name: "Webshop", + }) + + const pubKey = await apiKeyModule.create({ + title: "Test key", + type: "publishable", + created_by: "test", + }) + + await api.post( + `/admin/api-keys/${pubKey.id}/sales-channels`, + { + add: [salesChannel.id, salesChannel2.id], + }, + adminHeaders + ) + + const errorRes = await api + .post( + "/store/carts", + {}, + { + headers: { "x-publishable-api-key": pubKey.token }, + } + ) + .catch((e) => e) + + expect(errorRes.response.status).toEqual(400) + expect(errorRes.response.data).toEqual({ + errors: expect.arrayContaining([ + `Cannot assign sales channel to cart. The Publishable API Key in the header has multiple associated sales channels. Please provide a sales channel ID in the request body.`, + ]), + message: + "Provided request body contains errors. Please check the data and retry the request", + }) + }) + + it("should create cart with sales channel if pub key does not have any scopes defined", async () => { + const salesChannel = await scModule.create({ + name: "Retail Store", + }) + + const pubKey = await apiKeyModule.create({ + title: "Test key", + type: "publishable", + created_by: "test", + }) + + const successRes = await api.post( + "/store/carts", + { + sales_channel_id: salesChannel.id, + }, + { + headers: { "x-publishable-api-key": pubKey.token }, + } + ) + + expect(successRes.status).toEqual(200) + expect(successRes.data.cart).toEqual( + expect.objectContaining({ + sales_channel_id: salesChannel.id, + }) + ) + }) + + it("should create cart with sales channel associated with pub key", async () => { + const salesChannel = await scModule.create({ + name: "Retail Store", + }) + + const pubKey = await apiKeyModule.create({ + title: "Test key", + type: "publishable", + created_by: "test", + }) + + await api.post( + `/admin/api-keys/${pubKey.id}/sales-channels`, + { + add: [salesChannel.id], + }, + adminHeaders + ) + + const successRes = await api.post( + "/store/carts", + {}, + { + headers: { "x-publishable-api-key": pubKey.token }, + } + ) + + expect(successRes.status).toEqual(200) + expect(successRes.data.cart).toEqual( + expect.objectContaining({ + sales_channel_id: salesChannel.id, + }) + ) + }) + it("should respond 400 bad request on unknown props", async () => { await expect( api.post(`/store/carts`, { diff --git a/packages/core/core-flows/src/api-key/steps/index.ts b/packages/core/core-flows/src/api-key/steps/index.ts index b41b8693ac..f67ddf957f 100644 --- a/packages/core/core-flows/src/api-key/steps/index.ts +++ b/packages/core/core-flows/src/api-key/steps/index.ts @@ -1,6 +1,7 @@ -export * from "./link-sales-channels-to-publishable-key" export * from "./create-api-keys" export * from "./delete-api-keys" +export * from "./link-sales-channels-to-publishable-key" export * from "./revoke-api-keys" export * from "./update-api-keys" export * from "./validate-sales-channel-exists" + diff --git a/packages/core/core-flows/src/api-key/workflows/index.ts b/packages/core/core-flows/src/api-key/workflows/index.ts index cb223e6778..94a04b7e9f 100644 --- a/packages/core/core-flows/src/api-key/workflows/index.ts +++ b/packages/core/core-flows/src/api-key/workflows/index.ts @@ -1,5 +1,6 @@ export * from "./create-api-keys" export * from "./delete-api-keys" -export * from "./update-api-keys" -export * from "./revoke-api-keys" export * from "./link-sales-channels-to-publishable-key" +export * from "./revoke-api-keys" +export * from "./update-api-keys" + diff --git a/packages/medusa/src/api-v2/store/carts/middlewares.ts b/packages/medusa/src/api-v2/store/carts/middlewares.ts index 1827d74034..d6c8c6b74f 100644 --- a/packages/medusa/src/api-v2/store/carts/middlewares.ts +++ b/packages/medusa/src/api-v2/store/carts/middlewares.ts @@ -1,5 +1,7 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/middlewares/authenticate-middleware" +import { ensurePublishableKeyAndSalesChannelMatch } from "../../utils/middlewares/common/ensure-pub-key-sales-channel-match" +import { maybeAttachPublishableKeyScopes } from "../../utils/middlewares/common/maybe-attach-pub-key-scopes" import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" import * as OrderQueryConfig from "../orders/query-config" @@ -47,6 +49,8 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [ StoreGetCartsCart, QueryConfig.retrieveTransformQueryConfig ), + maybeAttachPublishableKeyScopes, + ensurePublishableKeyAndSalesChannelMatch, ], }, { diff --git a/packages/medusa/src/api-v2/utils/middlewares/common/ensure-pub-key-sales-channel-match.ts b/packages/medusa/src/api-v2/utils/middlewares/common/ensure-pub-key-sales-channel-match.ts new file mode 100644 index 0000000000..65aac0c83b --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/common/ensure-pub-key-sales-channel-match.ts @@ -0,0 +1,52 @@ +import { NextFunction } from "express" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" +import { StoreCreateCartType } from "../../../store/carts/validators" + +/** + * If a publishable key (PK) is passed in the header of the request AND + * the request carries a sales channel id param in the url or body, + * we check if the sales channel is valid for the key. + * + * If the request does not carry a sales channel id, we attempt to assign + * a sales channel associated with the PK. + * + * @throws If sales channel id is passed as a url or body param + * but that id is not in the scope defined by the PK from the header. + * If the PK is associated with multiple sales channels but no + * sales channel id is passed in the request. + */ +export async function ensurePublishableKeyAndSalesChannelMatch( + req: MedusaRequest & { publishableApiKeyScopes }, + res: MedusaResponse, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const pubKeySalesChannels = + req.publishableApiKeyScopes?.sales_channel_ids ?? [] + const channelId = req.validatedBody?.sales_channel_id + + req.errors = req.errors ?? [] + + if (pubKeySalesChannels.length) { + if (channelId && !pubKeySalesChannels.includes(channelId)) { + req.errors.push( + `Sales channel ID in payload ${channelId} is not associated with the Publishable API Key in the header.` + ) + } + + if (!channelId) { + if (pubKeySalesChannels.length > 1) { + req.errors.push( + `Cannot assign sales channel to cart. The Publishable API Key in the header has multiple associated sales channels. Please provide a sales channel ID in the request body.` + ) + } else { + req.validatedBody.sales_channel_id = pubKeySalesChannels[0] + } + } + } + } + + next() +} diff --git a/packages/medusa/src/api-v2/utils/middlewares/common/maybe-attach-pub-key-scopes.ts b/packages/medusa/src/api-v2/utils/middlewares/common/maybe-attach-pub-key-scopes.ts new file mode 100644 index 0000000000..1828047a86 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/common/maybe-attach-pub-key-scopes.ts @@ -0,0 +1,48 @@ +import { RemoteQueryFunction } from "@medusajs/types" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { NextFunction } from "express" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +/** + * If a publishable key (PK) is passed in the header of the request, we attach + * the IDs of resources within the scope of the key. + * + * @param req - request object + * @param res - response object + * @param next - next middleware call + * + * @throws if sales channel id is passed as a url or body param + * but that id is not in the scope defined by the PK from the header + */ +export async function maybeAttachPublishableKeyScopes( + req: MedusaRequest & { publishableApiKeyScopes: any }, + res: MedusaResponse, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const remoteQuery = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "api_key", + fields: ["sales_channels.id"], + variables: { + filters: { token: pubKey }, + }, + }) + + const [apiKey] = await remoteQuery(queryObject) + + req.publishableApiKeyScopes = { + sales_channel_ids: apiKey?.sales_channels.map((sc) => sc.id) ?? [], + } + } + + next() +}