diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index f640146f0..edb9536ae 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -82,6 +82,9 @@ const ShopifyCheckoutKit = { onDispatch: jest.fn((callback: (envelopeJson: string) => void) => shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback), ), + onMessageRejected: jest.fn(callback => + shopifyCheckoutKitEventEmitter.addListener('onMessageRejected', callback), + ), preload: jest.fn(), present: jest.fn(), dismiss: jest.fn(), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 40a3354b5..bf00a6952 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -13,10 +13,13 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; +import kotlin.Unit; public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { @@ -125,6 +128,8 @@ public WritableMap getConfig() { resultConfig.putString("colorScheme", colorSchemeStringFor(checkoutConfig.getAppearance())); resultConfig.putString("logLevel", logLevelStringFor(checkoutConfig.getLogLevel())); resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); + resultConfig.putArray("allowedMessageOrigins", + Arguments.fromList(new ArrayList<>(checkoutConfig.getAllowedMessageOrigins()))); return resultConfig; } @@ -140,6 +145,24 @@ public void setConfig(ReadableMap config) { configuration.setPreloading(new Preloading(config.getBoolean("preloading"))); } + if (config.hasKey("allowedMessageOrigins")) { + configuration.setAllowedMessageOrigins(toStringSet(config.getArray("allowedMessageOrigins"))); + } + + if (config.hasKey("hasMessageRejectedCallback") + && config.getBoolean("hasMessageRejectedCallback")) { + configuration.setOnMessageRejected(rejection -> { + WritableMap detail = Arguments.createMap(); + detail.putString("origin", rejection.getOrigin()); + detail.putString("message", rejection.getMessage()); + detail.putString("reason", rejection.getReason()); + emitOnMessageRejected(detail); + return Unit.INSTANCE; + }); + } else { + configuration.setOnMessageRejected(null); + } + if (config.hasKey("logLevel")) { LogLevel logLevel = logLevelFor(config.getString("logLevel")); @@ -275,6 +298,20 @@ static String colorSchemeStringFor(CheckoutAppearance appearance) { return STOREFRONT_COLOR_SCHEME; } + private static Set toStringSet(ReadableArray array) { + Set values = new HashSet<>(); + if (array == null) { + return values; + } + for (int i = 0; i < array.size(); i++) { + String value = array.getString(i); + if (value != null) { + values.add(value); + } + } + return values; + } + static LogLevel logLevelFor(String logLevel) { if (logLevel == null) { return null; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index 03b527b8b..ed2276e0b 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -201,8 +201,15 @@ export enum ColorScheme { storefront = "storefront" } -// Warning: (ae-forgotten-export) The symbol "CommonConfiguration" needs to be exported by the entry point index.d.ts -// +// @public (undocumented) +export interface CommonConfiguration { + allowedMessageOrigins?: string[]; + logLevel?: LogLevel; + onMessageRejected?: (detail: RejectedMessage) => void; + preloading?: boolean; + title?: string; +} + // @public (undocumented) export type Configuration = CommonConfiguration & { acceleratedCheckouts?: AcceleratedCheckoutConfiguration; @@ -274,6 +281,16 @@ export interface PresentCallbacks { // @public (undocumented) export type ProtocolHandlers = ProtocolHandlers_2; +// @public (undocumented) +export interface RejectedMessage { + // (undocumented) + message: string; + // (undocumented) + origin: string; + // (undocumented) + reason: string; +} + // @public (undocumented) export enum RenderState { // (undocumented) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index c99cf281e..a17e9ce9e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -58,6 +58,19 @@ - (void)emitOnDispatchFromSwift:(NSString *)value eventEmitterCallbackWrapper->_eventEmitterCallback("onDispatch", value); } +- (void)emitOnMessageRejectedFromSwift:(NSDictionary *)value +{ + EventEmitterCallbackWrapper *eventEmitterCallbackWrapper = + (EventEmitterCallbackWrapper *)objc_getAssociatedObject( + self, RCTShopifyCheckoutKitEventEmitterCallbackKey); + + if (eventEmitterCallbackWrapper == nil) { + return; + } + + eventEmitterCallbackWrapper->_eventEmitterCallback("onMessageRejected", value); +} + @end // TurboModule registration. `RCTModuleProviders` (generated by codegen from diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 1a35c076d..2841f0b71 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -164,12 +164,24 @@ class RCTShopifyCheckoutKit: NSObject { ShopifyCheckoutKit.configuration.preloading.enabled = preloading } + if let allowedMessageOrigins = configuration["allowedMessageOrigins"] as? [String] { + ShopifyCheckoutKit.configuration.allowedMessageOrigins = allowedMessageOrigins + } + if let colorScheme = configuration["colorScheme"] as? String, let appearance = appearanceFor(colorScheme) { ShopifyCheckoutKit.configuration.appearance = appearance } + if configuration["hasMessageRejectedCallback"] as? Bool == true { + ShopifyCheckoutKit.configuration.onMessageRejected = { [weak self] rejection in + self?.emitMessageRejected(rejection) + } + } else { + ShopifyCheckoutKit.configuration.onMessageRejected = nil + } + if let tintColorHex = iosConfig?["tintColor"] as? String { ShopifyCheckoutKit.configuration.tintColor = UIColor(hex: tintColorHex) } @@ -199,6 +211,7 @@ class RCTShopifyCheckoutKit: NSObject { "tintColor": ShopifyCheckoutKit.configuration.tintColor, "backgroundColor": ShopifyCheckoutKit.configuration.backgroundColor, "closeButtonColor": ShopifyCheckoutKit.configuration.closeButtonTintColor, + "allowedMessageOrigins": ShopifyCheckoutKit.configuration.allowedMessageOrigins, "logLevel": logLevelToString(ShopifyCheckoutKit.configuration.logLevel) ] } @@ -343,6 +356,17 @@ extension RCTShopifyCheckoutKit { perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) } + private func emitMessageRejected(_ rejection: MessageRejection) { + perform( + NSSelectorFromString("emitOnMessageRejectedFromSwift:"), + with: [ + "origin": rejection.origin, + "message": rejection.message, + "reason": rejection.reason + ] + ) + } + /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards /// it to the JS dispatch event stream. private func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx index ba370ced5..f04b89b20 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx @@ -43,6 +43,11 @@ export function ShopifyCheckoutProvider({ instance.current = new ShopifyCheckout(configuration, features); } + useEffect(() => { + const checkout = instance.current; + return () => checkout?.teardown(); + }, []); + useEffect(() => { if (!instance.current || !configuration) { return; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index e274b9993..a95cc9976 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -1,10 +1,6 @@ import type {CheckoutException} from './errors'; import type {ProtocolHandlers} from './protocol'; -import type { - ApplePayContactField, - ColorScheme, - LogLevel, -} from './enums'; +import type {ApplePayContactField, ColorScheme, LogLevel} from './enums'; export { AcceleratedCheckoutWallet, ApplePayContactField, @@ -81,7 +77,7 @@ export interface AndroidAutomaticColors { dark: AndroidColors; } -interface CommonConfiguration { +export interface CommonConfiguration { /** * Sets the title of the Checkout sheet. * @@ -113,6 +109,33 @@ interface CommonConfiguration { * @default true */ preloading?: boolean; + /** + * Origins trusted to send incoming checkout messages, in addition to the + * loaded checkout origin and `shop.app` (including its subdomains). + * + * The native surface is open by default: when this is empty (the default), + * messages from any origin are accepted. Provide one or more origins to + * restrict which origins are trusted. Entries may be exact origins + * (`https://example.com`), wildcard subdomains (`https://*.example.com`), or + * `'*'` to explicitly disable origin validation. + * + * Rejected messages are logged by the native SDK at debug level; they are + * never silently dropped. + * + * @default [] (all origins trusted) + */ + allowedMessageOrigins?: string[]; + /** + * Invoked when an incoming checkout message is rejected by origin + * validation. Treat the payload as untrusted. + */ + onMessageRejected?: (detail: RejectedMessage) => void; +} + +export interface RejectedMessage { + origin: string; + message: string; + reason: string; } export type Configuration = CommonConfiguration & { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 20ba43eab..38c885b3f 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -18,10 +18,12 @@ import type { AndroidAutomaticColors, AndroidColors, Configuration, + CommonConfiguration, Features, GeolocationRequestEvent, IosColors, PresentCallbacks, + RejectedMessage, ShopifyCheckoutKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './enums'; @@ -48,6 +50,12 @@ const defaultFeatures: Features = { handleGeolocationRequests: true, }; +// Native checkout configuration is process-global, so its rejection callback +// must also have one shared subscription and one current owner in JavaScript. +let messageRejectedSubscription: {remove: () => void} | undefined; +let messageRejectedCallback: Configuration['onMessageRejected']; +let messageRejectedOwner: ShopifyCheckout | undefined; + class ShopifyCheckout implements ShopifyCheckoutKit { private features: Features; @@ -160,7 +168,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @returns The current Configuration */ public getConfig(): Configuration { - return coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()); + return { + ...coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()), + ...(messageRejectedCallback + ? {onMessageRejected: messageRejectedCallback} + : {}), + }; } /** @@ -173,16 +186,25 @@ class ShopifyCheckout implements ShopifyCheckoutKit { configuration.acceleratedCheckouts, ); } - RNShopifyCheckoutKit.setConfig(configuration); + this.configureMessageRejectionCallback(configuration.onMessageRejected); + const nativeConfiguration = {...configuration}; + delete nativeConfiguration.onMessageRejected; + RNShopifyCheckoutKit.setConfig({ + ...nativeConfiguration, + hasMessageRejectedCallback: + typeof configuration.onMessageRejected === 'function', + }); } /** * Cleans up resources and event listeners used by the checkout sheet. - * Currently a no-op — retained as part of the public API for forward - * compatibility with future protocol-client subscriptions. */ public teardown() { this.releaseDispatchSubscription(); + if (messageRejectedOwner === this) { + this.configureMessageRejectionCallback(undefined); + RNShopifyCheckoutKit.setConfig({hasMessageRejectedCallback: false}); + } } /** @@ -233,6 +255,21 @@ class ShopifyCheckout implements ShopifyCheckoutKit { // --- private + private configureMessageRejectionCallback( + callback: Configuration['onMessageRejected'], + ): void { + messageRejectedCallback = callback; + messageRejectedOwner = callback ? this : undefined; + if (callback && !messageRejectedSubscription) { + messageRejectedSubscription = RNShopifyCheckoutKit.onMessageRejected( + detail => messageRejectedCallback?.(detail), + ); + } else if (!callback && messageRejectedSubscription) { + messageRejectedSubscription.remove(); + messageRejectedSubscription = undefined; + } + } + /** * Accelerated Checkouts is only supported from iOS 16.0 onwards */ @@ -374,7 +411,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private permissionGranted(status: PermissionStatus): boolean { return status === 'granted'; } - } // API @@ -408,12 +444,14 @@ export type { CheckoutProtocolMethod, CheckoutProtocolPayloads, Configuration, + CommonConfiguration, ErrorResponse, Features, GeolocationRequestEvent, IosColors, PresentCallbacks, ProtocolHandlers, + RejectedMessage, RenderStateChangeEvent, }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index c4bcaee5e..745beb2e7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -35,6 +35,8 @@ type ConfigurationSpec = { colorScheme?: string; logLevel?: string; preloading?: boolean; + allowedMessageOrigins?: string[]; + hasMessageRejectedCallback: boolean; colors?: ColorsSpec; }; @@ -46,15 +48,20 @@ type ConfigurationResultSpec = { tintColor?: string; backgroundColor?: string; closeButtonColor?: string; + allowedMessageOrigins: string[]; +}; + +export type RejectedMessageSpec = { + origin: string; + message: string; + reason: string; }; export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; + readonly onMessageRejected: CodegenTypes.EventEmitter; - present( - checkoutUrl: string, - subscribedMethods: string[], - ): void; + present(checkoutUrl: string, subscribedMethods: string[]): void; preload(checkoutUrl: string): void; dismiss(): void; invalidateCache(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index bb8195e4b..4ca8f86b3 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -52,6 +52,28 @@ describe('ShopifyCheckoutProvider', () => { expect(component).toBeTruthy(); }); + it('removes the message rejection subscription on unmount', () => { + const remove = jest.fn(); + NativeModules.ShopifyCheckoutKit.onMessageRejected.mockReturnValueOnce({ + remove, + }); + const configuration: Configuration = { + onMessageRejected: jest.fn(), + }; + + const component = render( + + + , + ); + component.unmount(); + + expect(remove).toHaveBeenCalledTimes(1); + expect( + NativeModules.ShopifyCheckoutKit.setConfig, + ).toHaveBeenLastCalledWith({hasMessageRejectedCallback: false}); + }); + it('creates ShopifyCheckout instance with configuration', () => { render( @@ -61,7 +83,7 @@ describe('ShopifyCheckoutProvider', () => { expect( NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(config); + ).toHaveBeenCalledWith({...config, hasMessageRejectedCallback: false}); }); it('skips configuration when no configuration is provided', () => { @@ -357,7 +379,10 @@ describe('useShopifyCheckout', () => { expect( NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(newConfig); + ).toHaveBeenCalledWith({ + ...newConfig, + hasMessageRejectedCallback: false, + }); }); it('provides getConfig function', async () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index fc949e42a..6041d1cf2 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -141,6 +141,11 @@ describe('Exports', () => { }); type Dispatch = (envelopeJson: string) => void; +type MessageRejectedDispatch = (detail: { + origin: string; + message: string; + reason: string; +}) => void; function lastDispatch(): Dispatch { const dispatch = NativeModule.onDispatch.mock.calls[ @@ -154,6 +159,16 @@ function lastDispatch(): Dispatch { return dispatch; } +function lastMessageRejectedDispatch(): MessageRejectedDispatch { + const dispatch = NativeModule.onMessageRejected.mock.calls[ + NativeModule.onMessageRejected.mock.calls.length - 1 + ]?.[0] as MessageRejectedDispatch | undefined; + if (!dispatch) { + throw new Error('Expected a message rejection event subscription'); + } + return dispatch; +} + describe('ShopifyCheckoutKit', () => { afterEach(() => { NativeModule.setConfig.mockReset(); @@ -163,7 +178,10 @@ describe('ShopifyCheckoutKit', () => { describe('instantiation', () => { it('calls `setConfig` with the specified config on instantiation', () => { new ShopifyCheckout(config); - expect(NativeModule.setConfig).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...config, + hasMessageRejectedCallback: false, + }); }); it('does not call `setConfig` if no config was specified on instantiation', () => { @@ -177,7 +195,10 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); instance.setConfig(config); expect(NativeModule.setConfig).toHaveBeenCalledTimes(1); - expect(NativeModule.setConfig).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...config, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with logLevel configuration', () => { @@ -187,7 +208,10 @@ describe('ShopifyCheckoutKit', () => { logLevel: LogLevel.debug, }; instance.setConfig(configWithLogLevel); - expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithLogLevel); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...configWithLogLevel, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with preloading configuration', () => { @@ -197,7 +221,10 @@ describe('ShopifyCheckoutKit', () => { preloading: false, }; instance.setConfig(configWithPreloading); - expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithPreloading); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...configWithPreloading, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with title configuration', () => { @@ -207,7 +234,100 @@ describe('ShopifyCheckoutKit', () => { title: 'Custom Checkout', }; instance.setConfig(configWithTitle); - expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithTitle); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...configWithTitle, + hasMessageRejectedCallback: false, + }); + }); + + it('calls `setConfig` with allowedMessageOrigins configuration', () => { + const instance = new ShopifyCheckout(); + const configWithAllowedOrigins: Configuration = { + colorScheme: ColorScheme.automatic, + allowedMessageOrigins: ['https://example.com', 'https://*.example.com'], + }; + instance.setConfig(configWithAllowedOrigins); + expect(NativeModule.setConfig).toHaveBeenCalledWith( + {...configWithAllowedOrigins, hasMessageRejectedCallback: false}, + ); + }); + + it('keeps onMessageRejected in JS and forwards rejection details', () => { + const first = jest.fn(); + const second = jest.fn(); + const instance = new ShopifyCheckout({onMessageRejected: first}); + const dispatch = lastMessageRejectedDispatch(); + const detail = { + origin: 'https://untrusted.example', + message: '{"type":"test"}', + reason: 'Origin is not allowed', + }; + + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + hasMessageRejectedCallback: true, + }); + dispatch(detail); + expect(first).toHaveBeenCalledWith(detail); + + instance.setConfig({onMessageRejected: second}); + expect(NativeModule.onMessageRejected).toHaveBeenCalledTimes(1); + dispatch(detail); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledWith(detail); + + instance.teardown(); + }); + + it('keeps a single global callback owned by the latest configured instance', () => { + const remove = jest.fn(); + NativeModule.onMessageRejected.mockReturnValueOnce({remove}); + const firstCallback = jest.fn(); + const secondCallback = jest.fn(); + const first = new ShopifyCheckout({onMessageRejected: firstCallback}); + const dispatch = lastMessageRejectedDispatch(); + const second = new ShopifyCheckout({onMessageRejected: secondCallback}); + const detail = { + origin: 'https://untrusted.example', + message: '{"type":"test"}', + reason: 'Origin is not allowed', + }; + + expect(NativeModule.onMessageRejected).toHaveBeenCalledTimes(1); + dispatch(detail); + expect(firstCallback).not.toHaveBeenCalled(); + expect(secondCallback).toHaveBeenCalledWith(detail); + + first.teardown(); + expect(remove).not.toHaveBeenCalled(); + + second.teardown(); + expect(remove).toHaveBeenCalledTimes(1); + expect(NativeModule.setConfig).toHaveBeenLastCalledWith({ + hasMessageRejectedCallback: false, + }); + }); + + it('removes the message rejection subscription when the callback is cleared', () => { + const remove = jest.fn(); + NativeModule.onMessageRejected.mockReturnValueOnce({remove}); + const instance = new ShopifyCheckout({onMessageRejected: jest.fn()}); + + instance.setConfig({}); + + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('removes the message rejection subscription during teardown', () => { + const remove = jest.fn(); + NativeModule.onMessageRejected.mockReturnValueOnce({remove}); + const instance = new ShopifyCheckout({onMessageRejected: jest.fn()}); + + instance.teardown(); + + expect(remove).toHaveBeenCalledTimes(1); + expect(NativeModule.setConfig).toHaveBeenLastCalledWith({ + hasMessageRejectedCallback: false, + }); }); }); @@ -668,6 +788,27 @@ describe('ShopifyCheckoutKit', () => { expect(result.logLevel).toBe('trace'); expect(result.colorScheme).toBe('sepia'); }); + + it('returns allowed origins and the configured rejection callback', () => { + const onMessageRejected = jest.fn(); + NativeModule.getConfig.mockReturnValueOnce({ + colorScheme: 'automatic', + logLevel: 'error', + preloading: true, + allowedMessageOrigins: ['https://example.com'], + }); + const instance = new ShopifyCheckout({onMessageRejected}); + + expect(instance.getConfig()).toStrictEqual({ + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + preloading: true, + allowedMessageOrigins: ['https://example.com'], + onMessageRejected, + }); + + instance.teardown(); + }); }); describe('Geolocation', () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 1a66e8940..0baf70494 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -317,6 +317,25 @@ public void testUnknownColorSchemeKeepsTheNativeDefaultAppearance() { .isEqualTo("storefront"); } + @Test + public void testOnlyInstallsMessageRejectedCallbackWhenRequested() { + JavaOnlyMap config = new JavaOnlyMap(); + JavaOnlyArray allowedMessageOrigins = new JavaOnlyArray(); + allowedMessageOrigins.pushString("https://example.com"); + config.putArray("allowedMessageOrigins", allowedMessageOrigins); + + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNull(); + + config.putBoolean("hasMessageRejectedCallback", true); + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNotNull(); + + config.putBoolean("hasMessageRejectedCallback", false); + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNull(); + } + @Test public void testCanConfigureLightColorSchemeWithValidColors() { JavaOnlyMap androidColors = createValidLightColors(); diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index 027e8c8d9..0b1512026 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -22,6 +22,7 @@ class ShopifyCheckoutKitTests: XCTestCase { ShopifyCheckoutKit.configuration.closeButtonTintColor = nil ShopifyCheckoutKit.configuration.logLevel = LogLevel.warn ShopifyCheckoutKit.configuration.preloading.enabled = true + ShopifyCheckoutKit.configuration.onMessageRejected = nil } private func getShopifyCheckoutKit() -> RCTShopifyCheckoutKit { @@ -99,6 +100,17 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["title"] as? String, "Custom Checkout") } + func testOnlyInstallsMessageRejectedCallbackWhenRequested() { + shopifyCheckoutKit.setConfig(["allowedMessageOrigins": ["https://example.com"]]) + XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected) + + shopifyCheckoutKit.setConfig(["hasMessageRejectedCallback": true]) + XCTAssertNotNil(ShopifyCheckoutKit.configuration.onMessageRejected) + + shopifyCheckoutKit.setConfig(["hasMessageRejectedCallback": false]) + XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected) + } + func testConfigureWithInvalidColors() { let configuration: [AnyHashable: Any] = [ "colors": [