mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
feat: handle webview proof deeplinks
This commit is contained in:
@@ -17,6 +17,7 @@ import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
|
|||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||||
import {
|
import {
|
||||||
charcoal,
|
charcoal,
|
||||||
slate200,
|
slate200,
|
||||||
@@ -27,6 +28,7 @@ import { WebViewNavBar } from '@/components/navbar/WebViewNavBar';
|
|||||||
import { WebViewFooter } from '@/components/WebViewFooter';
|
import { WebViewFooter } from '@/components/WebViewFooter';
|
||||||
import { selfUrl } from '@/consts/links';
|
import { selfUrl } from '@/consts/links';
|
||||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||||
|
import { handleUrl, parseAndValidateUrlParams } from '@/navigation/deeplinks';
|
||||||
import type { SharedRoutesParamList } from '@/navigation/types';
|
import type { SharedRoutesParamList } from '@/navigation/types';
|
||||||
import {
|
import {
|
||||||
DISALLOWED_SCHEMES,
|
DISALLOWED_SCHEMES,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
isTrustedDomain,
|
isTrustedDomain,
|
||||||
isUserInitiatedTopFrameNavigation,
|
isUserInitiatedTopFrameNavigation,
|
||||||
shouldAlwaysOpenExternally,
|
shouldAlwaysOpenExternally,
|
||||||
|
type WebViewRequestWithIosProps,
|
||||||
} from '@/utils/webview';
|
} from '@/utils/webview';
|
||||||
|
|
||||||
export interface WebViewScreenParams {
|
export interface WebViewScreenParams {
|
||||||
@@ -73,6 +76,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
|
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const selfClient = useSelfClient();
|
||||||
const params = route?.params as WebViewScreenParams | undefined;
|
const params = route?.params as WebViewScreenParams | undefined;
|
||||||
const safeParams: WebViewScreenParams = params ?? { url: defaultUrl };
|
const safeParams: WebViewScreenParams = params ?? { url: defaultUrl };
|
||||||
const { url, title } = safeParams;
|
const { url, title } = safeParams;
|
||||||
@@ -136,6 +140,37 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const interceptSelfRedirect = useCallback(
|
||||||
|
(req: WebViewNavigation & WebViewRequestWithIosProps) => {
|
||||||
|
const validatedParams = parseAndValidateUrlParams(req.url);
|
||||||
|
const hasSelfParams = Boolean(
|
||||||
|
validatedParams.selfApp || validatedParams.sessionId,
|
||||||
|
);
|
||||||
|
if (!hasSelfParams) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hostname = new URL(req.url).hostname;
|
||||||
|
const isSelfHost =
|
||||||
|
hostname === 'self.xyz' || hostname.endsWith('.self.xyz');
|
||||||
|
if (!isSelfHost || !isTrustedDomain(req.url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUserInitiatedTopFrameNavigation(req)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUrl(selfClient, req.url);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[selfClient],
|
||||||
|
);
|
||||||
|
|
||||||
const openUrl = useCallback(async (targetUrl: string) => {
|
const openUrl = useCallback(async (targetUrl: string) => {
|
||||||
// Block disallowed schemes (blacklist approach)
|
// Block disallowed schemes (blacklist approach)
|
||||||
// Allow everything else - more practical than maintaining a whitelist
|
// Allow everything else - more practical than maintaining a whitelist
|
||||||
@@ -247,6 +282,10 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
|
|||||||
onShouldStartLoadWithRequest={req => {
|
onShouldStartLoadWithRequest={req => {
|
||||||
const isHttps = /^https:\/\//i.test(req.url);
|
const isHttps = /^https:\/\//i.test(req.url);
|
||||||
|
|
||||||
|
if (interceptSelfRedirect(req)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Allow about:blank/srcdoc during trusted sessions (some wallets use this before redirecting)
|
// Allow about:blank/srcdoc during trusted sessions (some wallets use this before redirecting)
|
||||||
if (isSessionTrusted && isAllowedAboutUrl(req.url)) {
|
if (isSessionTrusted && isAllowedAboutUrl(req.url)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { LottieViewProps } from 'lottie-react-native';
|
import type { LottieViewProps } from 'lottie-react-native';
|
||||||
import LottieView from 'lottie-react-native';
|
import LottieView from 'lottie-react-native';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Linking, StyleSheet, View } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import { SystemBars } from 'react-native-edge-to-edge';
|
import { SystemBars } from 'react-native-edge-to-edge';
|
||||||
import { ScrollView, Spinner } from 'tamagui';
|
import { ScrollView, Spinner } from 'tamagui';
|
||||||
import { useIsFocused, useNavigation } from '@react-navigation/native';
|
import { useIsFocused, useNavigation } from '@react-navigation/native';
|
||||||
@@ -36,6 +36,7 @@ import type { RootStackParamList } from '@/navigation';
|
|||||||
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
|
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
|
||||||
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
|
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
|
||||||
import { ProofStatus } from '@/stores/proofTypes';
|
import { ProofStatus } from '@/stores/proofTypes';
|
||||||
|
import { handleDeeplinkCallbackNavigation } from '@/utils/deeplinkCallbacks';
|
||||||
|
|
||||||
const SuccessScreen: React.FC = () => {
|
const SuccessScreen: React.FC = () => {
|
||||||
const selfClient = useSelfClient();
|
const selfClient = useSelfClient();
|
||||||
@@ -95,6 +96,21 @@ const SuccessScreen: React.FC = () => {
|
|||||||
setCountdown(null);
|
setCountdown(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigateWithDeeplinkCallback = useCallback(
|
||||||
|
async (deeplink: string) => {
|
||||||
|
try {
|
||||||
|
await handleDeeplinkCallbackNavigation({
|
||||||
|
deeplinkCallback: deeplink,
|
||||||
|
navigation,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open deep link:', error);
|
||||||
|
onOkPress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigation, onOkPress],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
}
|
}
|
||||||
@@ -191,13 +207,15 @@ const SuccessScreen: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setCountdown(null);
|
setCountdown(null);
|
||||||
if (selfApp?.deeplinkCallback) {
|
if (selfApp?.deeplinkCallback) {
|
||||||
Linking.openURL(selfApp.deeplinkCallback).catch(err => {
|
navigateWithDeeplinkCallback(selfApp.deeplinkCallback);
|
||||||
console.error('Failed to open deep link:', err);
|
|
||||||
onOkPress();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [countdown, selfApp?.deeplinkCallback, onOkPress]);
|
}, [
|
||||||
|
countdown,
|
||||||
|
selfApp?.deeplinkCallback,
|
||||||
|
navigateWithDeeplinkCallback,
|
||||||
|
onOkPress,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
|
|||||||
|
|
||||||
import { SelfAppBuilder } from '@selfxyz/common/utils/appType';
|
import { SelfAppBuilder } from '@selfxyz/common/utils/appType';
|
||||||
|
|
||||||
import { selfLogoReverseUrl } from '@/consts/links';
|
import { appsUrl, selfLogoReverseUrl } from '@/consts/links';
|
||||||
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
|
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
|
||||||
import { POINTS_API_BASE_URL } from '@/services/points/constants';
|
import { POINTS_API_BASE_URL } from '@/services/points/constants';
|
||||||
import type { IncomingPoints } from '@/services/points/types';
|
import type { IncomingPoints } from '@/services/points/types';
|
||||||
@@ -166,18 +166,30 @@ export const hasUserDoneThePointsDisclosure = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPointsDisclosureMetadata = (walletAddress: string) => ({
|
||||||
|
action: 'points-disclosure',
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
nonce: v4(),
|
||||||
|
wallet: walletAddress.toLowerCase(),
|
||||||
|
});
|
||||||
|
|
||||||
export const pointsSelfApp = async () => {
|
export const pointsSelfApp = async () => {
|
||||||
const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0';
|
const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0';
|
||||||
|
const pointsAddress = (await getPointsAddress()).toLowerCase();
|
||||||
|
const disclosureMetadata = buildPointsDisclosureMetadata(pointsAddress);
|
||||||
const builder = new SelfAppBuilder({
|
const builder = new SelfAppBuilder({
|
||||||
appName: '✨ Self Points',
|
appName: '✨ Self Points',
|
||||||
endpoint: endpoint.toLowerCase(),
|
endpoint: endpoint.toLowerCase(),
|
||||||
endpointType: 'celo',
|
endpointType: 'celo',
|
||||||
scope: 'minimal-disclosure-quest',
|
scope: 'minimal-disclosure-quest',
|
||||||
userId: v4(),
|
userId: pointsAddress,
|
||||||
userIdType: 'uuid',
|
userIdType: 'hex',
|
||||||
disclosures: {},
|
disclosures: {},
|
||||||
logoBase64: selfLogoReverseUrl,
|
logoBase64: selfLogoReverseUrl,
|
||||||
header: '',
|
header: '',
|
||||||
|
deeplinkCallback: appsUrl,
|
||||||
|
userDefinedData: JSON.stringify(disclosureMetadata),
|
||||||
|
selfDefinedData: pointsAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
return builder.build();
|
return builder.build();
|
||||||
|
|||||||
44
app/src/utils/deeplinkCallbacks.ts
Normal file
44
app/src/utils/deeplinkCallbacks.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||||
|
|
||||||
|
import { Linking } from 'react-native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
import type { RootStackParamList } from '@/navigation';
|
||||||
|
|
||||||
|
const isSelfHostname = (hostname: string) => {
|
||||||
|
return hostname === 'self.xyz' || hostname.endsWith('.self.xyz');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelfHostedUrl = (deeplink: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(deeplink);
|
||||||
|
if (url.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isSelfHostname(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleDeeplinkCallbackNavigation = async ({
|
||||||
|
deeplinkCallback,
|
||||||
|
navigation,
|
||||||
|
}: {
|
||||||
|
deeplinkCallback: string;
|
||||||
|
navigation: NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
}) => {
|
||||||
|
if (isSelfHostedUrl(deeplinkCallback)) {
|
||||||
|
navigation.navigate('WebView', {
|
||||||
|
url: deeplinkCallback,
|
||||||
|
title: 'Explore Apps',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Linking.openURL(deeplinkCallback);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSelfHostedDeeplink = isSelfHostedUrl;
|
||||||
@@ -86,11 +86,29 @@ const mockPlatform = jest.requireMock('react-native').Platform as {
|
|||||||
select: (specifics: { ios?: unknown; android?: unknown }) => unknown;
|
select: (specifics: { ios?: unknown; android?: unknown }) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { handleUrl: mockHandleUrl, parseAndValidateUrlParams } =
|
||||||
|
jest.requireMock('@/navigation/deeplinks') as {
|
||||||
|
handleUrl: jest.Mock;
|
||||||
|
parseAndValidateUrlParams: jest.Mock;
|
||||||
|
};
|
||||||
|
const { useSelfClient: mockUseSelfClient } = jest.requireMock(
|
||||||
|
'@selfxyz/mobile-sdk-alpha',
|
||||||
|
) as { useSelfClient: jest.Mock };
|
||||||
|
|
||||||
jest.mock('@react-navigation/native', () => ({
|
jest.mock('@react-navigation/native', () => ({
|
||||||
useNavigation: jest.fn(),
|
useNavigation: jest.fn(),
|
||||||
useFocusEffect: jest.fn(),
|
useFocusEffect: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||||
|
useSelfClient: jest.fn(() => ({ getSelfAppState: jest.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/navigation/deeplinks', () => ({
|
||||||
|
handleUrl: jest.fn(),
|
||||||
|
parseAndValidateUrlParams: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('@/components/navbar/WebViewNavBar', () => ({
|
jest.mock('@/components/navbar/WebViewNavBar', () => ({
|
||||||
WebViewNavBar: ({ children, onBackPress, ...props }: any) => (
|
WebViewNavBar: ({ children, onBackPress, ...props }: any) => (
|
||||||
<mock-webview-navbar {...props}>
|
<mock-webview-navbar {...props}>
|
||||||
@@ -155,10 +173,13 @@ describe('WebViewScreen URL sanitization and navigation interception', () => {
|
|||||||
goBack: mockGoBack,
|
goBack: mockGoBack,
|
||||||
canGoBack: () => true,
|
canGoBack: () => true,
|
||||||
});
|
});
|
||||||
|
mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() });
|
||||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
mockAlert.alert.mockClear();
|
mockAlert.alert.mockClear();
|
||||||
mockLinking.canOpenURL.mockReset();
|
mockLinking.canOpenURL.mockReset();
|
||||||
mockLinking.openURL.mockReset();
|
mockLinking.openURL.mockReset();
|
||||||
|
parseAndValidateUrlParams.mockReturnValue({});
|
||||||
|
mockHandleUrl.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -297,6 +318,24 @@ describe('WebViewScreen URL sanitization and navigation interception', () => {
|
|||||||
expect(String(msg)).toContain('Failed to open externally');
|
expect(String(msg)).toContain('Failed to open externally');
|
||||||
expect(String(msg)).not.toMatch(/Failed to open URL externally/);
|
expect(String(msg)).not.toMatch(/Failed to open URL externally/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('intercepts trusted redirect self links carrying selfApp payloads', async () => {
|
||||||
|
const deeplinkUrl = 'https://redirect.self.xyz?selfApp=%7B%7D';
|
||||||
|
parseAndValidateUrlParams.mockReturnValue({
|
||||||
|
selfApp: '{}',
|
||||||
|
});
|
||||||
|
render(<WebViewScreen {...createProps('https://apps.self.xyz')} />);
|
||||||
|
const webview = screen.getByTestId('webview');
|
||||||
|
|
||||||
|
const result = await webview.props.onShouldStartLoadWithRequest?.({
|
||||||
|
url: deeplinkUrl,
|
||||||
|
isTopFrame: true,
|
||||||
|
navigationType: 'click',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockHandleUrl).toHaveBeenCalledWith(expect.any(Object), deeplinkUrl);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WebViewScreen same-origin security', () => {
|
describe('WebViewScreen same-origin security', () => {
|
||||||
@@ -321,10 +360,12 @@ describe('WebViewScreen same-origin security', () => {
|
|||||||
goBack: jest.fn(),
|
goBack: jest.fn(),
|
||||||
canGoBack: () => true,
|
canGoBack: () => true,
|
||||||
});
|
});
|
||||||
|
mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() });
|
||||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
mockAlert.alert.mockClear();
|
mockAlert.alert.mockClear();
|
||||||
mockLinking.canOpenURL.mockReset();
|
mockLinking.canOpenURL.mockReset();
|
||||||
mockLinking.openURL.mockReset();
|
mockLinking.openURL.mockReset();
|
||||||
|
parseAndValidateUrlParams.mockReturnValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
48
app/tests/src/services/points/utils.test.ts
Normal file
48
app/tests/src/services/points/utils.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||||
|
|
||||||
|
import { pointsSelfApp } from '@/services/points/utils';
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({
|
||||||
|
v4: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/providers/authProvider', () => ({
|
||||||
|
getOrGeneratePointsAddress: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUuid = jest.requireMock('uuid').v4 as jest.Mock;
|
||||||
|
const mockGetOrGeneratePointsAddress = jest.requireMock(
|
||||||
|
'@/providers/authProvider',
|
||||||
|
).getOrGeneratePointsAddress as jest.Mock;
|
||||||
|
|
||||||
|
describe('pointsSelfApp', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUuid.mockReset();
|
||||||
|
mockGetOrGeneratePointsAddress.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a SelfApp with wallet-bound metadata and deeplink callback', async () => {
|
||||||
|
mockUuid
|
||||||
|
.mockImplementationOnce(() => 'nonce-uuid')
|
||||||
|
.mockImplementationOnce(() => 'session-uuid');
|
||||||
|
mockGetOrGeneratePointsAddress.mockResolvedValue(
|
||||||
|
'0xABCDEF1234567890ABCDEF1234567890ABCDEF12',
|
||||||
|
);
|
||||||
|
|
||||||
|
const selfApp = await pointsSelfApp();
|
||||||
|
const metadata = JSON.parse(selfApp.userDefinedData);
|
||||||
|
|
||||||
|
expect(selfApp.userIdType).toBe('hex');
|
||||||
|
expect(selfApp.userId).toBe('abcdef1234567890abcdef1234567890abcdef12');
|
||||||
|
expect(selfApp.deeplinkCallback).toBe('https://apps.self.xyz');
|
||||||
|
expect(metadata.nonce).toBe('nonce-uuid');
|
||||||
|
expect(metadata.wallet).toBe('0xabcdef1234567890abcdef1234567890abcdef12');
|
||||||
|
expect(metadata.action).toBe('points-disclosure');
|
||||||
|
expect(new Date(metadata.issuedAt).toString()).not.toBe('Invalid Date');
|
||||||
|
expect(selfApp.selfDefinedData).toBe(
|
||||||
|
'0xabcdef1234567890abcdef1234567890abcdef12',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
app/tests/src/utils/deeplinkCallbacks.test.ts
Normal file
65
app/tests/src/utils/deeplinkCallbacks.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||||
|
|
||||||
|
import { Linking } from 'react-native';
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleDeeplinkCallbackNavigation,
|
||||||
|
isSelfHostedDeeplink,
|
||||||
|
} from '@/utils/deeplinkCallbacks';
|
||||||
|
|
||||||
|
jest.mock('react-native', () => ({
|
||||||
|
Linking: {
|
||||||
|
openURL: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('deeplinkCallbacks', () => {
|
||||||
|
const mockNavigation = {
|
||||||
|
navigate: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes self-hosted https callbacks through in-app WebView', async () => {
|
||||||
|
await handleDeeplinkCallbackNavigation({
|
||||||
|
deeplinkCallback: 'https://apps.self.xyz/proof/done',
|
||||||
|
navigation: mockNavigation,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNavigation.navigate).toHaveBeenCalledWith('WebView', {
|
||||||
|
url: 'https://apps.self.xyz/proof/done',
|
||||||
|
title: 'Explore Apps',
|
||||||
|
});
|
||||||
|
expect(Linking.openURL).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens non-self callbacks externally', async () => {
|
||||||
|
await handleDeeplinkCallbackNavigation({
|
||||||
|
deeplinkCallback: 'https://example.com/next',
|
||||||
|
navigation: mockNavigation,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNavigation.navigate).not.toHaveBeenCalled();
|
||||||
|
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/next');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats malformed callbacks as external fallbacks', async () => {
|
||||||
|
await handleDeeplinkCallbackNavigation({
|
||||||
|
deeplinkCallback: 'not-a-url',
|
||||||
|
navigation: mockNavigation,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNavigation.navigate).not.toHaveBeenCalled();
|
||||||
|
expect(Linking.openURL).toHaveBeenCalledWith('not-a-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects self-hosted https deeplinks', () => {
|
||||||
|
expect(isSelfHostedDeeplink('https://apps.self.xyz/foo')).toBe(true);
|
||||||
|
expect(isSelfHostedDeeplink('https://malicious.com/foo')).toBe(false);
|
||||||
|
expect(isSelfHostedDeeplink('ftp://apps.self.xyz/foo')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user