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.
- package/CHANGELOG.md +9 -0
- package/README.md +12 -12
- package/db_create.sql +0 -1
- package/package.json +3 -1
- package/src/hooks.server.unit.test.ts +163 -0
- package/src/lib/app-state.svelte.unit.test.ts +73 -0
- package/src/lib/auth-redirect.unit.test.ts +92 -0
- package/src/lib/fetch-interceptor.unit.test.ts +99 -0
- package/src/lib/focus.unit.test.ts +91 -0
- package/src/lib/google.unit.test.ts +189 -0
- package/src/lib/server/brevo.ts +3 -3
- package/src/lib/server/brevo.unit.test.ts +186 -0
- package/src/lib/server/db.unit.test.ts +91 -0
- package/src/lib/server/email/mfa-code.ts +2 -2
- package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
- package/src/lib/server/email/password-reset.ts +3 -3
- package/src/lib/server/email/password-reset.unit.test.ts +70 -0
- package/src/lib/server/email/verify-email.ts +3 -3
- package/src/lib/server/email/verify-email.unit.test.ts +79 -0
- package/src/lib/server/turnstile.ts +2 -2
- package/src/lib/server/turnstile.unit.test.ts +111 -0
- package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
- package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
- package/src/routes/auth/forgot/+server.ts +2 -2
- package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
- package/src/routes/auth/google/+server.unit.test.ts +132 -0
- package/src/routes/auth/login/+server.ts +2 -2
- package/src/routes/auth/login/+server.unit.test.ts +221 -0
- package/src/routes/auth/logout/+server.unit.test.ts +85 -0
- package/src/routes/auth/mfa/+server.ts +2 -2
- package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
- package/src/routes/auth/register/+server.ts +9 -3
- package/src/routes/auth/register/+server.unit.test.ts +182 -0
- package/src/routes/auth/reset/+server.ts +5 -4
- package/src/routes/auth/reset/+server.unit.test.ts +139 -0
- package/src/routes/auth/verify/[token]/+server.ts +2 -2
- package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
- package/src/routes/login/+page.svelte +5 -1
- package/src/service-worker.unit.test.ts +228 -0
- 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 {
|
|
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 {
|
|
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: {
|
|
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
|
+
})
|