mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
034cfb539a
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.
191 lines
7.2 KiB
TypeScript
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 }
|
|
)
|
|
);
|
|
});
|
|
});
|
|
});
|