Skip to content

Commit 7d7bd68

Browse files
committed
test: Google OAuth authentication
1 parent 6c95b94 commit 7d7bd68

File tree

4 files changed

+676
-3
lines changed

4 files changed

+676
-3
lines changed

playground/nuxt.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ export default defineNuxtConfig({
1212
tokenExpiration: 10,
1313
// TODO: if it is uncommented it makes `yarn test:types` fails - for some unknown reason
1414
// INFO: if it is commented out you can not login to the playground
15-
permissions: {
15+
/* permissions: {
1616
admin: ['*'],
17-
},
17+
}, */
1818
google: {
1919
clientId: process.env.GOOGLE_CLIENT_ID || 'demo-client-id',
2020
clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'demo-client-secret',

src/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export type {
1414
ResetPasswordFormProps,
1515
Permission,
1616
DatabaseType,
17-
DatabaseConfig
17+
DatabaseConfig,
18+
GoogleOAuthOptions
1819
} from '../types'
1920

2021
export { defaultDisplayFields, defaultFieldLabels } from '../types'
Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import type { ModuleOptions, User } from '../../src/types'
3+
import type { H3Event } from 'h3'
4+
import { defaultOptions } from '../../src/module'
5+
6+
// Mock h3 functions
7+
vi.mock('h3', () => ({
8+
defineEventHandler: vi.fn(handler => handler),
9+
sendRedirect: vi.fn(),
10+
getQuery: vi.fn(),
11+
setCookie: vi.fn()
12+
}))
13+
14+
vi.mock('#imports', () => ({
15+
useRuntimeConfig: vi.fn()
16+
}))
17+
18+
// Mock Google OAuth utilities
19+
vi.mock('../../src/runtime/server/utils/google-oauth', () => ({
20+
createGoogleOAuth2Client: vi.fn(),
21+
getGoogleUserFromCode: vi.fn(),
22+
findOrCreateGoogleUser: vi.fn(),
23+
createAuthTokenForUser: vi.fn()
24+
}))
25+
26+
// Import after mocks
27+
const callbackHandler = await import('../../src/runtime/server/api/nuxt-users/auth/google/callback.get')
28+
29+
describe('Google OAuth Callback API', () => {
30+
let testOptions: ModuleOptions
31+
let mockEvent: Partial<H3Event>
32+
let mockGetQuery: ReturnType<typeof vi.fn>
33+
let mockSendRedirect: ReturnType<typeof vi.fn>
34+
let mockSetCookie: ReturnType<typeof vi.fn>
35+
let mockUseRuntimeConfig: ReturnType<typeof vi.fn>
36+
let mockCreateGoogleOAuth2Client: ReturnType<typeof vi.fn>
37+
let mockGetGoogleUserFromCode: ReturnType<typeof vi.fn>
38+
let mockFindOrCreateGoogleUser: ReturnType<typeof vi.fn>
39+
let mockCreateAuthTokenForUser: ReturnType<typeof vi.fn>
40+
41+
const mockUser: User = {
42+
id: 1,
43+
email: 'test@example.com',
44+
name: 'Test User',
45+
password: 'hashed-password',
46+
role: 'user',
47+
google_id: 'google-123',
48+
profile_picture: 'https://example.com/pic.jpg',
49+
active: true,
50+
created_at: '2024-01-01T00:00:00.000Z',
51+
updated_at: '2024-01-01T00:00:00.000Z'
52+
}
53+
54+
beforeEach(async () => {
55+
vi.clearAllMocks()
56+
57+
// Get mocked functions
58+
const h3 = await import('h3')
59+
const imports = await import('#imports')
60+
const googleOAuth = await import('../../src/runtime/server/utils/google-oauth')
61+
62+
mockGetQuery = h3.getQuery as ReturnType<typeof vi.fn>
63+
mockSendRedirect = h3.sendRedirect as ReturnType<typeof vi.fn>
64+
mockSetCookie = h3.setCookie as ReturnType<typeof vi.fn>
65+
mockUseRuntimeConfig = imports.useRuntimeConfig as ReturnType<typeof vi.fn>
66+
mockCreateGoogleOAuth2Client = googleOAuth.createGoogleOAuth2Client as ReturnType<typeof vi.fn>
67+
mockGetGoogleUserFromCode = googleOAuth.getGoogleUserFromCode as ReturnType<typeof vi.fn>
68+
mockFindOrCreateGoogleUser = googleOAuth.findOrCreateGoogleUser as ReturnType<typeof vi.fn>
69+
mockCreateAuthTokenForUser = googleOAuth.createAuthTokenForUser as ReturnType<typeof vi.fn>
70+
71+
// Setup test options
72+
testOptions = {
73+
...defaultOptions,
74+
auth: {
75+
...defaultOptions.auth,
76+
google: {
77+
clientId: 'test-client-id',
78+
clientSecret: 'test-client-secret',
79+
successRedirect: '/',
80+
errorRedirect: '/login?error=oauth_failed'
81+
}
82+
}
83+
}
84+
85+
mockUseRuntimeConfig.mockReturnValue({
86+
nuxtUsers: testOptions
87+
})
88+
89+
// Setup mock event
90+
mockEvent = {
91+
node: {
92+
req: {
93+
headers: {
94+
host: 'localhost:3000',
95+
'x-forwarded-proto': 'http'
96+
}
97+
},
98+
res: {}
99+
}
100+
} as unknown as H3Event
101+
102+
// Default mock returns
103+
mockCreateGoogleOAuth2Client.mockReturnValue({})
104+
mockGetGoogleUserFromCode.mockResolvedValue({
105+
id: 'google-123',
106+
email: 'test@example.com',
107+
name: 'Test User',
108+
picture: 'https://example.com/pic.jpg',
109+
verified_email: true
110+
})
111+
mockFindOrCreateGoogleUser.mockResolvedValue(mockUser)
112+
mockCreateAuthTokenForUser.mockResolvedValue('test-token-123')
113+
})
114+
115+
afterEach(() => {
116+
vi.clearAllMocks()
117+
})
118+
119+
describe('Successful OAuth Flow', () => {
120+
it('should complete OAuth flow and redirect with oauth_success flag', async () => {
121+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
122+
123+
await callbackHandler.default(mockEvent as H3Event)
124+
125+
expect(mockCreateGoogleOAuth2Client).toHaveBeenCalled()
126+
expect(mockGetGoogleUserFromCode).toHaveBeenCalledWith({}, 'valid-auth-code')
127+
expect(mockFindOrCreateGoogleUser).toHaveBeenCalled()
128+
expect(mockCreateAuthTokenForUser).toHaveBeenCalledWith(mockUser, testOptions, true)
129+
expect(mockSetCookie).toHaveBeenCalledWith(
130+
mockEvent,
131+
'auth_token',
132+
'test-token-123',
133+
expect.objectContaining({
134+
httpOnly: true,
135+
sameSite: 'lax',
136+
path: '/'
137+
})
138+
)
139+
expect(mockSendRedirect).toHaveBeenCalledWith(mockEvent, '/?oauth_success=true')
140+
})
141+
142+
it('should append oauth_success to existing query params', async () => {
143+
const optionsWithQuery = {
144+
...testOptions,
145+
auth: {
146+
...testOptions.auth,
147+
google: {
148+
...testOptions.auth.google!,
149+
successRedirect: '/dashboard?view=profile'
150+
}
151+
}
152+
}
153+
mockUseRuntimeConfig.mockReturnValue({ nuxtUsers: optionsWithQuery })
154+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
155+
156+
await callbackHandler.default(mockEvent as H3Event)
157+
158+
expect(mockSendRedirect).toHaveBeenCalledWith(
159+
mockEvent,
160+
'/dashboard?view=profile&oauth_success=true'
161+
)
162+
})
163+
164+
it('should set cookie with correct maxAge for rememberMe', async () => {
165+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
166+
167+
await callbackHandler.default(mockEvent as H3Event)
168+
169+
expect(mockSetCookie).toHaveBeenCalledWith(
170+
mockEvent,
171+
'auth_token',
172+
expect.any(String),
173+
expect.objectContaining({
174+
maxAge: 60 * 60 * 24 * 30 // 30 days default
175+
})
176+
)
177+
})
178+
})
179+
180+
describe('Error Handling', () => {
181+
it('should redirect to error page when Google OAuth is not configured', async () => {
182+
const optionsWithoutGoogle = {
183+
...testOptions,
184+
auth: {
185+
...testOptions.auth,
186+
google: undefined
187+
}
188+
}
189+
mockUseRuntimeConfig.mockReturnValue({ nuxtUsers: optionsWithoutGoogle })
190+
191+
await callbackHandler.default(mockEvent as H3Event)
192+
193+
expect(mockSendRedirect).toHaveBeenCalledWith(
194+
mockEvent,
195+
'/login?error=oauth_not_configured'
196+
)
197+
})
198+
199+
it('should handle OAuth error from Google', async () => {
200+
mockGetQuery.mockReturnValue({ error: 'access_denied' })
201+
202+
await callbackHandler.default(mockEvent as H3Event)
203+
204+
expect(mockSendRedirect).toHaveBeenCalledWith(
205+
mockEvent,
206+
'/login?error=oauth_failed'
207+
)
208+
})
209+
210+
it('should handle missing authorization code', async () => {
211+
mockGetQuery.mockReturnValue({})
212+
213+
await callbackHandler.default(mockEvent as H3Event)
214+
215+
expect(mockSendRedirect).toHaveBeenCalledWith(
216+
mockEvent,
217+
expect.stringContaining('error=oauth_failed')
218+
)
219+
})
220+
221+
it('should handle user not found when auto-registration is disabled', async () => {
222+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
223+
mockFindOrCreateGoogleUser.mockResolvedValue(null)
224+
225+
await callbackHandler.default(mockEvent as H3Event)
226+
227+
// Should redirect to error page (specific error is in callback implementation)
228+
expect(mockSendRedirect).toHaveBeenCalled()
229+
const redirectCall = mockSendRedirect.mock.calls[0]
230+
expect(redirectCall[1]).toContain('error=')
231+
})
232+
233+
it('should handle inactive user account', async () => {
234+
const inactiveUser = { ...mockUser, active: false }
235+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
236+
mockFindOrCreateGoogleUser.mockResolvedValue(inactiveUser)
237+
238+
await callbackHandler.default(mockEvent as H3Event)
239+
240+
// Should redirect to error page
241+
expect(mockSendRedirect).toHaveBeenCalled()
242+
const redirectCall = mockSendRedirect.mock.calls[0]
243+
expect(redirectCall[1]).toContain('error=')
244+
})
245+
246+
it('should handle exception during OAuth flow', async () => {
247+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
248+
mockGetGoogleUserFromCode.mockRejectedValue(new Error('Network error'))
249+
250+
await callbackHandler.default(mockEvent as H3Event)
251+
252+
expect(mockSendRedirect).toHaveBeenCalledWith(
253+
mockEvent,
254+
'/login?error=oauth_failed'
255+
)
256+
})
257+
})
258+
259+
describe('Security Checks', () => {
260+
it('should not create token for inactive user', async () => {
261+
const inactiveUser = { ...mockUser, active: false }
262+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
263+
mockFindOrCreateGoogleUser.mockResolvedValue(inactiveUser)
264+
265+
await callbackHandler.default(mockEvent as H3Event)
266+
267+
expect(mockCreateAuthTokenForUser).not.toHaveBeenCalled()
268+
expect(mockSetCookie).not.toHaveBeenCalled()
269+
})
270+
271+
it('should not create token for null user', async () => {
272+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
273+
mockFindOrCreateGoogleUser.mockResolvedValue(null)
274+
275+
await callbackHandler.default(mockEvent as H3Event)
276+
277+
expect(mockCreateAuthTokenForUser).not.toHaveBeenCalled()
278+
expect(mockSetCookie).not.toHaveBeenCalled()
279+
})
280+
281+
it('should use custom error redirect when configured', async () => {
282+
const optionsWithCustomError = {
283+
...testOptions,
284+
auth: {
285+
...testOptions.auth,
286+
google: {
287+
...testOptions.auth.google!,
288+
errorRedirect: '/custom-error'
289+
}
290+
}
291+
}
292+
mockUseRuntimeConfig.mockReturnValue({ nuxtUsers: optionsWithCustomError })
293+
mockGetQuery.mockReturnValue({ error: 'access_denied' })
294+
295+
await callbackHandler.default(mockEvent as H3Event)
296+
297+
expect(mockSendRedirect).toHaveBeenCalledWith(mockEvent, '/custom-error')
298+
})
299+
})
300+
301+
describe('Cookie Configuration', () => {
302+
it('should set secure cookie in production', async () => {
303+
const originalEnv = process.env.NODE_ENV
304+
process.env.NODE_ENV = 'production'
305+
306+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
307+
308+
await callbackHandler.default(mockEvent as H3Event)
309+
310+
expect(mockSetCookie).toHaveBeenCalledWith(
311+
mockEvent,
312+
'auth_token',
313+
expect.any(String),
314+
expect.objectContaining({
315+
secure: true
316+
})
317+
)
318+
319+
process.env.NODE_ENV = originalEnv
320+
})
321+
322+
it('should not set secure cookie in development', async () => {
323+
const originalEnv = process.env.NODE_ENV
324+
process.env.NODE_ENV = 'development'
325+
326+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
327+
328+
await callbackHandler.default(mockEvent as H3Event)
329+
330+
expect(mockSetCookie).toHaveBeenCalledWith(
331+
mockEvent,
332+
'auth_token',
333+
expect.any(String),
334+
expect.objectContaining({
335+
secure: false
336+
})
337+
)
338+
339+
process.env.NODE_ENV = originalEnv
340+
})
341+
342+
it('should set httpOnly cookie', async () => {
343+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
344+
345+
await callbackHandler.default(mockEvent as H3Event)
346+
347+
expect(mockSetCookie).toHaveBeenCalledWith(
348+
mockEvent,
349+
'auth_token',
350+
expect.any(String),
351+
expect.objectContaining({
352+
httpOnly: true
353+
})
354+
)
355+
})
356+
357+
it('should set sameSite to lax', async () => {
358+
mockGetQuery.mockReturnValue({ code: 'valid-auth-code' })
359+
360+
await callbackHandler.default(mockEvent as H3Event)
361+
362+
expect(mockSetCookie).toHaveBeenCalledWith(
363+
mockEvent,
364+
'auth_token',
365+
expect.any(String),
366+
expect.objectContaining({
367+
sameSite: 'lax'
368+
})
369+
)
370+
})
371+
})
372+
})
373+

0 commit comments

Comments
 (0)