Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ describe('CheckoutProtocol', () => {
).toBe('checkout-123');
});

it('treats undefined optional checkout fields as absent but rejects undefined required fields', () => {
const checkoutEnvelope = {
id: 'checkout-123',
currency: 'USD',
status: 'incomplete',
line_items: [],
totals: [],
links: [],
ucp: {version: '2026-04-08'},
order: undefined,
fulfillment: undefined,
};

const decoded = decodeProtocolPayload(CheckoutProtocol.start, checkoutEnvelope);
expect(decoded).not.toHaveProperty('order');
expect(decoded).not.toHaveProperty('fulfillment');
expect(() =>
decodeProtocolPayload(CheckoutProtocol.start, {
...checkoutEnvelope,
totals: undefined,
}),
).toThrow('Invalid Checkout.totals');
});

it.each(checkoutPayloadMethods)(
'converts %s checkout schema fields to camelCase while preserving dynamic map keys',
method => {
Expand Down
49 changes: 49 additions & 0 deletions platforms/web/src/checkout-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,55 @@ describe("<shopify-checkout>", () => {
expect(onStartSpy).toHaveBeenCalledOnce();
});

it("delivers a structured-cloned checkout with undefined optional fields", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
const listenForEvent = waitForEvent(checkout, "start", onStartSpy);
const payload = structuredClone(
makeCheckoutPayload({ order: undefined, fulfillment: undefined }),
);

expect(Object.hasOwn(payload.checkout, "order")).toBe(true);
simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
});
await listenForEvent;

expect(onStartSpy).toHaveBeenCalledOnce();
const event = onStartSpy.mock.calls[0]![0] as CustomEvent;
expect(event.detail.checkout).not.toHaveProperty("order");
expect(event.detail.checkout).not.toHaveProperty("fulfillment");
expect(checkout.checkout).toStrictEqual(event.detail.checkout);
});

it("drops an invalid present order and records a decode error", async () => {
const telemetrySpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError");
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
checkout.addEventListener("start", onStartSpy);

simulateProtocolMessageEvent(
checkout,
"ec.start",
makeCheckoutPayload({
order: {
id: undefined,
permalink_url: "https://example.test/orders/order-1",
},
}),
{ source: mockCheckoutWindow },
);
await flushProtocolDispatch();

expect(onStartSpy).not.toHaveBeenCalled();
expect(telemetrySpy).toHaveBeenCalledWith({ method: "ec.start", failureType: "params" });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"<shopify-checkout>: dropped ec.start: failed to decode payload",
"Invalid Checkout.order.id",
);
});

