sveltekit-auth-example 5.8.2 → 5.8.4

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 +9 -0
  2. package/README.md +12 -12
  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
@@ -0,0 +1,221 @@
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', () => ({ sendMfaCodeEmail: 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 { sendMfaCodeEmail } from '$lib/server/email'
11
+ import { verifyTurnstileToken } from '$lib/server/turnstile'
12
+ import jwt from 'jsonwebtoken'
13
+
14
+ const mockQuery = vi.mocked(query)
15
+ const mockSendMfaCodeEmail = vi.mocked(sendMfaCodeEmail)
16
+ const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
17
+
18
+ const mockUser: User = { id: 7, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
19
+
20
+ const successResult: AuthenticationResult = {
21
+ user: mockUser,
22
+ sessionId: 'sess-123',
23
+ status: 'Login successful.',
24
+ statusCode: 200
25
+ }
26
+
27
+ const failResult: AuthenticationResult = {
28
+ user: null,
29
+ sessionId: '',
30
+ status: 'Invalid credentials.',
31
+ statusCode: 401
32
+ }
33
+
34
+ function makeEvent({
35
+ body = { email: 'user@example.com', password: 'Password1!', turnstileToken: 'tok' } as Record<string, unknown>,
36
+ mfaTrustedCookie = undefined as string | undefined
37
+ } = {}) {
38
+ return {
39
+ request: {
40
+ json: vi.fn().mockResolvedValue(body),
41
+ headers: { get: vi.fn().mockReturnValue(null) }
42
+ },
43
+ cookies: {
44
+ get: vi.fn().mockReturnValue(mfaTrustedCookie),
45
+ set: vi.fn(),
46
+ delete: vi.fn()
47
+ },
48
+ locals: {} as App.Locals,
49
+ getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
50
+ } as unknown as Parameters<typeof POST>[0]
51
+ }
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks()
55
+ mockVerifyTurnstileToken.mockResolvedValue(true)
56
+ })
57
+
58
+ // ── MFA flow ─────────────────────────────────────────────────────────────────
59
+
60
+ describe('POST /auth/login — MFA flow', () => {
61
+ beforeEach(() => {
62
+ mockQuery
63
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any) // authenticate
64
+ .mockResolvedValueOnce({ rows: [] } as any) // delete_session
65
+ .mockResolvedValueOnce({ rows: [{ code: '123456' }] } as any) // create_mfa_code
66
+ })
67
+
68
+ it('returns { mfaRequired: true } when no trusted cookie is present', async () => {
69
+ const res = await POST(makeEvent())
70
+
71
+ expect(res.status).toBe(200)
72
+ const body = await res.json()
73
+ expect(body).toEqual({ mfaRequired: true })
74
+ })
75
+
76
+ it('emails the MFA code to the user', async () => {
77
+ await POST(makeEvent())
78
+
79
+ expect(mockSendMfaCodeEmail).toHaveBeenCalledWith('user@example.com', '123456')
80
+ })
81
+
82
+ it('deletes the pre-created session before sending the MFA code', async () => {
83
+ await POST(makeEvent())
84
+
85
+ expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_session'), [mockUser.id])
86
+ })
87
+
88
+ it('does not set a session cookie', async () => {
89
+ const event = makeEvent()
90
+ await POST(event)
91
+ expect(event.cookies.set).not.toHaveBeenCalled()
92
+ })
93
+ })
94
+
95
+ // ── MFA trusted device ───────────────────────────────────────────────────────
96
+
97
+ describe('POST /auth/login — MFA trusted device', () => {
98
+ it('skips MFA and returns { message, user } when trusted cookie is valid', async () => {
99
+ const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
100
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
101
+
102
+ const res = await POST(makeEvent({ mfaTrustedCookie: trustedToken }))
103
+
104
+ expect(res.status).toBe(200)
105
+ const body = await res.json()
106
+ expect(body.user).toEqual(mockUser)
107
+ expect(body.mfaRequired).toBeUndefined()
108
+ })
109
+
110
+ it('sets a session cookie when the trusted cookie is valid', async () => {
111
+ const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
112
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
113
+ const event = makeEvent({ mfaTrustedCookie: trustedToken })
114
+
115
+ await POST(event)
116
+
117
+ expect(event.cookies.set).toHaveBeenCalledWith('session', 'sess-123', expect.objectContaining({
118
+ httpOnly: true,
119
+ sameSite: 'lax',
120
+ secure: true,
121
+ path: '/'
122
+ }))
123
+ })
124
+
125
+ it('sets event.locals.user when the trusted cookie is valid', async () => {
126
+ const trustedToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret')
127
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
128
+ const event = makeEvent({ mfaTrustedCookie: trustedToken })
129
+
130
+ await POST(event)
131
+
132
+ expect(event.locals.user).toEqual(mockUser)
133
+ })
134
+
135
+ it('falls through to MFA when the trusted cookie has the wrong purpose', async () => {
136
+ const badToken = jwt.sign({ userId: mockUser.id, purpose: 'other' }, 'test-secret')
137
+ mockQuery
138
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
139
+ .mockResolvedValueOnce({ rows: [] } as any)
140
+ .mockResolvedValueOnce({ rows: [{ code: '654321' }] } as any)
141
+
142
+ const res = await POST(makeEvent({ mfaTrustedCookie: badToken }))
143
+
144
+ expect((await res.json()).mfaRequired).toBe(true)
145
+ })
146
+
147
+ it('falls through to MFA and deletes cookie when trusted token is expired', async () => {
148
+ const expiredToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret', { expiresIn: -1 })
149
+ mockQuery
150
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
151
+ .mockResolvedValueOnce({ rows: [] } as any)
152
+ .mockResolvedValueOnce({ rows: [{ code: '654321' }] } as any)
153
+ const event = makeEvent({ mfaTrustedCookie: expiredToken })
154
+
155
+ await POST(event)
156
+
157
+ expect(event.cookies.delete).toHaveBeenCalledWith('mfa_trusted', { path: '/' })
158
+ })
159
+ })
160
+
161
+ // ── Credential failures ───────────────────────────────────────────────────────
162
+
163
+ describe('POST /auth/login — credential failures', () => {
164
+ it('throws the DB status code on invalid credentials', async () => {
165
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: failResult }] } as any)
166
+
167
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
168
+ })
169
+
170
+ it('throws 503 when the database is unreachable', async () => {
171
+ mockQuery.mockRejectedValueOnce(new Error('db down'))
172
+
173
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 503 })
174
+ })
175
+
176
+ it('throws 400 when the request body is invalid JSON', async () => {
177
+ const event = makeEvent()
178
+ vi.mocked(event.request.json).mockRejectedValue(new SyntaxError('Unexpected token'))
179
+
180
+ await expect(POST(event)).rejects.toMatchObject({ status: 400 })
181
+ })
182
+
183
+ it('throws 400 when Turnstile verification fails', async () => {
184
+ mockVerifyTurnstileToken.mockResolvedValue(false)
185
+
186
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
187
+ })
188
+ })
189
+
190
+ // ── Brute-force lockout ───────────────────────────────────────────────────────
191
+
192
+ describe('POST /auth/login — brute-force lockout', () => {
193
+ it('throws 429 after 5 failed attempts', async () => {
194
+ mockQuery.mockResolvedValue({ rows: [{ authenticationResult: failResult }] } as any)
195
+
196
+ // 5 failures to trigger lockout
197
+ for (let i = 0; i < 5; i++) {
198
+ await POST(makeEvent({ body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' } })).catch(() => {})
199
+ }
200
+
201
+ await expect(
202
+ POST(makeEvent({ body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' } }))
203
+ ).rejects.toMatchObject({ status: 429 })
204
+ })
205
+
206
+ it('clears the lockout tracker on successful login', async () => {
207
+ // Register a failed attempt first
208
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: failResult }] } as any)
209
+ await POST(makeEvent({ body: { email: 'clear@example.com', password: 'wrong', turnstileToken: 'tok' } })).catch(() => {})
210
+
211
+ // Then succeed — mfa flow needs 3 query responses
212
+ mockQuery
213
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
214
+ .mockResolvedValueOnce({ rows: [] } as any)
215
+ .mockResolvedValueOnce({ rows: [{ code: '000000' }] } as any)
216
+
217
+ // Should not throw 429
218
+ const res = await POST(makeEvent({ body: { email: 'clear@example.com', password: 'Password1!', turnstileToken: 'tok' } }))
219
+ expect((await res.json()).mfaRequired).toBe(true)
220
+ })
221
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
4
+
5
+ import { POST } from './+server'
6
+ import { query } from '$lib/server/db'
7
+
8
+ const mockQuery = vi.mocked(query)
9
+
10
+ const mockUser: User = { id: 3, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
11
+
12
+ function makeEvent({ noUser = false } = {}) {
13
+ return {
14
+ locals: { user: noUser ? undefined : mockUser },
15
+ cookies: { delete: vi.fn() }
16
+ } as unknown as Parameters<typeof POST>[0]
17
+ }
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks()
21
+ })
22
+
23
+ describe('POST /auth/logout', () => {
24
+ it('returns 200 with a logout message', async () => {
25
+ mockQuery.mockResolvedValue({ rows: [] } as any)
26
+ const res = await POST(makeEvent())
27
+
28
+ expect(res.status).toBe(200)
29
+ const body = await res.json()
30
+ expect(body.message).toBe('Logout successful.')
31
+ })
32
+
33
+ it('deletes the session cookie', async () => {
34
+ mockQuery.mockResolvedValue({ rows: [] } as any)
35
+ const event = makeEvent()
36
+
37
+ await POST(event)
38
+
39
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
40
+ })
41
+
42
+ it('calls delete_session with the user id when authenticated', async () => {
43
+ mockQuery.mockResolvedValue({ rows: [] } as any)
44
+
45
+ await POST(makeEvent())
46
+
47
+ expect(mockQuery).toHaveBeenCalledWith(
48
+ expect.stringContaining('delete_session'),
49
+ [mockUser.id]
50
+ )
51
+ })
52
+
53
+ it('skips the DB call when no user is authenticated', async () => {
54
+ const event = makeEvent({ noUser: true })
55
+
56
+ await POST(event)
57
+
58
+ expect(mockQuery).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('still deletes the cookie when no user is authenticated', async () => {
62
+ const event = makeEvent({ noUser: true })
63
+
64
+ await POST(event)
65
+
66
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
67
+ })
68
+
69
+ it('still deletes the cookie when the DB call fails', async () => {
70
+ mockQuery.mockRejectedValue(new Error('db down'))
71
+ const event = makeEvent()
72
+
73
+ await POST(event)
74
+
75
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
76
+ })
77
+
78
+ it('still returns 200 when the DB call fails', async () => {
79
+ mockQuery.mockRejectedValue(new Error('db down'))
80
+
81
+ const res = await POST(makeEvent())
82
+
83
+ expect(res.status).toBe(200)
84
+ })
85
+ })
@@ -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 { verifyTurnstileToken } from '$lib/server/turnstile'
8
8
 
@@ -65,7 +65,7 @@ export const POST: RequestHandler = async event => {
65
65
  })
66
66
 
67
67
  // Issue a 30-day trusted-device cookie so MFA is not required again on this device
68
- const trustedToken = jwt.sign({ userId, purpose: 'mfa-trusted' }, JWT_SECRET as Secret, {
68
+ const trustedToken = jwt.sign({ userId, purpose: 'mfa-trusted' }, env.JWT_SECRET as Secret, {
69
69
  expiresIn: '30d'
70
70
  })
71
71
  cookies.set(MFA_TRUSTED_COOKIE, trustedToken, {
@@ -0,0 +1,153 @@
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/turnstile', () => ({ verifyTurnstileToken: vi.fn() }))
6
+
7
+ import { POST } from './+server'
8
+ import { query } from '$lib/server/db'
9
+ import { verifyTurnstileToken } from '$lib/server/turnstile'
10
+ import jwt from 'jsonwebtoken'
11
+
12
+ const mockQuery = vi.mocked(query)
13
+ const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
14
+
15
+ const mockUser: User = { id: 5, email: 'user@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
16
+
17
+ function makeEvent(body: Record<string, unknown> = { email: 'user@example.com', code: '123456', turnstileToken: 'tok' }) {
18
+ return {
19
+ request: {
20
+ json: vi.fn().mockResolvedValue(body),
21
+ headers: { get: vi.fn().mockReturnValue(null) }
22
+ },
23
+ cookies: { set: vi.fn() },
24
+ getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
25
+ } as unknown as Parameters<typeof POST>[0]
26
+ }
27
+
28
+ /** Set up the three DB calls needed for a successful MFA verification. */
29
+ function setupSuccessQueries() {
30
+ mockQuery
31
+ .mockResolvedValueOnce({ rows: [{ userId: mockUser.id }] } as any) // verify_mfa_code
32
+ .mockResolvedValueOnce({ rows: [{ sessionId: 'sess-xyz' }] } as any) // create_session
33
+ .mockResolvedValueOnce({ rows: [{ get_session: mockUser }] } as any) // get_session
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ mockVerifyTurnstileToken.mockResolvedValue(true)
39
+ })
40
+
41
+ describe('POST /auth/mfa', () => {
42
+ it('returns 200 with message and user on success', async () => {
43
+ setupSuccessQueries()
44
+
45
+ const res = await POST(makeEvent())
46
+
47
+ expect(res.status).toBe(200)
48
+ const body = await res.json()
49
+ expect(body.message).toBe('Login successful.')
50
+ expect(body.user).toEqual(mockUser)
51
+ })
52
+
53
+ it('sets an httpOnly session cookie on success', async () => {
54
+ setupSuccessQueries()
55
+ const event = makeEvent()
56
+
57
+ await POST(event)
58
+
59
+ expect(event.cookies.set).toHaveBeenCalledWith('session', 'sess-xyz', expect.objectContaining({
60
+ httpOnly: true,
61
+ sameSite: 'lax',
62
+ secure: true,
63
+ path: '/'
64
+ }))
65
+ })
66
+
67
+ it('sets an mfa_trusted cookie on success', async () => {
68
+ setupSuccessQueries()
69
+ const event = makeEvent()
70
+
71
+ await POST(event)
72
+
73
+ expect(event.cookies.set).toHaveBeenCalledWith('mfa_trusted', expect.any(String), expect.objectContaining({
74
+ httpOnly: true,
75
+ sameSite: 'lax',
76
+ secure: true,
77
+ path: '/',
78
+ maxAge: 30 * 24 * 60 * 60
79
+ }))
80
+ })
81
+
82
+ it('mfa_trusted cookie is a valid JWT with correct payload', async () => {
83
+ setupSuccessQueries()
84
+ const event = makeEvent()
85
+
86
+ await POST(event)
87
+
88
+ const [, trustedToken] = vi.mocked(event.cookies.set).mock.calls.find(([name]) => name === 'mfa_trusted')!
89
+ const payload = jwt.verify(trustedToken as string, 'test-secret') as Record<string, unknown>
90
+ expect(payload.userId).toBe(mockUser.id)
91
+ expect(payload.purpose).toBe('mfa-trusted')
92
+ })
93
+
94
+ it('verifies the MFA code with the lowercased email', async () => {
95
+ setupSuccessQueries()
96
+
97
+ await POST(makeEvent({ email: 'User@Example.COM', code: '123456', turnstileToken: 'tok' }))
98
+
99
+ expect(mockQuery).toHaveBeenCalledWith(
100
+ expect.stringContaining('verify_mfa_code'),
101
+ ['user@example.com', '123456']
102
+ )
103
+ })
104
+
105
+ it('throws 401 when the MFA code is invalid or expired', async () => {
106
+ mockQuery.mockResolvedValueOnce({ rows: [{ userId: null }] } as any)
107
+
108
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
109
+ })
110
+
111
+ it('throws 400 when email is missing', async () => {
112
+ await expect(
113
+ POST(makeEvent({ code: '123456', turnstileToken: 'tok' }))
114
+ ).rejects.toMatchObject({ status: 400 })
115
+ })
116
+
117
+ it('throws 400 when code is missing', async () => {
118
+ await expect(
119
+ POST(makeEvent({ email: 'user@example.com', turnstileToken: 'tok' }))
120
+ ).rejects.toMatchObject({ status: 400 })
121
+ })
122
+
123
+ it('throws 400 when the request body is invalid JSON', async () => {
124
+ const event = makeEvent()
125
+ vi.mocked(event.request.json).mockRejectedValue(new SyntaxError('Unexpected token'))
126
+
127
+ await expect(POST(event)).rejects.toMatchObject({ status: 400 })
128
+ })
129
+
130
+ it('throws 400 when Turnstile verification fails', async () => {
131
+ mockVerifyTurnstileToken.mockResolvedValue(false)
132
+
133
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
134
+ })
135
+
136
+ it('uses CF-Connecting-IP header when present', async () => {
137
+ setupSuccessQueries()
138
+ const event = makeEvent()
139
+ vi.mocked(event.request.headers.get).mockReturnValue('9.9.9.9')
140
+
141
+ await POST(event)
142
+
143
+ expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '9.9.9.9')
144
+ })
145
+
146
+ it('falls back to getClientAddress when CF-Connecting-IP is absent', async () => {
147
+ setupSuccessQueries()
148
+
149
+ await POST(makeEvent())
150
+
151
+ expect(mockVerifyTurnstileToken).toHaveBeenCalledWith('tok', '127.0.0.1')
152
+ })
153
+ })
@@ -1,7 +1,7 @@
1
1
  import { error, json } from '@sveltejs/kit'
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 { sendVerificationEmail } from '$lib/server/email'
7
7
  import { verifyTurnstileToken } from '$lib/server/turnstile'
@@ -24,7 +24,13 @@ import { verifyTurnstileToken } from '$lib/server/turnstile'
24
24
  * @throws The status code from the registration result on other failures (e.g. duplicate email).
25
25
  */
26
26
  export const POST: RequestHandler = async event => {
27
- let body: { email?: string; password?: string; firstName?: string; lastName?: string; turnstileToken?: string }
27
+ let body: {
28
+ email?: string
29
+ password?: string
30
+ firstName?: string
31
+ lastName?: string
32
+ turnstileToken?: string
33
+ }
28
34
  try {
29
35
  body = await event.request.json()
30
36
  } catch {
@@ -67,7 +73,7 @@ export const POST: RequestHandler = async event => {
67
73
 
68
74
  const token = jwt.sign(
69
75
  { subject: authenticationResult.user.id, purpose: 'verify-email' },
70
- JWT_SECRET as jwt.Secret,
76
+ env.JWT_SECRET as jwt.Secret,
71
77
  { expiresIn: '24h' }
72
78
  )
73
79
 
@@ -0,0 +1,182 @@
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', () => ({ sendVerificationEmail: 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 { sendVerificationEmail } from '$lib/server/email'
11
+ import { verifyTurnstileToken } from '$lib/server/turnstile'
12
+ import jwt from 'jsonwebtoken'
13
+
14
+ const mockQuery = vi.mocked(query)
15
+ const mockSendVerificationEmail = vi.mocked(sendVerificationEmail)
16
+ const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
17
+
18
+ const mockUser: User = { id: 9, email: 'new@example.com', firstName: 'Jane', lastName: 'Doe', role: 'user' }
19
+
20
+ const successResult: AuthenticationResult = {
21
+ user: mockUser,
22
+ sessionId: 'pre-sess-abc',
23
+ status: 'Registration successful.',
24
+ statusCode: 200
25
+ }
26
+
27
+ const validBody = {
28
+ email: 'new@example.com',
29
+ password: 'Password1!',
30
+ firstName: 'Jane',
31
+ lastName: 'Doe',
32
+ turnstileToken: 'tok'
33
+ }
34
+
35
+ function makeEvent(body: Record<string, unknown> = validBody) {
36
+ return {
37
+ request: {
38
+ json: vi.fn().mockResolvedValue(body),
39
+ headers: { get: vi.fn().mockReturnValue(null) }
40
+ },
41
+ getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
42
+ } as unknown as Parameters<typeof POST>[0]
43
+ }
44
+
45
+ /** Set up the two DB calls for a happy-path registration. */
46
+ function setupSuccessQueries() {
47
+ mockQuery
48
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any) // register
49
+ .mockResolvedValueOnce({ rows: [] } as any) // delete_session
50
+ }
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+ mockVerifyTurnstileToken.mockResolvedValue(true)
55
+ mockSendVerificationEmail.mockResolvedValue(undefined)
56
+ })
57
+
58
+ describe('POST /auth/register', () => {
59
+ it('returns 200 with success message and emailVerification flag', async () => {
60
+ setupSuccessQueries()
61
+
62
+ const res = await POST(makeEvent())
63
+
64
+ expect(res.status).toBe(200)
65
+ const body = await res.json()
66
+ expect(body.emailVerification).toBe(true)
67
+ expect(body.message).toContain('Registration successful')
68
+ })
69
+
70
+ it('sends a verification email to the registered address', async () => {
71
+ setupSuccessQueries()
72
+
73
+ await POST(makeEvent())
74
+
75
+ expect(mockSendVerificationEmail).toHaveBeenCalledOnce()
76
+ expect(mockSendVerificationEmail).toHaveBeenCalledWith('new@example.com', expect.any(String))
77
+ })
78
+
79
+ it('sends a JWT with purpose verify-email and the user id as subject', async () => {
80
+ setupSuccessQueries()
81
+
82
+ await POST(makeEvent())
83
+
84
+ const [, token] = mockSendVerificationEmail.mock.calls[0]
85
+ const payload = jwt.verify(token, 'test-secret') as Record<string, unknown>
86
+ expect(payload.purpose).toBe('verify-email')
87
+ expect(payload.subject).toBe(mockUser.id)
88
+ })
89
+
90
+ it('deletes the pre-verification session after successful registration', async () => {
91
+ setupSuccessQueries()
92
+
93
+ await POST(makeEvent())
94
+
95
+ expect(mockQuery).toHaveBeenCalledWith(
96
+ expect.stringContaining('delete_session'),
97
+ [successResult.sessionId]
98
+ )
99
+ })
100
+
101
+ it('calls the register SQL function with the full body', async () => {
102
+ setupSuccessQueries()
103
+
104
+ await POST(makeEvent())
105
+
106
+ expect(mockQuery).toHaveBeenCalledWith(
107
+ expect.stringContaining('register'),
108
+ [JSON.stringify(validBody)]
109
+ )
110
+ })
111
+
112
+ it('still sends the verification email when delete_session fails', async () => {
113
+ mockQuery
114
+ .mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
115
+ .mockRejectedValueOnce(new Error('db down'))
116
+
117
+ const res = await POST(makeEvent())
118
+
119
+ expect(mockSendVerificationEmail).toHaveBeenCalledOnce()
120
+ expect(res.status).toBe(200)
121
+ })
122
+ })
123
+
124
+ describe('POST /auth/register — validation', () => {
125
+ it('throws 400 when email is missing', async () => {
126
+ const { email: _, ...noEmail } = validBody
127
+ await expect(POST(makeEvent(noEmail))).rejects.toMatchObject({ status: 400 })
128
+ })
129
+
130
+ it('throws 400 when password is missing', async () => {
131
+ const { password: _, ...noPassword } = validBody
132
+ await expect(POST(makeEvent(noPassword))).rejects.toMatchObject({ status: 400 })
133
+ })
134
+
135
+ it('throws 400 when firstName is missing', async () => {
136
+ const { firstName: _, ...noFirst } = validBody
137
+ await expect(POST(makeEvent(noFirst))).rejects.toMatchObject({ status: 400 })
138
+ })
139
+
140
+ it('throws 400 when lastName is missing', async () => {
141
+ const { lastName: _, ...noLast } = validBody
142
+ await expect(POST(makeEvent(noLast))).rejects.toMatchObject({ status: 400 })
143
+ })
144
+
145
+ it('throws 400 when password has no uppercase letter', async () => {
146
+ await expect(POST(makeEvent({ ...validBody, password: 'password1!' }))).rejects.toMatchObject({ status: 400 })
147
+ })
148
+
149
+ it('throws 400 when password has no number', async () => {
150
+ await expect(POST(makeEvent({ ...validBody, password: 'Password!' }))).rejects.toMatchObject({ status: 400 })
151
+ })
152
+
153
+ it('throws 400 when password has no special character', async () => {
154
+ await expect(POST(makeEvent({ ...validBody, password: 'Password1' }))).rejects.toMatchObject({ status: 400 })
155
+ })
156
+
157
+ it('throws 400 when password is too short', async () => {
158
+ await expect(POST(makeEvent({ ...validBody, password: 'P1!' }))).rejects.toMatchObject({ status: 400 })
159
+ })
160
+
161
+ it('throws 400 when Turnstile verification fails', async () => {
162
+ mockVerifyTurnstileToken.mockResolvedValue(false)
163
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 400 })
164
+ })
165
+
166
+ it('throws 400 when the request body is invalid JSON', async () => {
167
+ const event = makeEvent()
168
+ vi.mocked(event.request.json).mockRejectedValue(new SyntaxError('Unexpected token'))
169
+ await expect(POST(event)).rejects.toMatchObject({ status: 400 })
170
+ })
171
+
172
+ it('throws 503 when the database is unreachable', async () => {
173
+ mockQuery.mockRejectedValueOnce(new Error('db down'))
174
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 503 })
175
+ })
176
+
177
+ it('throws the DB status code on registration failure (e.g. duplicate email)', async () => {
178
+ const dupResult: AuthenticationResult = { user: null, sessionId: '', status: 'Email already registered.', statusCode: 409 }
179
+ mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: dupResult }] } as any)
180
+ await expect(POST(makeEvent())).rejects.toMatchObject({ status: 409 })
181
+ })
182
+ })