Fix tunnel flow back-navigation leaking out of flow (#1916)

* add new screens

* fixes

* cover additional gap

* add webview dev url env var

* better menu

* updates
This commit is contained in:
Justin Hernandez
2026-04-06 23:21:44 -07:00
committed by GitHub
parent 822e1eea4d
commit 8cb8913e09
22 changed files with 600 additions and 48 deletions

View File

@@ -74,6 +74,12 @@ android {
.toInt()
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "WEBVIEW_DEV_URL", "\"${System.getenv("WEBVIEW_DEV_URL") ?: ""}\"")
}
buildFeatures {
buildConfig = true
}
compileOptions {

View File

@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.testapp.screens
import xyz.self.testapp.BuildConfig
actual fun getDevServerUrl(): String? = BuildConfig.WEBVIEW_DEV_URL.ifBlank { null }

View File

@@ -48,6 +48,8 @@ import xyz.self.sdk.api.SelfSdkError
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.VerificationResult
expect fun getDevServerUrl(): String?
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SdkLaunchScreen(navController: NavController) {
@@ -65,6 +67,7 @@ fun SdkLaunchScreen(navController: NavController) {
val environment = if (useMockDocument) SelfEnvironment.STG else SelfEnvironment.PROD
val coroutineScope = rememberCoroutineScope()
val devServerUrl = remember { getDevServerUrl() }
val sdk =
remember(environment, appName, appEndpoint) {
SelfSdk.configure(
@@ -73,6 +76,7 @@ fun SdkLaunchScreen(navController: NavController) {
debug = true,
appName = appName.ifBlank { null },
appEndpoint = appEndpoint.ifBlank { null },
devServerUrl = devServerUrl,
),
)
}

View File

@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.testapp.screens
actual fun getDevServerUrl(): String? = null

View File

@@ -124,6 +124,9 @@ actual class SelfSdk private constructor(
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.debug)
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_REQUEST, serializeRequest(request))
putExtra(SelfVerificationActivity.EXTRA_CONFIG, serializeConfig(config))
if (config.devServerUrl != null) {
putExtra(SelfVerificationActivity.EXTRA_DEV_SERVER_URL, config.devServerUrl)
}
}
// Launch the verification activity

View File

@@ -27,6 +27,7 @@ class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val devServerUrl: String? = null,
) {
private lateinit var webView: WebView
var pendingPermissionRequest: PermissionRequest? = null
@@ -55,9 +56,17 @@ class AndroidWebViewHost(
request: WebResourceRequest?,
): Boolean {
val uri = request?.url ?: return true
val devHost = devServerUrl?.let { Uri.parse(it) }
val isAllowed =
(uri.scheme == "https" && uri.host == "self-app-alpha.vercel.app") ||
(isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173)
(isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173) ||
(
isDebugMode &&
devHost != null &&
uri.scheme == devHost.scheme &&
uri.host == devHost.host &&
uri.port == devHost.port
)
return !isAllowed
}
@@ -79,10 +88,18 @@ class AndroidWebViewHost(
request.deny()
return
}
val devHost = devServerUrl?.let { Uri.parse(it) }
val isTrusted =
(origin.scheme == "https" && origin.host == "self-app-alpha.vercel.app") ||
(origin.scheme == "https" && origin.host == "verify.didit.me") ||
(isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1")
(isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1" && origin.port == 5173) ||
(
isDebugMode &&
devHost != null &&
origin.scheme == devHost.scheme &&
origin.host == devHost.host &&
origin.port == devHost.port
)
if (!isTrusted) {
request.deny()
return
@@ -156,7 +173,12 @@ class AndroidWebViewHost(
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
val baseUrl = "https://self-app-alpha.vercel.app/tunnel/tour/1"
val baseUrl =
if (isDebugMode && devServerUrl != null) {
"${devServerUrl.trimEnd('/')}/tunnel/tour/1"
} else {
"https://self-app-alpha.vercel.app/tunnel/tour/1"
}
val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl
loadUrl(url)
}

View File

@@ -63,7 +63,8 @@ class SelfVerificationActivity : AppCompatActivity() {
return
}
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
val devServerUrl = intent.getStringExtra(EXTRA_DEV_SERVER_URL)
webViewHost = AndroidWebViewHost(this, router, isDebugMode, devServerUrl)
val webView = webViewHost.createWebView(queryParams)
setContentView(webView)
}
@@ -179,6 +180,7 @@ class SelfVerificationActivity : AppCompatActivity() {
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
const val EXTRA_VERIFICATION_REQUEST = "xyz.self.sdk.VERIFICATION_REQUEST"
const val EXTRA_CONFIG = "xyz.self.sdk.CONFIG"
const val EXTRA_DEV_SERVER_URL = "xyz.self.sdk.DEV_SERVER_URL"
const val RESULT_CODE_SUCCESS = RESULT_OK
const val RESULT_CODE_ERROR = RESULT_FIRST_USER

View File

@@ -34,4 +34,5 @@ data class SelfSdkConfig(
val appEndpoint: String? = null,
val endpointType: String? = null,
val chainID: Int? = null,
val devServerUrl: String? = null,
)

View File

@@ -54,6 +54,7 @@ import { TourScreen as TunnelTourScreen } from './screens/tunnel/TourScreen';
import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerScreen';
import { TunnelDiscloseScreen } from './screens/tunnel/TunnelDiscloseScreen';
import { TunnelIDTypeScreen } from './screens/tunnel/TunnelIDTypeScreen';
import { TunnelKycFailureScreen } from './screens/tunnel/TunnelKycFailureScreen';
import { TunnelKycSuccessScreen } from './screens/tunnel/TunnelKycSuccessScreen';
import { TunnelKycWrapper } from './screens/tunnel/TunnelKycWrapper';
import { TunnelProofReceiptScreen } from './screens/tunnel/TunnelProofReceiptScreen';
@@ -110,6 +111,7 @@ export const App: React.FC = () => (
<Route path="/coming-soon" element={<ComingSoonScreen />} />
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
<Route path="/tunnel/kyc" element={<TunnelKycWrapper />} />
<Route path="/tunnel/kyc-failure" element={<TunnelKycFailureScreen />} />
<Route path="/tunnel/kyc-success" element={<TunnelKycSuccessScreen />} />
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />
<Route path="/tunnel/registration/id-type" element={<TunnelIDTypeScreen />} />

View File

@@ -14,6 +14,7 @@ interface DevScreenLink {
interface DevScreenGroup {
title: string;
links: DevScreenLink[];
description?: string;
}
const screenGroups: DevScreenGroup[] = [
@@ -72,15 +73,26 @@ const screenGroups: DevScreenGroup[] = [
],
},
{
title: 'Tunnel',
title: 'Tunnel — Screens',
links: [
{ href: '/tunnel/tour/1', label: 'Tour' },
{ href: '/tunnel/kyc', label: 'KYC Mock' },
{ href: '/tunnel/registration/country', label: 'Country Picker' },
{ href: '/tunnel/registration/id-type', label: 'ID Type' },
{ href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
{ href: '/tunnel/kyc-failure', label: 'KYC Failure' },
{ href: '/tunnel/recovery-required', label: 'Recovery Required' },
{ href: '/tunnel/proof/generating', label: 'Proving' },
{ href: '/tunnel/proof/result', label: 'Result' },
{ href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
],
},
{
title: 'Tunnel — Mock KYC',
description: 'Mocks diverge after /tunnel/kyc; some outcomes intentionally share the same final route.',
links: [
{ href: '/tunnel/tour/1?mock=success', label: 'Flow → KYC Success, Then Proof Failure' },
{ href: '/tunnel/tour/1?mock=kyc-failure', label: 'Flow → KYC Error (Retryable)' },
{ href: '/tunnel/tour/1?mock=registration-failure', label: 'Flow → KYC Error (Fatal → Tour Step 4)' },
{ href: '/tunnel/tour/1?mock=cancel', label: 'Flow → KYC Cancel → Tour Step 4' },
],
},
{
@@ -103,10 +115,10 @@ export const DevRouteMenu: React.FC = () => {
}
}, [isOpen]);
const currentLabel = useMemo(
() => allLinks.find(link => link.href === location.pathname)?.label ?? 'Dev Screens',
[location.pathname],
);
const currentLabel = useMemo(() => {
const fullPath = `${location.pathname}${location.search}`;
return allLinks.find(link => link.href === fullPath)?.label ?? 'Dev Screens';
}, [location.pathname, location.search]);
return (
<div
@@ -150,8 +162,20 @@ export const DevRouteMenu: React.FC = () => {
>
{group.title}
</div>
{group.description ? (
<div
style={{
color: 'rgba(255, 255, 255, 0.65)',
fontSize: 11,
lineHeight: 1.4,
padding: '0 2px 6px',
}}
>
{group.description}
</div>
) : null}
{group.links.map(link => {
const isActive = location.pathname === link.href;
const isActive = `${location.pathname}${location.search}` === link.href;
return (
<button

View File

@@ -21,6 +21,7 @@ const CONTAINER_ID = 'didit-sdk-container';
type Phase = 'loading' | 'active' | 'waiting' | 'error';
interface ProviderLaunchState {
backPath?: string;
countryCode?: string;
documentType?: string;
nextPath?: string;
@@ -32,9 +33,10 @@ export const ProviderLaunchScreen: React.FC = () => {
const { client, analytics, haptic, lifecycle } = useSelfClient();
const { verificationId: ctxVerificationId, environment } = useVerificationRequest();
const { countryCode = '', documentType = '', nextPath } = (location.state as ProviderLaunchState) || {};
const { backPath, countryCode = '', documentType = '', nextPath } = (location.state as ProviderLaunchState) || {};
const defaultNextPath = nextPath ?? '/onboarding/provider-result';
const isTunnelFlow = defaultNextPath.startsWith('/tunnel/') || backPath?.startsWith('/tunnel/') === true;
const verificationId = ctxVerificationId ?? `didit-${Date.now()}`;
const [phase, setPhase] = useState<Phase>('loading');
@@ -222,13 +224,20 @@ export const ProviderLaunchScreen: React.FC = () => {
countryCode,
documentType,
});
if (isTunnelFlow) {
navigate(backPath ?? '/tunnel/tour/4', { replace: true });
return;
}
lifecycle.dismiss({ reason: 'back' });
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/', { state: { skipOnboardingRedirect: true } });
return;
}
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate]);
navigate('/', { state: { skipOnboardingRedirect: true } });
}, [analytics, backPath, countryCode, documentType, haptic, isTunnelFlow, lifecycle, navigate]);
const handleRetry = useCallback(() => {
haptic.trigger('selection');

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { LaunchRecoveryScreen as EuclidLaunchRecoveryScreen, LeftArrowIcon } from '@selfxyz/euclid';
@@ -12,19 +12,26 @@ import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
export const LaunchRecoveryScreen: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const backPath = (location.state as { backPath?: string } | null)?.backPath ?? '/settings/security';
const onClose = useCallback(() => {
haptic.trigger('selection');
navigate('/settings/security');
}, [navigate, haptic]);
navigate(backPath, { replace: true });
}, [backPath, navigate, haptic]);
const isTunnelFlow = backPath.startsWith('/tunnel/');
const onEnterRecoveryPhrase = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('recovery_enter_phrase_pressed');
navigate('/recovery/phrase-input');
}, [navigate, haptic, analytics]);
const target = isTunnelFlow
? `/recovery/phrase-input?returnTo=${encodeURIComponent(backPath)}`
: '/recovery/phrase-input';
navigate(target);
}, [backPath, isTunnelFlow, navigate, haptic, analytics]);
return (
<div className="launch-recovery-screen">

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
import { LaunchTour1Screen, LaunchTour2Screen, LaunchTour3Screen, LaunchTour4Screen } from '@selfxyz/euclid';
import { loadSelectedDocument } from '@selfxyz/mobile-sdk-alpha/browser';
@@ -14,13 +14,15 @@ import { WEB_SAFE_AREA } from '../../utils/insets';
export const TourScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { step } = useParams<{ step: string }>();
const stepNum = parseInt(step ?? '1', 10);
const { client } = useSelfClient();
const mockParam = import.meta.env.DEV ? location.search : '';
const onNext = useCallback(async () => {
if (stepNum < 4) {
navigate(`/tunnel/tour/${stepNum + 1}`);
navigate(`/tunnel/tour/${stepNum + 1}${mockParam}`);
return;
}
@@ -34,12 +36,12 @@ export const TourScreen: React.FC = () => {
// Fall through to KYC when document state is unavailable.
}
navigate('/tunnel/kyc');
}, [navigate, stepNum, client]);
navigate(`/tunnel/kyc${mockParam}`);
}, [navigate, stepNum, client, mockParam]);
const onRestore = useCallback(() => {
navigate('/recovery');
}, []);
navigate('/recovery', { state: { backPath: `/tunnel/tour/${step ?? '1'}` } });
}, [navigate, step]);
switch (step) {
case '1':

View File

@@ -51,7 +51,7 @@ export const TunnelIDTypeScreen: React.FC = () => {
countryName={getCountryName(countryCode)}
idTypes={idTypes}
onIDTypeSelect={onIDTypeSelect}
onBack={() => navigate(-1)}
onBack={() => navigate('/tunnel/registration/country')}
renderFlag={renderFlag}
renderIDTypeIcon={renderIDTypeIcon}
/>

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { KycFailureScreen as EuclidKycFailureScreen } from '@selfxyz/euclid';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
export const TunnelKycFailureScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const handleDismiss = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('tunnel_kyc_failure_dismissed');
navigate('/tunnel/tour/4', { replace: true });
}, [analytics, haptic, navigate]);
const handleTryAgain = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('tunnel_kyc_failure_retry_pressed');
navigate('/tunnel/kyc', { replace: true });
}, [analytics, haptic, navigate]);
return <EuclidKycFailureScreen {...WEB_SAFE_AREA} onDismiss={handleDismiss} onTryAgain={handleTryAgain} />;
};

View File

@@ -24,15 +24,15 @@ export const TunnelKycSuccessScreen: React.FC = () => {
if (!providerResult) return;
if (providerResult.status === 'cancel') {
navigate(-1);
navigate('/tunnel/tour/4', { replace: true });
return;
}
if (providerResult.status === 'error') {
if (providerResult.error?.retryable === false) {
navigate('/onboarding/failure', { replace: true });
navigate('/tunnel/tour/4', { replace: true });
} else {
navigate('/tunnel/kyc', { replace: true });
navigate('/tunnel/kyc-failure', { replace: true, state: { providerResult } });
}
return;
}

View File

@@ -5,13 +5,29 @@
import type React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { createMockProviderResult, getMockOutcomeFromSearch } from '../../utils/mockOnboardingFlow';
/**
* Redirects `/tunnel/kyc` to `ProviderLaunchScreen` at `/onboarding/provider`
* with the tunnel-specific `nextPath` injected into navigation state.
*
* In dev mode, supports `?mock=kyc-failure|registration-failure|cancel|success`
* to skip the real provider and jump straight to the result screen.
*/
export const TunnelKycWrapper: React.FC = () => {
const location = useLocation();
const incomingState = (location.state as Record<string, unknown>) ?? {};
const mockOutcome = getMockOutcomeFromSearch(location.search);
if (import.meta.env.DEV && location.search.includes('mock=')) {
return (
<Navigate
to="/tunnel/kyc-success"
replace
state={{ providerResult: createMockProviderResult({ outcome: mockOutcome }) }}
/>
);
}
return (
<Navigate
@@ -19,6 +35,7 @@ export const TunnelKycWrapper: React.FC = () => {
replace
state={{
...incomingState,
backPath: '/tunnel/tour/4',
nextPath: '/tunnel/kyc-success',
}}
/>

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { ProofRequestScreen, SelfLogo } from '@selfxyz/euclid';
@@ -14,9 +14,14 @@ import { WEB_SAFE_AREA } from '../../utils/insets';
import { titleCaseDisclosure } from '../../utils/provingUtils';
export const TunnelProofReceiptScreen: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const { displayLabels, request, appName, appEndpoint, timestamp } = useVerificationRequest();
const { backPath = '/tunnel/proof/result', backState } =
(location.state as { backPath?: string; backState?: Record<string, unknown> } | null) ?? {};
const showConfirm = backPath !== '/tunnel/proof/result' || backState?.success === true;
const onConfirm = useCallback(() => {
haptic.trigger('selection');
@@ -36,15 +41,15 @@ export const TunnelProofReceiptScreen: React.FC = () => {
const onClose = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('tunnel_proof_receipt_closed');
navigate(-1);
}, [navigate, haptic, analytics]);
navigate(backPath, { replace: true, state: backState });
}, [analytics, backPath, backState, haptic, navigate]);
return (
<ProofRequestScreen
{...WEB_SAFE_AREA}
variant="default"
onClose={onClose}
onConfirm={onConfirm}
onConfirm={showConfirm ? onConfirm : undefined}
appIcon={<SelfLogo size={40} />}
appName={appName}
appEndpoint={appEndpoint}

View File

@@ -12,10 +12,11 @@ import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
const TUNNEL_RECOVERY_RETURN_PATH = '/tunnel/proof/generating';
const TUNNEL_RECOVERY_BACK_PATH = '/tunnel/tour/4';
export const TunnelRecoveryRequiredScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, lifecycle } = useSelfClient();
const { analytics, haptic } = useSelfClient();
const onRecoverWithPhrase = useCallback(() => {
haptic.trigger('selection');
@@ -26,9 +27,8 @@ export const TunnelRecoveryRequiredScreen: React.FC = () => {
const onCancel = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('tunnel_recovery_cancelled');
lifecycle.dismiss({ reason: 'back' });
navigate('/', { replace: true });
}, [analytics, haptic, lifecycle, navigate]);
navigate(TUNNEL_RECOVERY_BACK_PATH, { replace: true });
}, [analytics, haptic, navigate]);
return (
<EuclidConflictDetectedScreen

View File

@@ -16,9 +16,33 @@ import { WEB_SAFE_AREA } from '../../utils/insets';
interface TunnelResultState {
success?: boolean;
error?: string;
source?: 'proving' | 'disclose';
source?: 'disclose' | 'kyc' | 'proving';
}
const getTunnelBackPath = (source: TunnelResultState['source']): string => {
switch (source) {
case 'disclose':
return '/tunnel/proof/disclose';
case 'kyc':
return '/tunnel/kyc';
case 'proving':
default:
return '/tunnel/proof/generating';
}
};
const getTunnelClosePath = (source: TunnelResultState['source']): string => {
switch (source) {
case 'disclose':
return '/tunnel/proof/disclose';
case 'kyc':
return '/tunnel/kyc';
case 'proving':
default:
return '/tunnel/tour/4';
}
};
export const TunnelResultScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -51,17 +75,18 @@ export const TunnelResultScreen: React.FC = () => {
}, [request.userId, verificationId, lifecycle, analytics]);
const onRetry = useCallback(() => {
navigate(source === 'disclose' ? '/tunnel/proof/disclose' : '/tunnel/proof/generating');
navigate(getTunnelBackPath(source), { replace: true });
}, [navigate, source]);
const onViewDetails = useCallback(() => {
navigate('/tunnel/proof/receipt');
}, [navigate]);
navigate('/tunnel/proof/receipt', {
state: { backPath: location.pathname, backState: location.state },
});
}, [location.pathname, location.state, navigate]);
const onCancel = useCallback(() => {
lifecycle.dismiss({ reason: 'back' });
navigate('/');
}, [lifecycle, navigate]);
navigate(getTunnelClosePath(source), { replace: true });
}, [navigate, source]);
if (success) {
return (

View File

@@ -8,7 +8,12 @@ import type React from 'react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LaunchRecoveryScreen } from '../../../src/screens/recovery/LaunchRecoveryScreen';
import { TourScreen as TunnelTourScreen } from '../../../src/screens/tunnel/TourScreen';
import { TunnelDiscloseScreen } from '../../../src/screens/tunnel/TunnelDiscloseScreen';
import { TunnelKycFailureScreen } from '../../../src/screens/tunnel/TunnelKycFailureScreen';
import { TunnelKycSuccessScreen } from '../../../src/screens/tunnel/TunnelKycSuccessScreen';
import { TunnelProofReceiptScreen } from '../../../src/screens/tunnel/TunnelProofReceiptScreen';
import { TunnelProvingScreen } from '../../../src/screens/tunnel/TunnelProvingScreen';
import { TunnelRecoveryRequiredScreen } from '../../../src/screens/tunnel/TunnelRecoveryRequiredScreen';
import { TunnelResultScreen } from '../../../src/screens/tunnel/TunnelResultScreen';
@@ -84,6 +89,18 @@ vi.mock('@selfxyz/euclid', () => ({
SelfLogo: () => null,
ProofGenerationScreen: ({ step }: { step: string }) => <div>{`proof-generation:${step}`}</div>,
ProofProgressScreen: ({ step }: { step: string }) => <div>{`proof-progress:${step}`}</div>,
ProofRequestScreen: ({ onClose, onConfirm }: { onClose: () => void; onConfirm?: () => void }) => (
<div>
<button onClick={onClose} type="button">
Close receipt
</button>
{onConfirm && (
<button onClick={onConfirm} type="button">
Confirm receipt
</button>
)}
</div>
),
ProofSuccessScreen: ({ onContinue, onViewDetails }: { onContinue: () => void; onViewDetails: () => void }) => (
<div>
<button onClick={onContinue} type="button">
@@ -131,6 +148,46 @@ vi.mock('@selfxyz/euclid', () => ({
</button>
</div>
),
KycVerificationSuccessScreen: ({ onGenerateProof }: { onGenerateProof: () => void }) => (
<button onClick={onGenerateProof} type="button">
Generate proof
</button>
),
KycFailureScreen: ({ onDismiss, onTryAgain }: { onDismiss: () => void; onTryAgain: () => void }) => (
<div>
<button onClick={onDismiss} type="button">
Dismiss KYC failure
</button>
<button onClick={onTryAgain} type="button">
Retry KYC
</button>
</div>
),
LaunchTour1Screen: ({ onRestore }: { onRestore: () => void }) => (
<button onClick={onRestore} type="button">
Restore tour 1
</button>
),
LaunchTour2Screen: () => <div>tour-2</div>,
LaunchTour3Screen: () => <div>tour-3</div>,
LaunchTour4Screen: () => <div>tour-4</div>,
LaunchRecoveryScreen: ({
onClose,
onEnterRecoveryPhrase,
}: {
onClose: () => void;
onEnterRecoveryPhrase: () => void;
}) => (
<div>
<button onClick={onClose} type="button">
Back from recovery
</button>
<button onClick={onEnterRecoveryPhrase} type="button">
Enter recovery phrase
</button>
</div>
),
LeftArrowIcon: () => null,
}));
const LocationDisplay: React.FC = () => {
@@ -138,6 +195,16 @@ const LocationDisplay: React.FC = () => {
return <div data-testid="location">{`${location.pathname}${location.search}`}</div>;
};
const StateDisplay: React.FC = () => {
const location = useLocation();
return (
<>
<div data-testid="location">{`${location.pathname}${location.search}`}</div>
<div data-testid="location-state">{JSON.stringify(location.state)}</div>
</>
);
};
const renderResultRoute = (
initialEntry:
| string
@@ -150,6 +217,8 @@ const renderResultRoute = (
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/tunnel/proof/result" element={<TunnelResultScreen />} />
<Route path="/tunnel/tour/4" element={<LocationDisplay />} />
<Route path="/tunnel/kyc" element={<LocationDisplay />} />
<Route path="/tunnel/proof/generating" element={<LocationDisplay />} />
<Route path="/tunnel/proof/disclose" element={<LocationDisplay />} />
<Route path="/tunnel/proof/receipt" element={<LocationDisplay />} />
@@ -159,6 +228,25 @@ const renderResultRoute = (
</MemoryRouter>,
);
const renderReceiptRoute = (
initialEntry:
| string
| {
pathname: string;
state?: unknown;
},
) =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
<Route path="/tunnel/kyc-success" element={<LocationDisplay />} />
<Route path="/tunnel/proof/result" element={<LocationDisplay />} />
</Routes>
<LocationDisplay />
</MemoryRouter>,
);
const renderProvingRoute = () =>
render(
<MemoryRouter initialEntries={['/tunnel/proof/generating']}>
@@ -183,13 +271,59 @@ const renderDiscloseRoute = () =>
</MemoryRouter>,
);
const renderKycSuccessRoute = (
initialEntry:
| string
| {
pathname: string;
state?: unknown;
},
) =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/tunnel/kyc-success" element={<TunnelKycSuccessScreen />} />
<Route path="/tunnel/kyc" element={<LocationDisplay />} />
<Route path="/tunnel/kyc-failure" element={<LocationDisplay />} />
<Route path="/tunnel/tour/4" element={<LocationDisplay />} />
<Route path="/tunnel/proof/generating" element={<LocationDisplay />} />
</Routes>
<LocationDisplay />
</MemoryRouter>,
);
const renderKycFailureRoute = () =>
render(
<MemoryRouter initialEntries={['/tunnel/kyc-failure']}>
<Routes>
<Route path="/tunnel/kyc-failure" element={<TunnelKycFailureScreen />} />
<Route path="/tunnel/kyc" element={<LocationDisplay />} />
<Route path="/tunnel/tour/4" element={<LocationDisplay />} />
</Routes>
<LocationDisplay />
</MemoryRouter>,
);
const renderRecoveryRequiredRoute = () =>
render(
<MemoryRouter initialEntries={['/tunnel/recovery-required']}>
<Routes>
<Route path="/tunnel/recovery-required" element={<TunnelRecoveryRequiredScreen />} />
<Route path="/recovery/phrase-input" element={<LocationDisplay />} />
<Route path="/" element={<LocationDisplay />} />
<Route path="/tunnel/tour/4" element={<LocationDisplay />} />
</Routes>
<LocationDisplay />
</MemoryRouter>,
);
const renderTourRestoreRoute = () =>
render(
<MemoryRouter initialEntries={['/tunnel/tour/1']}>
<Routes>
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
<Route path="/recovery" element={<LaunchRecoveryScreen />} />
<Route path="/recovery/phrase-input" element={<LocationDisplay />} />
<Route path="/settings/security" element={<LocationDisplay />} />
</Routes>
<LocationDisplay />
</MemoryRouter>,
@@ -263,6 +397,39 @@ describe('tunnel flow screens', () => {
expectLocation('/tunnel/proof/receipt');
});
it('routes proving failure close back to tunnel tour step 4', () => {
renderResultRoute({
pathname: '/tunnel/proof/result',
state: { success: false, error: 'TEE down', source: 'proving' },
});
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expectLocation('/tunnel/tour/4');
});
it('keeps disclose failure close inside the tunnel disclose route', () => {
renderResultRoute({
pathname: '/tunnel/proof/result',
state: { success: false, error: 'TEE down', source: 'disclose' },
});
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expectLocation('/tunnel/proof/disclose');
});
it('keeps kyc failure close inside the tunnel kyc route', () => {
renderResultRoute({
pathname: '/tunnel/proof/result',
state: { success: false, error: 'Provider cancelled', source: 'kyc' },
});
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expectLocation('/tunnel/kyc');
});
it('routes account recovery choice to the recovery-required screen', async () => {
storeState.currentState = 'account_recovery_choice';
@@ -292,6 +459,65 @@ describe('tunnel flow screens', () => {
expectLocation('/recovery/phrase-input?returnTo=%2Ftunnel%2Fproof%2Fgenerating');
});
it('keeps recovery required cancel inside the tunnel flow', () => {
renderRecoveryRequiredRoute();
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expectLocation('/tunnel/tour/4');
});
it('keeps receipt close on the provided tunnel back path', () => {
renderReceiptRoute({
pathname: '/tunnel/proof/receipt',
state: { backPath: '/tunnel/proof/result' },
});
fireEvent.click(screen.getByRole('button', { name: /close receipt/i }));
expectLocation('/tunnel/proof/result');
});
it('restores result state when closing receipt', () => {
const resultState = { success: true };
render(
<MemoryRouter
initialEntries={[
{
pathname: '/tunnel/proof/receipt',
state: { backPath: '/tunnel/proof/result', backState: resultState },
},
]}
>
<Routes>
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
<Route path="/tunnel/proof/result" element={<StateDisplay />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('button', { name: /close receipt/i }));
expect(screen.getByTestId('location-state').textContent).toBe(JSON.stringify(resultState));
});
it('keeps kyc cancel inside the tunnel flow', async () => {
renderKycSuccessRoute({
pathname: '/tunnel/kyc-success',
state: {
providerResult: {
provider: 'didit',
status: 'cancel',
},
},
});
await waitFor(() => {
expectLocation('/tunnel/tour/4');
});
});
it('routes to error result when proving setup throws before init starts', async () => {
const { initSelfAppFromRequest } = await import('../../../src/utils/selfAppContext');
vi.mocked(initSelfAppFromRequest).mockImplementationOnce(() => {
@@ -307,6 +533,156 @@ describe('tunnel flow screens', () => {
expect(analytics.trackEvent).toHaveBeenCalledWith('tunnel_proving_init_failed', { error: 'bad request' });
});
it('routes retryable kyc error to the failure screen', async () => {
renderKycSuccessRoute({
pathname: '/tunnel/kyc-success',
state: {
providerResult: {
provider: 'didit',
status: 'error',
error: { code: 'provider_unknown_error', message: 'Something went wrong', retryable: true },
},
},
});
await waitFor(() => {
expectLocation('/tunnel/kyc-failure');
});
});
it('routes non-retryable kyc error back to the tour', async () => {
renderKycSuccessRoute({
pathname: '/tunnel/kyc-success',
state: {
providerResult: {
provider: 'didit',
status: 'error',
error: { code: 'provider_declined', message: 'Declined', retryable: false },
},
},
});
await waitFor(() => {
expectLocation('/tunnel/tour/4');
});
});
it('retries kyc failure back into the tunnel kyc step', () => {
renderKycFailureRoute();
fireEvent.click(screen.getByRole('button', { name: /retry kyc/i }));
expectLocation('/tunnel/kyc');
});
it('dismisses kyc failure back to the tunnel tour', () => {
renderKycFailureRoute();
fireEvent.click(screen.getByRole('button', { name: /dismiss kyc failure/i }));
expectLocation('/tunnel/tour/4');
});
it('falls back to result screen when receipt has no backPath', () => {
renderReceiptRoute({
pathname: '/tunnel/proof/receipt',
});
fireEvent.click(screen.getByRole('button', { name: /close receipt/i }));
expectLocation('/tunnel/proof/result');
});
it('defaults missing failure source close to tunnel tour step 4', () => {
renderResultRoute({
pathname: '/tunnel/proof/result',
state: { success: false, error: 'Unknown error' },
});
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expectLocation('/tunnel/tour/4');
});
it('keeps tour restore back button inside the tunnel flow', () => {
renderTourRestoreRoute();
fireEvent.click(screen.getByRole('button', { name: /restore tour 1/i }));
fireEvent.click(screen.getByRole('button', { name: /back from recovery/i }));
expectLocation('/tunnel/tour/1');
});
it('forwards returnTo when entering recovery phrase from tunnel tour', () => {
renderTourRestoreRoute();
fireEvent.click(screen.getByRole('button', { name: /restore tour 1/i }));
fireEvent.click(screen.getByRole('button', { name: /enter recovery phrase/i }));
expectLocation(`/recovery/phrase-input?returnTo=${encodeURIComponent('/tunnel/tour/1')}`);
});
it('hides confirm button on receipt when backState is missing', () => {
render(
<MemoryRouter
initialEntries={[
{
pathname: '/tunnel/proof/receipt',
state: { backPath: '/tunnel/proof/result' },
},
]}
>
<Routes>
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
<Route path="/tunnel/proof/result" element={<LocationDisplay />} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByRole('button', { name: /confirm receipt/i })).toBeNull();
});
it('hides confirm button on receipt when opened from failure context', () => {
render(
<MemoryRouter
initialEntries={[
{
pathname: '/tunnel/proof/receipt',
state: { backPath: '/tunnel/proof/result', backState: { success: false, error: 'TEE down' } },
},
]}
>
<Routes>
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
<Route path="/tunnel/proof/result" element={<LocationDisplay />} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByRole('button', { name: /confirm receipt/i })).toBeNull();
expect(screen.getByRole('button', { name: /close receipt/i })).toBeTruthy();
});
it('shows confirm button on receipt when opened from success context', () => {
render(
<MemoryRouter
initialEntries={[
{
pathname: '/tunnel/proof/receipt',
state: { backPath: '/tunnel/proof/result', backState: { success: true } },
},
]}
>
<Routes>
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
<Route path="/tunnel/proof/result" element={<LocationDisplay />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: /confirm receipt/i })).toBeTruthy();
});
it('routes to error result when disclose setup throws before init starts', async () => {
const { initSelfAppFromRequest } = await import('../../../src/utils/selfAppContext');
vi.mocked(initSelfAppFromRequest).mockImplementationOnce(() => {

View File

@@ -85,5 +85,5 @@ export default defineConfig({
emptyOutDir: true,
sourcemap: true,
},
server: { host: '0.0.0.0', port: 5173 },
server: { host: '0.0.0.0', port: 5173, allowedHosts: ['.ngrok-free.app'] },
});