From f1e45279830a9ef0b58f86c6b21188248a6ca4dd Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Wed, 21 Oct 2026 14:13:23 -0700 Subject: [PATCH 1/5] Add smart-account recovery email alert enrollment --- .github/workflows/ci.yml | 16 + package-lock.json | 6 +- package.json | 1 + src/App.css | 2 + src/components/RecoveryAlertsCard.css | 180 +++++ src/components/RecoveryAlertsCard.js | 784 ++++++++++++++++++++++ src/components/RecoveryAlertsCard.test.js | 298 ++++++++ src/lib/apiClient.js | 7 +- src/lib/apiClient.test.js | 28 + src/lib/recoveryAlerts.js | 214 ++++++ src/lib/recoveryAlerts.test.js | 218 ++++++ src/pages/Account.js | 5 +- tests/browser/recovery-alerts.spec.cjs | 225 +++++++ tests/productionRuntime.test.cjs | 54 ++ 14 files changed, 2033 insertions(+), 5 deletions(-) create mode 100644 src/components/RecoveryAlertsCard.css create mode 100644 src/components/RecoveryAlertsCard.js create mode 100644 src/components/RecoveryAlertsCard.test.js create mode 100644 src/lib/recoveryAlerts.js create mode 100644 src/lib/recoveryAlerts.test.js create mode 100644 tests/browser/recovery-alerts.spec.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63e687..2f66ef0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,22 @@ jobs: # (e.g. ESM/CJS interop regressions in new deps). run: npm run build + - name: Install browser for recovery-alert layout checks + if: matrix.node == '22' + run: npx playwright install --with-deps chromium + + - name: Check recovery-alert browser flow and small windows + if: matrix.node == '22' + run: npm run test:recovery-browser + + - name: Preserve recovery-alert screenshots and failure traces + if: always() && matrix.node == '22' + uses: actions/upload-artifact@v4 + with: + name: recovery-alert-browser + path: test-results/sentry/ + if-no-files-found: ignore + deploy: name: Deploy production needs: [test] diff --git a/package-lock.json b/package-lock.json index 2dd8f3a..00883c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10178,9 +10178,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index 0152398..5dfd0e9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test": "react-scripts test", "test:ci": "react-scripts test --watchAll=false --maxWorkers=50%", "test:sentry-browser": "playwright test --config playwright.config.cjs", + "test:recovery-browser": "playwright test --config playwright.config.cjs tests/browser/recovery-alerts.spec.cjs", "test:production-runtime": "node --test tests/productionRuntime.test.cjs", "test:deployment": "node --test tests/deploymentRequest.test.cjs", "eject": "react-scripts eject" diff --git a/src/App.css b/src/App.css index dfdbc21..38a66d3 100644 --- a/src/App.css +++ b/src/App.css @@ -2010,6 +2010,8 @@ .auth-kv__row dd { margin: 0; + min-width: 0; + overflow-wrap: anywhere; font-weight: 500; color: var(--text); } diff --git a/src/components/RecoveryAlertsCard.css b/src/components/RecoveryAlertsCard.css new file mode 100644 index 0000000..38b042e --- /dev/null +++ b/src/components/RecoveryAlertsCard.css @@ -0,0 +1,180 @@ +.recovery-alerts { + scroll-margin-top: 100px; + min-width: 0; +} +.recovery-alerts h2 { + display: flex; + align-items: center; + gap: 10px; +} +.recovery-alerts h2 svg { + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + flex: 0 0 auto; +} +.recovery-alerts h3 { + margin: 0; + font-size: 1rem; + font-weight: 650; +} +.recovery-alerts .auth-input { + width: 100%; + min-width: 0; + box-sizing: border-box; +} +.recovery-alerts .auth-alert { + overflow-wrap: anywhere; +} +.recovery-email { + overflow-wrap: anywhere; +} +.recovery-note { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.6; +} +.recovery-subscriptions { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.recovery-subscription, +.recovery-remove { + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid var(--panel-outline); + border-radius: 16px; + padding: 18px; + min-width: 0; +} +.recovery-subscription__heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.recovery-subscription__heading h3 { + overflow-wrap: anywhere; + flex: 1 1 130px; +} +.recovery-subscription .status-chip { + font-size: 0.72rem; +} +.recovery-address { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} +.recovery-address code { + overflow-wrap: anywhere; + word-break: break-all; + font-size: 0.8rem; + line-height: 1.7; + min-width: 0; +} +.recovery-copy { + flex: 0 0 auto; + min-height: 36px; + border: 1px solid var(--panel-outline); + color: var(--muted); + background: transparent; + border-radius: 7px; + padding: 4px 6px; + cursor: pointer; + font: inherit; + font-size: 0.74rem; +} +.recovery-copy:hover, +.recovery-copy:focus-visible { + background: var(--accent-soft); + color: var(--text); + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.recovery-operation { + display: flex; + flex-direction: column; + gap: 6px; + border-left: 3px solid var(--accent); + padding: 8px 0 8px 12px; + font-size: 0.85rem; + line-height: 1.5; + overflow-wrap: anywhere; +} +.recovery-empty { + margin: 0; + padding: 16px; + border: 1px dashed var(--panel-outline); + border-radius: 14px; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.6; +} +.recovery-enroll { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.recovery-network { + margin: 0; + color: var(--text); + font-size: 0.9rem; + font-weight: 600; +} +.recovery-link-details summary { + cursor: pointer; + font-weight: 600; + line-height: 1.6; + padding: 10px 0; + color: var(--text); +} +.recovery-link-details summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + border-radius: 5px; +} +.recovery-link-details[open] summary { + margin-bottom: 12px; +} +.recovery-disable, +.recovery-refresh { + align-self: flex-start; + text-align: left; + line-height: 1.6; +} +.recovery-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.recovery-actions .button { + flex: 1 1 140px; + min-width: 0; +} +.recovery-alerts .button { + white-space: normal; + min-width: 0; +} +.recovery-account-select { + font-size: 0.75rem; +} +@media (max-width: 400px) { + .recovery-subscription, + .recovery-remove { + padding: 14px; + } + .recovery-address { + flex-wrap: wrap; + } + .recovery-address code { + flex-basis: 100%; + } +} diff --git a/src/components/RecoveryAlertsCard.js b/src/components/RecoveryAlertsCard.js new file mode 100644 index 0000000..d783d7b --- /dev/null +++ b/src/components/RecoveryAlertsCard.js @@ -0,0 +1,784 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useAuth } from '../context/AuthContext'; +import { authService as defaultAuthService } from '../lib/authService'; +import { + discoverWallets, + enrollRecoveryAlerts, + readWalletSelection, + recoveryAlertsService, +} from '../lib/recoveryAlerts'; +import PasswordInput from './PasswordInput'; +import './RecoveryAlertsCard.css'; + +const ERROR_COPY = { + wallet_unavailable: + 'Open this page in a browser with Pali or another compatible smart-account wallet.', + wallet_account_missing: + 'Select your smart account in the wallet, then connect again.', + unsupported_chain: + 'This network is not monitored. Switch networks in your wallet, then connect again.', + monitor_unavailable: + 'Monitoring is temporarily unavailable. Your existing alerts have not been removed.', + recovery_not_installed: + 'This account needs guardian recovery installed before alerts can be enabled.', + invalid_signature: + 'The smart account could not verify this signature. Select its current authorized signer and try again.', + invalid_challenge: + 'The enrollment request could not be verified. Refresh and try again.', + challenge_expired: + 'The enrollment request expired. Try again to get a fresh signature request.', + wallet_changed: + 'The wallet account or network changed. Connect again before enabling alerts.', + invalid_credentials: 'Your current Sysnode password is incorrect.', + totp_required: 'Enter the current six-digit authenticator code.', + invalid_totp_code: + 'That authenticator code did not work. Enter the current code and try again.', + email_not_verified: + 'Verify your Sysnode email before enabling recovery alerts.', + unauthorized: 'Your session expired. Please sign in again.', + network_error: + 'We could not reach Sysnode. Check your connection and try again.', + not_found: 'This alert subscription no longer exists. Refresh the list.', + invalid_body: 'Check the supplied details and try again.', +}; + +function errorText(error) { + if (error && error.enrollmentMayHaveCompleted) + return 'Your wallet changed while enrollment was being saved. Refresh the list to check whether alerts were enabled for the original account.'; + if (error && (error.code === 4001 || error.code === 'user_rejected')) + return 'The signature request was canceled. No alerts were enabled.'; + return ( + ERROR_COPY[error && error.code] || + 'We could not complete that request. Please try again.' + ); +} + +function datetime(value, milliseconds = false) { + if (!Number.isFinite(Number(value)) || Number(value) <= 0) + return 'Not yet checked'; + return new Date(Number(value) * (milliseconds ? 1 : 1000)).toLocaleString( + undefined, + { dateStyle: 'medium', timeStyle: 'short' } + ); +} + +function monitoringCopy(status) { + if (status === 'active') + return { + title: 'Monitoring active', + tone: 'is-positive', + description: 'The monitor is caught up with the chain.', + }; + if (status === 'catching_up') + return { + title: 'Checking chain history', + tone: 'is-warning', + description: + 'Checking for recent and existing recoveries. Monitoring starts when this check is complete.', + }; + return { + title: 'Monitoring delayed', + tone: 'is-warning', + description: + 'Chain data is not current. Alerts may be delayed; check the account in your wallet.', + }; +} + +function AccountAddress({ address }) { + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(address); + setCopied(true); + } catch (_error) { + setCopied(false); + } + } + return ( +
+ {address} + +
+ ); +} + +export default function RecoveryAlertsCard({ + service = recoveryAlertsService, + authService = defaultAuthService, + defaultOpen = false, + discover = discoverWallets, +}) { + const { user, handleAuthLost } = useAuth(); + const userId = user && user.id; + const [open, setOpen] = useState( + defaultOpen || + (typeof window !== 'undefined' && + window.location.hash === '#recovery-alerts') + ); + const [wallets, setWallets] = useState([]); + const [walletId, setWalletId] = useState(''); + const [selection, setSelection] = useState(null); + const [account, setAccount] = useState(''); + const [label, setLabel] = useState(''); + const [chains, setChains] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const [phase, setPhase] = useState(''); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [removing, setRemoving] = useState(null); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [needTotp, setNeedTotp] = useState(false); + const generation = useRef(0); + const fetchGeneration = useRef(0); + const userRef = useRef(userId); + userRef.current = userId; + const mounted = useRef(true); + const busy = useRef(false); + const wallet = wallets.find((item) => item.id === walletId); + const walletRef = useRef(wallet); + walletRef.current = wallet; + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + generation.current += 1; + }; + }, []); + useEffect( + () => + discover((choices) => { + setWallets(choices); + setWalletId( + (current) => current || (choices[0] && choices[0].id) || '' + ); + }), + [discover] + ); + + const handleError = useCallback( + (err) => { + if (err && err.code === 'unauthorized') { + handleAuthLost(); + return; + } + setError(errorText(err)); + }, + [handleAuthLost] + ); + + const load = useCallback(async () => { + const id = userRef.current; + if (id == null) return; + const request = ++fetchGeneration.current; + setLoading(true); + try { + const [supported, saved] = await Promise.all([ + service.chains(), + service.subscriptions(), + ]); + if ( + !mounted.current || + userRef.current !== id || + fetchGeneration.current !== request + ) + return; + if (!Array.isArray(supported) || !Array.isArray(saved)) + throw new Error('invalid_response'); + setChains(supported); + setSubscriptions(saved); + setLoaded(true); + } catch (err) { + if ( + mounted.current && + userRef.current === id && + fetchGeneration.current === request + ) + handleError(err); + } finally { + if ( + mounted.current && + userRef.current === id && + fetchGeneration.current === request + ) + setLoading(false); + } + }, [service, handleError]); + + useEffect(() => { + generation.current += 1; + fetchGeneration.current += 1; + setSelection(null); + setAccount(''); + setSubscriptions([]); + setChains([]); + setLoaded(false); + setError(null); + setNotice(null); + setRemoving(null); + setPassword(''); + setCode(''); + setNeedTotp(false); + setLabel(''); + setLoading(false); + if (open && userId != null) load(); + }, [userId, open, load]); + + useEffect(() => { + if (!open || !loaded) return undefined; + const timer = setInterval(() => { + if (document.visibilityState !== 'hidden' && !busy.current) load(); + }, 30000); + return () => clearInterval(timer); + }, [open, loaded, load]); + + useEffect(() => { + generation.current += 1; + setSelection(null); + setAccount(''); + const provider = wallet && wallet.provider; + if (!provider) return undefined; + const changed = () => { + generation.current += 1; + setSelection(null); + setAccount(''); + setNotice(null); + setError(ERROR_COPY.wallet_changed); + }; + provider.on?.('accountsChanged', changed); + provider.on?.('chainChanged', changed); + provider.on?.('disconnect', changed); + return () => { + provider.removeListener?.('accountsChanged', changed); + provider.removeListener?.('chainChanged', changed); + provider.removeListener?.('disconnect', changed); + }; + }, [wallet]); + + async function connect() { + if (!wallet || busy.current) return; + busy.current = true; + setPhase('connecting'); + setError(null); + setNotice(null); + const provider = wallet.provider; + const id = userId; + generation.current += 1; + try { + const selected = await readWalletSelection(provider, true); + if ( + !mounted.current || + walletRef.current?.provider !== provider || + userRef.current !== id + ) + return; + if (!chains.some((chain) => chain.chainId === selected.chainId)) + throw Object.assign(new Error(), { code: 'unsupported_chain' }); + setSelection(selected); + setAccount(selected.accounts[0]); + setError(null); + } catch (err) { + if ( + mounted.current && + walletRef.current?.provider === provider && + userRef.current === id + ) + handleError(err); + } finally { + busy.current = false; + if (mounted.current) setPhase(''); + } + } + + async function enroll(event) { + event.preventDefault(); + if (busy.current || !selection || !account || !wallet) return; + busy.current = true; + fetchGeneration.current += 1; + setLoading(false); + setPhase('preparing'); + setError(null); + setNotice(null); + const attempt = generation.current; + const id = userId; + try { + await enrollRecoveryAlerts({ + provider: wallet.provider, + service, + chainId: selection.chainId, + account, + label: label.trim(), + isCurrent: () => + mounted.current && + userRef.current === id && + generation.current === attempt, + onProgress: (next) => { + if (mounted.current && generation.current === attempt) setPhase(next); + }, + }); + if (!mounted.current || userRef.current !== id) return; + setNotice( + 'Account linked for recovery alerts. Monitoring starts after the initial chain checks.' + ); + setLabel(''); + await load(); + } catch (err) { + if (mounted.current && userRef.current === id) handleError(err); + } finally { + busy.current = false; + if (mounted.current) setPhase(''); + } + } + + async function remove(event) { + event.preventDefault(); + if (busy.current || !removing || !password || !user) return; + busy.current = true; + fetchGeneration.current += 1; + setLoading(false); + setPhase('removing'); + setError(null); + setNotice(null); + const id = userId; + try { + const oldAuthHash = await authService.deriveStepUpAuthHash( + password, + user.email + ); + if (!mounted.current || userRef.current !== id) return; + await service.remove(removing.id, { + oldAuthHash, + ...(code ? { code } : {}), + }); + if (!mounted.current || userRef.current !== id) return; + setRemoving(null); + setPassword(''); + setCode(''); + setNotice( + 'Alerts for this account were disabled. This does not cancel any on-chain recovery.' + ); + await load(); + } catch (err) { + if (mounted.current && userRef.current === id) { + if (err && err.code === 'totp_required') setNeedTotp(true); + handleError(err); + } + } finally { + busy.current = false; + if (mounted.current) setPhase(''); + } + } + + const chain = + selection && chains.find((item) => item.chainId === selection.chainId); + const isLinked = + selection && + subscriptions.some( + (item) => + item.chainId === selection.chainId && + item.account.toLowerCase() === account.toLowerCase() + ); + const phaseLabel = { + connecting: 'Connecting…', + preparing: 'Preparing request…', + signing: 'Confirm in wallet…', + saving: 'Saving alerts…', + removing: 'Disabling alerts…', + }[phase]; + + return ( +
+
+
+

+ + Recovery alerts +

+

+ Email warnings when guardian recovery starts on your smart account. +

+
+ +
+ {open && ( +
+

+ Recovery-start, reminder and outcome emails go to{' '} + {user && user.email}. + Keys and recovery control stay in your wallet. +

+

+ Alerts may be delayed and cannot cancel recovery. Keep access to + your wallet. +

+ {error && ( +
+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} + {loading && !loaded &&

Loading recovery alerts…

} +
+ {subscriptions.map((subscription) => { + const network = chains.find( + (item) => item.chainId === subscription.chainId + ); + const monitor = monitoringCopy( + subscription.monitoring && subscription.monitoring.status + ); + return ( +
+
+

{subscription.label || 'Smart account'}

+ + {monitor.title} + +
+ +

+ {network ? network.name : `Chain ${subscription.chainId}`} + {network && network.testnet ? ' · Testnet' : ''} +

+

{monitor.description}

+

+ Last chain check:{' '} + {datetime( + subscription.monitoring && + subscription.monitoring.lastCheckedAt, + true + )} +

+ {(subscription.pending || []).map((operation) => ( +
+ + {{ + scheduled: 'Recovery pending', + pending: 'Recovery pending', + ready: 'Recovery can be executed', + expired: 'Recovery expired', + executed: 'Recovery executed', + canceled: 'Recovery canceled or invalidated', + invalidated: 'Recovery no longer pending', + orphaned: 'Recovery observation no longer canonical', + unknown: 'Recovery status unavailable', + }[operation.status] || 'Recovery observed'} + + + {['pending', 'ready', 'scheduled'].includes( + operation.status + ) + ? 'Can execute from' + : 'Original execution time'}{' '} + {datetime(operation.executableAt)} + + {operation.expiresAt && ( + + {['pending', 'ready', 'scheduled'].includes( + operation.status + ) + ? 'Expires' + : 'Original expiry'}{' '} + {datetime(operation.expiresAt)} + + )} + + Review this operation in your wallet. Passing the + deadline does not mean it has executed. + +
+ ))} + +
+ ); + })} + {loaded && subscriptions.length === 0 && ( +

+ No smart accounts linked yet. Connect one below to begin. +

+ )} +
+ {removing ? ( +
+

+ Disable alerts for {removing.label || 'this smart account'}? +

+ +

+ {chains.find((item) => item.chainId === removing.chainId) + ?.name || `Chain ${removing.chainId}`} +

+

+ This stops its emails only. Any pending recovery remains + on-chain. Confirm with your Sysnode password. +

+
+ + setPassword(event.target.value)} + disabled={!!phase} + required + /> +
+ {needTotp && ( +
+ + + setCode(event.target.value.replace(/\D/g, '')) + } + required + disabled={!!phase} + /> +
+ )} +
+ + +
+ + ) : ( +
+ + {subscriptions.length === 0 + ? 'Link a smart account' + : 'Link another smart account'} + +
+

+ An off-chain signature links this account address to your + Sysnode email. No private keys or funds are sent. +

+ {wallets.length > 0 ? ( + <> +
+ + +
+ + + ) : ( +

+ Open this page in a browser with Pali or another compatible + smart-account wallet. Select your smart account in the + wallet before connecting. +

+ )} + {loaded && !chains.length && ( +

+ No recovery networks are enabled on this service yet. +

+ )} + {chains.length > 0 && ( +

+ Available networks:{' '} + {chains + .map( + (item) => + `${item.name}${item.testnet ? ' (testnet)' : ''}` + ) + .join(', ')} + . +

+ )} + {selection && ( + <> +

+ {chain && chain.name} + {chain && chain.testnet ? ' · Testnet' : ''} +

+
+ + +
+ +
+ + setLabel(event.target.value)} + /> +
+ + + )} + +
+ )} + +
+ )} +
+ ); +} diff --git a/src/components/RecoveryAlertsCard.test.js b/src/components/RecoveryAlertsCard.test.js new file mode 100644 index 0000000..551f689 --- /dev/null +++ b/src/components/RecoveryAlertsCard.test.js @@ -0,0 +1,298 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { act } from 'react'; +import userEvent from '@testing-library/user-event'; +import RecoveryAlertsCard from './RecoveryAlertsCard'; +import { AuthProvider } from '../context/AuthContext'; + +const ACCOUNT = `0x${'12'.repeat(20)}`; +const CHAIN_ID = 57057; +const CHAIN = { chainId: CHAIN_ID, name: 'zkTanenbaum', testnet: true }; +const SIGNATURE = `0x${'ab'.repeat(24000)}`; +const SUBSCRIPTION = { + id: 1, + chainId: CHAIN_ID, + account: ACCOUNT, + label: 'Savings', + monitoring: { status: 'catching_up', lastCheckedAt: 1792600000000 }, + pending: [], +}; + +const click = (element) => + act(async () => { + await userEvent.click(element); + }); +const type = (element, value) => + act(async () => { + await userEvent.type(element, value); + }); + +function setup({ + subscriptions = [], + open = true, + hasWallet = true, + totp = false, + service: overrides = {}, +} = {}) { + const handlers = {}; + const provider = { + request: jest.fn(async ({ method }) => + method === 'eth_chainId' + ? `0x${CHAIN_ID.toString(16)}` + : method === 'personal_sign' + ? SIGNATURE + : [ACCOUNT] + ), + on: jest.fn((name, fn) => { + handlers[name] = fn; + }), + removeListener: jest.fn(), + }; + const discover = jest.fn((cb) => { + cb(hasWallet ? [{ id: 'pali', name: 'Pali', provider }] : []); + return () => {}; + }); + const service = { + chains: jest.fn().mockResolvedValue([CHAIN]), + subscriptions: jest.fn().mockResolvedValue(subscriptions), + challenge: jest.fn().mockResolvedValue({ + id: 'nonce', + message: 'Enable recovery alerts', + account: ACCOUNT, + chainId: CHAIN_ID, + }), + subscribe: jest.fn().mockResolvedValue(SUBSCRIPTION), + remove: jest.fn().mockResolvedValue({ ok: true }), + ...overrides, + }; + const authService = { + me: jest.fn().mockResolvedValue({ + user: { + id: 1, + email: 'qa@example.test', + emailVerified: true, + totpEnabled: totp, + }, + }), + deriveStepUpAuthHash: jest.fn().mockResolvedValue('auth-hash'), + }; + const view = render( + + + + ); + return { ...view, service, authService, provider, handlers }; +} + +test('collapsed card adds no background API requests until it is opened', async () => { + const { service } = setup({ open: false }); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Expand recovery alerts' }) + ).toBeInTheDocument() + ); + expect(service.chains).not.toHaveBeenCalled(); + await click(screen.getByRole('button', { name: 'Expand recovery alerts' })); + await screen.findByText(/No smart accounts linked yet/); + expect(service.subscriptions).toHaveBeenCalledTimes(1); +}); + +test('enrollment requests an off-chain signature, saves only proof and refreshes monitoring', async () => { + const { service, provider } = setup(); + await screen.findByText(/No smart accounts linked yet/); + await click(screen.getByRole('button', { name: 'Connect wallet' })); + await screen.findByRole('button', { name: 'Enable recovery alerts' }); + expect( + screen.getByText(/An off-chain signature links this account address/) + ).toBeInTheDocument(); + await type(screen.getByLabelText(/Account label/), 'Savings'); + service.subscriptions.mockResolvedValue([SUBSCRIPTION]); + await click(screen.getByRole('button', { name: 'Enable recovery alerts' })); + await screen.findByText('Checking chain history'); + expect(service.subscribe).toHaveBeenCalledWith('nonce', SIGNATURE, 'Savings'); + expect( + provider.request.mock.calls.some( + ([call]) => call.method === 'eth_sendTransaction' + ) + ).toBe(false); + expect( + screen.getByText(/Account linked for recovery alerts/) + ).toBeInTheDocument(); + expect( + screen.getByText(/Checking for recent and existing recoveries/) + ).toBeInTheDocument(); +}); + +test('normal accountsChanged on first wallet permission does not break connection', async () => { + const { provider, handlers } = setup(); + await screen.findByText(/No smart accounts linked yet/); + provider.request.mockImplementation(async ({ method }) => { + if (method === 'eth_requestAccounts') handlers.accountsChanged([ACCOUNT]); + return method === 'eth_chainId' ? `0x${CHAIN_ID.toString(16)}` : [ACCOUNT]; + }); + await click(screen.getByRole('button', { name: 'Connect wallet' })); + expect( + await screen.findByRole('button', { name: 'Enable recovery alerts' }) + ).toBeEnabled(); +}); + +test('a rejected signature does not claim success or persist enrollment', async () => { + const { provider, service } = setup(); + await screen.findByText(/No smart accounts linked yet/); + await click(screen.getByRole('button', { name: 'Connect wallet' })); + await screen.findByRole('button', { name: 'Enable recovery alerts' }); + provider.request.mockImplementation(async ({ method }) => { + if (method === 'personal_sign') + throw Object.assign(new Error(), { code: 4001 }); + return method === 'eth_chainId' ? `0x${CHAIN_ID.toString(16)}` : [ACCOUNT]; + }); + await click(screen.getByRole('button', { name: 'Enable recovery alerts' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + /signature request was canceled/ + ); + expect(service.subscribe).not.toHaveBeenCalled(); +}); + +test('shows degraded monitoring and accurate expiry without claiming recovery has executed', async () => { + setup({ + subscriptions: [ + { + ...SUBSCRIPTION, + monitoring: { status: 'degraded', lastCheckedAt: 1792600000000 }, + pending: [ + { + operationId: 'op', + executableAt: 1792700000, + expiresAt: 1792900000, + status: 'ready', + }, + ], + }, + ], + }); + await screen.findByText('Monitoring delayed'); + expect(screen.getByText(/Chain data is not current/)).toBeInTheDocument(); + expect(screen.getByText('Recovery can be executed')).toBeInTheDocument(); + expect( + screen.getByText(/Passing the deadline does not mean it has executed/) + ).toBeInTheDocument(); + expect(screen.getByText(/Last chain check:/)).not.toHaveTextContent('58775'); +}); + +test.each([ + ['canceled', 'Recovery canceled or invalidated'], + ['invalidated', 'Recovery no longer pending'], + ['orphaned', 'Recovery observation no longer canonical'], +])( + 'labels %s without inventing an operator action or a current execution deadline', + async (status, label) => { + setup({ + subscriptions: [ + { + ...SUBSCRIPTION, + pending: [ + { + operationId: 'op', + executableAt: 1792700000, + expiresAt: 1792900000, + status, + }, + ], + }, + ], + }); + expect(await screen.findByText(label)).toBeInTheDocument(); + expect(screen.queryByText(/Can execute from/)).not.toBeInTheDocument(); + expect(screen.getByText(/Original execution time/)).toBeInTheDocument(); + } +); + +test('disabling requires password and TOTP and identifies the exact account', async () => { + const { service, authService } = setup({ + subscriptions: [SUBSCRIPTION], + totp: true, + }); + await screen.findByText('Savings'); + await click(screen.getByRole('button', { name: 'Disable these alerts…' })); + expect(screen.getByText('Disable alerts for Savings?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Disable alerts' })).toBeDisabled(); + await type( + screen.getByLabelText('Current Sysnode password'), + 'correct-password' + ); + await type(screen.getByLabelText('Authenticator code'), '123456'); + service.subscriptions.mockResolvedValue([]); + await click(screen.getByRole('button', { name: 'Disable alerts' })); + await screen.findByText(/This does not cancel any on-chain recovery/); + expect(authService.deriveStepUpAuthHash).toHaveBeenCalledWith( + 'correct-password', + 'qa@example.test' + ); + expect(service.remove).toHaveBeenCalledWith(1, { + oldAuthHash: 'auth-hash', + code: '123456', + }); + expect( + screen.queryByLabelText('Current Sysnode password') + ).not.toBeInTheDocument(); +}); + +test('stale 2FA identity responds to server step-up requirement, without losing subscription', async () => { + const { service } = setup({ + subscriptions: [SUBSCRIPTION], + service: { remove: jest.fn().mockRejectedValue({ code: 'totp_required' }) }, + }); + await screen.findByText('Savings'); + await click(screen.getByRole('button', { name: 'Disable these alerts…' })); + fireEvent.change(screen.getByLabelText('Current Sysnode password'), { + target: { value: 'password' }, + }); + await click(screen.getByRole('button', { name: 'Disable alerts' })); + await screen.findByLabelText('Authenticator code'); + expect(screen.getByText('Savings')).toBeInTheDocument(); + expect(service.subscriptions).toHaveBeenCalledTimes(1); +}); + +test('a slow status refresh cannot restore a subscription after it was disabled', async () => { + const { service } = setup({ subscriptions: [SUBSCRIPTION] }); + await screen.findByText('Savings'); + let finishOldRefresh; + service.subscriptions.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldRefresh = resolve; + }) + ); + await click(screen.getByRole('button', { name: 'Refresh status' })); + await click(screen.getByRole('button', { name: 'Disable these alerts…' })); + fireEvent.change(screen.getByLabelText('Current Sysnode password'), { + target: { value: 'password' }, + }); + service.subscriptions.mockResolvedValue([]); + await click(screen.getByRole('button', { name: 'Disable alerts' })); + await screen.findByText(/No smart accounts linked yet/); + await act(async () => { + finishOldRefresh([SUBSCRIPTION]); + }); + expect(screen.queryByText('Savings')).not.toBeInTheDocument(); + expect(screen.getByText(/No smart accounts linked yet/)).toBeInTheDocument(); +}); + +test('no wallet and load failure show retryable honest states', async () => { + setup({ + hasWallet: false, + service: { chains: jest.fn().mockRejectedValue({ code: 'network_error' }) }, + }); + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not reach Sysnode/ + ); + expect( + screen.getByText(/Open this page in a browser with Pali/) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Refresh status' })).toBeEnabled(); +}); diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js index c7e6987..35ec69a 100644 --- a/src/lib/apiClient.js +++ b/src/lib/apiClient.js @@ -192,8 +192,13 @@ export function createApiClient({ const isVerifyPassword = url.startsWith('/auth/verify-password'); const isVerifyPasswordMismatch = isVerifyPassword && apiError.code === 'invalid_credentials'; + const isRecoveryPasswordMismatch = + String(error.config && error.config.method).toUpperCase() === + 'DELETE' && + /^\/recovery\/subscriptions\/[^/?]+$/.test(url) && + apiError.code === 'invalid_credentials'; if ( - !isAuthEndpoint || + (!isAuthEndpoint && !isRecoveryPasswordMismatch) || (isVerifyPassword && !isVerifyPasswordMismatch) ) { try { diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js index d3c076f..8b84306 100644 --- a/src/lib/apiClient.test.js +++ b/src/lib/apiClient.test.js @@ -148,6 +148,34 @@ describe('createApiClient — no global Content-Type (Codex round 3 P2)', () => }); describe('createApiClient — 401 handling', () => { + test('wrong recovery-alert step-up password does not invalidate the session', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ onAuthLost }); + const adapter = new MockAdapter(client); + adapter + .onDelete('/recovery/subscriptions/1') + .reply(401, { error: 'invalid_credentials' }); + await expect( + client.delete('/recovery/subscriptions/1') + ).rejects.toMatchObject({ code: 'invalid_credentials' }); + expect(onAuthLost).not.toHaveBeenCalled(); + adapter.restore(); + }); + + test('recovery-alert session expiry still invalidates the session', async () => { + const onAuthLost = jest.fn(); + const client = createApiClient({ onAuthLost }); + const adapter = new MockAdapter(client); + adapter + .onDelete('/recovery/subscriptions/1') + .reply(401, { error: 'unauthorized' }); + await expect( + client.delete('/recovery/subscriptions/1') + ).rejects.toMatchObject({ code: 'unauthorized' }); + expect(onAuthLost).toHaveBeenCalledTimes(1); + adapter.restore(); + }); + test('invokes onAuthLost for non-auth 401s', async () => { const onAuthLost = jest.fn(); const client = createApiClient({ diff --git a/src/lib/recoveryAlerts.js b/src/lib/recoveryAlerts.js new file mode 100644 index 0000000..ba85892 --- /dev/null +++ b/src/lib/recoveryAlerts.js @@ -0,0 +1,214 @@ +import { apiClient } from './apiClient'; + +export function createRecoveryAlertsService(client = apiClient) { + return { + async chains() { + return (await client.get('/recovery/chains')).data.chains; + }, + async subscriptions() { + return (await client.get('/recovery/subscriptions')).data.subscriptions; + }, + async challenge(chainId, account) { + return (await client.post('/recovery/challenges', { chainId, account })) + .data; + }, + async subscribe(challengeId, signature, label) { + const body = { challengeId, signature }; + if (label) body.label = label; + return (await client.post('/recovery/subscriptions', body)).data + .subscription; + }, + async remove(id, credentials) { + return ( + await client.delete( + `/recovery/subscriptions/${encodeURIComponent(id)}`, + { + data: credentials, + } + ) + ).data; + }, + }; +} + +export const recoveryAlertsService = createRecoveryAlertsService(); + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; + +function walletError(code) { + return Object.assign(new Error(code), { code }); +} + +export function parseEvmChainId(value) { + if (typeof value !== 'string' || !/^0x[0-9a-f]+$/i.test(value)) return null; + const number = Number(value); + return Number.isSafeInteger(number) && number > 0 ? number : null; +} + +export async function readWalletSelection(provider, connect = false) { + if (!provider || typeof provider.request !== 'function') + throw walletError('wallet_unavailable'); + const accounts = await provider.request({ + method: connect ? 'eth_requestAccounts' : 'eth_accounts', + }); + const chainId = parseEvmChainId( + await provider.request({ method: 'eth_chainId' }) + ); + if ( + !Array.isArray(accounts) || + !accounts.length || + accounts.some( + (account) => typeof account !== 'string' || !ADDRESS.test(account) + ) + ) { + throw walletError('wallet_account_missing'); + } + if (!chainId) throw walletError('unsupported_chain'); + return { + accounts: [...new Set(accounts.map((account) => account.toLowerCase()))], + chainId, + }; +} + +// Only sign the server-issued message, using the standard wallet interface. +// The backend verifies the signature against the contract, not its EOA owner. +export async function enrollRecoveryAlerts({ + provider, + service = recoveryAlertsService, + chainId, + account, + label, + onProgress = () => {}, + isCurrent = () => true, +}) { + let changed = false; + let submitting = false; + const invalidate = () => { + changed = true; + }; + provider.on?.('accountsChanged', invalidate); + provider.on?.('chainChanged', invalidate); + provider.on?.('disconnect', invalidate); + const assertCurrent = async () => { + if (changed || !isCurrent()) throw walletError('wallet_changed'); + const selected = await readWalletSelection(provider); + if ( + changed || + !isCurrent() || + selected.chainId !== chainId || + !selected.accounts.includes(account.toLowerCase()) + ) { + throw walletError('wallet_changed'); + } + }; + try { + await assertCurrent(); + onProgress('preparing'); + const challenge = await service.challenge(chainId, account); + if ( + !challenge || + typeof challenge.id !== 'string' || + typeof challenge.message !== 'string' || + !challenge.message || + challenge.chainId !== chainId || + String(challenge.account).toLowerCase() !== account.toLowerCase() + ) { + throw walletError('invalid_challenge'); + } + await assertCurrent(); + const encoded = `0x${Array.from( + new TextEncoder().encode(challenge.message), + (byte) => byte.toString(16).padStart(2, '0') + ).join('')}`; + onProgress('signing'); + const signature = await provider.request({ + method: 'personal_sign', + params: [encoded, account], + }); + // Contract signatures can be much longer than a 65-byte ECDSA signature. + if ( + typeof signature !== 'string' || + !/^0x(?:[0-9a-fA-F]{2})+$/.test(signature) + ) + throw walletError('invalid_signature'); + await assertCurrent(); + onProgress('saving'); + submitting = true; + const subscription = await service.subscribe( + challenge.id, + signature, + label + ); + if (changed || !isCurrent()) { + const error = walletError('wallet_changed'); + error.enrollmentMayHaveCompleted = true; + throw error; + } + return subscription; + } catch (error) { + if ((changed || !isCurrent()) && error.code !== 'wallet_changed') { + const out = walletError('wallet_changed'); + out.enrollmentMayHaveCompleted = submitting; + throw out; + } + throw error; + } finally { + provider.removeListener?.('accountsChanged', invalidate); + provider.removeListener?.('chainChanged', invalidate); + provider.removeListener?.('disconnect', invalidate); + } +} + +// EIP-6963 metadata is display-only. Authorization never trusts a wallet name, +// icon or brand flag; the selected provider still needs an on-chain proof. +export function discoverWallets( + onChange, + target = typeof window === 'undefined' ? null : window +) { + if (!target) return () => {}; + const choices = []; + const add = (provider, name) => { + if ( + !provider || + typeof provider.request !== 'function' || + choices.some((entry) => entry.provider === provider) + ) + return; + choices.push({ + provider, + name: String(name || 'Browser wallet').slice(0, 64), + id: `wallet-${choices.length}`, + }); + onChange([...choices]); + }; + const announced = (event) => { + const detail = event && event.detail; + if (detail && detail.info) add(detail.provider, detail.info.name); + }; + target.addEventListener('eip6963:announceProvider', announced); + target.dispatchEvent(new Event('eip6963:requestProvider')); + const injected = target.ethereum; + if (injected && Array.isArray(injected.providers)) { + injected.providers.forEach((provider, i) => + add( + provider, + provider.isPali + ? 'Pali' + : provider.isMetaMask + ? 'MetaMask' + : `Browser wallet ${i + 1}` + ) + ); + } else if (injected) { + add( + injected, + injected.isPali + ? 'Pali' + : injected.isMetaMask + ? 'MetaMask' + : 'Browser wallet' + ); + } + return () => + target.removeEventListener('eip6963:announceProvider', announced); +} diff --git a/src/lib/recoveryAlerts.test.js b/src/lib/recoveryAlerts.test.js new file mode 100644 index 0000000..aa2023c --- /dev/null +++ b/src/lib/recoveryAlerts.test.js @@ -0,0 +1,218 @@ +import { + createRecoveryAlertsService, + discoverWallets, + enrollRecoveryAlerts, + parseEvmChainId, + readWalletSelection, +} from './recoveryAlerts'; + +const ACCOUNT = `0x${'12'.repeat(20)}`; +const OTHER = `0x${'34'.repeat(20)}`; +const CHAIN = 57057; +const SIGNATURE = `0x${'ab'.repeat(24000)}`; + +function fixture() { + const handlers = {}; + const provider = { + request: jest.fn(async ({ method }) => { + if (method === 'eth_chainId') return `0x${CHAIN.toString(16)}`; + if (method === 'personal_sign') return SIGNATURE; + return [ACCOUNT]; + }), + on: jest.fn((event, fn) => { + handlers[event] = fn; + }), + removeListener: jest.fn(), + }; + const service = { + challenge: jest.fn().mockResolvedValue({ + id: 'nonce', + message: 'Enable recovery alerts — user@example.test', + chainId: CHAIN, + account: ACCOUNT, + }), + subscribe: jest.fn().mockResolvedValue({ id: 1, account: ACCOUNT }), + }; + return { provider, service, handlers }; +} + +test('uses authenticated API methods and never sends wallet keys or an RPC URL', async () => { + const client = { + get: jest + .fn() + .mockResolvedValue({ data: { chains: [], subscriptions: [] } }), + post: jest.fn().mockResolvedValue({ data: { subscription: { id: 1 } } }), + delete: jest.fn().mockResolvedValue({ data: { ok: true } }), + }; + const service = createRecoveryAlertsService(client); + await service.chains(); + await service.subscriptions(); + await service.challenge(CHAIN, ACCOUNT); + await service.subscribe('nonce', SIGNATURE, 'Savings'); + await service.remove(1, { oldAuthHash: 'hash', code: '123456' }); + expect(client.get.mock.calls).toEqual([ + ['/recovery/chains'], + ['/recovery/subscriptions'], + ]); + expect(client.post.mock.calls).toEqual([ + ['/recovery/challenges', { chainId: CHAIN, account: ACCOUNT }], + [ + '/recovery/subscriptions', + { challengeId: 'nonce', signature: SIGNATURE, label: 'Savings' }, + ], + ]); + expect(client.delete).toHaveBeenCalledWith('/recovery/subscriptions/1', { + data: { oldAuthHash: 'hash', code: '123456' }, + }); +}); + +test('standard personal_sign preserves a long ERC1271 signature and UTF8 bytes', async () => { + const { provider, service } = fixture(); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + label: 'Savings', + }) + ).resolves.toEqual({ id: 1, account: ACCOUNT }); + const call = provider.request.mock.calls.find( + ([arg]) => arg.method === 'personal_sign' + )[0]; + expect(Buffer.from(call.params[0].slice(2), 'hex').toString('utf8')).toBe( + 'Enable recovery alerts — user@example.test' + ); + expect(call.params[1]).toBe(ACCOUNT); + expect(service.subscribe).toHaveBeenCalledWith('nonce', SIGNATURE, 'Savings'); + expect(provider.removeListener).toHaveBeenCalledTimes(3); +}); + +test('a user rejection never saves an enrollment', async () => { + const { provider, service } = fixture(); + provider.request.mockImplementation(async ({ method }) => { + if (method === 'personal_sign') + throw Object.assign(new Error(), { code: 4001 }); + return method === 'eth_chainId' ? `0x${CHAIN.toString(16)}` : [ACCOUNT]; + }); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + }) + ).rejects.toMatchObject({ code: 4001 }); + expect(service.subscribe).not.toHaveBeenCalled(); +}); + +test('account changes while signing invalidate proof even if a signature arrives', async () => { + const { provider, service, handlers } = fixture(); + provider.request.mockImplementation(async ({ method }) => { + if (method === 'personal_sign') { + handlers.accountsChanged([OTHER]); + return SIGNATURE; + } + return method === 'eth_chainId' ? `0x${CHAIN.toString(16)}` : [ACCOUNT]; + }); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + }) + ).rejects.toMatchObject({ code: 'wallet_changed' }); + expect(service.subscribe).not.toHaveBeenCalled(); +}); + +test('a network change while requesting the challenge prevents any signing', async () => { + const { provider, service, handlers } = fixture(); + service.challenge.mockImplementation(async () => { + handlers.chainChanged('0x1'); + return { id: 'nonce', message: 'Test', chainId: CHAIN, account: ACCOUNT }; + }); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + }) + ).rejects.toMatchObject({ code: 'wallet_changed' }); + expect( + provider.request.mock.calls.some(([arg]) => arg.method === 'personal_sign') + ).toBe(false); +}); + +test('challenge identity must match the user selected chain and smart account', async () => { + const { provider, service } = fixture(); + service.challenge.mockResolvedValue({ + id: 'nonce', + message: 'wrong', + chainId: CHAIN, + account: OTHER, + }); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + }) + ).rejects.toMatchObject({ code: 'invalid_challenge' }); + expect(service.subscribe).not.toHaveBeenCalled(); +}); + +test('context switching while the save is in flight does not falsely claim no subscription was made', async () => { + const { provider, service, handlers } = fixture(); + service.subscribe.mockImplementation(async () => { + handlers.chainChanged('0x1'); + return { id: 1 }; + }); + await expect( + enrollRecoveryAlerts({ + provider, + service, + chainId: CHAIN, + account: ACCOUNT, + }) + ).rejects.toMatchObject({ + code: 'wallet_changed', + enrollmentMayHaveCompleted: true, + }); +}); + +test('reads only safe chain IDs and well formed accounts', async () => { + expect(parseEvmChainId('0xde e1')).toBeNull(); + expect(parseEvmChainId('57057')).toBeNull(); + expect(parseEvmChainId('0xffffffffffffffffffff')).toBeNull(); + const { provider } = fixture(); + provider.request.mockResolvedValue(['not an address']); + await expect(readWalletSelection(provider)).rejects.toMatchObject({ + code: 'wallet_account_missing', + }); +}); + +test('EIP6963 discovery deduplicates providers and never renders supplied SVG or HTML', () => { + const provider = { request: jest.fn() }; + const target = new EventTarget(); + const changed = jest.fn(); + target.ethereum = provider; + const stop = discoverWallets(changed, target); + const event = new Event('eip6963:announceProvider'); + event.detail = { + info: { + name: '', + uuid: 'uuid', + icon: 'data:image/svg+xml,bad', + }, + provider, + }; + target.dispatchEvent(event); + expect(changed).toHaveBeenCalledTimes(1); + expect(changed.mock.calls[0][0]).toEqual([ + { name: 'Browser wallet', id: 'wallet-0', provider }, + ]); + stop(); +}); diff --git a/src/pages/Account.js b/src/pages/Account.js index a2f62b0..913aac9 100644 --- a/src/pages/Account.js +++ b/src/pages/Account.js @@ -8,6 +8,7 @@ import TwoFactorCard from '../components/TwoFactorCard'; import NotificationPreferencesCard from '../components/NotificationPreferencesCard'; import DeleteAccountCard from '../components/DeleteAccountCard'; import GovernanceActivityLink from '../components/GovernanceActivityLink'; +import RecoveryAlertsCard from '../components/RecoveryAlertsCard'; import { useAuth } from '../context/AuthContext'; export default function Account() { @@ -42,7 +43,7 @@ export default function Account() { <>
@@ -100,6 +101,8 @@ export default function Account() { + + {/* * Settings-style disclosure: heavier forms (Change password, * Notifications) render their header inline but hide the form diff --git a/tests/browser/recovery-alerts.spec.cjs b/tests/browser/recovery-alerts.spec.cjs new file mode 100644 index 0000000..4943184 --- /dev/null +++ b/tests/browser/recovery-alerts.spec.cjs @@ -0,0 +1,225 @@ +const { test, expect } = require('@playwright/test'); + +const ACCOUNT = `0x${'12'.repeat(20)}`; +const CHAIN = { + chainId: 57057, + name: 'zkTanenbaum', + recoveryModule: `0x${'45'.repeat(20)}`, + testnet: true, +}; +const signature = `0x${'ab'.repeat(24000)}`; + +async function fixtures(page, { monitored = false, degraded = false } = {}) { + let subscriptions = monitored + ? [ + { + id: 1, + account: ACCOUNT, + chainId: 57057, + label: 'Savings with a long but valid account label', + createdAt: Date.now(), + monitoring: { + status: degraded ? 'degraded' : 'active', + lastCheckedAt: Date.now(), + lastBlockTimestamp: Math.floor(Date.now() / 1000), + }, + pending: [ + { + operationId: `0x${'de'.repeat(32)}`, + scheduledAt: Math.floor(Date.now() / 1000), + executableAt: Math.floor(Date.now() / 1000) + 259200, + expiresAt: Math.floor(Date.now() / 1000) + 864000, + status: 'pending', + }, + ], + }, + ] + : []; + const writes = []; + await page.addInitScript( + ({ ACCOUNT, signature }) => { + const listeners = new Map(); + window.__walletCalls = []; + const provider = { + isPali: true, + on(event, callback) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event).add(callback); + }, + removeListener(event, callback) { + listeners.get(event)?.delete(callback); + }, + async request({ method, params }) { + window.__walletCalls.push({ method, params }); + if (method === 'eth_chainId') return '0xdee1'; + if (method === 'eth_accounts' || method === 'eth_requestAccounts') + return [ACCOUNT]; + if (method === 'personal_sign') return signature; + throw new Error(`Unexpected wallet method: ${method}`); + }, + }; + window.ethereum = provider; + }, + { ACCOUNT, signature } + ); + await page.route('**/auth/me', (route) => + route.fulfill({ + json: { + user: { + id: 1, + email: 'recovery.alerts.qa@example.test', + emailVerified: true, + saltV: 'ab'.repeat(32), + notificationPrefs: {}, + totpEnabled: true, + }, + }, + }) + ); + await page.route('**/vault', (route) => + route.fulfill({ json: { empty: true } }) + ); + await page.route('**/recovery/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/recovery/chains') + return route.fulfill({ json: { chains: [CHAIN] } }); + if ( + path === '/recovery/subscriptions' && + route.request().method() === 'GET' + ) + return route.fulfill({ json: { subscriptions } }); + const body = route.request().postDataJSON(); + writes.push({ path, body }); + if (path === '/recovery/challenges') + return route.fulfill({ + json: { + id: 'nonce', + message: 'Sysnode recovery alert enrollment\nAccount: ' + ACCOUNT, + chainId: 57057, + account: ACCOUNT, + expiresAt: Date.now() + 300000, + }, + }); + if (path === '/recovery/subscriptions') { + expect(body).toEqual({ + challengeId: 'nonce', + signature, + label: 'Savings', + }); + const subscription = { + id: 1, + account: ACCOUNT, + chainId: 57057, + label: 'Savings', + monitoring: { status: 'catching_up', lastCheckedAt: null }, + pending: [], + }; + subscriptions = [subscription]; + return route.fulfill({ json: { subscription } }); + } + if (path === '/recovery/subscriptions/1') + return route.fulfill({ + status: 401, + json: { error: 'invalid_credentials' }, + }); + throw new Error(`Unexpected API: ${path}`); + }); + return { writes }; +} + +async function noHorizontalOverflow(page) { + const overflow = await page.evaluate(() => { + if (document.documentElement.scrollWidth <= window.innerWidth) return []; + return [...document.querySelectorAll('body *')] + .filter( + (element) => + element.getBoundingClientRect().right > window.innerWidth + 1 + ) + .map((element) => ({ + tag: element.tagName, + className: element.className, + width: element.getBoundingClientRect().width, + })); + }); + expect(overflow).toEqual([]); +} + +test('320px recovery enrollment uses standard wallet signing and shows catch-up instead of claiming monitoring active', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 320, height: 500 }); + const fixture = await fixtures(page); + await page.goto('/account#recovery-alerts'); + await page + .getByRole('button', { name: 'Connect wallet', exact: true }) + .click(); + await page.getByLabel('Account label (optional)').fill('Savings'); + await page + .getByRole('button', { name: 'Enable recovery alerts', exact: true }) + .click(); + await expect( + page.getByText('Checking chain history', { exact: true }) + ).toBeVisible(); + await expect( + page.getByText(/Account linked for recovery alerts/) + ).toBeVisible(); + expect(fixture.writes).toHaveLength(2); + const calls = await page.evaluate(() => window.__walletCalls); + expect(calls.filter((call) => call.method === 'personal_sign')).toHaveLength( + 1 + ); + expect(calls.some((call) => call.method === 'eth_sendTransaction')).toBe( + false + ); + await noHorizontalOverflow(page); + await page + .locator('#recovery-alerts') + .screenshot({ + path: testInfo.outputPath('recovery-enrollment-320.png'), + style: '.site-header { visibility: hidden !important; }', + }); +}); + +test('small-window pending recovery and disable confirmation remain readable with correct account and network', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 360, height: 500 }); + const fixture = await fixtures(page, { monitored: true, degraded: true }); + await page.goto('/account#recovery-alerts'); + await expect( + page.getByText('Monitoring delayed', { exact: true }) + ).toBeVisible(); + await expect( + page.getByText('Recovery pending', { exact: true }) + ).toBeVisible(); + await page + .locator('#recovery-alerts') + .screenshot({ + path: testInfo.outputPath('recovery-pending-360.png'), + style: '.site-header { visibility: hidden !important; }', + }); + await page.getByRole('button', { name: 'Disable these alerts…' }).click(); + await expect( + page.getByRole('heading', { + name: 'Disable alerts for Savings with a long but valid account label?', + }) + ).toBeVisible(); + await page.getByLabel('Current Sysnode password').fill('wrong-password'); + await page.getByLabel('Authenticator code').fill('123456'); + await page + .getByRole('button', { name: 'Disable alerts', exact: true }) + .click(); + await expect(page.getByRole('alert')).toHaveText( + /current Sysnode password is incorrect/ + ); + expect(fixture.writes.at(-1).body.code).toBe('123456'); + await noHorizontalOverflow(page); + await page + .locator('.recovery-remove') + .screenshot({ + path: testInfo.outputPath('recovery-disable-360.png'), + style: '.site-header { visibility: hidden !important; }', + }); + await page.getByRole('button', { name: 'Keep alerts' }).click(); + await expect(page.getByLabel('Current Sysnode password')).toHaveCount(0); +}); diff --git a/tests/productionRuntime.test.cjs b/tests/productionRuntime.test.cjs index d25c7eb..c007abc 100644 --- a/tests/productionRuntime.test.cjs +++ b/tests/productionRuntime.test.cjs @@ -91,3 +91,57 @@ test('axios ignores an inherited parseReviver when decoding JSON responses', asy else delete Object.prototype.parseReviver; } }); + +for (const arrayHeaders of [true, false]) { + test(`redirects strip credential headers across origins (${arrayHeaders ? 'array' : 'metacharacter'} headers)`, async () => { + const http = require('node:http'); + const follow = require('follow-redirects').http; + const target = http.createServer((req, res) => { + req.resume(); + res.end(JSON.stringify(req.headers)); + }); + const source = http.createServer((req, res) => { + req.resume(); + res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/capture` }); + res.end(); + }); + try { + target.listen(0, '127.0.0.1'); + await once(target, 'listening'); + source.listen(0, '127.0.0.1'); + await once(source, 'listening'); + const headerObject = { + Authorization: 'synthetic-authorization', + Cookie: 'synthetic-session', + 'Proxy-Authorization': 'synthetic-proxy', + 'X-Api^Key': 'synthetic-custom-one', + 'X-Token|Secret': 'synthetic-custom-two', + 'X-Public': 'harmless', + }; + const headers = arrayHeaders ? Object.entries(headerObject).flat() : headerObject; + const received = await new Promise((resolve, reject) => { + const req = follow.request({ + hostname: '127.0.0.1', port: source.address().port, path: '/', headers, + sensitiveHeaders: ['X-Api^Key', 'X-Token|Secret'], + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (error) { reject(error); } + }); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); + }); + assert.equal(received.authorization, undefined); + assert.equal(received.cookie, undefined); + assert.equal(received['proxy-authorization'], undefined); + assert.equal(received['x-api^key'], undefined); + assert.equal(received['x-token|secret'], undefined); + assert.equal(received['x-public'], 'harmless'); + } finally { + await Promise.all([source, target].map((server) => new Promise((resolve) => server.close(resolve)))); + } + }); +} From 5152893b8b9305012d8f0d651df6982c12476115 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Wed, 21 Oct 2026 14:17:14 -0700 Subject: [PATCH 2/5] Use the existing same-origin API proxy for recovery alerts --- README.md | 20 ++++++++++++++++++++ src/lib/apiClient.js | 2 +- src/lib/apiClient.test.js | 8 ++++---- src/lib/recoveryAlerts.js | 10 +++++----- src/lib/recoveryAlerts.test.js | 10 +++++----- tests/browser/recovery-alerts.spec.cjs | 12 ++++++------ 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 364db1e..e8fe243 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ https://sysnode.info/gov/* -> backend https://sysnode.info/mnstats -> backend https://sysnode.info/mncount -> backend https://sysnode.info/govlist -> backend +https://sysnode.info/api/recovery/* -> backend ``` The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep. @@ -51,6 +52,25 @@ REACT_APP_API_BASE=https://your-backend.example npm start The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches. +## Recovery email alerts + +Open **Account → Recovery alerts**, connect a deployed smart account on a +supported network, and sign the enrollment message in the wallet. Alerts go to +the signed-in user's verified email; private keys never leave the wallet. +The backend verifies the account's ERC-1271 signature and monitors independently +of this page being open. The initial supported chain is zkTanenbaum (57057). + +Check the displayed monitoring status and on-chain execution time. Delivery is +best effort; a delayed monitor is not evidence that an account is safe. Email +links open the review page, never authorize cancellation or a transaction. +Disabling an alert subscription requires the current Sysnode password and TOTP +when configured; it does not disable the on-chain guardian policy. + +The public `/api/recovery/` prefix uses the existing `/api/` reverse proxy. +Backends also accept `/recovery/` when that proxy strips `/api`, while local +development can use `/api/recovery/` directly. Keep both on the same origin as +the frontend and do not replace the credentialed cookie/CSRF boundary. + ## Getting Started Install dependencies: diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js index 35ec69a..eeea917 100644 --- a/src/lib/apiClient.js +++ b/src/lib/apiClient.js @@ -195,7 +195,7 @@ export function createApiClient({ const isRecoveryPasswordMismatch = String(error.config && error.config.method).toUpperCase() === 'DELETE' && - /^\/recovery\/subscriptions\/[^/?]+$/.test(url) && + /^\/api\/recovery\/subscriptions\/[^/?]+$/.test(url) && apiError.code === 'invalid_credentials'; if ( (!isAuthEndpoint && !isRecoveryPasswordMismatch) || diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js index 8b84306..deae5f4 100644 --- a/src/lib/apiClient.test.js +++ b/src/lib/apiClient.test.js @@ -153,10 +153,10 @@ describe('createApiClient — 401 handling', () => { const client = createApiClient({ onAuthLost }); const adapter = new MockAdapter(client); adapter - .onDelete('/recovery/subscriptions/1') + .onDelete('/api/recovery/subscriptions/1') .reply(401, { error: 'invalid_credentials' }); await expect( - client.delete('/recovery/subscriptions/1') + client.delete('/api/recovery/subscriptions/1') ).rejects.toMatchObject({ code: 'invalid_credentials' }); expect(onAuthLost).not.toHaveBeenCalled(); adapter.restore(); @@ -167,10 +167,10 @@ describe('createApiClient — 401 handling', () => { const client = createApiClient({ onAuthLost }); const adapter = new MockAdapter(client); adapter - .onDelete('/recovery/subscriptions/1') + .onDelete('/api/recovery/subscriptions/1') .reply(401, { error: 'unauthorized' }); await expect( - client.delete('/recovery/subscriptions/1') + client.delete('/api/recovery/subscriptions/1') ).rejects.toMatchObject({ code: 'unauthorized' }); expect(onAuthLost).toHaveBeenCalledTimes(1); adapter.restore(); diff --git a/src/lib/recoveryAlerts.js b/src/lib/recoveryAlerts.js index ba85892..db2bf19 100644 --- a/src/lib/recoveryAlerts.js +++ b/src/lib/recoveryAlerts.js @@ -3,25 +3,25 @@ import { apiClient } from './apiClient'; export function createRecoveryAlertsService(client = apiClient) { return { async chains() { - return (await client.get('/recovery/chains')).data.chains; + return (await client.get('/api/recovery/chains')).data.chains; }, async subscriptions() { - return (await client.get('/recovery/subscriptions')).data.subscriptions; + return (await client.get('/api/recovery/subscriptions')).data.subscriptions; }, async challenge(chainId, account) { - return (await client.post('/recovery/challenges', { chainId, account })) + return (await client.post('/api/recovery/challenges', { chainId, account })) .data; }, async subscribe(challengeId, signature, label) { const body = { challengeId, signature }; if (label) body.label = label; - return (await client.post('/recovery/subscriptions', body)).data + return (await client.post('/api/recovery/subscriptions', body)).data .subscription; }, async remove(id, credentials) { return ( await client.delete( - `/recovery/subscriptions/${encodeURIComponent(id)}`, + `/api/recovery/subscriptions/${encodeURIComponent(id)}`, { data: credentials, } diff --git a/src/lib/recoveryAlerts.test.js b/src/lib/recoveryAlerts.test.js index aa2023c..fa339d7 100644 --- a/src/lib/recoveryAlerts.test.js +++ b/src/lib/recoveryAlerts.test.js @@ -51,17 +51,17 @@ test('uses authenticated API methods and never sends wallet keys or an RPC URL', await service.subscribe('nonce', SIGNATURE, 'Savings'); await service.remove(1, { oldAuthHash: 'hash', code: '123456' }); expect(client.get.mock.calls).toEqual([ - ['/recovery/chains'], - ['/recovery/subscriptions'], + ['/api/recovery/chains'], + ['/api/recovery/subscriptions'], ]); expect(client.post.mock.calls).toEqual([ - ['/recovery/challenges', { chainId: CHAIN, account: ACCOUNT }], + ['/api/recovery/challenges', { chainId: CHAIN, account: ACCOUNT }], [ - '/recovery/subscriptions', + '/api/recovery/subscriptions', { challengeId: 'nonce', signature: SIGNATURE, label: 'Savings' }, ], ]); - expect(client.delete).toHaveBeenCalledWith('/recovery/subscriptions/1', { + expect(client.delete).toHaveBeenCalledWith('/api/recovery/subscriptions/1', { data: { oldAuthHash: 'hash', code: '123456' }, }); }); diff --git a/tests/browser/recovery-alerts.spec.cjs b/tests/browser/recovery-alerts.spec.cjs index 4943184..eb311e5 100644 --- a/tests/browser/recovery-alerts.spec.cjs +++ b/tests/browser/recovery-alerts.spec.cjs @@ -79,18 +79,18 @@ async function fixtures(page, { monitored = false, degraded = false } = {}) { await page.route('**/vault', (route) => route.fulfill({ json: { empty: true } }) ); - await page.route('**/recovery/**', async (route) => { + await page.route('**/api/recovery/**', async (route) => { const path = new URL(route.request().url()).pathname; - if (path === '/recovery/chains') + if (path === '/api/recovery/chains') return route.fulfill({ json: { chains: [CHAIN] } }); if ( - path === '/recovery/subscriptions' && + path === '/api/recovery/subscriptions' && route.request().method() === 'GET' ) return route.fulfill({ json: { subscriptions } }); const body = route.request().postDataJSON(); writes.push({ path, body }); - if (path === '/recovery/challenges') + if (path === '/api/recovery/challenges') return route.fulfill({ json: { id: 'nonce', @@ -100,7 +100,7 @@ async function fixtures(page, { monitored = false, degraded = false } = {}) { expiresAt: Date.now() + 300000, }, }); - if (path === '/recovery/subscriptions') { + if (path === '/api/recovery/subscriptions') { expect(body).toEqual({ challengeId: 'nonce', signature, @@ -117,7 +117,7 @@ async function fixtures(page, { monitored = false, degraded = false } = {}) { subscriptions = [subscription]; return route.fulfill({ json: { subscription } }); } - if (path === '/recovery/subscriptions/1') + if (path === '/api/recovery/subscriptions/1') return route.fulfill({ status: 401, json: { error: 'invalid_credentials' }, From 9a2daf0f336972ce795796c6111ba078aab4b8ac Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Wed, 21 Oct 2026 14:19:30 -0700 Subject: [PATCH 3/5] Apply confirmed alert removal before refreshing status --- src/components/RecoveryAlertsCard.js | 5 +++++ src/components/RecoveryAlertsCard.test.js | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/components/RecoveryAlertsCard.js b/src/components/RecoveryAlertsCard.js index d783d7b..2c25367 100644 --- a/src/components/RecoveryAlertsCard.js +++ b/src/components/RecoveryAlertsCard.js @@ -362,6 +362,11 @@ export default function RecoveryAlertsCard({ ...(code ? { code } : {}), }); if (!mounted.current || userRef.current !== id) return; + // The acknowledged mutation is authoritative even if a following status + // refresh fails. Never leave a disabled watch labelled as active. + setSubscriptions((current) => + current.filter((subscription) => subscription.id !== removing.id) + ); setRemoving(null); setPassword(''); setCode(''); diff --git a/src/components/RecoveryAlertsCard.test.js b/src/components/RecoveryAlertsCard.test.js index 551f689..aa044f5 100644 --- a/src/components/RecoveryAlertsCard.test.js +++ b/src/components/RecoveryAlertsCard.test.js @@ -258,6 +258,21 @@ test('stale 2FA identity responds to server step-up requirement, without losing expect(service.subscriptions).toHaveBeenCalledTimes(1); }); +test('a successful disable removes the active card even when refresh fails', async () => { + const { service } = setup({ subscriptions: [SUBSCRIPTION] }); + await screen.findByText('Savings'); + await click(screen.getByRole('button', { name: 'Disable these alerts…' })); + fireEvent.change(screen.getByLabelText('Current Sysnode password'), { + target: { value: 'password' }, + }); + service.subscriptions.mockRejectedValue({ code: 'network_error' }); + await click(screen.getByRole('button', { name: 'Disable alerts' })); + await screen.findByText(/This does not cancel any on-chain recovery/); + expect(screen.queryByText('Savings')).not.toBeInTheDocument(); + expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument(); + expect(screen.getByText(/No smart accounts linked yet/)).toBeInTheDocument(); +}); + test('a slow status refresh cannot restore a subscription after it was disabled', async () => { const { service } = setup({ subscriptions: [SUBSCRIPTION] }); await screen.findByText('Savings'); From 0e20052fd44727aa3f733423c97411a36067fdfe Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Wed, 21 Oct 2026 14:22:15 -0700 Subject: [PATCH 4/5] Keep recovery monitoring health and module identity current --- src/components/RecoveryAlertsCard.js | 28 +++++++++++++++----- src/components/RecoveryAlertsCard.test.js | 32 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/components/RecoveryAlertsCard.js b/src/components/RecoveryAlertsCard.js index 2c25367..f186959 100644 --- a/src/components/RecoveryAlertsCard.js +++ b/src/components/RecoveryAlertsCard.js @@ -204,8 +204,13 @@ export default function RecoveryAlertsCard({ mounted.current && userRef.current === id && fetchGeneration.current === request - ) + ) { + setSubscriptions((current) => current.map((subscription) => ({ + ...subscription, + monitoring: { ...subscription.monitoring, status: 'degraded' }, + }))); handleError(err); + } } finally { if ( mounted.current && @@ -237,10 +242,15 @@ export default function RecoveryAlertsCard({ useEffect(() => { if (!open || !loaded) return undefined; - const timer = setInterval(() => { + const refreshVisible = () => { if (document.visibilityState !== 'hidden' && !busy.current) load(); - }, 30000); - return () => clearInterval(timer); + }; + const timer = setInterval(refreshVisible, 30000); + document.addEventListener('visibilitychange', refreshVisible); + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', refreshVisible); + }; }, [open, loaded, load]); useEffect(() => { @@ -392,6 +402,8 @@ export default function RecoveryAlertsCard({ subscriptions.some( (item) => item.chainId === selection.chainId && + chain && + item.recoveryModule?.toLowerCase() === chain.recoveryModule?.toLowerCase() && item.account.toLowerCase() === account.toLowerCase() ); const phaseLabel = { @@ -463,7 +475,11 @@ export default function RecoveryAlertsCard({ (item) => item.chainId === subscription.chainId ); const monitor = monitoringCopy( - subscription.monitoring && subscription.monitoring.status + subscription.monitoring?.status === 'active' && + (!subscription.monitoring.lastCheckedAt || + Date.now() - subscription.monitoring.lastCheckedAt > 60000) + ? 'degraded' + : subscription.monitoring?.status ); return (
- {operation.expiresAt && ( + {Number(operation.expiresAt) > 0 && ( {['pending', 'ready', 'scheduled'].includes( operation.status diff --git a/src/components/RecoveryAlertsCard.test.js b/src/components/RecoveryAlertsCard.test.js index aa044f5..46adeec 100644 --- a/src/components/RecoveryAlertsCard.test.js +++ b/src/components/RecoveryAlertsCard.test.js @@ -7,11 +7,12 @@ import { AuthProvider } from '../context/AuthContext'; const ACCOUNT = `0x${'12'.repeat(20)}`; const CHAIN_ID = 57057; -const CHAIN = { chainId: CHAIN_ID, name: 'zkTanenbaum', testnet: true }; +const CHAIN = { chainId: CHAIN_ID, name: 'zkTanenbaum', testnet: true, recoveryModule: `0x${'45'.repeat(20)}` }; const SIGNATURE = `0x${'ab'.repeat(24000)}`; const SUBSCRIPTION = { id: 1, chainId: CHAIN_ID, + recoveryModule: CHAIN.recoveryModule, account: ACCOUNT, label: 'Savings', monitoring: { status: 'catching_up', lastCheckedAt: 1792600000000 }, @@ -273,6 +274,35 @@ test('a successful disable removes the active card even when refresh fails', asy expect(screen.getByText(/No smart accounts linked yet/)).toBeInTheDocument(); }); +test('a failed status refresh preserves history but no longer claims active monitoring', async () => { + const { service } = setup({ subscriptions: [{ + ...SUBSCRIPTION, + monitoring: { status: 'active', lastCheckedAt: Date.now() }, + }] }); + await screen.findByText('Monitoring active'); + service.subscriptions.mockRejectedValue({ code: 'network_error' }); + await click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('Monitoring delayed'); + expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument(); + expect(screen.getByText('Savings')).toBeInTheDocument(); +}); + +test('an old module subscription does not block enrolling the current module', async () => { + setup({ subscriptions: [{ ...SUBSCRIPTION, recoveryModule: `0x${'67'.repeat(20)}` }] }); + await screen.findByText('Savings'); + await click(screen.getByRole('button', { name: 'Connect wallet' })); + expect(await screen.findByRole('button', { name: 'Enable recovery alerts' })).toBeEnabled(); +}); + +test('an old active snapshot is not displayed as current monitoring', async () => { + setup({ subscriptions: [{ + ...SUBSCRIPTION, + monitoring: { status: 'active', lastCheckedAt: Date.now() - 120000 }, + }] }); + expect(await screen.findByText('Monitoring delayed')).toBeInTheDocument(); + expect(screen.queryByText('Monitoring active')).not.toBeInTheDocument(); +}); + test('a slow status refresh cannot restore a subscription after it was disabled', async () => { const { service } = setup({ subscriptions: [SUBSCRIPTION] }); await screen.findByText('Savings'); From fb22c0116f60889a222a264efcc4692ddb455255 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Wed, 21 Oct 2026 14:51:17 -0700 Subject: [PATCH 5/5] fix: preserve recovery alert deep links through login --- src/pages/Login.test.js | 36 +++++++++++++++++++++++++++++++++- src/parts/PrivateRoute.js | 7 ++++++- src/parts/PrivateRoute.test.js | 23 +++++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/pages/Login.test.js b/src/pages/Login.test.js index 694911b..8150982 100644 --- a/src/pages/Login.test.js +++ b/src/pages/Login.test.js @@ -33,7 +33,17 @@ function renderLogin( -
ACCOUNT PAGE
} /> + ( + <> +
ACCOUNT PAGE
+ + {location.pathname + location.search + location.hash} + + + )} + />
@@ -178,6 +188,30 @@ test('sends the user to /account on successful login', async () => { expect(service.login).toHaveBeenCalledWith('a@b.com', 'hunter22a'); }); +test('returns to the exact recovery-alert deep link after signing in', async () => { + const user = { id: 1, email: 'a@b.com', emailVerified: true }; + const service = mockService({ + login: jest.fn().mockResolvedValue({ user, expiresAt: 1 }), + me: jest.fn() + .mockRejectedValueOnce(Object.assign(new Error('unauth'), { status: 401 })) + .mockResolvedValue({ user }), + }); + renderLogin(service, { + initialPath: { + pathname: '/login', + state: { from: '/account?view=security#recovery-alerts' }, + }, + }); + await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1)); + await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter22a'); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + await screen.findByText('ACCOUNT PAGE'); + expect(screen.getByTestId('account-route')).toHaveTextContent( + '/account?view=security#recovery-alerts' + ); +}); + test('completes a pending TOTP login before navigating', async () => { const master = new Uint8Array(32).fill(7); const service = mockService({ diff --git a/src/parts/PrivateRoute.js b/src/parts/PrivateRoute.js index 757aeb1..c3fa777 100644 --- a/src/parts/PrivateRoute.js +++ b/src/parts/PrivateRoute.js @@ -35,7 +35,12 @@ export default function PrivateRoute({ ); diff --git a/src/parts/PrivateRoute.test.js b/src/parts/PrivateRoute.test.js index 08bc401..a36474c 100644 --- a/src/parts/PrivateRoute.test.js +++ b/src/parts/PrivateRoute.test.js @@ -10,7 +10,17 @@ function mount(service, initialPath) { -
LOGIN
} /> + ( + <> +
LOGIN
+ + {location.state && location.state.from} + + + )} + />
ACCOUNT
} />
@@ -58,3 +68,14 @@ test('shows a neutral placeholder while booting', () => { mount(service, '/account'); expect(screen.getByText(/checking your session/i)).toBeInTheDocument(); }); + +test('keeps the recovery-alert deep link through an anonymous redirect', async () => { + const service = { + me: jest.fn().mockRejectedValue(Object.assign(new Error('unauth'), { status: 401 })), + }; + mount(service, '/account?view=security#recovery-alerts'); + await screen.findByText('LOGIN'); + expect(screen.getByTestId('return-route')).toHaveTextContent( + '/account?view=security#recovery-alerts' + ); +});