Skip to content

Commit ff1017e

Browse files
committed
test: add coverage for auth/Google Identity Services and fix hardcoded Supabase URL in CSP
- Add test files for supabaseClient, GoogleAuthCallback, entitlementService (new) - Expand tests for AuthProvider, GoogleOneTap, UserMenu, Tooltip, authService, resolveUserAvatar (edge cases, error paths, state branches) - Replace hardcoded Supabase project URL in cspHeaders.js assertion with dynamic env read via process.env.VITE_SUPABASE_URL - Fix formatting in vitest.config.js
1 parent 40edf07 commit ff1017e

11 files changed

Lines changed: 697 additions & 48 deletions

scripts/cspHeaders.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ export function assertVideoExportCspDirectives(csp, source) {
7878
return { mediaSrc, connectSrc: connectSrc ?? '' };
7979
}
8080

81+
/**
82+
* Read the configured Supabase project URL from the environment.
83+
* @returns {string}
84+
*/
85+
function getSupabaseOrigin() {
86+
const url = process.env.VITE_SUPABASE_URL;
87+
if (!url) {
88+
throw new Error(
89+
'VITE_SUPABASE_URL must be set in the environment to validate CSP connect-src'
90+
);
91+
}
92+
try {
93+
return new URL(url).origin;
94+
} catch {
95+
throw new Error(`VITE_SUPABASE_URL is not a valid URL: "${url}"`);
96+
}
97+
}
98+
8199
/**
82100
* Assert CSP directives required for Supabase auth and Google profile avatars.
83101
* @param {string} csp
@@ -87,10 +105,11 @@ export function assertVideoExportCspDirectives(csp, source) {
87105
export function assertAuthCspDirectives(csp, source) {
88106
const directives = parseCspDirectives(csp);
89107

108+
const supabaseOrigin = getSupabaseOrigin();
90109
const connectSrc = directives.get('connect-src');
91-
if (!connectSrc?.includes('https://qketsapzqpzmccljfjcm.supabase.co')) {
110+
if (!connectSrc?.includes(supabaseOrigin)) {
92111
throw new Error(
93-
`${source}: connect-src must include https://qketsapzqpzmccljfjcm.supabase.co (Supabase auth)`
112+
`${source}: connect-src must include ${supabaseOrigin} (Supabase auth)`
94113
);
95114
}
96115

src/components/GoogleOneTap.test.jsx

Lines changed: 157 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,47 @@
1-
/**
2-
* Copyright (c) 2025 Bayan Flow
3-
* Licensed under Elastic License 2.0 OR Commercial
4-
* See LICENSE for details.
5-
*/
6-
71
import { describe, it, expect, vi, beforeEach } from 'vitest';
8-
import { render, waitFor } from '@testing-library/react';
2+
import { render, waitFor, act } from '@testing-library/react';
93
import { MemoryRouter } from 'react-router-dom';
104
import GoogleOneTap from './GoogleOneTap';
115
import { useAuth } from '../hooks/useAuth';
126
import { initOneTap } from '../lib/googleIdentity';
137
import * as authService from '../services/authService';
148

9+
const mockNavigate = vi.fn();
10+
let capturedOnCredential = null;
11+
1512
vi.mock('../hooks/useAuth', () => ({
1613
useAuth: vi.fn(),
1714
}));
1815

1916
vi.mock('../lib/googleIdentity', () => ({
20-
initOneTap: vi.fn(),
17+
initOneTap: vi.fn(options => {
18+
capturedOnCredential = options.onCredential;
19+
return Promise.resolve({ cancel: vi.fn() });
20+
}),
2121
}));
2222

2323
vi.mock('../services/authService', () => ({
2424
signInWithGoogleIdToken: vi.fn(async () => undefined),
2525
}));
2626