it("measures navigation from before the checkout window opens", async () => {
let now = 100;
vi.spyOn(performance, "now").mockImplementation(() => now);
Expand Down
1 change: 1 addition & 0 deletions protocol/languages/typescript/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { checkoutProtocolCatalog, checkoutProtocolCatalogPayloadDecoders, checko
export { Client, type DecodeErrorContext } from './client';
export { windowOpenSuccess, windowOpenRejected } from './window_open';
export { EmbeddedCheckoutProtocol } from './embedded_checkout_protocol';
export { ProtocolValidationError, type ProtocolValidationReason, } from './protocol_codec_runtime';
4 changes: 4 additions & 0 deletions protocol/languages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ export {
export {Client, type DecodeErrorContext} from './client';
export {windowOpenSuccess, windowOpenRejected} from './window_open';
export {EmbeddedCheckoutProtocol} from './embedded_checkout_protocol';
export {
ProtocolValidationError,
type ProtocolValidationReason,
} from './protocol_codec_runtime';
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
type JSONRecord = Record<string, unknown>;
export type ProtocolValidationReason = 'missing_required' | 'invalid_type';
/** A schema path and reason that can be reported without exposing payload values. */
export declare class ProtocolValidationError extends TypeError {
readonly modelPath: string;
readonly reason: ProtocolValidationReason;
constructor(modelPath: string, reason: ProtocolValidationReason);
}
export declare function decodeProtocolObject(value: unknown, modelName: string): JSONRecord;
export declare function encodeProtocolObject(value: unknown, modelName: string): unknown;
export {};
61 changes: 50 additions & 11 deletions protocol/languages/typescript/src/protocol_codec_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ import type {RenameChild, RenameEntry} from './generated/ProtocolRenameMap';
type JSONRecord = Record<string, unknown>;
type Direction = 'decode' | 'encode';

export type ProtocolValidationReason = 'missing_required' | 'invalid_type';

/** A schema path and reason that can be reported without exposing payload values. */
export class ProtocolValidationError extends TypeError {
readonly modelPath: string;
readonly reason: ProtocolValidationReason;

constructor(modelPath: string, reason: ProtocolValidationReason) {
// The generated decoders supply model names and the checks below supply
// schema fields. Keep even direct calls with arbitrary names safe to log.
const safePath = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/.test(
modelPath,
)
? modelPath
: 'ProtocolObject';
super(`Invalid ${safePath}`);
this.name = 'ProtocolValidationError';
this.modelPath = safePath;
this.reason = reason;
}
}

const REQUIRED_FIELDS: Record<string, readonly string[]> = {
Checkout: ['currency', 'id', 'line_items', 'links', 'status', 'totals', 'ucp'],
ErrorResponse: ['messages', 'ucp'],
Expand All @@ -16,7 +38,7 @@ const REQUIRED_STRING_FIELDS: Record<string, readonly string[]> = {
WindowOpenRequest: ['url'],
};

const NESTED_REQUIRED_FIELDS: Record<string, readonly string[]> = {
const NESTED_REQUIRED_STRING_FIELDS: Record<string, readonly string[]> = {
order: ['id', 'permalink_url'],
ucp: ['version'],
};
Expand Down Expand Up @@ -49,20 +71,26 @@ function walkObject(
direction === 'decode' && modelName === 'FulfillmentOption'
? normalizeLegacyFulfillmentOptionDescription(value)
: value;
if (!entries || !isObjectRecord(input)) {
if (!isObjectRecord(input) || (!entries && direction === 'encode')) {
return input;
}

const sourceIndex = direction === 'decode' ? 0 : 1;
const targetIndex = direction === 'decode' ? 1 : 0;

const entryBySource = new Map<string, RenameEntry>();
for (const entry of entries) {
for (const entry of entries ?? []) {
entryBySource.set(entry[sourceIndex], entry);
}

const output: JSONRecord = {};
for (const [key, item] of Object.entries(input)) {
// Structured clone preserves undefined-valued own properties whereas JSON
// omits them. Only normalize objects being walked as protocol models; an
// unknown extension value is passed through without changing its contents.
if (direction === 'decode' && item === undefined) {
continue;
}
const entry = entryBySource.get(key);
if (entry) {
output[entry[targetIndex]] = walkChild(item, entry[2], direction);
Expand Down Expand Up @@ -141,19 +169,23 @@ function isObjectRecord(value: unknown): value is JSONRecord {

function requireObject(value: unknown, label: string): JSONRecord {
if (!isObjectRecord(value)) {
throw new TypeError(`Invalid ${label}`);
throw new ProtocolValidationError(label, 'invalid_type');
}
return value;
}

function hasOwnField(value: JSONRecord, field: string): boolean {
return Object.prototype.hasOwnProperty.call(value, field);
}

function requireFields(
value: JSONRecord,
fields: readonly string[],
label: string,
): void {
for (const field of fields) {
if (!(field in value)) {
throw new TypeError(`Invalid ${label}`);
if (!hasOwnField(value, field) || value[field] === undefined) {
throw new ProtocolValidationError(`${label}.${field}`, 'missing_required');
}
}
}
Expand All @@ -164,18 +196,25 @@ function requireStringFields(
label: string,
): void {
for (const field of fields) {
if (field in value && typeof value[field] !== 'string') {
throw new TypeError(`Invalid ${label}`);
if (
hasOwnField(value, field) &&
value[field] !== undefined &&
typeof value[field] !== 'string'
) {
throw new ProtocolValidationError(`${label}.${field}`, 'invalid_type');
}
}
}

function requireNestedFields(value: JSONRecord, label: string): void {
for (const [field, requiredFields] of Object.entries(NESTED_REQUIRED_FIELDS)) {
if (!(field in value)) {
for (const [field, requiredStringFields] of Object.entries(
NESTED_REQUIRED_STRING_FIELDS,
)) {
if (!hasOwnField(value, field) || value[field] === undefined) {
continue;
}
const nested = requireObject(value[field], `${label}.${field}`);
requireFields(nested, requiredFields, `${label}.${field}`);
requireFields(nested, requiredStringFields, `${label}.${field}`);
requireStringFields(nested, requiredStringFields, `${label}.${field}`);
}
}
Loading
Loading