diff --git a/e2e/mail-deeplink-call-anchor.spec.ts b/e2e/mail-deeplink-call-anchor.spec.ts new file mode 100644 index 000000000..aa8802a78 --- /dev/null +++ b/e2e/mail-deeplink-call-anchor.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { getCachedAuth } from './helpers/auth-cache'; + +/** + * Mail deep-link: customers open https://app.dfx.swiss/settings?a=call from the + * verification-call mail. Without a session they are sent to /login; after login + * they must return to /settings?a=call so the Verification Call section scrolls + * into view (useAnchor). + * + * Full wallet login cannot be automated here. This spec covers the unauthenticated + * hop and proves the memorized return path still carries `a=call` by opening the + * mail-login tile in the same SPA session and inspecting the signInWithMail + * redirectUri payload (ConnectMail builds it from AppHandlingContext.redirectPath). + * + * Authenticated state uses getCachedAuth (same pattern as login-process.spec.ts) + * so the settings screen can be opened with `?session=` without driving a real login + * — that shows the target UI (Verification Call section), not the post-login hop. + * + * Does NOT cover: + * - Completing wallet login and the post-login navigate(redirectPath) hop + * (that path is covered by unit tests on useNavigation / setRedirect, not here) + * - Full magic-link mail login end-to-end + */ +test.describe('Mail deep-link a=call survives login redirect memory', () => { + test('unauthenticated /settings?a=call → /login keeps a=call in redirectPath', async ({ page }) => { + let capturedRedirectUri: string | undefined; + + await page.route('**/*', async (route) => { + const request = route.request(); + if (request.method() === 'POST') { + try { + const body = request.postDataJSON() as { redirectUri?: string; mail?: string } | null; + if (body && typeof body.redirectUri === 'string' && body.mail) { + capturedRedirectUri = body.redirectUri; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + return; + } + } catch { + // not JSON / not the mail sign-in body — fall through + } + } + await route.continue(); + }); + + await page.goto('/settings?a=call'); + await page.waitForLoadState('networkidle'); + + // Guard must send unauthenticated users to login. + await expect(page).toHaveURL(/\/login/, { timeout: 15000 }); + + // State the mail recipient sees before authenticating. + await expect(page).toHaveScreenshot('01-login-before-auth.png', { + maxDiffPixels: 1000, + fullPage: true, + }); + + // Same SPA session: open mail login so ConnectMail reads redirectPath. + const mailTile = page.locator('img[src*="mail"]'); + await expect(mailTile.first()).toBeVisible({ timeout: 15000 }); + await mailTile.first().click(); + + const emailInput = page.locator('input[type="email"]'); + await expect(emailInput).toBeVisible({ timeout: 10000 }); + await emailInput.fill('deeplink-probe@example.com'); + + const submit = page.locator('button[type="submit"]'); + await expect(submit).toBeEnabled({ timeout: 5000 }); + await submit.click(); + + await expect + .poll(() => capturedRedirectUri, { + timeout: 15000, + message: 'signInWithMail should have been called with a redirectUri', + }) + .toBeTruthy(); + + expect(capturedRedirectUri).toContain('/settings'); + expect(capturedRedirectUri).toContain('a=call'); + }); + + test('authenticated /settings?a=call shows Verification Call section', async ({ page, request }) => { + const { token } = await getCachedAuth(request, 'evm'); + + await page.goto(`/settings?a=call&session=${token}`); + await page.waitForLoadState('networkidle'); + + // useAnchor scrolls the section into view after ~100 ms; require it visibly present. + const verificationCallHeading = page.getByRole('heading', { name: /Verification Call|Verifizierungsanruf/i }); + await expect(verificationCallHeading).toBeVisible({ timeout: 20000 }); + + await expect(page).toHaveScreenshot('02-settings-verification-call.png', { + maxDiffPixels: 1000, + fullPage: true, + }); + }); +}); diff --git a/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-01-login-before-auth-chromium-darwin.png b/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-01-login-before-auth-chromium-darwin.png new file mode 100644 index 000000000..9b4c30838 Binary files /dev/null and b/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-01-login-before-auth-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-02-settings-verification-call-chromium-darwin.png b/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-02-settings-verification-call-chromium-darwin.png new file mode 100644 index 000000000..8a7cc076a Binary files /dev/null and b/e2e/screenshots/baseline/mail-deeplink-call-anchor.spec.ts-02-settings-verification-call-chromium-darwin.png differ diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 19e41d27b..5ef04b00f 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -119,6 +119,10 @@ "title": "Login-Prozess", "description": "Login und Home-Seite nach Authentifizierung." }, + "mail-deeplink-call-anchor": { + "title": "Mail-Deep-Link Verification Call", + "description": "Unangemeldeter Login nach /settings?a=call und Settings mit Verification-Call-Sektion nach Session." + }, "signature-login": { "title": "Signatur-Login", "description": "Wallet-Signatur-Login: Home, Buy, Sell, Settings, Transaktionen und Fehlerfall." diff --git a/src/__tests__/navigation-hook.test.ts b/src/__tests__/navigation-hook.test.ts new file mode 100644 index 000000000..49a2ddaa0 --- /dev/null +++ b/src/__tests__/navigation-hook.test.ts @@ -0,0 +1,131 @@ +// Full coverage of useNavigation beyond setRedirect (see navigation-redirect.test.ts). + +jest.mock('@dfx.swiss/react', () => ({})); +jest.mock('src/dto/safe.dto', () => ({})); + +const mockSetRedirectPath = jest.fn(); +const mockNavigateTo = jest.fn(); +let mockPathname = '/settings'; +let mockSearch = '?a=call'; +let mockRedirectPath: string | undefined; + +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigateTo, + useLocation: () => ({ pathname: mockPathname, search: mockSearch }), +})); + +jest.mock('../contexts/app-handling.context', () => ({ + useAppHandlingContext: () => ({ + redirectPath: mockRedirectPath, + setRedirectPath: mockSetRedirectPath, + }), +})); + +import { act, renderHook } from '@testing-library/react'; +import { useNavigation } from '../hooks/navigation.hook'; + +describe('useNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPathname = '/settings'; + mockSearch = '?a=call'; + mockRedirectPath = undefined; + }); + + it('passes a number target through to navigateTo without setting a redirect path', () => { + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate(-1); + }); + + expect(mockNavigateTo).toHaveBeenCalledTimes(1); + expect(mockNavigateTo).toHaveBeenCalledWith(-1); + expect(mockSetRedirectPath).not.toHaveBeenCalled(); + }); + + it('merges live search with object target search; target params win on collision', () => { + mockSearch = '?a=call&b=live'; + mockPathname = '/settings'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate({ pathname: '/x', search: '?b=2' }); + }); + + expect(mockNavigateTo).toHaveBeenCalledTimes(1); + const target = mockNavigateTo.mock.calls[0][0] as { pathname: string; search: string }; + expect(target.pathname).toBe('/x'); + const params = new URLSearchParams(target.search.startsWith('?') ? target.search.slice(1) : target.search); + expect(params.get('a')).toBe('call'); + expect(params.get('b')).toBe('2'); + }); + + it('clearParams on object navigate removes only the listed keys from the merge', () => { + mockSearch = '?a=call&code=secret&keep=yes'; + mockPathname = '/kyc'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate({ pathname: '/2fa', search: '' }, { clearParams: ['code'] }); + }); + + const target = mockNavigateTo.mock.calls[0][0] as { pathname: string; search: string }; + const params = new URLSearchParams(target.search.startsWith('?') ? target.search.slice(1) : target.search); + expect(params.get('code')).toBeNull(); + expect(params.get('a')).toBe('call'); + expect(params.get('keep')).toBe('yes'); + }); + + it('setParams navigates to the current pathname with merged params', () => { + mockPathname = '/settings'; + mockSearch = '?a=call'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.setParams(new URLSearchParams('x=1')); + }); + + expect(mockNavigateTo).toHaveBeenCalledTimes(1); + expect(mockNavigateTo.mock.calls[0][0]).toBe('/settings?a=call&x=1'); + }); + + it('clearParams navigates with replace to the current pathname without the listed keys', () => { + mockPathname = '/settings'; + mockSearch = '?a=call&keep=1'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.clearParams(['a']); + }); + + expect(mockNavigateTo).toHaveBeenCalledTimes(1); + const [to, options] = mockNavigateTo.mock.calls[0]; + const target = to as { pathname: string; search: string }; + expect(target.pathname).toBe('/settings'); + const params = new URLSearchParams(target.search.startsWith('?') ? target.search.slice(1) : target.search); + expect(params.get('a')).toBeNull(); + expect(params.get('keep')).toBe('1'); + expect(options).toEqual(expect.objectContaining({ replace: true })); + }); + + it('goBack without a stored redirect path lands on /account', () => { + mockRedirectPath = undefined; + mockPathname = '/login'; + mockSearch = ''; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.goBack(); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledWith(undefined); + expect(mockNavigateTo).toHaveBeenCalled(); + expect(mockNavigateTo.mock.calls[0][0]).toBe('/account'); + }); +}); diff --git a/src/__tests__/navigation-redirect.test.ts b/src/__tests__/navigation-redirect.test.ts new file mode 100644 index 000000000..c9ea3841f --- /dev/null +++ b/src/__tests__/navigation-redirect.test.ts @@ -0,0 +1,143 @@ +// Regression: setRedirect must remember pathname + allowlisted search so mail deep-links +// (e.g. /settings?a=call) return to the same section after login, without forwarding +// sensitive params (e.g. code=) into magic-link / Alby redirectUri. +// An explicit options.redirectPath must stay untouched. + +// utils.ts pulls ESM from @dfx.swiss/react via navigation.hook → relativeUrl; stub it. +jest.mock('@dfx.swiss/react', () => ({})); +jest.mock('src/dto/safe.dto', () => ({})); + +const mockSetRedirectPath = jest.fn(); +const mockNavigateTo = jest.fn(); +let mockPathname = '/settings'; +let mockSearch = '?a=call'; +let mockRedirectPath: string | undefined; + +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigateTo, + useLocation: () => ({ pathname: mockPathname, search: mockSearch }), +})); + +jest.mock('../contexts/app-handling.context', () => ({ + useAppHandlingContext: () => ({ + redirectPath: mockRedirectPath, + setRedirectPath: mockSetRedirectPath, + }), +})); + +import { act, renderHook } from '@testing-library/react'; +import { useNavigation } from '../hooks/navigation.hook'; + +describe('useNavigation setRedirect query survival', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPathname = '/settings'; + mockSearch = '?a=call'; + mockRedirectPath = undefined; + }); + + it('includes the location query when memorizing the current path for return', () => { + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/login', { setRedirect: true }); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledTimes(1); + expect(mockSetRedirectPath).toHaveBeenCalledWith('/settings?a=call'); + }); + + it('uses an explicit redirectPath unchanged (no query appended from location)', () => { + mockPathname = '/settings'; + mockSearch = '?a=call'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/login', { setRedirect: true, redirectPath: '/account' }); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledTimes(1); + expect(mockSetRedirectPath).toHaveBeenCalledWith('/account'); + expect(mockSetRedirectPath.mock.calls[0][0]).not.toContain('a=call'); + }); + + it('stores the bare pathname when the location has no query (no empty ?)', () => { + mockPathname = '/settings'; + mockSearch = ''; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/login', { setRedirect: true }); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledTimes(1); + expect(mockSetRedirectPath).toHaveBeenCalledWith('/settings'); + expect(mockSetRedirectPath.mock.calls[0][0]).not.toContain('?'); + }); + + it('discards code (KYC access hash) when memorizing the return path', () => { + mockPathname = '/kyc'; + mockSearch = '?code=secret-kyc-hash'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/2fa', { setRedirect: true }); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledTimes(1); + expect(mockSetRedirectPath).toHaveBeenCalledWith('/kyc'); + expect(mockSetRedirectPath.mock.calls[0][0]).not.toContain('code'); + }); + + it('keeps allowlisted params and drops the rest when mixed', () => { + mockPathname = '/settings'; + mockSearch = '?a=call&code=secret-kyc-hash&user=alice@example.com'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/login', { setRedirect: true }); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledTimes(1); + expect(mockSetRedirectPath).toHaveBeenCalledWith('/settings?a=call'); + const stored: string = mockSetRedirectPath.mock.calls[0][0]; + expect(stored).not.toContain('code'); + expect(stored).not.toContain('user'); + }); + + it('navigateTo receives the merged relative target (not the bare path)', () => { + mockPathname = '/settings'; + mockSearch = '?a=call'; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.navigate('/login', { setRedirect: true }); + }); + + // navigate(string) must call navigateTo(relativeUrl({ path: to, params: live search })), + // not navigateTo(to) alone — otherwise live query is dropped on the way to the target. + expect(mockNavigateTo).toHaveBeenCalled(); + expect(mockNavigateTo.mock.calls[0][0]).toBe('/login?a=call'); + }); + + it('goBack navigates to the stored redirectPath, not a hard-coded default', () => { + mockRedirectPath = '/settings?a=call'; + mockPathname = '/login'; + mockSearch = ''; + + const { result } = renderHook(() => useNavigation()); + + act(() => { + result.current.goBack(); + }); + + expect(mockSetRedirectPath).toHaveBeenCalledWith(undefined); + expect(mockNavigateTo).toHaveBeenCalled(); + expect(mockNavigateTo.mock.calls[0][0]).toBe('/settings?a=call'); + }); +}); diff --git a/src/__tests__/personal-iban.test.ts b/src/__tests__/personal-iban.test.ts index c3a537801..720c88086 100644 --- a/src/__tests__/personal-iban.test.ts +++ b/src/__tests__/personal-iban.test.ts @@ -20,8 +20,10 @@ import de from '../translations/languages/de.json'; import fr from '../translations/languages/fr.json'; import italian from '../translations/languages/it.json'; import { + canOfferCollectionIban, FRICK_ACCOUNT_HOLDER_NAME, FRICK_BANK_NAME, + FRICK_EUR_COLLECTION_IBAN, getFrickCollectionIban, getOfferableCollectionIban, getPersonalIbanErrorMessage, @@ -399,3 +401,44 @@ describe('personalIbanOnlyParams', () => { expect([...params.keys()]).toEqual([]); }); }); + +describe('canOfferCollectionIban', () => { + const verifiedPersonal = { + currency: { name: 'EUR' }, + isPersonalIban: true, + bank: FRICK_BANK_NAME, + name: FRICK_ACCOUNT_HOLDER_NAME, + remittanceInfo: 'RF18 5390 0754 7034', + iban: 'LI21088110105923K000F', + }; + + it('returns true for a verified Frick personal IBAN that is not the collection account', () => { + expect(canOfferCollectionIban(verifiedPersonal)).toBe(true); + }); + + it('returns false when iban is missing', () => { + const { iban: _iban, ...withoutIban } = verifiedPersonal; + expect(canOfferCollectionIban(withoutIban)).toBe(false); + }); + + it('returns false for the shared collection IBAN itself', () => { + expect(canOfferCollectionIban({ ...verifiedPersonal, iban: FRICK_EUR_COLLECTION_IBAN })).toBe(false); + }); + + it('returns false without remittance info', () => { + expect(canOfferCollectionIban({ ...verifiedPersonal, remittanceInfo: undefined })).toBe(false); + }); + + it('returns false for non-EUR', () => { + expect(canOfferCollectionIban({ ...verifiedPersonal, currency: { name: 'CHF' } })).toBe(false); + }); + + it('returns false when the response is not a verified Frick personal IBAN', () => { + expect( + canOfferCollectionIban({ + ...verifiedPersonal, + isPersonalIban: false, + }), + ).toBe(false); + }); +}); diff --git a/src/__tests__/redirect-params.test.ts b/src/__tests__/redirect-params.test.ts new file mode 100644 index 000000000..5bdb38a63 --- /dev/null +++ b/src/__tests__/redirect-params.test.ts @@ -0,0 +1,67 @@ +import { + allowedParamsOnly, + EXTERNAL_LOGIN_ALLOWED_PARAMS, + LOGIN_RETURN_ALLOWED_PARAMS, +} from '../util/redirect-params'; + +describe('allowedParamsOnly', () => { + it('returns empty params for empty search', () => { + const params = allowedParamsOnly('', LOGIN_RETURN_ALLOWED_PARAMS); + expect([...params.keys()]).toEqual([]); + }); + + it('copies only allowed keys when only those are present', () => { + const params = allowedParamsOnly('?a=call', LOGIN_RETURN_ALLOWED_PARAMS); + expect(params.get('a')).toBe('call'); + expect([...params.keys()]).toEqual(['a']); + }); + + it('returns empty params when only forbidden keys are present', () => { + const params = allowedParamsOnly('?code=secret&user=alice@example.com', LOGIN_RETURN_ALLOWED_PARAMS); + expect([...params.keys()]).toEqual([]); + }); + + it('keeps allowed keys and drops the rest when mixed', () => { + const params = allowedParamsOnly( + '?a=call&code=secret&user=alice@example.com', + LOGIN_RETURN_ALLOWED_PARAMS, + ); + expect(params.get('a')).toBe('call'); + expect(params.get('code')).toBeNull(); + expect(params.get('user')).toBeNull(); + expect([...params.keys()]).toEqual(['a']); + }); + + it('preserves empty values (key present with no value)', () => { + const params = allowedParamsOnly('?a=', LOGIN_RETURN_ALLOWED_PARAMS); + expect(params.has('a')).toBe(true); + expect(params.get('a')).toBe(''); + }); + + it('uses the first value when a key appears more than once', () => { + const params = allowedParamsOnly('?a=first&a=second', LOGIN_RETURN_ALLOWED_PARAMS); + expect(params.get('a')).toBe('first'); + expect([...params.keys()]).toEqual(['a']); + }); + + it('emits allowed keys in the order of the allowlist, not of search', () => { + const allowed = ['personal-iban', 'a'] as const; + const params = allowedParamsOnly('?a=call&personal-iban=frick', allowed); + expect([...params.keys()]).toEqual(['personal-iban', 'a']); + }); + + it('omits an allowlisted key that is missing from search', () => { + const params = allowedParamsOnly('?a=call', EXTERNAL_LOGIN_ALLOWED_PARAMS); + expect([...params.keys()]).toEqual([]); + }); +}); + +describe('named allowlist subsets', () => { + it('LOGIN_RETURN_ALLOWED_PARAMS is only a', () => { + expect([...LOGIN_RETURN_ALLOWED_PARAMS]).toEqual(['a']); + }); + + it('EXTERNAL_LOGIN_ALLOWED_PARAMS is only personal-iban', () => { + expect([...EXTERNAL_LOGIN_ALLOWED_PARAMS]).toEqual(['personal-iban']); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index e22d572b8..3faf66820 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,12 +1,13 @@ -// Mock @dfx.swiss/react to avoid ES module issues +// Mock @dfx.swiss/react to avoid ES module issues. +// Plain functions (not jest.fn) so implementations cannot be wiped by mockReset elsewhere. jest.mock('@dfx.swiss/react', () => ({ Asset: {}, Fiat: {}, KycFile: {}, UserAddress: {}, Utils: { - formatAmount: jest.fn((amount: number) => amount.toFixed(2)), - formatAmountCrypto: jest.fn((amount: number) => amount.toString()), + formatAmount: (amount: number) => Number(amount).toFixed(2), + formatAmountCrypto: (amount: number) => String(amount), }, })); @@ -22,26 +23,42 @@ import { isEmpty, removeNullFields, delay, + timeout, + url, isAbsoluteUrl, isSafeRedirectUri, + isNode, blankedAddress, + toBase64, + readFileAsText, + openPdfFromString, + downloadPdfFromString, + openImageFromString, + handleOpenFile, + sortAddressesByBlockchain, formatBytes, + fetchJson, formatUnits, filenameDateFormat, extractFilename, + downloadFile, formatChf, formatChfOrDash, formatCurrency, + formatAmountForDisplay, formatSwissDate, formatSwissDateTime, formatSwissDateTimeWithSeconds, formatSwissTime, FormatType, deepEqual, + isAsset, equalsIgnoreCase, + findCustodyBalanceString, formatLocationAddress, apiUrl, relativeUrl, + redirectAllowedParams, } from '../util/utils'; describe('utils', () => { @@ -123,6 +140,54 @@ describe('utils', () => { }); }); + describe('timeout', () => { + it('resolves when the promise wins the race', async () => { + await expect(timeout(Promise.resolve('ok'), 500)).resolves.toBe('ok'); + }); + + it('rejects with Error("Timeout") when the timer wins', async () => { + jest.useFakeTimers(); + try { + const never = new Promise(() => undefined); + const resultPromise = timeout(never, 50); + const expectation = expect(resultPromise).rejects.toThrow('Timeout'); + jest.advanceTimersByTime(50); + await expectation; + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('url', () => { + const originalPublicUrl = process.env.REACT_APP_PUBLIC_URL; + + afterEach(() => { + process.env.REACT_APP_PUBLIC_URL = originalPublicUrl; + }); + + it('builds from REACT_APP_PUBLIC_URL when base is omitted', () => { + process.env.REACT_APP_PUBLIC_URL = 'https://app.example.com/'; + expect(url({ path: 'settings' })).toBe('https://app.example.com/settings'); + }); + + it('uses an explicit base and appends params', () => { + const result = url({ + base: 'https://app.example.com', + path: 'login', + params: new URLSearchParams({ a: 'call' }), + }); + expect(result).toBe('https://app.example.com/login?a=call'); + }); + + it('treats an absolute path as the base', () => { + // url() normalizes base with a trailing slash before applying params + expect(url({ path: 'https://other.example.com/x', params: new URLSearchParams({ q: '1' }) })).toBe( + 'https://other.example.com/x/?q=1', + ); + }); + }); + describe('isAbsoluteUrl', () => { it('should return true for absolute URLs', () => { expect(isAbsoluteUrl('http://example.com')).toBe(true); @@ -196,12 +261,24 @@ describe('utils', () => { }); }); + describe('isNode', () => { + it('returns true for a DOM Node', () => { + expect(isNode(document.createElement('div'))).toBe(true); + expect(isNode(document.createTextNode('x'))).toBe(true); + }); + + it('returns false for null and non-nodes', () => { + expect(isNode(null)).toBe(false); + expect(isNode({} as EventTarget)).toBe(false); + }); + }); + describe('blankedAddress', () => { it('should truncate long addresses', () => { const address = '0x1234567890abcdef1234567890abcdef12345678'; const result = blankedAddress(address, { displayLength: 16 }); - expect(result).toContain('...'); - expect(result.length).toBeLessThan(address.length); + // displayLength 16 minus 0x offset 2 → 14 visible chars split half/half around '...' + expect(result).toBe('0x1234567...2345678'); }); it('should not truncate short addresses', () => { @@ -209,6 +286,227 @@ describe('utils', () => { const result = blankedAddress(address, { displayLength: 20 }); expect(result).toBe(address); }); + + it('uses default options when called with only the address', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const result = blankedAddress(address); + expect(result).toContain('...'); + expect(result.startsWith('0x')).toBe(true); + }); + + it('derives displayLength from width and accounts for 0x prefix', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const result = blankedAddress(address, { width: 200, scale: 1 }); + expect(result).toContain('...'); + expect(result.startsWith('0x')).toBe(true); + }); + + it('truncates non-0x addresses without the prefix offset', () => { + const address = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + const result = blankedAddress(address, { displayLength: 12 }); + expect(result).toBe('bc1qxy...hx0wlh'); + }); + }); + + describe('toBase64 and readFileAsText', () => { + it('toBase64 resolves a data URL for a file', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const result = await toBase64(file); + expect(result).toMatch(/^data:text\/plain;base64,/); + }); + + it('toBase64 rejects when FileReader errors', async () => { + const OriginalFileReader = global.FileReader; + const readerError = new ProgressEvent('error') as ProgressEvent; + class FailingReader { + result: string | null = null; + onload: ((ev: ProgressEvent) => void) | null = null; + onerror: ((ev: ProgressEvent) => void) | null = null; + readAsDataURL() { + queueMicrotask(() => { + this.onerror?.(readerError); + }); + } + } + // @ts-expect-error partial FileReader mock + global.FileReader = FailingReader; + try { + await expect(toBase64(new File(['x'], 'x.txt'))).rejects.toBe(readerError); + } finally { + global.FileReader = OriginalFileReader; + } + }); + + it('toBase64 resolves undefined when result is empty', async () => { + const OriginalFileReader = global.FileReader; + class EmptyResultReader { + result: string | null = null; + onload: ((ev: ProgressEvent) => void) | null = null; + onerror: ((ev: ProgressEvent) => void) | null = null; + readAsDataURL() { + queueMicrotask(() => { + this.result = null; + this.onload?.(new ProgressEvent('load') as ProgressEvent); + }); + } + } + // @ts-expect-error partial FileReader mock + global.FileReader = EmptyResultReader; + try { + await expect(toBase64(new File(['x'], 'x.txt'))).resolves.toBeUndefined(); + } finally { + global.FileReader = OriginalFileReader; + } + }); + + it('readFileAsText resolves file content', async () => { + const file = new File(['plain text'], 'note.txt', { type: 'text/plain' }); + await expect(readFileAsText(file)).resolves.toBe('plain text'); + }); + + it('readFileAsText rejects when FileReader errors', async () => { + const OriginalFileReader = global.FileReader; + const readerError = new ProgressEvent('error') as ProgressEvent; + class FailingReader { + result: string | null = null; + onload: ((ev: ProgressEvent) => void) | null = null; + onerror: ((ev: ProgressEvent) => void) | null = null; + readAsText() { + queueMicrotask(() => { + this.onerror?.(readerError); + }); + } + } + // @ts-expect-error partial FileReader mock + global.FileReader = FailingReader; + try { + await expect(readFileAsText(new File(['x'], 'x.txt'))).rejects.toBe(readerError); + } finally { + global.FileReader = OriginalFileReader; + } + }); + }); + + describe('PDF / image open helpers and handleOpenFile', () => { + const sampleBase64 = Buffer.from('%PDF-1.4 sample').toString('base64'); + const imageBase64 = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'); + // jsdom does not implement these; assign mocks (spyOn requires an existing function). + let createObjectURL: jest.Mock; + let revokeObjectURL: jest.Mock; + let openSpy: jest.SpyInstance; + let clickSpy: jest.SpyInstance; + + beforeEach(() => { + createObjectURL = jest.fn(() => 'blob:mock-url'); + revokeObjectURL = jest.fn(); + (URL as any).createObjectURL = createObjectURL; + (URL as any).revokeObjectURL = revokeObjectURL; + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + }); + + afterEach(() => { + delete (URL as any).createObjectURL; + delete (URL as any).revokeObjectURL; + openSpy.mockRestore(); + clickSpy.mockRestore(); + document.body.innerHTML = ''; + }); + + it('openPdfFromString opens a new tab by default', () => { + openPdfFromString(sampleBase64); + expect(createObjectURL).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('openPdfFromString embeds inline when newTab is false', () => { + openPdfFromString(sampleBase64, false); + expect(openSpy).not.toHaveBeenCalled(); + const embed = document.body.querySelector('embed'); + expect(embed).not.toBeNull(); + expect(embed?.type).toBe('application/pdf'); + expect(embed?.src).toContain('blob:mock-url'); + }); + + it('downloadPdfFromString downloads via downloadFile', () => { + downloadPdfFromString(sampleBase64, 'doc.pdf'); + expect(createObjectURL).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(document.body.querySelector('a')).toBeNull(); + }); + + it('openImageFromString opens a new tab by default', () => { + openImageFromString(imageBase64, 'image/png'); + expect(openSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('openImageFromString embeds inline when newTab is false', () => { + openImageFromString(imageBase64, 'image/png', false); + expect(openSpy).not.toHaveBeenCalled(); + const img = document.body.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.src).toContain('blob:mock-url'); + }); + + it('handleOpenFile sets an error for invalid content', () => { + const setError = jest.fn(); + handleOpenFile({ content: null, contentType: 'application/pdf' } as any, setError); + expect(setError).toHaveBeenCalledWith('Invalid file type'); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('handleOpenFile opens a PDF for application/* content', () => { + const setError = jest.fn(); + handleOpenFile( + { + content: { type: 'Buffer', data: [1, 2, 3] }, + contentType: 'application/pdf', + } as any, + setError, + true, + ); + expect(setError).not.toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('handleOpenFile opens an image for image/* content', () => { + const setError = jest.fn(); + handleOpenFile( + { + content: { type: 'Buffer', data: [9, 8, 7] }, + contentType: 'image/png', + } as any, + setError, + false, + ); + expect(setError).not.toHaveBeenCalled(); + expect(document.body.querySelector('img')).not.toBeNull(); + }); + + it('handleOpenFile does nothing for unsupported file types', () => { + const setError = jest.fn(); + handleOpenFile( + { + content: { type: 'Buffer', data: [1] }, + contentType: 'text/plain', + } as any, + setError, + ); + expect(setError).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + expect(document.body.querySelector('embed')).toBeNull(); + expect(document.body.querySelector('img')).toBeNull(); + }); + }); + + describe('sortAddressesByBlockchain', () => { + it('sorts by the first blockchain name', () => { + const a = { blockchains: ['Ethereum'] } as any; + const b = { blockchains: ['Bitcoin'] } as any; + expect(sortAddressesByBlockchain(a, b)).toBeGreaterThan(0); + expect(sortAddressesByBlockchain(b, a)).toBeLessThan(0); + expect(sortAddressesByBlockchain(a, a)).toBe(0); + }); }); describe('formatBytes', () => { @@ -217,6 +515,26 @@ describe('utils', () => { expect(formatBytes(1024)).toBe('1 KB'); expect(formatBytes(1024 * 1024)).toBe('1 MB'); }); + + it('clamps negative decimals to zero', () => { + expect(formatBytes(2048, -1)).toBe('2 KB'); + }); + }); + + describe('fetchJson', () => { + it('fetches and parses JSON', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ ok: true }), + }); + const originalFetch = global.fetch; + global.fetch = fetchMock as any; + try { + await expect(fetchJson('https://example.com/data')).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/data'); + } finally { + global.fetch = originalFetch; + } + }); }); describe('formatUnits', () => { @@ -225,6 +543,10 @@ describe('utils', () => { expect(formatUnits('1500000000000000000', 18)).toBe('1.5'); }); + it('defaults decimals to 18 when omitted', () => { + expect(formatUnits('1000000000000000000')).toBe('1'); + }); + it('should handle zero', () => { expect(formatUnits('0', 18)).toBe('0'); }); @@ -245,6 +567,55 @@ describe('utils', () => { it('should return undefined for missing header', () => { expect(extractFilename(undefined)).toBeUndefined(); }); + + it('should return undefined when the header has no filename match', () => { + expect(extractFilename('inline')).toBeUndefined(); + expect(extractFilename('attachment; size=12')).toBeUndefined(); + }); + }); + + describe('downloadFile', () => { + let createObjectURL: jest.Mock; + let revokeObjectURL: jest.Mock; + let clickSpy: jest.SpyInstance; + let clickedDownload: string | undefined; + + beforeEach(() => { + createObjectURL = jest.fn(() => 'blob:download'); + revokeObjectURL = jest.fn(); + (URL as any).createObjectURL = createObjectURL; + (URL as any).revokeObjectURL = revokeObjectURL; + clickedDownload = undefined; + // Capture download on the anchor while click runs — the element is removed right after. + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + clickedDownload = this.download; + }); + }); + + afterEach(() => { + delete (URL as any).createObjectURL; + delete (URL as any).revokeObjectURL; + clickSpy.mockRestore(); + document.body.innerHTML = ''; + }); + + it('uses content-disposition filename when present', () => { + downloadFile(new Blob(['x']), { 'content-disposition': 'attachment; filename="report.pdf"' }, 'fallback.bin'); + expect(clickSpy).toHaveBeenCalled(); + expect(clickedDownload).toBe('report.pdf'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + expect(document.body.querySelector('a')).toBeNull(); + }); + + it('falls back to the provided filename without content-disposition', () => { + downloadFile(new Blob(['x']), {}, 'fallback.bin'); + expect(createObjectURL).toHaveBeenCalled(); + expect(clickedDownload).toBe('fallback.bin'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + expect(document.body.querySelector('a')).toBeNull(); + }); }); describe('formatCurrency', () => { @@ -259,8 +630,52 @@ describe('utils', () => { expect(formatCurrency(1234.56, 2, 2, FormatType.US)).toBe('1,234.56'); }); + it('parses string amounts and defaults to Swiss format', () => { + const result = formatCurrency('12.5', 2, 2); + expect(result).toContain('12'); + }); + it('should return null for invalid values', () => { expect(formatCurrency(NaN)).toBeNull(); + expect(formatCurrency(null as unknown as number)).toBeNull(); + }); + + it('returns "< 0.01" for tiny positive amounts when fraction digits are allowed', () => { + expect(formatCurrency(0.005, 0, 2)).toBe('< 0.01'); + }); + + it('formats tiny positives normally when maximumFractionDigits is 0', () => { + // maximumFractionDigits falsy → skip the "< 0.01" shortcut + const result = formatCurrency(0.005, 0, 0, FormatType.US); + expect(result).not.toBe('< 0.01'); + }); + + it('formats TINY amounts under 1000 with two decimals and thin-space thousands separator above', () => { + const under = formatCurrency(12.5, 0, 2, FormatType.TINY); + expect(under).toBe('12.50'); + // en-US groups thousands with ",", then TINY replaces "," with thin space + const over = formatCurrency(1500, 0, 2, FormatType.TINY); + expect(over).toBe('1 500'); + const negativeOver = formatCurrency(-1500, 0, 2, FormatType.TINY); + expect(negativeOver).toBe('-1 500'); + const withSep = formatCurrency(12345, 0, 2, FormatType.TINY); + expect(withSep).toContain(' '); + expect(withSep?.replace(/\u2009/g, '')).toBe('12345'); + }); + + it('returns undefined for an unknown format enum value', () => { + // Covers the false branch of the final format === TINY check (fall-through). + expect(formatCurrency(1, 0, 2, 99 as FormatType)).toBeUndefined(); + }); + }); + + describe('formatAmountForDisplay', () => { + it('returns empty string without a value', () => { + expect(formatAmountForDisplay(undefined)).toBe(''); + }); + + it('formats via Utils.formatAmount and rewrites trailing .00', () => { + expect(formatAmountForDisplay(12)).toBe('12.-'); }); }); @@ -356,6 +771,22 @@ describe('utils', () => { it('should handle null and undefined', () => { expect(deepEqual(null, null)).toBe(true); expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual(undefined, undefined)).toBe(true); + expect(deepEqual({ a: 1 }, null)).toBe(false); + }); + + it('returns false for differing types, key sets, or missing keys', () => { + expect(deepEqual(1, '1')).toBe(false); + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(deepEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false); + }); + }); + + describe('isAsset', () => { + it('detects assets by chainId', () => { + expect(isAsset({ chainId: 1 } as any)).toBe(true); + expect(isAsset({ name: 'EUR' } as any)).toBe(false); }); }); @@ -368,6 +799,26 @@ describe('utils', () => { it('should return false for different strings', () => { expect(equalsIgnoreCase('abc', 'def')).toBe(false); }); + + it('handles undefined sides', () => { + expect(equalsIgnoreCase(undefined, undefined)).toBe(true); + expect(equalsIgnoreCase('a', undefined)).toBe(false); + expect(equalsIgnoreCase(undefined, 'a')).toBe(false); + }); + }); + + describe('findCustodyBalanceString', () => { + it('returns the formatted balance when the asset is found', () => { + const asset = { name: 'BTC' } as any; + const balances = [{ asset: { name: 'BTC' }, balance: 1.5 }] as any; + expect(findCustodyBalanceString(asset, balances)).toBe('1.5'); + }); + + it('returns empty string when the asset is missing', () => { + const asset = { name: 'ETH' } as any; + const balances = [{ asset: { name: 'BTC' }, balance: 1 }] as any; + expect(findCustodyBalanceString(asset, balances)).toBe(''); + }); }); describe('formatLocationAddress', () => { @@ -435,5 +886,29 @@ describe('utils', () => { const query = new URLSearchParams(result.slice(result.indexOf('?') + 1)); expect(query.get('issue-type')).toBe('LimitRequest'); }); + + it('delegates absolute paths to url()', () => { + const result = relativeUrl({ + path: 'https://example.com/callback', + params: new URLSearchParams({ a: '1' }), + }); + // url() normalizes base with a trailing slash + expect(result).toBe('https://example.com/callback/?a=1'); + }); + }); + + describe('redirectAllowedParams', () => { + it('copies only a when present', () => { + const params = redirectAllowedParams('?a=call&code=secret&user=alice@example.com'); + expect(params.get('a')).toBe('call'); + expect(params.get('code')).toBeNull(); + expect(params.get('user')).toBeNull(); + expect([...params.keys()]).toEqual(['a']); + }); + + it('returns an empty set when a is absent', () => { + const params = redirectAllowedParams('?code=secret&user=alice@example.com'); + expect([...params.keys()]).toEqual([]); + }); }); }); diff --git a/src/hooks/navigation.hook.ts b/src/hooks/navigation.hook.ts index 8ca2cd78b..27668bcec 100644 --- a/src/hooks/navigation.hook.ts +++ b/src/hooks/navigation.hook.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { NavigateOptions, To, useLocation, useNavigate } from 'react-router-dom'; import { useAppHandlingContext } from '../contexts/app-handling.context'; -import { relativeUrl } from '../util/utils'; +import { redirectAllowedParams, relativeUrl } from '../util/utils'; interface NavigationOptions extends NavigateOptions { clearParams?: string[]; @@ -22,7 +22,11 @@ export function useNavigation(): NavigationInterface { const { redirectPath, setRedirectPath } = useAppHandlingContext(); function navigate(to: To | number, options?: NavigationOptions) { - if (options?.setRedirect) setRedirectPath(options?.redirectPath ?? pathname); + // Remember only allowlisted query params (e.g. a=call) so deep-links survive login without + // leaking code=/user= into magic-link / Alby redirectUri. Explicit redirectPath unchanged. + if (options?.setRedirect) { + setRedirectPath(options?.redirectPath ?? relativeUrl({ path: pathname, params: redirectAllowedParams(search) })); + } switch (typeof to) { case 'number': diff --git a/src/util/personal-iban.ts b/src/util/personal-iban.ts index ad2b74c81..d470a9c3e 100644 --- a/src/util/personal-iban.ts +++ b/src/util/personal-iban.ts @@ -1,4 +1,5 @@ import { FiatPaymentMethod, PersonalIbanProvider, TransactionError } from '@dfx.swiss/react'; +import { allowedParamsOnly, EXTERNAL_LOGIN_ALLOWED_PARAMS } from './redirect-params'; /** Bank Frick personal-IBAN accounts are held by DFX AG (routing sub-account), never the customer. */ export const FRICK_BANK_NAME = 'Bank Frick'; @@ -118,17 +119,9 @@ export function getOfferableCollectionIban(info: { return normalized !== collectionIban ? collectionIban : undefined; } -/** - * Allowlist for external-login callbacks: only forward an explicitly present `personal-iban`. - * Do not copy the entire live search (would leak `user`, `arbitrary`, etc.). - */ +/** External-login allowlist; see EXTERNAL_LOGIN_ALLOWED_PARAMS in redirect-params.ts. */ export function personalIbanOnlyParams(search: string): URLSearchParams { - const params = new URLSearchParams(); - const personalIban = new URLSearchParams(search).get('personal-iban'); - if (personalIban != null) { - params.set('personal-iban', personalIban); - } - return params; + return allowedParamsOnly(search, EXTERNAL_LOGIN_ALLOWED_PARAMS); } /** diff --git a/src/util/redirect-params.ts b/src/util/redirect-params.ts new file mode 100644 index 000000000..b25f24f1f --- /dev/null +++ b/src/util/redirect-params.ts @@ -0,0 +1,38 @@ +/** + * Single source of truth for which query keys may appear in an outbound redirect URI. + * Adding a key here is the only place that decides what leaves the app on login-return + * paths and external login callbacks — check every use of that key before extending. + */ + +/** + * Copy from `search` exactly the keys listed in `allowed`, in that order, and nothing else. + * A key missing from `search` is omitted (no empty placeholder). An empty value (`?a=`) is kept. + */ +export function allowedParamsOnly(search: string, allowed: readonly string[]): URLSearchParams { + const source = new URLSearchParams(search); + const params = new URLSearchParams(); + for (const key of allowed) { + const value = source.get(key); + if (value != null) { + params.set(key, value); + } + } + return params; +} + +/** + * Login-return path (`setRedirect`): only `a` (mail section anchor, see useAnchor). + * Note that `a` is double-booked: payment-link routes read it as the `amount` shorthand + * (payment-link.context.tsx). Those routes are unguarded and never reach setRedirect, + * so the two uses do not collide today — but check both before adding a key here. + * Do not copy the entire live search — that would leak code=/user=/arbitrary into the + * outbound link. + * Explicit options.redirectPath is not filtered by this helper. + */ +export const LOGIN_RETURN_ALLOWED_PARAMS = ['a'] as const; + +/** + * External login callbacks (magic-link mail, Alby): only an explicitly present `personal-iban`. + * Do not copy the entire live search (would leak `user`, `arbitrary`, etc.). + */ +export const EXTERNAL_LOGIN_ALLOWED_PARAMS = ['personal-iban'] as const; diff --git a/src/util/utils.ts b/src/util/utils.ts index 01cdb7dea..8a7b1726b 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,5 +1,6 @@ import { Asset, Fiat, KycFile, UserAddress, Utils } from '@dfx.swiss/react'; import { CustodyAsset, CustodyAssetBalance } from 'src/dto/safe.dto'; +import { allowedParamsOnly, LOGIN_RETURN_ALLOWED_PARAMS } from './redirect-params'; export function isDefined(item: T | undefined): item is T { return item != null; @@ -80,6 +81,11 @@ export function relativeUrl({ path, params }: { path: string; params?: URLSearch return query ? `${pathname}?${query}` : pathname; } +/** Login-return allowlist; see LOGIN_RETURN_ALLOWED_PARAMS in redirect-params.ts. */ +export function redirectAllowedParams(search: string): URLSearchParams { + return allowedParamsOnly(search, LOGIN_RETURN_ALLOWED_PARAMS); +} + // SDK call path (DfxHttpClient config.url): the leading slash is FORBIDDEN because the client joins // baseUrl + '/' + url. A leading slash would produce a double slash (e.g. .../v1//realunit/...) and // a 404 before any guard runs. Use for call() configs, never for router navigation.