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
265 changes: 264 additions & 1 deletion platforms/web/src/checkout-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,12 +743,61 @@ describe("<shopify-checkout>", () => {
});

describe("message routing", () => {
it("handles protocol messages from any HTTPS origin when the source matches", async () => {
it("accepts protocol messages from the cart URL origin by default", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: new URL(checkout.src).origin,
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("accepts protocol messages from shop.app by default", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: "https://shop.app",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("drops protocol messages from an untrusted HTTPS origin by default", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).not.toHaveBeenCalled();
expect(checkout.checkout).toBeUndefined();
});

it("accepts protocol messages from a configured allowed origin", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://other.example.com",
});
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: "https://other.example.com",
Expand All @@ -759,6 +808,167 @@ describe("<shopify-checkout>", () => {
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("accepts an exact configured origin with a trailing slash", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://other.example.com/",
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
});

it.each([
"https://user@other.example.com",
"https://other.example.com/path",
"https://other.example.com?query=value",
"https://other.example.com#fragment",
])("ignores a configured URL that is not an origin: %s", async (pattern) => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": pattern,
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).not.toHaveBeenCalled();
});

it("accepts protocol messages from a shop.app subdomain by default", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: "https://checkout.shop.app",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("accepts protocol messages matching a configured wildcard subdomain", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://*.example.com",
});
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: "https://fr.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("normalizes a default HTTPS port in a configured wildcard subdomain", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://*.example.com:443",
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://checkout.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
});

it("normalizes a default HTTPS port in an exact configured origin", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://other.example.com:443",
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
});

it("supports configured origins in browsers without URL.canParse", async () => {
const originalCanParse = URL.canParse;
Object.defineProperty(URL, "canParse", { configurable: true, value: undefined });

try {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://other.example.com",
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
} finally {
Object.defineProperty(URL, "canParse", { configurable: true, value: originalCanParse });
}
});

it("does not match the apex origin for a wildcard subdomain pattern", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "https://*.example.com",
});
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).not.toHaveBeenCalled();
expect(checkout.checkout).toBeUndefined();
});

it("accepts protocol messages from any origin when allowedOrigins includes '*'", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({
"allowed-origins": "*",
});
const onStartSpy = vi.fn();
const payload = makeCheckoutPayload();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", payload, {
source: mockCheckoutWindow,
origin: "https://anything.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).toHaveBeenCalledOnce();
expect(checkout.checkout).toEqual(decodeCheckout(payload));
});

it("drops protocol messages when the source is not the checkout window", async () => {
const { checkout } = openPopupCheckout();
const otherWindow = createMockWindow();
Expand Down Expand Up @@ -858,6 +1068,59 @@ describe("<shopify-checkout>", () => {
});
});

describe("onMessageRejected callback", () => {
it("invokes onMessageRejected with origin, data, and reason for dropped messages", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onMessageRejected = vi.fn();
checkout.onMessageRejected = onMessageRejected;
const onStartSpy = vi.fn();
checkout.addEventListener("ec.start", onStartSpy);

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(onStartSpy).not.toHaveBeenCalled();
expect(onMessageRejected).toHaveBeenCalledOnce();
expect(onMessageRejected).toHaveBeenCalledWith(
expect.objectContaining({
origin: "https://other.example.com",
reason: expect.stringContaining("not in allowlist"),
data: expect.objectContaining({ method: "ec.start" }),
}),
);
});

it("falls back to a warning when onMessageRejected is not set", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" });
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: "https://other.example.com",
});
await flushProtocolDispatch();

expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("not in allowlist"));
});

it("does not invoke onMessageRejected for trusted origins", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const onMessageRejected = vi.fn();
checkout.onMessageRejected = onMessageRejected;

simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), {
source: mockCheckoutWindow,
origin: new URL(checkout.src).origin,
});
await flushProtocolDispatch();

expect(onMessageRejected).not.toHaveBeenCalled();
});
});

describe("addEventListener override", () => {
it("is a no-op when called with a null listener", () => {
const checkout = renderCheckout();
Expand Down
Loading
Loading