Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/components/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;
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(<AuthProvider>{child}</AuthProvider>);
});
}
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 (
<>
<input defaultValue="draft" />
<output>{loading ? 'pending' : (user?.id ?? 'signed-out')}</output>
</>
);
}
await render(<Child />);
const input = host.querySelector('input')!;
input.value = 'retained draft';
expect(host.querySelector('output')?.textContent).toBe('pending');
mocks.session = { data: null, isPending: false };
await render(<Child />);
expect(host.querySelector('output')?.textContent).toBe('signed-out');
mocks.session = {
data: { user: { id: 'synthetic-account', email: 'audit@example.invalid' } },
isPending: false,
};
await render(<Child />);
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(<LoginClient />);
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: '/' });
});
});
28 changes: 17 additions & 11 deletions src/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -63,12 +69,9 @@ function AuthProviderReady({ auth, children }: { auth: AuthClientModule; childre
[auth, isPending, sessionUser]
);

return (
<AuthContext.Provider value={value}>
<AnalyticsTracker useSession={auth.useSession} />
{children}
</AuthContext.Provider>
);
useEffect(() => onValue(value), [onValue, value]);

return <AnalyticsTracker useSession={auth.useSession} />;
}

export function AuthProvider({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -101,11 +104,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[]
);

if (!auth) {
return <AuthContext.Provider value={bootValue}>{children}</AuthContext.Provider>;
}
const [value, setValue] = useState<AuthContextValue>(bootValue);

return <AuthProviderReady auth={auth}>{children}</AuthProviderReady>;
return (
<AuthContext.Provider value={value}>
{auth && <AuthProviderReady auth={auth} onValue={setValue} />}
{children}
</AuthContext.Provider>
);
}

export function useAuth(): AuthContextValue {
Expand Down
13 changes: 12 additions & 1 deletion src/components/LoginClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ 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',
reason: error instanceof Error ? error.message : 'Google One Tap failed',
source: 'login-client',
});
});
return () => {
active = false;
};
}, []);

const handleSignIn = async () => {
Expand Down Expand Up @@ -94,7 +101,11 @@ export default function LoginClient() {
)}
</Button>

{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
{error && (
<p role="alert" className="mt-4 text-sm text-red-400">
{error}
</p>
)}
</div>
</div>
);
Expand Down
55 changes: 55 additions & 0 deletions src/lib/auth-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((_, 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();
});
});
13 changes: 12 additions & 1 deletion src/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ type AuthClientConfig = {
googleClientId: string | null;
};

export async function startGoogleOneTap(callbackPath = '/library'): Promise<void> {
let oneTapInFlight: Promise<void> | null = null;

export function startGoogleOneTap(callbackPath = '/library'): Promise<void> {
if (oneTapInFlight) return oneTapInFlight;
const pending = initializeGoogleOneTap(callbackPath).finally(() => {
if (oneTapInFlight === pending) oneTapInFlight = null;
});
oneTapInFlight = pending;
return pending;
}

async function initializeGoogleOneTap(callbackPath: string): Promise<void> {
const response = await fetch('/api/auth/client-config', {
credentials: 'same-origin',
cache: 'no-store',
Expand Down
82 changes: 82 additions & 0 deletions tests/one-tap.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading