Harden checkout activity API flow
This commit is contained in:
parent
4e46608c35
commit
11b656c327
18
README.md
18
README.md
|
|
@ -18,7 +18,7 @@ This project follows:
|
|||
6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`.
|
||||
7. Extension renders a button and opens `redirectUrl` in the current window.
|
||||
|
||||
Failures return `{ "success": false }`; the extension renders nothing and does not block the native Thank You Page.
|
||||
Business failures return `{ "success": false }`; route-level validation errors can include an `error` object and non-2xx status. The extension renders nothing for either case and does not block the native Thank You Page.
|
||||
|
||||
## Local Setup
|
||||
|
||||
|
|
@ -153,7 +153,19 @@ Failure response:
|
|||
}
|
||||
```
|
||||
|
||||
CORS headers are returned for `POST` and `OPTIONS`; source trust relies on Shopify checkout session token validation.
|
||||
Route-level validation failures include an error code and use the matching HTTP status:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ORDER_ID_REQUIRED",
|
||||
"message": "orderId is required."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All route responses include `Cache-Control: no-store, max-age=0`. Authenticated `POST` and `OPTIONS` responses are wrapped with Shopify's checkout CORS helper; fallback CORS is only used when authentication itself fails before the helper is available. Source trust relies on Shopify checkout session token validation.
|
||||
|
||||
## Dreame Signing
|
||||
|
||||
|
|
@ -253,6 +265,8 @@ Current test coverage:
|
|||
- `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data.
|
||||
- `test/config.test.ts`: app-owned Metaobject default type and legacy type override.
|
||||
- `test/activity-flow.test.ts`: inactive status, non-zero status code, configurable success codes, configurable status active policy, successful push payload shape, push failure, missing email, missing customer ID.
|
||||
- `test/activity-route.test.ts`: checkout auth/CORS/cache HTTP contract, invalid shop context, missing order ID, auth failure handling.
|
||||
- `test/checkout-extension.test.ts`: extension app URL normalization, session-token POST request, non-2xx and invalid JSON fallback behavior.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,23 @@ import { findSuccessfulPush, recordSuccessfulPush } from "../services/idempotenc
|
|||
import { logActivityPush } from "../services/logger.server";
|
||||
import { getOrderDetails } from "../services/order.server";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
const CACHE_CONTROL = "no-store, max-age=0";
|
||||
const CHECKOUT_CORS_HEADERS = ["Authorization", "Content-Type"];
|
||||
const FALLBACK_CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Cache-Control": CACHE_CONTROL,
|
||||
};
|
||||
|
||||
type CheckoutAuthResult = Awaited<ReturnType<typeof authenticate.public.checkout>> & {
|
||||
admin?: unknown;
|
||||
session?: { shop?: string };
|
||||
shop?: string;
|
||||
};
|
||||
|
||||
type CheckoutCors = CheckoutAuthResult["cors"];
|
||||
|
||||
type ActivityRequestBody = {
|
||||
orderId?: string;
|
||||
orderNumber?: string;
|
||||
|
|
@ -22,35 +33,58 @@ type ActivityRequestBody = {
|
|||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
return handleOptions(request);
|
||||
}
|
||||
|
||||
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
|
||||
return fallbackCors(
|
||||
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
|
||||
Allow: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
return handleOptions(request);
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
|
||||
return fallbackCors(
|
||||
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
|
||||
Allow: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let authResult: CheckoutAuthResult;
|
||||
|
||||
try {
|
||||
authResult = (await authenticate.public.checkout(request, {
|
||||
corsHeaders: CHECKOUT_CORS_HEADERS,
|
||||
})) as CheckoutAuthResult;
|
||||
} catch (error) {
|
||||
return authErrorResponse(error);
|
||||
}
|
||||
|
||||
const { cors, sessionToken } = authResult;
|
||||
const shop = normalizeShopDomain(authResult.shop || authResult.session?.shop || sessionToken.dest);
|
||||
if (!shop) {
|
||||
return checkoutCors(
|
||||
cors,
|
||||
errorResponse("INVALID_SHOP_CONTEXT", "A valid Shopify shop context is required.", 401),
|
||||
);
|
||||
}
|
||||
|
||||
const body = await parseJsonBody(request);
|
||||
if (!body) {
|
||||
return checkoutCors(cors, errorResponse("INVALID_JSON", "Request body must be valid JSON.", 400));
|
||||
}
|
||||
|
||||
if (typeof body.orderId !== "string" || body.orderId.trim() === "") {
|
||||
return checkoutCors(cors, errorResponse("ORDER_ID_REQUIRED", "orderId is required.", 400));
|
||||
}
|
||||
|
||||
try {
|
||||
const checkoutAuth = (authenticate as unknown as { public?: { checkout?: (request: Request) => Promise<unknown> } })
|
||||
.public?.checkout;
|
||||
if (!checkoutAuth) {
|
||||
throw new Error("Shopify checkout authentication is unavailable");
|
||||
}
|
||||
|
||||
const authResult = (await checkoutAuth(request)) as {
|
||||
shop?: string;
|
||||
admin?: unknown;
|
||||
sessionToken?: { dest?: string; iss?: string };
|
||||
session?: { shop?: string };
|
||||
};
|
||||
const shop = authResult.shop || authResult.session?.shop || shopFromSessionToken(authResult.sessionToken);
|
||||
const adminResult = authResult.admin ? { admin: authResult.admin } : shop ? await unauthenticated.admin(shop) : null;
|
||||
const admin = adminResult?.admin as {
|
||||
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
|
||||
|
|
@ -59,15 +93,10 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||
throw new Error("Invalid Shopify checkout session");
|
||||
}
|
||||
|
||||
const body = (await request.json()) as ActivityRequestBody;
|
||||
if (!body.orderId) {
|
||||
return json({ success: false }, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
const result = await runActivityCheck(
|
||||
{
|
||||
shop,
|
||||
orderId: body.orderId,
|
||||
orderId: body.orderId.trim(),
|
||||
orderNumber: body.orderNumber,
|
||||
locale: body.locale,
|
||||
},
|
||||
|
|
@ -84,21 +113,90 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||
},
|
||||
);
|
||||
|
||||
return json(result, { headers: CORS_HEADERS });
|
||||
return checkoutCors(cors, json(result, { headers: noStoreHeaders() }));
|
||||
} catch {
|
||||
return json({ success: false }, { headers: CORS_HEADERS });
|
||||
return checkoutCors(cors, errorResponse("ACTIVITY_CHECK_FAILED", "Activity check failed.", 500));
|
||||
}
|
||||
}
|
||||
|
||||
function shopFromSessionToken(sessionToken: { dest?: string; iss?: string } | undefined): string | undefined {
|
||||
const source = sessionToken?.dest || sessionToken?.iss;
|
||||
if (!source) {
|
||||
return undefined;
|
||||
async function handleOptions(request: Request): Promise<Response> {
|
||||
try {
|
||||
const { cors } = await authenticate.public.checkout(request, {
|
||||
corsHeaders: CHECKOUT_CORS_HEADERS,
|
||||
});
|
||||
return checkoutCors(cors, new Response(null, { status: 204, headers: noStoreHeaders() }));
|
||||
} catch (error) {
|
||||
return authErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonBody(request: Request): Promise<ActivityRequestBody | null> {
|
||||
try {
|
||||
return (await request.json()) as ActivityRequestBody;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShopDomain(value: unknown): string | null {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(source).hostname;
|
||||
const url = value.includes("://") ? value : `https://${value}`;
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname.endsWith(".myshopify.com") ? hostname : null;
|
||||
} catch {
|
||||
return source.replace(/^https?:\/\//, "").split("/")[0];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorResponse(code: string, message: string, status: number, headers: HeadersInit = {}): Response {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
},
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
...headers,
|
||||
...noStoreHeaders(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function authErrorResponse(error: unknown): Response {
|
||||
if (error instanceof Response) {
|
||||
return fallbackCors(withNoStore(error));
|
||||
}
|
||||
|
||||
return fallbackCors(errorResponse("CHECKOUT_AUTHENTICATION_FAILED", "Checkout authentication failed.", 401));
|
||||
}
|
||||
|
||||
function checkoutCors(cors: CheckoutCors, response: Response): Response {
|
||||
return cors(withNoStore(response));
|
||||
}
|
||||
|
||||
function fallbackCors(response: Response): Response {
|
||||
for (const [key, value] of Object.entries(FALLBACK_CORS_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function withNoStore(response: Response): Response {
|
||||
response.headers.set("Cache-Control", CACHE_CONTROL);
|
||||
return response;
|
||||
}
|
||||
|
||||
function noStoreHeaders(): HeadersInit {
|
||||
return {
|
||||
"Cache-Control": CACHE_CONTROL,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ curl -i -X OPTIONS https://<public-backend-domain>/api/action/activity/check
|
|||
|
||||
- 首页或健康页返回 200。
|
||||
- OPTIONS 返回 204,并包含 CORS headers。
|
||||
- 未带 Shopify checkout session token 的 POST 应返回 `{"success":false}`。
|
||||
- 未带 Shopify checkout session token 的 POST 应返回 HTTP 4xx,且不应触发第三方推送。
|
||||
|
||||
## 11. 部署 Shopify 配置和扩展
|
||||
|
||||
|
|
|
|||
|
|
@ -97,11 +97,25 @@ Content-Type: application/json
|
|||
}
|
||||
```
|
||||
|
||||
路由级校验失败会返回对应 HTTP 状态码和错误码,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ORDER_ID_REQUIRED",
|
||||
"message": "orderId is required."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 前端不传 `api_secret`。
|
||||
- 前端传入的订单字段只作为最小定位信息,不作为可信业务数据。
|
||||
- 完整订单数据由后端通过 Shopify Admin GraphQL 查询。
|
||||
- `POST` 和 `OPTIONS` 响应带 `Cache-Control: no-store, max-age=0`。
|
||||
- 已认证的 Checkout Extension 响应使用 Shopify checkout CORS helper 包装;认证失败前无法拿到 helper 时使用最小 fallback CORS。
|
||||
- CORS 对 Checkout Extension 放开,来源可信由 session token 校验负责。
|
||||
|
||||
## 5. Shopify 配置
|
||||
|
|
@ -445,7 +459,7 @@ curl -i -X OPTIONS https://<backend-domain>/api/action/activity/check
|
|||
|
||||
- 首页或健康页返回 200。
|
||||
- OPTIONS 返回 204,并包含 CORS headers。
|
||||
- 未带 session token 的 POST 不应成功推送。
|
||||
- 未带 session token 的 POST 应返回 HTTP 4xx,不应成功推送。
|
||||
|
||||
### 11.5 部署 Shopify 配置和 Extension
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +1,33 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { reactExtension, Button, InlineStack, useApi } from "@shopify/ui-extensions-react/checkout";
|
||||
|
||||
type ActivityResponse =
|
||||
| {
|
||||
success: true;
|
||||
redirectUrl: string;
|
||||
buttonText: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
};
|
||||
import {
|
||||
getAppUrlSetting,
|
||||
getCheckoutLocale,
|
||||
getCheckoutOrder,
|
||||
requestActivityCheck,
|
||||
type ActivityResponse,
|
||||
type CheckoutExtensionApi,
|
||||
} from "./activity-api";
|
||||
|
||||
export default reactExtension("purchase.thank-you.block.render", () => <DreameActivity />);
|
||||
|
||||
function DreameActivity() {
|
||||
const api = useApi<"purchase.thank-you.block.render">() as any;
|
||||
const api = useApi<"purchase.thank-you.block.render">() as unknown as CheckoutExtensionApi;
|
||||
const [response, setResponse] = useState<ActivityResponse>({ success: false });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkActivity() {
|
||||
const appUrl = String(api.settings?.current?.app_url || "").replace(/\/$/, "");
|
||||
const order = api.orderConfirmation?.current?.order || api.orderConfirmation?.value?.order;
|
||||
const orderId = order?.id;
|
||||
if (!appUrl || !orderId) {
|
||||
return;
|
||||
}
|
||||
const result = await requestActivityCheck({
|
||||
appUrl: getAppUrlSetting(api),
|
||||
order: getCheckoutOrder(api),
|
||||
locale: getCheckoutLocale(api),
|
||||
getSessionToken: () => api.sessionToken.get(),
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await api.sessionToken.get();
|
||||
const locale =
|
||||
api.localization?.current?.language?.isoCode ||
|
||||
api.localization?.value?.language?.isoCode ||
|
||||
api.i18n?.locale ||
|
||||
"en";
|
||||
const result = (await fetch(`${appUrl}/api/action/activity/check`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orderId,
|
||||
orderNumber: order?.name,
|
||||
locale,
|
||||
}),
|
||||
}).then((res) => res.json())) as ActivityResponse;
|
||||
|
||||
if (!cancelled) {
|
||||
setResponse(result.success ? result : { success: false });
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setResponse({ success: false });
|
||||
}
|
||||
if (!cancelled) {
|
||||
setResponse(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
export type ActivityResponse =
|
||||
| {
|
||||
success: true;
|
||||
redirectUrl: string;
|
||||
buttonText: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
};
|
||||
|
||||
export type CheckoutOrder = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type CheckoutExtensionApi = {
|
||||
settings?: {
|
||||
current?: {
|
||||
app_url?: unknown;
|
||||
};
|
||||
value?: {
|
||||
app_url?: unknown;
|
||||
};
|
||||
};
|
||||
orderConfirmation?: {
|
||||
current?: {
|
||||
order?: CheckoutOrder;
|
||||
};
|
||||
value?: {
|
||||
order?: CheckoutOrder;
|
||||
};
|
||||
};
|
||||
localization?: {
|
||||
current?: {
|
||||
language?: {
|
||||
isoCode?: string;
|
||||
};
|
||||
};
|
||||
value?: {
|
||||
language?: {
|
||||
isoCode?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
i18n?: {
|
||||
locale?: string;
|
||||
};
|
||||
sessionToken: {
|
||||
get: () => Promise<string>;
|
||||
};
|
||||
};
|
||||
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
type RequestActivityCheckOptions = {
|
||||
appUrl: unknown;
|
||||
order?: CheckoutOrder;
|
||||
locale: string;
|
||||
getSessionToken: () => Promise<string>;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function normalizeAppUrl(rawValue: unknown): string | null {
|
||||
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue.trim());
|
||||
if (url.protocol !== "https:" || url.username || url.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return `${url.origin}${pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestActivityCheck({
|
||||
appUrl,
|
||||
order,
|
||||
locale,
|
||||
getSessionToken,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS,
|
||||
}: RequestActivityCheckOptions): Promise<ActivityResponse> {
|
||||
const baseUrl = normalizeAppUrl(appUrl);
|
||||
if (!baseUrl || !order?.id) {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const token = await getSessionToken();
|
||||
if (typeof token !== "string" || token.trim() === "") {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
const endpoint = new URL("api/action/activity/check", `${baseUrl}/`).toString();
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orderId: order.id,
|
||||
orderNumber: order.name,
|
||||
locale,
|
||||
}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
return toActivityResponse(payload);
|
||||
} catch {
|
||||
return hiddenActivityResponse();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppUrlSetting(api: CheckoutExtensionApi): unknown {
|
||||
return api.settings?.current?.app_url ?? api.settings?.value?.app_url;
|
||||
}
|
||||
|
||||
export function getCheckoutOrder(api: CheckoutExtensionApi): CheckoutOrder | undefined {
|
||||
return api.orderConfirmation?.current?.order ?? api.orderConfirmation?.value?.order;
|
||||
}
|
||||
|
||||
export function getCheckoutLocale(api: CheckoutExtensionApi): string {
|
||||
return (
|
||||
api.localization?.current?.language?.isoCode ||
|
||||
api.localization?.value?.language?.isoCode ||
|
||||
api.i18n?.locale ||
|
||||
"en"
|
||||
);
|
||||
}
|
||||
|
||||
function toActivityResponse(payload: unknown): ActivityResponse {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
const response = payload as Record<string, unknown>;
|
||||
if (response.success !== true) {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
if (typeof response.redirectUrl !== "string" || response.redirectUrl.trim() === "") {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
if (typeof response.buttonText !== "string" || response.buttonText.trim() === "") {
|
||||
return hiddenActivityResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redirectUrl: response.redirectUrl,
|
||||
buttonText: response.buttonText,
|
||||
};
|
||||
}
|
||||
|
||||
function hiddenActivityResponse(): ActivityResponse {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkout: vi.fn(),
|
||||
unauthenticatedAdmin: vi.fn(),
|
||||
runActivityCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../app/shopify.server", () => ({
|
||||
authenticate: {
|
||||
public: {
|
||||
checkout: mocks.checkout,
|
||||
},
|
||||
},
|
||||
unauthenticated: {
|
||||
admin: mocks.unauthenticatedAdmin,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/activity.server", () => ({
|
||||
runActivityCheck: mocks.runActivityCheck,
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/config.server", () => ({
|
||||
getActivityConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/dreame-client.server", () => ({
|
||||
callPush: vi.fn(),
|
||||
callStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/idempotency.server", () => ({
|
||||
findSuccessfulPush: vi.fn(),
|
||||
recordSuccessfulPush: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/logger.server", () => ({
|
||||
logActivityPush: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../app/services/order.server", () => ({
|
||||
getOrderDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function cors(response: Response): Response {
|
||||
response.headers.set("X-Cors-Applied", "true");
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postAction(body: unknown): Promise<Response> {
|
||||
const { action } = await import("../app/routes/api.action.activity.check");
|
||||
return action({
|
||||
request: new Request("https://app.example.com/api/action/activity/check", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
context: {},
|
||||
params: {},
|
||||
} as ActionFunctionArgs) as Promise<Response>;
|
||||
}
|
||||
|
||||
describe("activity check route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkout.mockResolvedValue({
|
||||
sessionToken: {
|
||||
dest: "https://demo.myshopify.com",
|
||||
},
|
||||
cors,
|
||||
});
|
||||
mocks.unauthenticatedAdmin.mockResolvedValue({
|
||||
admin: {
|
||||
graphql: vi.fn(),
|
||||
},
|
||||
});
|
||||
mocks.runActivityCheck.mockResolvedValue({
|
||||
success: true,
|
||||
redirectUrl: "https://activity.example.com",
|
||||
buttonText: "Claim",
|
||||
});
|
||||
});
|
||||
|
||||
test("wraps success responses with Shopify cors and no-store cache headers", async () => {
|
||||
const response = await postAction({
|
||||
orderId: "gid://shopify/Order/123",
|
||||
orderNumber: "#1001",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Cors-Applied")).toBe("true");
|
||||
expect(response.headers.get("Cache-Control")).toContain("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
redirectUrl: "https://activity.example.com",
|
||||
buttonText: "Claim",
|
||||
});
|
||||
expect(mocks.runActivityCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
shop: "demo.myshopify.com",
|
||||
orderId: "gid://shopify/Order/123",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects checkout tokens without a myshopify shop context", async () => {
|
||||
mocks.checkout.mockResolvedValue({
|
||||
sessionToken: {
|
||||
dest: "https://not-shop.example.com",
|
||||
},
|
||||
cors,
|
||||
});
|
||||
|
||||
const response = await postAction({
|
||||
orderId: "gid://shopify/Order/123",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("X-Cors-Applied")).toBe("true");
|
||||
expect(response.headers.get("Cache-Control")).toContain("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_SHOP_CONTEXT",
|
||||
message: "A valid Shopify shop context is required.",
|
||||
},
|
||||
});
|
||||
expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled();
|
||||
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns a bad request when orderId is missing", async () => {
|
||||
const response = await postAction({
|
||||
orderNumber: "#1001",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("X-Cors-Applied")).toBe("true");
|
||||
expect(response.headers.get("Cache-Control")).toContain("no-store");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: "ORDER_ID_REQUIRED",
|
||||
message: "orderId is required.",
|
||||
},
|
||||
});
|
||||
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("preserves Shopify authentication failures as HTTP errors", async () => {
|
||||
mocks.checkout.mockRejectedValue(new Response("Unauthorized", { status: 401 }));
|
||||
|
||||
const response = await postAction({
|
||||
orderId: "gid://shopify/Order/123",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await response.text()).toBe("Unauthorized");
|
||||
expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled();
|
||||
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses Shopify checkout authentication for OPTIONS responses", async () => {
|
||||
const { loader } = await import("../app/routes/api.action.activity.check");
|
||||
const response = await loader({
|
||||
request: new Request("https://app.example.com/api/action/activity/check", {
|
||||
method: "OPTIONS",
|
||||
}),
|
||||
context: {},
|
||||
params: {},
|
||||
} as LoaderFunctionArgs);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("X-Cors-Applied")).toBe("true");
|
||||
expect(response.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(mocks.checkout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
import { normalizeAppUrl, requestActivityCheck } from "../extensions/thank-you-activity/src/activity-api";
|
||||
|
||||
describe("checkout extension activity api", () => {
|
||||
test("normalizes only HTTPS app URLs", () => {
|
||||
expect(normalizeAppUrl("https://app.example.com/")).toBe("https://app.example.com");
|
||||
expect(normalizeAppUrl(" https://app.example.com/path/ ")).toBe("https://app.example.com/path");
|
||||
expect(normalizeAppUrl("http://localhost:3000")).toBeNull();
|
||||
expect(normalizeAppUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
test("posts order context with a fresh checkout session token", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
redirectUrl: "https://activity.example.com",
|
||||
buttonText: "Claim",
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const getSessionToken = vi.fn(async () => "checkout-token");
|
||||
|
||||
const result = await requestActivityCheck({
|
||||
appUrl: "https://app.example.com/",
|
||||
order: {
|
||||
id: "gid://shopify/Order/123",
|
||||
name: "#1001",
|
||||
},
|
||||
locale: "en",
|
||||
getSessionToken,
|
||||
fetchImpl,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
redirectUrl: "https://activity.example.com",
|
||||
buttonText: "Claim",
|
||||
});
|
||||
expect(getSessionToken).toHaveBeenCalledOnce();
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://app.example.com/api/action/activity/check",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: "Bearer checkout-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orderId: "gid://shopify/Order/123",
|
||||
orderNumber: "#1001",
|
||||
locale: "en",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns hidden state for non-2xx and invalid JSON responses", async () => {
|
||||
const nonOk = await requestActivityCheck({
|
||||
appUrl: "https://app.example.com",
|
||||
order: { id: "gid://shopify/Order/123" },
|
||||
locale: "en",
|
||||
getSessionToken: async () => "checkout-token",
|
||||
fetchImpl: async () => new Response(JSON.stringify({ success: true }), { status: 500 }),
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
const invalidJson = await requestActivityCheck({
|
||||
appUrl: "https://app.example.com",
|
||||
order: { id: "gid://shopify/Order/123" },
|
||||
locale: "en",
|
||||
getSessionToken: async () => "checkout-token",
|
||||
fetchImpl: async () => new Response("not json", { status: 200 }),
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(nonOk).toEqual({ success: false });
|
||||
expect(invalidJson).toEqual({ success: false });
|
||||
});
|
||||
|
||||
test("does not call the backend when app URL or order ID is unavailable", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
const invalidUrl = await requestActivityCheck({
|
||||
appUrl: "http://localhost:3000",
|
||||
order: { id: "gid://shopify/Order/123" },
|
||||
locale: "en",
|
||||
getSessionToken: async () => "checkout-token",
|
||||
fetchImpl,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const missingOrder = await requestActivityCheck({
|
||||
appUrl: "https://app.example.com",
|
||||
order: undefined,
|
||||
locale: "en",
|
||||
getSessionToken: async () => "checkout-token",
|
||||
fetchImpl,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(invalidUrl).toEqual({ success: false });
|
||||
expect(missingOrder).toEqual({ success: false });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue