Files
TREK/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx
T
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00

191 lines
7.2 KiB
TypeScript

// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-011
import React from 'react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { useAuthStore } from '../../store/authStore';
import { useNativeHandoff } from './useNativeHandoff';
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../native/browserSignIn', () => ({
HANDOFF_PATH: '/native-handoff',
rememberHandoffReturn: vi.fn(),
requestHandoffCode: vi.fn(),
signInWithPasskey: vi.fn(),
}));
const challenge = 'ab'.repeat(32);
const realLocation = window.location;
const logout = vi.fn(async () => {});
function setSearch(search: string) {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...realLocation, search, href: 'http://localhost/native-handoff' + search, replace: vi.fn() },
});
}
function renderHandoff() {
return renderHook(() => useNativeHandoff(), {
wrapper: ({ children }: { children: React.ReactNode }) => <MemoryRouter>{children}</MemoryRouter>,
});
}
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
logout.mockClear();
vi.mocked(requestHandoffCode).mockReset();
sessionStorage.clear();
setSearch(`?challenge=${challenge}`);
useAuthStore.setState({
isLoading: false,
isAuthenticated: true,
user: { id: 1, username: 'maria' } as never,
logout,
});
});
afterEach(() => {
Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation });
});
describe('useNativeHandoff', () => {
it('FE-NATIVE-HANDOFF-001: is ready for a signed-in user with a valid challenge', () => {
const { result } = renderHandoff();
expect(result.current.valid).toBe(true);
expect(result.current.ready).toBe(true);
expect(result.current.userName).toBe('maria');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-002: rejects a malformed challenge and does nothing with it', () => {
setSearch('?challenge=nope');
useAuthStore.setState({ isAuthenticated: false });
const { result } = renderHandoff();
expect(result.current.valid).toBe(false);
expect(result.current.ready).toBe(false);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-003: sends a visitor to the login page and back here afterwards', () => {
useAuthStore.setState({ isAuthenticated: false });
renderHandoff();
expect(mockNavigate).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`,
{ replace: true }
);
});
it('FE-NATIVE-HANDOFF-004: starts SSO straight away when the app asked for it, without looping', () => {
setSearch(`?challenge=${challenge}&start=oidc`);
useAuthStore.setState({ isAuthenticated: false });
renderHandoff();
expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1');
expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`);
expect(rememberHandoffReturn).toHaveBeenCalledWith(`/native-handoff?challenge=${challenge}`);
});
it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => {
useAuthStore.setState({ isAuthenticated: false, isLoading: true });
const { result } = renderHandoff();
expect(result.current.ready).toBe(false);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => {
vi.mocked(requestHandoffCode).mockResolvedValue('c/1');
localStorage.setItem('trek_native_signin', String(Date.now()));
const { result } = renderHandoff();
expect(requestHandoffCode).not.toHaveBeenCalled();
await act(async () => {
await result.current.openApp();
});
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1');
// Handed back: the browser registers its service worker again from here.
expect(localStorage.getItem('trek_native_signin')).toBeNull();
});
it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => {
vi.mocked(requestHandoffCode).mockRejectedValue(new Error('401'));
const { result } = renderHandoff();
await act(async () => {
await result.current.openApp();
});
expect(result.current.status).toBe('failed');
});
it('FE-NATIVE-HANDOFF-008: lets the user switch accounts and come back', async () => {
const { result } = renderHandoff();
await act(async () => {
await result.current.switchAccount();
});
expect(logout).toHaveBeenCalled();
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`,
{ replace: true }
)
);
});
describe('when the app asked for a passkey', () => {
const loadUser = vi.fn(async () => {
useAuthStore.setState({ isAuthenticated: true });
});
beforeEach(() => {
setSearch(`?challenge=${challenge}&start=passkey`);
useAuthStore.setState({ isAuthenticated: false, loadUser });
loadUser.mockClear();
vi.mocked(signInWithPasskey).mockReset().mockResolvedValue();
});
it('FE-NATIVE-HANDOFF-009: asks for the passkey itself instead of sending the user to the login', () => {
const { result } = renderHandoff();
expect(result.current.passkeyStep).toBe(true);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-010: signs in with the passkey and goes straight back to the app', async () => {
vi.mocked(requestHandoffCode).mockResolvedValue('c1');
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(signInWithPasskey).toHaveBeenCalled();
expect(loadUser).toHaveBeenCalled();
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c1');
});
it('FE-NATIVE-HANDOFF-011: stays quiet when the prompt is dismissed and offers the full login on request', async () => {
vi.mocked(signInWithPasskey).mockRejectedValue(Object.assign(new Error('no'), { name: 'NotAllowedError' }));
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(result.current.status).toBe('idle');
expect(requestHandoffCode).not.toHaveBeenCalled();
act(() => result.current.otherSignIn());
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`,
{ replace: true }
)
);
});
});
});