sveltekit-auth-example 5.8.2 → 5.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +11 -11
  3. package/db_create.sql +0 -1
  4. package/package.json +3 -1
  5. package/src/hooks.server.unit.test.ts +163 -0
  6. package/src/lib/app-state.svelte.unit.test.ts +73 -0
  7. package/src/lib/auth-redirect.unit.test.ts +92 -0
  8. package/src/lib/fetch-interceptor.unit.test.ts +99 -0
  9. package/src/lib/focus.unit.test.ts +91 -0
  10. package/src/lib/google.unit.test.ts +189 -0
  11. package/src/lib/server/brevo.ts +3 -3
  12. package/src/lib/server/brevo.unit.test.ts +186 -0
  13. package/src/lib/server/db.unit.test.ts +91 -0
  14. package/src/lib/server/email/mfa-code.ts +2 -2
  15. package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
  16. package/src/lib/server/email/password-reset.ts +3 -3
  17. package/src/lib/server/email/password-reset.unit.test.ts +70 -0
  18. package/src/lib/server/email/verify-email.ts +3 -3
  19. package/src/lib/server/email/verify-email.unit.test.ts +79 -0
  20. package/src/lib/server/turnstile.ts +2 -2
  21. package/src/lib/server/turnstile.unit.test.ts +111 -0
  22. package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
  23. package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
  24. package/src/routes/auth/forgot/+server.ts +2 -2
  25. package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
  26. package/src/routes/auth/google/+server.unit.test.ts +132 -0
  27. package/src/routes/auth/login/+server.ts +2 -2
  28. package/src/routes/auth/login/+server.unit.test.ts +221 -0
  29. package/src/routes/auth/logout/+server.unit.test.ts +85 -0
  30. package/src/routes/auth/mfa/+server.ts +2 -2
  31. package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
  32. package/src/routes/auth/register/+server.ts +9 -3
  33. package/src/routes/auth/register/+server.unit.test.ts +182 -0
  34. package/src/routes/auth/reset/+server.ts +5 -4
  35. package/src/routes/auth/reset/+server.unit.test.ts +139 -0
  36. package/src/routes/auth/verify/[token]/+server.ts +2 -2
  37. package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
  38. package/src/routes/login/+page.svelte +5 -1
  39. package/src/service-worker.unit.test.ts +228 -0
  40. package/vite.config.ts +5 -5
@@ -1,4 +1,4 @@
1
- import { DOMAIN, EMAIL } from '$env/static/private'
1
+ import { env } from '$env/dynamic/private'
2
2
  import { sendMessage } from '$lib/server/brevo'
3
3
 
4
4
  /**
@@ -10,12 +10,12 @@ import { sendMessage } from '$lib/server/brevo'
10
10
  export const sendVerificationEmail = async (toEmail: string, token: string) => {
11
11
  await sendMessage({
12
12
  to: [{ email: toEmail }],
13
- sender: { email: EMAIL },
13
+ sender: { email: env.EMAIL },
14
14
  subject: 'Verify your email address',
15
15
  tags: ['account'],
16
16
  htmlContent: `
17
17
  <p>Thanks for registering! Please verify your email address by clicking the link below:</p>
18
- <p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
18
+ <p><a href="${env.DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
19
19
  <p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
20
20
  `
21
21
  })
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$env/dynamic/private', () => ({ env: { EMAIL: 'no-reply@example.com', DOMAIN: 'https://example.com' } }))
4
+
5
+ vi.mock('$lib/server/brevo', () => ({ sendMessage: vi.fn() }))
6
+
7
+ import { sendVerificationEmail } from './verify-email'
8
+ import { sendMessage } from '$lib/server/brevo'
9
+
10
+ const mockSendMessage = vi.mocked(sendMessage)
11
+
12
+ describe('sendVerificationEmail', () => {
13
+ beforeEach(() => {
14
+ mockSendMessage.mockReset()
15
+ })
16
+
17
+ it('calls sendMessage with the correct recipient', async () => {
18
+ mockSendMessage.mockResolvedValue(undefined)
19
+
20
+ await sendVerificationEmail('new@example.com', 'tok-abc')
21
+
22
+ expect(mockSendMessage).toHaveBeenCalledOnce()
23
+ const [msg] = mockSendMessage.mock.calls[0]
24
+ expect(msg.to).toEqual([{ email: 'new@example.com' }])
25
+ })
26
+
27
+ it('uses the EMAIL env var as the sender', async () => {
28
+ mockSendMessage.mockResolvedValue(undefined)
29
+
30
+ await sendVerificationEmail('new@example.com', 'tok-abc')
31
+
32
+ const [msg] = mockSendMessage.mock.calls[0]
33
+ expect(msg.sender).toEqual({ email: 'no-reply@example.com' })
34
+ })
35
+
36
+ it('sets the expected subject line', async () => {
37
+ mockSendMessage.mockResolvedValue(undefined)
38
+
39
+ await sendVerificationEmail('new@example.com', 'tok-abc')
40
+
41
+ const [msg] = mockSendMessage.mock.calls[0]
42
+ expect(msg.subject).toBe('Verify your email address')
43
+ })
44
+
45
+ it('includes the verification link with DOMAIN and token in the HTML content', async () => {
46
+ mockSendMessage.mockResolvedValue(undefined)
47
+
48
+ await sendVerificationEmail('new@example.com', 'tok-xyz-789')
49
+
50
+ const [msg] = mockSendMessage.mock.calls[0]
51
+ expect(msg.htmlContent).toContain('https://example.com/auth/verify/tok-xyz-789')
52
+ })
53
+
54
+ it('mentions the 24-hour expiry in the HTML content', async () => {
55
+ mockSendMessage.mockResolvedValue(undefined)
56
+
57
+ await sendVerificationEmail('new@example.com', 'tok-abc')
58
+
59
+ const [msg] = mockSendMessage.mock.calls[0]
60
+ expect(msg.htmlContent).toContain('24 hours')
61
+ })
62
+
63
+ it('tags the message as account', async () => {
64
+ mockSendMessage.mockResolvedValue(undefined)
65
+
66
+ await sendVerificationEmail('new@example.com', 'tok-abc')
67
+
68
+ const [msg] = mockSendMessage.mock.calls[0]
69
+ expect(msg.tags).toContain('account')
70
+ })
71
+
72
+ it('propagates errors thrown by sendMessage', async () => {
73
+ mockSendMessage.mockRejectedValue(new Error('Brevo API unavailable'))
74
+
75
+ await expect(sendVerificationEmail('new@example.com', 'tok-abc')).rejects.toThrow(
76
+ 'Brevo API unavailable'
77
+ )
78
+ })
79
+ })
@@ -1,4 +1,4 @@
1
- import { TURNSTILE_SECRET_KEY } from '$env/static/private'
1
+ import { env } from '$env/dynamic/private'
2
2
 
3
3
  /**
4
4
  * Verifies a Cloudflare Turnstile challenge token against the siteverify API.
@@ -11,7 +11,7 @@ export async function verifyTurnstileToken(token: string, ip?: string): Promise<
11
11
  if (!token) return false
12
12
 
13
13
  const formData = new FormData()
14
- formData.append('secret', TURNSTILE_SECRET_KEY)
14
+ formData.append('secret', env.TURNSTILE_SECRET_KEY)
15
15
  formData.append('response', token)
16
16
  if (ip) formData.append('remoteip', ip)
17
17
 
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$env/dynamic/private', () => ({ env: { TURNSTILE_SECRET_KEY: 'test-secret-key' } }))
4
+
5
+ import { verifyTurnstileToken } from './turnstile'
6
+
7
+ describe('verifyTurnstileToken', () => {
8
+ beforeEach(() => {
9
+ vi.restoreAllMocks()
10
+ })
11
+
12
+ it('returns false immediately for an empty token', async () => {
13
+ const fetchSpy = vi.spyOn(globalThis, 'fetch')
14
+
15
+ const result = await verifyTurnstileToken('')
16
+
17
+ expect(result).toBe(false)
18
+ expect(fetchSpy).not.toHaveBeenCalled()
19
+ })
20
+
21
+ it('returns true when the API responds with success: true', async () => {
22
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
23
+ new Response(JSON.stringify({ success: true }), { status: 200 })
24
+ )
25
+
26
+ const result = await verifyTurnstileToken('valid-token')
27
+
28
+ expect(result).toBe(true)
29
+ })
30
+
31
+ it('returns false when the API responds with success: false', async () => {
32
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
33
+ new Response(JSON.stringify({ success: false }), { status: 200 })
34
+ )
35
+
36
+ const result = await verifyTurnstileToken('invalid-token')
37
+
38
+ expect(result).toBe(false)
39
+ })
40
+
41
+ it('posts to the Cloudflare siteverify endpoint', async () => {
42
+ const fetchSpy = vi
43
+ .spyOn(globalThis, 'fetch')
44
+ .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 }))
45
+
46
+ await verifyTurnstileToken('some-token')
47
+
48
+ const [url, init] = fetchSpy.mock.calls[0]
49
+ expect(url).toBe('https://challenges.cloudflare.com/turnstile/v0/siteverify')
50
+ expect((init as RequestInit).method).toBe('POST')
51
+ })
52
+
53
+ it('includes the secret key and token in the form data', async () => {
54
+ const fetchSpy = vi
55
+ .spyOn(globalThis, 'fetch')
56
+ .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 }))
57
+
58
+ await verifyTurnstileToken('my-token')
59
+
60
+ const [, init] = fetchSpy.mock.calls[0]
61
+ const body = (init as RequestInit).body as FormData
62
+ expect(body.get('secret')).toBe('test-secret-key')
63
+ expect(body.get('response')).toBe('my-token')
64
+ })
65
+
66
+ it('includes remoteip in the form data when ip is provided', async () => {
67
+ const fetchSpy = vi
68
+ .spyOn(globalThis, 'fetch')
69
+ .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 }))
70
+
71
+ await verifyTurnstileToken('my-token', '1.2.3.4')
72
+
73
+ const [, init] = fetchSpy.mock.calls[0]
74
+ const body = (init as RequestInit).body as FormData
75
+ expect(body.get('remoteip')).toBe('1.2.3.4')
76
+ })
77
+
78
+ it('omits remoteip from the form data when ip is not provided', async () => {
79
+ const fetchSpy = vi
80
+ .spyOn(globalThis, 'fetch')
81
+ .mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 }))
82
+
83
+ await verifyTurnstileToken('my-token')
84
+
85
+ const [, init] = fetchSpy.mock.calls[0]
86
+ const body = (init as RequestInit).body as FormData
87
+ expect(body.get('remoteip')).toBeNull()
88
+ })
89
+
90
+ it('returns false and logs an error when fetch throws', async () => {
91
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
92
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
93
+
94
+ const result = await verifyTurnstileToken('some-token')
95
+
96
+ expect(result).toBe(false)
97
+ expect(consoleSpy).toHaveBeenCalledWith(
98
+ 'Turnstile verification error:',
99
+ expect.any(Error)
100
+ )
101
+ })
102
+
103
+ it('returns false when the response body is not valid JSON', async () => {
104
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not-json', { status: 200 }))
105
+ vi.spyOn(console, 'error').mockImplementation(() => {})
106
+
107
+ const result = await verifyTurnstileToken('some-token')
108
+
109
+ expect(result).toBe(false)
110
+ })
111
+ })
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
4
+
5
+ import { PUT, DELETE } from './+server'
6
+ import { query } from '$lib/server/db'
7
+
8
+ const mockQuery = vi.mocked(query)
9
+
10
+ const adminUser: UserProperties = { id: 42, role: 'admin', email: 'admin@example.com' }
11
+
12
+ function makeEvent({
13
+ noUser = false,
14
+ body = {} as unknown
15
+ } = {}) {
16
+ return {
17
+ locals: { user: noUser ? undefined : adminUser },
18
+ request: { json: vi.fn().mockResolvedValue(body) },
19
+ cookies: { delete: vi.fn() }
20
+ } as unknown as Parameters<typeof PUT>[0]
21
+ }
22
+
23
+ beforeEach(() => {
24
+ mockQuery.mockReset()
25
+ })
26
+
27
+ // ── PUT ───────────────────────────────────────────────────────────────────────
28
+
29
+ describe('PUT /api/v1/user', () => {
30
+ it('returns 200 with a success message on valid update', async () => {
31
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 1 } as any)
32
+
33
+ const res = await PUT(makeEvent({ body: { firstName: 'Jane' } }))
34
+
35
+ expect(res.status).toBe(200)
36
+ const body = await res.json()
37
+ expect(body.message).toContain('Successfully updated')
38
+ })
39
+
40
+ it('calls update_user with the user id and JSON-encoded update', async () => {
41
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 1 } as any)
42
+ const update = { firstName: 'Jane', phone: '412-555-0000' }
43
+
44
+ await PUT(makeEvent({ body: update }))
45
+
46
+ expect(mockQuery).toHaveBeenCalledWith(
47
+ expect.stringContaining('update_user'),
48
+ [adminUser.id, JSON.stringify(update)]
49
+ )
50
+ })
51
+
52
+ it('throws 401 when user is not authenticated', async () => {
53
+ await expect(PUT(makeEvent({ noUser: true }))).rejects.toMatchObject({ status: 401 })
54
+ })
55
+
56
+ it('throws 503 when the database call fails', async () => {
57
+ mockQuery.mockRejectedValue(new Error('db down'))
58
+
59
+ await expect(PUT(makeEvent())).rejects.toMatchObject({ status: 503 })
60
+ })
61
+ })
62
+
63
+ // ── DELETE ───────────────────────────────────────────────────────────────────
64
+
65
+ describe('DELETE /api/v1/user', () => {
66
+ it('returns 200 with a success message on valid deletion', async () => {
67
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 1 } as any)
68
+
69
+ const res = await DELETE(makeEvent())
70
+
71
+ expect(res.status).toBe(200)
72
+ const body = await res.json()
73
+ expect(body.message).toContain('deleted')
74
+ })
75
+
76
+ it('calls delete_user with the user id', async () => {
77
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 1 } as any)
78
+
79
+ await DELETE(makeEvent())
80
+
81
+ expect(mockQuery).toHaveBeenCalledWith(
82
+ expect.stringContaining('delete_user'),
83
+ [adminUser.id]
84
+ )
85
+ })
86
+
87
+ it('deletes the session cookie on success', async () => {
88
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 1 } as any)
89
+ const event = makeEvent()
90
+
91
+ await DELETE(event)
92
+
93
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
94
+ })
95
+
96
+ it('throws 401 when user is not authenticated', async () => {
97
+ await expect(DELETE(makeEvent({ noUser: true }))).rejects.toMatchObject({ status: 401 })
98
+ })
99
+
100
+ it('throws 503 when the database call fails', async () => {
101
+ mockQuery.mockRejectedValue(new Error('db down'))
102
+
103
+ await expect(DELETE(makeEvent())).rejects.toMatchObject({ status: 503 })
104
+ })
105
+
106
+ it('does not delete the session cookie when the db call fails', async () => {
107
+ mockQuery.mockRejectedValue(new Error('db down'))
108
+ const event = makeEvent()
109
+
110
+ await DELETE(event).catch(() => {})
111
+
112
+ expect(event.cookies.delete).not.toHaveBeenCalled()
113
+ })
114
+ })
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import { POST } from './+server'
4
+
5
+ const event = {} as Parameters<typeof POST>[0]
6
+
7
+ describe('POST /auth/[slug]', () => {
8
+ it('throws 404 for any unrecognized auth path', async () => {
9
+ await expect(POST(event)).rejects.toMatchObject({ status: 404 })
10
+ })
11
+
12
+ it('includes "Invalid endpoint" in the error message', async () => {
13
+ await expect(POST(event)).rejects.toMatchObject({ body: { message: 'Invalid endpoint.' } })
14
+ })
15
+ })
@@ -1,7 +1,7 @@
1
1
  import type { Secret } from 'jsonwebtoken'
2
2
  import type { RequestHandler } from './$types'
3
3
  import jwt from 'jsonwebtoken'
4
- import { JWT_SECRET } from '$env/static/private'
4
+ import { env } from '$env/dynamic/private'
5
5
  import { query } from '$lib/server/db'
6
6
  import { sendPasswordResetEmail } from '$lib/server/email'
7
7
  import { error } from '@sveltejs/kit'
@@ -30,7 +30,7 @@ export const POST: RequestHandler = async event => {
30
30
  if (rows.length > 0) {
31
31
  const token = jwt.sign(
32
32
  { subject: rows[0].userId, purpose: 'reset-password' },
33
- JWT_SECRET as Secret,
33
+ env.JWT_SECRET as Secret,
34
34
  { expiresIn: '30m' }
35
35
  )
36
36
  try {
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$env/dynamic/private', () => ({ env: { JWT_SECRET: 'test-secret' } }))
4
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
5
+ vi.mock('$lib/server/email', () => ({ sendPasswordResetEmail: vi.fn() }))
6
+ vi.mock('$lib/server/turnstile', () => ({ verifyTurnstileToken: vi.fn() }))
7
+
8
+ import { POST } from './+server'
9
+ import { query } from '$lib/server/db'
10
+ import { sendPasswordResetEmail } from '$lib/server/email'
11
+ import { verifyTurnstileToken } from '$lib/server/turnstile'
12
+
13
+ const mockQuery = vi.mocked(query)
14
+ const mockSendPasswordResetEmail = vi.mocked(sendPasswordResetEmail)
15
+ const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
16
+
17
+ function makeEvent(body: Record<string, unknown> = {}) {
18
+ return {
19
+ request: {
20
+ json: vi.fn().mockResolvedValue({ turnstileToken: 'tok', email: 'user@example.com', ...body }),
21
+ headers: { get: vi.fn().mockReturnValue(null) }
22
+ },
23
+ getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
24
+ } as unknown as Parameters<typeof POST>[0]
25
+ }
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks()
29
+ mockVerifyTurnstileToken.mockResolvedValue(true)
30
+ })
31
+
32
+ describe('POST /auth/forgot', () => {
33
+ it('returns 204 when user email exists', async () => {
34
+ mockQuery.mockResolvedValue({ rows: [{ userId: 1 }], rowCount: 1 } as any)
35
+ mockSendPasswordResetEmail.mockResolvedValue(undefined)
36
+
37
+ const res = await POST(makeEvent())
38
+
39
+ expect(res.status).toBe(204)
40
+ })
41
+
42
+ it('returns 204 when email is not found (prevents user enumeration)', async () => {
43
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any)
44
+
45
+ const res = await POST(makeEvent())
46
+
47
+ expect(res.status).toBe(204)
48
+ })
49
+
50
+ it('sends a password reset email when user exists', async () => {
51
+ mockQuery.mockResolvedValue({ rows: [{ userId: 7 }], rowCount: 1 } as any)
52
+ mockSendPasswordResetEmail.mockResolvedValue(undefined)
53
+
54
+ await POST(makeEvent())
55
+
56
+ expect(mockSendPasswordResetEmail).toHaveBeenCalledOnce()
57
+ expect(mockSendPasswordResetEmail).toHaveBeenCalledWith('user@example.com', expect.any(String))
58
+ })
59
+
60
+ it('does not send an email when user is not found', async () => {
61
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any)
62
+
63
+ await POST(makeEvent())
64
+
65
+ expect(mockSendPasswordResetEmail).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('queries users table with the provided email', async () => {
69
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any)
70
+
71
+ await POST(makeEvent({ email: 'test@example.com' }))
72
+
73
+ expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('users'), ['test@example.com'])
74
+ })
75
+
76
+ it('throws 400 when Turnstile verification fails', async () => {
77
+ mockVerifyTurnstileToken.mockResolvedValue(false)
78
+
79
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
80
+ })
81
+
82
+ it('verifies the Turnstile token using the CF-Connecting-IP header when present', async () => {
83
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any)
84
+ const event = makeEvent()
85
+ vi.mocked(event.request.headers.get).mockReturnValue('1.2.3.4')
86
+
87
+ await POST(event)
88
+
89
+ expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '1.2.3.4')
90
+ })
91
+
92
+ it('falls back to getClientAddress when CF-Connecting-IP is absent', async () => {
93
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any)
94
+ const event = makeEvent()
95
+ vi.mocked(event.request.headers.get).mockReturnValue(null)
96
+
97
+ await POST(event)
98
+
99
+ expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '127.0.0.1')
100
+ })
101
+
102
+ it('still returns 204 when sendPasswordResetEmail throws', async () => {
103
+ mockQuery.mockResolvedValue({ rows: [{ userId: 3 }], rowCount: 1 } as any)
104
+ mockSendPasswordResetEmail.mockRejectedValue(new Error('SMTP failure'))
105
+
106
+ const res = await POST(makeEvent())
107
+
108
+ expect(res.status).toBe(204)
109
+ })
110
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$env/static/public', () => ({ PUBLIC_GOOGLE_CLIENT_ID: 'test-client-id' }))
4
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
5
+ vi.mock('google-auth-library', () => ({
6
+ OAuth2Client: vi.fn()
7
+ }))
8
+
9
+ import { POST } from './+server'
10
+ import { query } from '$lib/server/db'
11
+ import { OAuth2Client } from 'google-auth-library'
12
+
13
+ const mockQuery = vi.mocked(query)
14
+ const MockOAuth2Client = vi.mocked(OAuth2Client)
15
+
16
+ const mockUser: User = { id: 1, email: 'jane@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
17
+ const mockUserSession: UserSession = { id: 'session-abc', user: mockUser }
18
+
19
+ function makeVerifyIdToken(payload: Record<string, unknown> | null) {
20
+ return vi.fn().mockResolvedValue({ getPayload: () => payload })
21
+ }
22
+
23
+ function setupOAuth2Mock(verifyIdToken: ReturnType<typeof vi.fn>) {
24
+ MockOAuth2Client.mockImplementation(function () {
25
+ return { verifyIdToken }
26
+ } as unknown as new (clientId: string) => OAuth2Client)
27
+ }
28
+
29
+ function makeEvent(body: Record<string, unknown> = { token: 'google-jwt' }) {
30
+ return {
31
+ request: { json: vi.fn().mockResolvedValue(body) },
32
+ cookies: { set: vi.fn() },
33
+ locals: {} as App.Locals
34
+ } as unknown as Parameters<typeof POST>[0]
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks()
39
+ })
40
+
41
+ describe('POST /auth/google', () => {
42
+ it('returns 200 with user data on successful sign-in', async () => {
43
+ setupOAuth2Mock(makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' }))
44
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
45
+
46
+ const res = await POST(makeEvent())
47
+
48
+ expect(res.status).toBe(200)
49
+ const body = await res.json()
50
+ expect(body.message).toBe('Successful Google Sign-In.')
51
+ expect(body.user).toEqual(mockUser)
52
+ })
53
+
54
+ it('sets an httpOnly session cookie on success', async () => {
55
+ setupOAuth2Mock(makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' }))
56
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
57
+ const event = makeEvent()
58
+
59
+ await POST(event)
60
+
61
+ expect(event.cookies.set).toHaveBeenCalledWith('session', 'session-abc', expect.objectContaining({
62
+ httpOnly: true,
63
+ sameSite: 'lax',
64
+ secure: true,
65
+ path: '/'
66
+ }))
67
+ })
68
+
69
+ it('sets event.locals.user on success', async () => {
70
+ setupOAuth2Mock(makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' }))
71
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
72
+ const event = makeEvent()
73
+
74
+ await POST(event)
75
+
76
+ expect(event.locals.user).toEqual(mockUser)
77
+ })
78
+
79
+ it('uses PUBLIC_GOOGLE_CLIENT_ID when verifying the token', async () => {
80
+ const verifyIdToken = makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
81
+ setupOAuth2Mock(verifyIdToken)
82
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
83
+
84
+ await POST(makeEvent({ token: 'my-token' }))
85
+
86
+ expect(MockOAuth2Client).toHaveBeenCalledWith('test-client-id')
87
+ expect(verifyIdToken).toHaveBeenCalledWith({ idToken: 'my-token', audience: 'test-client-id' })
88
+ })
89
+
90
+ it('passes the Google user data to the DB upsert', async () => {
91
+ setupOAuth2Mock(makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' }))
92
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
93
+
94
+ await POST(makeEvent())
95
+
96
+ expect(mockQuery).toHaveBeenCalledWith(
97
+ expect.stringContaining('start_gmail_user_session'),
98
+ [JSON.stringify({ firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' })]
99
+ )
100
+ })
101
+
102
+ it('falls back to placeholder names when given_name/family_name are missing', async () => {
103
+ setupOAuth2Mock(makeVerifyIdToken({ email: 'noname@example.com' }))
104
+ mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
105
+
106
+ await POST(makeEvent())
107
+
108
+ expect(mockQuery).toHaveBeenCalledWith(
109
+ expect.any(String),
110
+ [JSON.stringify({ firstName: 'UnknownFirstName', lastName: 'UnknownLastName', email: 'noname@example.com' })]
111
+ )
112
+ })
113
+
114
+ it('throws 401 when the Google token is invalid', async () => {
115
+ setupOAuth2Mock(vi.fn().mockRejectedValue(new Error('Invalid token')))
116
+
117
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
118
+ })
119
+
120
+ it('throws 401 when the DB upsert fails', async () => {
121
+ setupOAuth2Mock(makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' }))
122
+ mockQuery.mockRejectedValue(new Error('db error'))
123
+
124
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
125
+ })
126
+
127
+ it('throws 401 when the Google payload is null', async () => {
128
+ setupOAuth2Mock(makeVerifyIdToken(null))
129
+
130
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
131
+ })
132
+ })
@@ -2,7 +2,7 @@ import { error, json } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
3
  import type { Secret } from 'jsonwebtoken'
4
4
  import jwt from 'jsonwebtoken'
5
- import { JWT_SECRET } from '$env/static/private'
5
+ import { env } from '$env/dynamic/private'
6
6
  import { query } from '$lib/server/db'
7
7
  import { sendMfaCodeEmail } from '$lib/server/email'
8
8
  import { verifyTurnstileToken } from '$lib/server/turnstile'
@@ -97,7 +97,7 @@ export const POST: RequestHandler = async event => {
97
97
  const trustedToken = cookies.get(MFA_TRUSTED_COOKIE)
98
98
  if (trustedToken) {
99
99
  try {
100
- const payload = jwt.verify(trustedToken, JWT_SECRET as Secret) as {
100
+ const payload = jwt.verify(trustedToken, env.JWT_SECRET as Secret) as {
101
101
  userId: number
102
102
  purpose: string
103
103
  }