diff --git a/src/components/AuthProvider.test.tsx b/src/components/AuthProvider.test.tsx new file mode 100644 index 0000000..49e31ac --- /dev/null +++ b/src/components/AuthProvider.test.tsx @@ -0,0 +1,86 @@ +import { act, useEffect, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + session: { data: null as null | { user: { id: string; email: string } }, isPending: true }, + social: vi.fn(), + signOut: vi.fn(), + oneTap: vi.fn(), +})); +vi.mock('@/lib/auth-client', () => ({ + useSession: () => mocks.session, + signIn: { social: mocks.social }, + signOut: mocks.signOut, + startGoogleOneTap: mocks.oneTap, +})); +vi.mock('@/lib/analytics', () => ({ trackReturnedOnce: vi.fn(), trackSignupOnce: vi.fn() })); +vi.mock('@/lib/foundry-monitoring', () => ({ captureAuthFailure: vi.fn() })); +import { AuthProvider, useAuth } from './AuthProvider'; +import LoginClient from './LoginClient'; +let host: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + mocks.session = { data: null, isPending: true }; + mocks.oneTap.mockReset().mockResolvedValue(undefined); + mocks.social.mockReset().mockResolvedValue({}); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); +async function render(child: ReactNode) { + await act(async () => { + root.render({child}); + }); +} +describe('auth loading preserves its subtree', () => { + it('retains DOM and local state through pending, signed-out and account changes', async () => { + let mounts = 0; + function Child() { + const { user, loading } = useAuth(); + useEffect(() => { + mounts += 1; + }, []); + return ( + <> + + {loading ? 'pending' : (user?.id ?? 'signed-out')} + + ); + } + await render(); + const input = host.querySelector('input')!; + input.value = 'retained draft'; + expect(host.querySelector('output')?.textContent).toBe('pending'); + mocks.session = { data: null, isPending: false }; + await render(); + expect(host.querySelector('output')?.textContent).toBe('signed-out'); + mocks.session = { + data: { user: { id: 'synthetic-account', email: 'audit@example.invalid' } }, + isPending: false, + }; + await render(); + expect(host.querySelector('output')?.textContent).toBe('synthetic-account'); + expect(host.querySelector('input')).toBe(input); + expect(input.value).toBe('retained draft'); + expect(mounts).toBe(1); + }); + it('initializes Login once and shows NetworkError while keeping standard sign-in', async () => { + mocks.oneTap.mockRejectedValue( + new DOMException('Synthetic FedCM network failure', 'NetworkError') + ); + await render(); + expect(mocks.oneTap).toHaveBeenCalledTimes(1); + expect(mocks.oneTap).toHaveBeenCalledWith('/library'); + expect(host.querySelector('[role=alert]')?.textContent).toBe( + 'One-tap sign-in is unavailable. Sign in with Google to continue.' + ); + const button = host.querySelector('button')!; + expect(button.disabled).toBe(false); + await act(async () => button.click()); + expect(mocks.social).toHaveBeenCalledWith({ provider: 'google', callbackURL: '/' }); + }); +}); diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx index 8e1e712..5ae49e6 100644 --- a/src/components/AuthProvider.tsx +++ b/src/components/AuthProvider.tsx @@ -34,7 +34,13 @@ function AnalyticsTracker({ useSession }: { useSession: AuthClientModule['useSes return null; } -function AuthProviderReady({ auth, children }: { auth: AuthClientModule; children: ReactNode }) { +function AuthProviderReady({ + auth, + onValue, +}: { + auth: AuthClientModule; + onValue: (value: AuthContextValue) => void; +}) { const { data: session, isPending } = auth.useSession(); const sessionUser = session?.user; @@ -63,12 +69,9 @@ function AuthProviderReady({ auth, children }: { auth: AuthClientModule; childre [auth, isPending, sessionUser] ); - return ( - - - {children} - - ); + useEffect(() => onValue(value), [onValue, value]); + + return ; } export function AuthProvider({ children }: { children: ReactNode }) { @@ -101,11 +104,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { [] ); - if (!auth) { - return {children}; - } + const [value, setValue] = useState(bootValue); - return {children}; + return ( + + {auth && } + {children} + + ); } export function useAuth(): AuthContextValue { diff --git a/src/components/LoginClient.tsx b/src/components/LoginClient.tsx index 6df8cd1..1a75f9a 100644 --- a/src/components/LoginClient.tsx +++ b/src/components/LoginClient.tsx @@ -13,7 +13,11 @@ export default function LoginClient() { const [redirecting, setRedirecting] = useState(false); useEffect(() => { + let active = true; void startGoogleOneTap('/library').catch((error) => { + if (active) { + setError('One-tap sign-in is unavailable. Sign in with Google to continue.'); + } captureAuthFailure({ provider: 'google', stage: 'one-tap', @@ -21,6 +25,9 @@ export default function LoginClient() { source: 'login-client', }); }); + return () => { + active = false; + }; }, []); const handleSignIn = async () => { @@ -94,7 +101,11 @@ export default function LoginClient() { )} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} ); diff --git a/src/lib/auth-client.test.ts b/src/lib/auth-client.test.ts new file mode 100644 index 0000000..1351438 --- /dev/null +++ b/src/lib/auth-client.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ oneTap: vi.fn(), vanilla: vi.fn() })); +vi.mock('better-auth/react', () => ({ + createAuthClient: () => ({ useSession: vi.fn(), signIn: {}, signOut: vi.fn() }), +})); +vi.mock('better-auth/client', () => ({ createAuthClient: mocks.vanilla })); +vi.mock('better-auth/client/plugins', () => ({ oneTapClient: vi.fn() })); +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); +}); +describe('concurrent One Tap initialization', () => { + it('shares initialization, propagates failure, then permits retry', async () => { + let reject!: (error: Error) => void; + mocks.oneTap.mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }) + ); + mocks.oneTap.mockResolvedValue(undefined); + mocks.vanilla.mockReturnValue({ oneTap: mocks.oneTap }); + const fetch = vi + .fn() + .mockImplementation( + async () => new Response(JSON.stringify({ googleClientId: 'synthetic-only' })) + ); + vi.stubGlobal('fetch', fetch); + const { startGoogleOneTap } = await import('./auth-client'); + const first = startGoogleOneTap('/library'); + const second = startGoogleOneTap('/library'); + expect(second).toBe(first); + const results = Promise.allSettled([first, second]); + await vi.waitFor(() => expect(mocks.oneTap).toHaveBeenCalledTimes(1)); + const error = new DOMException('Synthetic network failure', 'NetworkError'); + reject(error); + expect(await results).toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + ]); + expect(fetch).toHaveBeenCalledTimes(1); + await startGoogleOneTap('/library'); + expect(mocks.oneTap).toHaveBeenCalledTimes(2); + }); + it('uses null config without invoking a provider', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ googleClientId: null }))) + ); + const { startGoogleOneTap } = await import('./auth-client'); + await startGoogleOneTap('/library'); + expect(mocks.vanilla).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 4f4c78e..83d16c8 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -15,7 +15,18 @@ type AuthClientConfig = { googleClientId: string | null; }; -export async function startGoogleOneTap(callbackPath = '/library'): Promise { +let oneTapInFlight: Promise | null = null; + +export function startGoogleOneTap(callbackPath = '/library'): Promise { + if (oneTapInFlight) return oneTapInFlight; + const pending = initializeGoogleOneTap(callbackPath).finally(() => { + if (oneTapInFlight === pending) oneTapInFlight = null; + }); + oneTapInFlight = pending; + return pending; +} + +async function initializeGoogleOneTap(callbackPath: string): Promise { const response = await fetch('/api/auth/client-config', { credentials: 'same-origin', cache: 'no-store', diff --git a/tests/one-tap.spec.ts b/tests/one-tap.spec.ts new file mode 100644 index 0000000..1524469 --- /dev/null +++ b/tests/one-tap.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from './fixtures'; + +test('one synthetic initialization across auth loading; errors retain standard sign-in', async ({ + page, + browserName, +}) => { + test.setTimeout(60000); + if (browserName === 'chromium') { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Network.enable'); + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 150, + downloadThroughput: 1_600_000 / 8, + uploadThroughput: 750_000 / 8, + }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } + let configRequests = 0; + await page.addInitScript(() => { + Object.assign(window, { + googleScriptInitialized: true, + syntheticOneTapInitializations: 0, + google: { + accounts: { + id: { + initialize: () => { + const state = window as unknown as { syntheticOneTapInitializations: number }; + state.syntheticOneTapInitializations += 1; + throw new DOMException('Synthetic FedCM network failure', 'NetworkError'); + }, + }, + }, + }, + }); + }); + await page.route('**/api/auth/get-session', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + await route.fulfill({ json: null }); + }); + await page.route('**/api/auth/client-config', (route) => { + configRequests += 1; + return route.fulfill({ json: { googleClientId: 'synthetic-local-only' } }); + }); + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + const sessionResponse = page.waitForResponse((response) => + response.url().includes('/api/auth/get-session') + ); + await page.goto('/login'); + await expect(page.getByRole('alert')).toHaveText( + 'One-tap sign-in is unavailable. Sign in with Google to continue.' + ); + await sessionResponse; + expect(configRequests).toBe(1); + expect( + await page.evaluate( + () => + (window as unknown as { syntheticOneTapInitializations: number }) + .syntheticOneTapInitializations + ) + ).toBe(1); + expect( + consoleErrors.some((message) => message.includes('Error during Google One Tap flow')) + ).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: test.info().outputPath('one-tap-error.png'), fullPage: true }); + await page.route('**/api/auth/sign-in/social', async (route) => { + expect(route.request().postDataJSON()).toMatchObject({ provider: 'google', callbackURL: '/' }); + await route.fulfill({ + status: 503, + json: { message: 'Synthetic standard sign-in unavailable' }, + }); + }); + await page.getByRole('button', { name: 'Sign in with Google', exact: true }).click(); + await expect(page.getByRole('alert')).toHaveText('Failed to sign in. Please try again.'); + await expect( + page.getByRole('button', { name: 'Sign in with Google', exact: true }) + ).toBeEnabled(); +});