diff --git a/packages/rtn-web-browser/jest.config.js b/packages/rtn-web-browser/jest.config.js index 3a0e3340f9f..fd22133a060 100644 --- a/packages/rtn-web-browser/jest.config.js +++ b/packages/rtn-web-browser/jest.config.js @@ -2,10 +2,10 @@ module.exports = { ...require('../../jest.config'), coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 25, + functions: 25, + lines: 35, + statements: 35, }, }, }; diff --git a/packages/rtn-web-browser/package.json b/packages/rtn-web-browser/package.json index 89f4e878b43..ee5e6408bde 100644 --- a/packages/rtn-web-browser/package.json +++ b/packages/rtn-web-browser/package.json @@ -11,9 +11,9 @@ "access": "public" }, "scripts": { - "prepare:ios": "echo 'no-op'", + "prepare:ios": "echo 'no-op'", "prepare:android": "echo 'no-op'", - "test": "echo 'no-op'", + "test": "jest -w 1 --coverage --logHeapUsage", "test:ios": "echo 'no-op'", "test:android": "./android/gradlew test -p ./android", "build-with-test": "npm run clean && npm test && tsc", diff --git a/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts b/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts new file mode 100644 index 00000000000..afbb034ff6e --- /dev/null +++ b/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Platform } from 'react-native'; + +import { nativeModule } from '../../nativeModule'; +import { isChromebook, openAuthSessionAsync } from '../openAuthSessionAsync'; + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + AppState: { addEventListener: jest.fn() }, + Linking: { addEventListener: jest.fn() }, + NativeModules: {}, +})); + +jest.mock('../../nativeModule', () => ({ + nativeModule: { openAuthSessionAsync: jest.fn() }, +})); + +const mockPlatform = Platform as jest.Mocked; +const mockNativeModule = nativeModule as jest.Mocked; + +describe('openAuthSessionAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPlatform.OS = 'ios'; + }); + + describe('isChromebook', () => { + it('returns false by default', async () => { + const result = await isChromebook(); + expect(result).toBe(false); + }); + }); + + describe('openAuthSessionAsync', () => { + it('enforces HTTPS on URLs', async () => { + mockNativeModule.openAuthSessionAsync.mockResolvedValue('result'); + + await openAuthSessionAsync('http://example.com', ['myapp://callback']); + + expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.com', + 'myapp://callback', + false, + ); + }); + + it('calls iOS implementation', async () => { + mockNativeModule.openAuthSessionAsync.mockResolvedValue('result'); + + const result = await openAuthSessionAsync( + 'https://example.com', + ['myapp://callback'], + true, + ); + + expect(result).toBe('result'); + expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.com', + 'myapp://callback', + true, + ); + }); + + it('finds first non-web URL for redirect', async () => { + const redirectUrls = ['https://web.com', 'myapp://deep']; + + await openAuthSessionAsync('https://example.com', redirectUrls); + + expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.com', + 'myapp://deep', + false, + ); + }); + }); +}); diff --git a/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts b/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts index 7ddee8ec30f..ff847256ea7 100644 --- a/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts +++ b/packages/rtn-web-browser/src/apis/openAuthSessionAsync.ts @@ -5,6 +5,7 @@ import { AppState, Linking, NativeEventSubscription, + NativeModules, Platform, } from 'react-native'; @@ -13,6 +14,32 @@ import { nativeModule } from '../nativeModule'; let appStateListener: NativeEventSubscription | undefined; let redirectListener: NativeEventSubscription | undefined; +export async function isChromebook(): Promise { + // expo go + try { + const Device = require('expo-device'); + if (Device?.hasPlatformFeatureAsync) { + if (await Device.hasPlatformFeatureAsync('org.chromium.arc')) return true; + if ( + await Device.hasPlatformFeatureAsync( + 'org.chromium.arc.device_management', + ) + ) + return true; + } + } catch { + // not using Expo + } + + // fallback to native module + try { + const nm = (NativeModules as any)?.ChromeOS; + if (nm?.isChromeOS) return !!(await nm.isChromeOS()); + } catch {} + + return false; +} + export const openAuthSessionAsync = async ( url: string, redirectUrls: string[], @@ -25,6 +52,15 @@ export const openAuthSessionAsync = async ( } if (Platform.OS === 'android') { + try { + const isChromebookRes = await isChromebook(); + if (isChromebookRes) { + return openAuthSessionChromeOs(httpsUrl, redirectUrls); + } + } catch { + // ignore and fallback to android + } + return openAuthSessionAndroid(httpsUrl, redirectUrls); } }; @@ -66,6 +102,27 @@ const openAuthSessionAndroid = async (url: string, redirectUrls: string[]) => { } }; +const openAuthSessionChromeOs = async (url: string, redirectUrls: string[]) => { + try { + const [redirectUrl] = await Promise.all([ + Promise.race([ + // wait for app to redirect, resulting in a redirectUrl + getRedirectPromise(redirectUrls), + // wait for app to return some other way, resulting in null + getAppStatePromise(), + ]), + Linking.openURL(url), + ]); + + if (redirectUrl) Linking.openURL(redirectUrl); + + return redirectUrl; + } finally { + removeAppStateListener(); + removeRedirectListener(); + } +}; + const getAppStatePromise = (): Promise => new Promise(resolve => { // remove any stray listeners before creating new ones