27+
vi.mock('react-router-dom', async importOriginal => {
28+
const actual = await importOriginal();
29+
return {
30+
...actual,
31+
useNavigate: () => mockNavigate,
32+
};
33+
});
34+
2735
describe('GoogleOneTap', () => {
2836
beforeEach(() => {
2937
vi.mocked(useAuth).mockReset();
3038
vi.mocked(initOneTap).mockReset();
3139
vi.mocked(authService.signInWithGoogleIdToken).mockReset();
32-
vi.mocked(initOneTap).mockResolvedValue({
33-
cancel: vi.fn(),
40+
mockNavigate.mockReset();
41+
capturedOnCredential = null;
42+
vi.mocked(initOneTap).mockImplementation(options => {
43+
capturedOnCredential = options.onCredential;
44+
return Promise.resolve({ cancel: vi.fn() });
3445
});
3546
});
3647

@@ -83,4 +94,140 @@ describe('GoogleOneTap', () => {
8394

8495
expect(initOneTap).not.toHaveBeenCalled();
8596
});
97+
98+
it('does not initialize when auth is not configured', () => {
99+
vi.mocked(useAuth).mockReturnValue({
100+
isConfigured: false,
101+
isLoading: false,
102+
isAuthenticated: false,
103+
});
104+
105+
render(
106+
<MemoryRouter initialEntries={['/']}>
107+
<GoogleOneTap />
108+
</MemoryRouter>
109+
);
110+
111+
expect(initOneTap).not.toHaveBeenCalled();
112+
});
113+
114+
it('does not initialize when auth is loading', () => {
115+
vi.mocked(useAuth).mockReturnValue({
116+
isConfigured: true,
117+
isLoading: true,
118+
isAuthenticated: false,
119+
});
120+
121+
render(
122+
<MemoryRouter initialEntries={['/']}>
123+
<GoogleOneTap />
124+
</MemoryRouter>
125+
);
126+
127+
expect(initOneTap).not.toHaveBeenCalled();
128+
});
129+
130+
it('calls signInWithGoogleIdToken on credential and navigates from /', async () => {
131+
vi.mocked(useAuth).mockReturnValue({
132+
isConfigured: true,
133+
isLoading: false,
134+
isAuthenticated: false,
135+
});
136+
137+
render(
138+
<MemoryRouter initialEntries={['/']}>
139+
<GoogleOneTap />
140+
</MemoryRouter>
141+
);
142+
143+
await waitFor(() => {
144+
expect(initOneTap).toHaveBeenCalled();
145+
});
146+
147+
await act(async () => {
148+
await capturedOnCredential('google-credential');
149+
});
150+
151+
expect(authService.signInWithGoogleIdToken).toHaveBeenCalledWith(
152+
'google-credential'
153+
);
154+
expect(mockNavigate).toHaveBeenCalledWith('/app', { replace: true });
155+
});
156+
157+
it('does not navigate from /app on credential', async () => {
158+
vi.mocked(useAuth).mockReturnValue({
159+
isConfigured: true,
160+
isLoading: false,
161+
isAuthenticated: false,
162+
});
163+
164+
render(
165+
<MemoryRouter initialEntries={['/app']}>
166+
<GoogleOneTap />
167+
</MemoryRouter>
168+
);
169+
170+
await waitFor(() => {
171+
expect(initOneTap).toHaveBeenCalled();
172+
});
173+
174+
await act(async () => {
175+
await capturedOnCredential('google-credential');
176+
});
177+
178+
expect(authService.signInWithGoogleIdToken).toHaveBeenCalled();
179+
expect(mockNavigate).not.toHaveBeenCalled();
180+
});
181+
182+
it('handles credential error gracefully', async () => {
183+
vi.mocked(useAuth).mockReturnValue({
184+
isConfigured: true,
185+
isLoading: false,
186+
isAuthenticated: false,
187+
});
188+
189+
render(
190+
<MemoryRouter initialEntries={['/']}>
191+
<GoogleOneTap />
192+
</MemoryRouter>
193+
);
194+
195+
await waitFor(() => {
196+
expect(initOneTap).toHaveBeenCalled();
197+
});
198+
199+
vi.mocked(authService.signInWithGoogleIdToken).mockRejectedValueOnce(
200+
new Error('Auth failed')
201+
);
202+
203+
await act(async () => {
204+
await capturedOnCredential('bad-credential');
205+
});
206+
207+
expect(authService.signInWithGoogleIdToken).toHaveBeenCalledWith(
208+
'bad-credential'
209+
);
210+
expect(mockNavigate).not.toHaveBeenCalled();
211+
});
212+
213+
it('handles initOneTap rejection gracefully', async () => {
214+
vi.mocked(useAuth).mockReturnValue({
215+
isConfigured: true,
216+
isLoading: false,
217+
isAuthenticated: false,
218+
});
219+
220+
const rejectError = new Error('GIS init failed');
221+
vi.mocked(initOneTap).mockRejectedValue(rejectError);
222+
223+
render(
224+
<MemoryRouter initialEntries={['/']}>
225+
<GoogleOneTap />
226+
</MemoryRouter>
227+
);
228+
229+
await waitFor(() => {
230+
expect(initOneTap).toHaveBeenCalled();
231+
});
232+
});
86233
});

src/components/UserMenu.test.jsx

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
/**
2-
* Copyright (c) 2025 Bayan Flow
3-
* Licensed under Elastic License 2.0 OR Commercial
4-
* See LICENSE for details.
5-
*/
6-
71
import { describe, it, expect, vi, beforeEach } from 'vitest';
82
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
93
import UserMenu from './UserMenu';
@@ -43,6 +37,21 @@ describe('UserMenu', () => {
4337
expect(container).toBeEmptyDOMElement();
4438
});
4539

40+
it('shows loading skeleton when loading and not authenticated', () => {
41+
vi.mocked(useAuth).mockReturnValue({
42+
isConfigured: true,
43+
isLoading: true,
44+
isAuthenticated: false,
45+
profile: null,
46+
signInWithGoogle: vi.fn(),
47+
signOut: vi.fn(),
48+
});
49+
50+
const { container } = render(<UserMenu />);
51+
const skeleton = container.querySelector('[aria-hidden="true"]');
52+
expect(skeleton).toBeInTheDocument();
53+
});
54+
4655
it('shows Google sign-in button when signed out', () => {
4756
vi.mocked(useAuth).mockReturnValue({
4857
isConfigured: true,
@@ -73,7 +82,6 @@ describe('UserMenu', () => {
7382
render(<UserMenu variant="compact" />);
7483
const button = screen.getByRole('button', { name: /sign in with google/i });
7584
expect(button).toBeInTheDocument();
76-
expect(button).not.toHaveAttribute('title');
7785
expect(screen.queryByText(/sign in with google/i)).not.toBeInTheDocument();
7886
});
7987

@@ -99,6 +107,57 @@ describe('UserMenu', () => {
99107
});
100108
});
101109

110+
it('handles sign-in error gracefully', async () => {
111+
const signInWithGoogle = vi
112+
.fn()
113+
.mockRejectedValue(new Error('Sign in failed'));
114+
vi.mocked(useAuth).mockReturnValue({
115+
isConfigured: true,
116+
isLoading: false,
117+
isAuthenticated: false,
118+
profile: null,
119+
signInWithGoogle,
120+
signOut: vi.fn(),
121+
});
122+
123+
render(<UserMenu variant="landing" />);
124+
fireEvent.click(
125+
screen.getByRole('button', { name: /sign in with google/i })
126+
);
127+
128+
await waitFor(() => {
129+
expect(signInWithGoogle).toHaveBeenCalled();
130+
});
131+
});
132+
133+
it('handles sign-out error gracefully', async () => {
134+
const signOut = vi.fn().mockRejectedValue(new Error('Sign out failed'));
135+
vi.mocked(useAuth).mockReturnValue({
136+
isConfigured: true,
137+
isLoading: false,
138+
isAuthenticated: true,
139+
profile: {
140+
displayName: 'Test User',
141+
email: 'user@example.com',
142+
avatarSrc: 'data:image/svg+xml;charset=utf-8,test',
143+
avatarSource: 'generated',
144+
plan: 'free',
145+
},
146+
signInWithGoogle: vi.fn(),
147+
signOut,
148+
});
149+
150+
render(<UserMenu />);
151+
fireEvent.click(
152+
screen.getByRole('button', { name: /account menu for test user/i })
153+
);
154+
fireEvent.click(screen.getByRole('menuitem', { name: /sign out/i }));
155+
156+
await waitFor(() => {
157+
expect(signOut).toHaveBeenCalled();
158+
});
159+
});
160+
102161
it('opens account menu when signed in', () => {
103162
vi.mocked(useAuth).mockReturnValue({
104163
isConfigured: true,

src/components/ui/Tooltip.test.jsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
/**
2-
* Copyright (c) 2025 Bayan Flow
3-
* Licensed under Elastic License 2.0 OR Commercial
4-
* See LICENSE for details.
5-
*/
6-
71
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
82
import { render, screen, fireEvent, act } from '@testing-library/react';
93
import Tooltip from './Tooltip';
@@ -48,4 +42,43 @@ describe('Tooltip', () => {
4842
'Sign in with Google'
4943
);
5044
});
45+
46+
it('hides tooltip on mouse leave', () => {
47+
render(
48+
<Tooltip label="Test tooltip">
49+
<button type="button">Hover</button>
50+
</Tooltip>
51+
);
52+
53+
fireEvent.focus(screen.getByRole('button', { name: 'Hover' }));
54+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
55+
56+
fireEvent.blur(screen.getByRole('button', { name: 'Hover' }));
57+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
58+
});
59+
60+
it('hides tooltip on blur after focus', () => {
61+
render(
62+
<Tooltip label="Blur test">
63+
<button type="button">Focus</button>
64+
</Tooltip>
65+
);
66+
67+
fireEvent.focus(screen.getByRole('button', { name: 'Focus' }));
68+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
69+
70+
fireEvent.blur(screen.getByRole('button', { name: 'Focus' }));
71+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
72+
});
73+
74+
it('shows without delay when delay is zero', () => {
75+
render(
76+
<Tooltip label="Instant" delay={0}>
77+
<button type="button">Instant</button>
78+
</Tooltip>
79+
);
80+
81+
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Instant' }));
82+
expect(screen.getByRole('tooltip')).toHaveTextContent('Instant');
83+
});
5184
});

0 commit comments

Comments
 (0)