Skip to content
100 changes: 100 additions & 0 deletions e2e/mail-deeplink-call-anchor.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions scripts/handbook/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/navigation-hook.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
143 changes: 143 additions & 0 deletions src/__tests__/navigation-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading