sveltekit-auth-example 5.8.3 → 5.8.5
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/.prettierignore +1 -0
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/eslint.config.mjs +19 -1
- package/package.json +2 -1
- package/src/app.d.ts +0 -2
- package/src/hooks.server.unit.test.ts +1 -2
- package/src/lib/Turnstile.svelte +1 -0
- package/src/lib/app-state.svelte.unit.test.ts +14 -4
- package/src/lib/fetch-interceptor.unit.test.ts +8 -1
- package/src/lib/focus.ts +1 -1
- package/src/lib/google.unit.test.ts +24 -16
- package/src/lib/server/brevo.unit.test.ts +4 -1
- package/src/lib/server/db.ts +2 -4
- package/src/lib/server/db.unit.test.ts +4 -1
- package/src/lib/server/email/password-reset.unit.test.ts +3 -1
- package/src/lib/server/email/verify-email.unit.test.ts +3 -1
- package/src/lib/server/turnstile.unit.test.ts +1 -4
- package/src/routes/api/v1/user/+server.ts +1 -1
- package/src/routes/api/v1/user/{+server.unit.test.ts → server.unit.test.ts} +7 -13
- package/src/routes/auth/forgot/{+server.unit.test.ts → server.unit.test.ts} +3 -1
- package/src/routes/auth/google/{+server.unit.test.ts → server.unit.test.ts} +48 -22
- package/src/routes/auth/login/+server.ts +1 -1
- package/src/routes/auth/login/{+server.unit.test.ts → server.unit.test.ts} +50 -15
- package/src/routes/auth/logout/{+server.unit.test.ts → server.unit.test.ts} +8 -5
- package/src/routes/auth/mfa/{+server.unit.test.ts → server.unit.test.ts} +48 -26
- package/src/routes/auth/register/+server.ts +1 -1
- package/src/routes/auth/register/{+server.unit.test.ts → server.unit.test.ts} +32 -15
- package/src/routes/auth/reset/+server.ts +2 -2
- package/src/routes/auth/reset/{+server.unit.test.ts → server.unit.test.ts} +12 -13
- package/src/routes/auth/verify/[token]/{+server.unit.test.ts → server.unit.test.ts} +20 -12
- package/src/routes/register/+page.svelte +1 -1
- package/src/service-worker.unit.test.ts +6 -4
- /package/src/routes/auth/[slug]/{+server.unit.test.ts → server.unit.test.ts} +0 -0
package/.prettierignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
- Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
|
|
4
4
|
|
|
5
|
+
# 5.8.5
|
|
6
|
+
|
|
7
|
+
- Fix lint errors
|
|
8
|
+
|
|
9
|
+
# 5.8.4
|
|
10
|
+
|
|
11
|
+
- Fix README: replace outdated SendGrid references with Brevo; update env var names (`BREVO_KEY`, `EMAIL`) and source file paths
|
|
12
|
+
|
|
5
13
|
# 5.8.3
|
|
6
14
|
|
|
7
15
|
- Add unit tests for all auth route handlers: `/auth/[slug]`, `/auth/forgot`, `/auth/google`, `/auth/login`, `/auth/logout`, `/auth/mfa`, `/auth/register`, `/auth/reset`, `/auth/verify/[token]`
|
package/README.md
CHANGED
|
@@ -118,4 +118,4 @@ The website supports two types of authentication:
|
|
|
118
118
|
|
|
119
119
|
> There is some overhead to checking the user session in a database each time versus using a JWT; however, validating each request avoids problems discussed in [this article](https://redis.com/blog/json-web-tokens-jwt-are-dangerous-for-user-sessions/). For a high-volume website, I would use Redis or the equivalent.
|
|
120
120
|
|
|
121
|
-
The forgot password / password reset functionality uses a JWT and [**
|
|
121
|
+
The forgot password / password reset functionality uses a JWT and [**Brevo**](https://brevo.com) to send the email. You would need to have a **Brevo** account and set the `BREVO_KEY` and `EMAIL` environment variables (see setup instructions above). Email sending is in `src/lib/server/brevo.ts` and the email templates are in `src/lib/server/email/`. This code could easily be replaced by nodemailer or something similar.
|
package/eslint.config.mjs
CHANGED
|
@@ -27,7 +27,18 @@ export default defineConfig(
|
|
|
27
27
|
rules: {
|
|
28
28
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
|
29
29
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
|
30
|
-
'no-undef': 'off'
|
|
30
|
+
'no-undef': 'off',
|
|
31
|
+
// Standard SvelteKit patterns (goto(), hrefs) don't need resolve()
|
|
32
|
+
'svelte/no-navigation-without-resolve': 'off',
|
|
33
|
+
// Allow underscore-prefixed names as conventional "discard" variables
|
|
34
|
+
'@typescript-eslint/no-unused-vars': [
|
|
35
|
+
'error',
|
|
36
|
+
{
|
|
37
|
+
argsIgnorePattern: '^_',
|
|
38
|
+
varsIgnorePattern: '^_',
|
|
39
|
+
caughtErrorsIgnorePattern: '^_'
|
|
40
|
+
}
|
|
41
|
+
]
|
|
31
42
|
}
|
|
32
43
|
},
|
|
33
44
|
{
|
|
@@ -44,5 +55,12 @@ export default defineConfig(
|
|
|
44
55
|
'svelte/no-at-html-tags': 'warn',
|
|
45
56
|
'svelte/require-each-key': 'warn'
|
|
46
57
|
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
// Test files routinely need `as any` to mock typed return values
|
|
61
|
+
files: ['**/*.unit.test.ts'],
|
|
62
|
+
rules: {
|
|
63
|
+
'@typescript-eslint/no-explicit-any': 'off'
|
|
64
|
+
}
|
|
47
65
|
}
|
|
48
66
|
)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-auth-example",
|
|
3
3
|
"description": "SvelteKit Authentication Example",
|
|
4
|
-
"version": "5.8.
|
|
4
|
+
"version": "5.8.5",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"pg": "^8.20.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
+
"@eslint/compat": "^1.0.3",
|
|
47
48
|
"@eslint/js": "^10.0.1",
|
|
48
49
|
"@playwright/test": "^1.58.2",
|
|
49
50
|
"@sveltejs/adapter-node": "^5.5.4",
|
package/src/app.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { error } from '@sveltejs/kit'
|
|
3
2
|
|
|
4
3
|
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
5
4
|
|
|
@@ -28,7 +27,7 @@ function makeEvent({
|
|
|
28
27
|
} as unknown as Parameters<typeof handle>[0]['event']
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
const resolve = vi.fn(async (
|
|
30
|
+
const resolve = vi.fn(async (_event: unknown) => new Response('ok', { status: 200 }))
|
|
32
31
|
|
|
33
32
|
beforeEach(() => {
|
|
34
33
|
vi.resetAllMocks()
|
package/src/lib/Turnstile.svelte
CHANGED
|
@@ -31,8 +31,7 @@ describe('appState', () => {
|
|
|
31
31
|
email: 'admin@example.com',
|
|
32
32
|
firstName: 'Jane',
|
|
33
33
|
lastName: 'Doe',
|
|
34
|
-
phone: '412-555-1212'
|
|
35
|
-
optOut: false
|
|
34
|
+
phone: '412-555-1212'
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
appState.user = user
|
|
@@ -41,7 +40,14 @@ describe('appState', () => {
|
|
|
41
40
|
})
|
|
42
41
|
|
|
43
42
|
it('can be cleared back to undefined', () => {
|
|
44
|
-
appState.user = {
|
|
43
|
+
appState.user = {
|
|
44
|
+
id: 1,
|
|
45
|
+
role: 'student',
|
|
46
|
+
email: 'a@b.com',
|
|
47
|
+
firstName: 'A',
|
|
48
|
+
lastName: 'B',
|
|
49
|
+
phone: ''
|
|
50
|
+
}
|
|
45
51
|
appState.user = undefined
|
|
46
52
|
expect(appState.user).toBeUndefined()
|
|
47
53
|
})
|
|
@@ -51,7 +57,11 @@ describe('appState', () => {
|
|
|
51
57
|
it('can be updated to show a notification', () => {
|
|
52
58
|
appState.toast = { title: 'Success', body: 'Your changes were saved.', isOpen: true }
|
|
53
59
|
|
|
54
|
-
expect(appState.toast).toEqual({
|
|
60
|
+
expect(appState.toast).toEqual({
|
|
61
|
+
title: 'Success',
|
|
62
|
+
body: 'Your changes were saved.',
|
|
63
|
+
isOpen: true
|
|
64
|
+
})
|
|
55
65
|
})
|
|
56
66
|
|
|
57
67
|
it('isOpen can be toggled independently', () => {
|
|
@@ -24,7 +24,14 @@ describe('setupFetchInterceptor', () => {
|
|
|
24
24
|
// Save and restore window.fetch around each test
|
|
25
25
|
originalFetch = window.fetch
|
|
26
26
|
mockGoto.mockReset()
|
|
27
|
-
appState.user = {
|
|
27
|
+
appState.user = {
|
|
28
|
+
id: 1,
|
|
29
|
+
role: 'admin',
|
|
30
|
+
email: 'a@b.com',
|
|
31
|
+
firstName: 'A',
|
|
32
|
+
lastName: 'B',
|
|
33
|
+
phone: ''
|
|
34
|
+
}
|
|
28
35
|
setupFetchInterceptor()
|
|
29
36
|
})
|
|
30
37
|
|
package/src/lib/focus.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @param form - The form element to search for invalid inputs.
|
|
8
8
|
*/
|
|
9
9
|
export const focusOnFirstError = (form: HTMLFormElement) => {
|
|
10
|
-
for (const field of form.elements) {
|
|
10
|
+
for (const field of Array.from(form.elements)) {
|
|
11
11
|
if (field instanceof HTMLInputElement && !field.checkValidity()) {
|
|
12
12
|
field.focus()
|
|
13
13
|
break
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
3
|
|
|
4
4
|
vi.mock('$env/static/public', () => ({ PUBLIC_GOOGLE_CLIENT_ID: 'test-client-id' }))
|
|
5
|
-
vi.mock('$lib/app-state.svelte', () => ({
|
|
5
|
+
vi.mock('$lib/app-state.svelte', () => ({
|
|
6
|
+
appState: { googleInitialized: false, user: undefined }
|
|
7
|
+
}))
|
|
6
8
|
vi.mock('$lib/auth-redirect', () => ({ redirectAfterLogin: vi.fn() }))
|
|
7
9
|
|
|
8
10
|
import { renderGoogleButton, initializeGoogleAccounts } from './google'
|
|
@@ -47,11 +49,14 @@ describe('renderGoogleButton', () => {
|
|
|
47
49
|
renderGoogleButton()
|
|
48
50
|
|
|
49
51
|
expect(mock.renderButton).toHaveBeenCalledOnce()
|
|
50
|
-
expect(mock.renderButton).toHaveBeenCalledWith(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
expect(mock.renderButton).toHaveBeenCalledWith(
|
|
53
|
+
btn,
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
type: 'standard',
|
|
56
|
+
theme: 'outline',
|
|
57
|
+
size: 'large'
|
|
58
|
+
})
|
|
59
|
+
)
|
|
55
60
|
})
|
|
56
61
|
|
|
57
62
|
it('falls back to 400 when the button has no width', () => {
|
|
@@ -118,9 +123,11 @@ describe('initializeGoogleAccounts', () => {
|
|
|
118
123
|
initializeGoogleAccounts()
|
|
119
124
|
|
|
120
125
|
expect(mock.initialize).toHaveBeenCalledOnce()
|
|
121
|
-
expect(mock.initialize).toHaveBeenCalledWith(
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
expect(mock.initialize).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
client_id: 'test-client-id'
|
|
129
|
+
})
|
|
130
|
+
)
|
|
124
131
|
})
|
|
125
132
|
|
|
126
133
|
it('sets appState.googleInitialized to true', () => {
|
|
@@ -153,19 +160,20 @@ describe('initializeGoogleAccounts', () => {
|
|
|
153
160
|
const { callback } = mock.initialize.mock.calls[0][0]
|
|
154
161
|
await callback({ credential: 'test-credential' })
|
|
155
162
|
|
|
156
|
-
expect(fetch).toHaveBeenCalledWith(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
164
|
+
'/auth/google',
|
|
165
|
+
expect.objectContaining({
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify({ token: 'test-credential' })
|
|
168
|
+
})
|
|
169
|
+
)
|
|
160
170
|
expect(appState.user).toEqual(user)
|
|
161
171
|
expect(mockRedirectAfterLogin).toHaveBeenCalledWith(user)
|
|
162
172
|
})
|
|
163
173
|
|
|
164
174
|
it('callback does not update state when the response is not ok', async () => {
|
|
165
175
|
const mock = installGoogleMock()
|
|
166
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
167
|
-
new Response(null, { status: 401 })
|
|
168
|
-
)
|
|
176
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 401 }))
|
|
169
177
|
|
|
170
178
|
initializeGoogleAccounts()
|
|
171
179
|
|
|
@@ -32,6 +32,7 @@ describe('sendMessage', () => {
|
|
|
32
32
|
it('throws if BREVO_KEY is missing', async () => {
|
|
33
33
|
vi.doMock('$env/dynamic/private', () => ({ env: {} }))
|
|
34
34
|
// Re-import to pick up the new mock
|
|
35
|
+
// @ts-expect-error Vite query string not recognized by tsc
|
|
35
36
|
const { sendMessage: sm } = await import('./brevo?nocache-1')
|
|
36
37
|
await expect(sm(validMessage)).rejects.toThrow('Brevo API key is missing')
|
|
37
38
|
})
|
|
@@ -160,7 +161,9 @@ describe('sendMessage', () => {
|
|
|
160
161
|
vi.useFakeTimers()
|
|
161
162
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Network failure'))
|
|
162
163
|
|
|
163
|
-
const rejection = expect(sendMessage(validMessage)).rejects.toThrow(
|
|
164
|
+
const rejection = expect(sendMessage(validMessage)).rejects.toThrow(
|
|
165
|
+
'Failed to send email after retries'
|
|
166
|
+
)
|
|
164
167
|
await vi.runAllTimersAsync()
|
|
165
168
|
await rejection
|
|
166
169
|
vi.useRealTimers()
|
package/src/lib/server/db.ts
CHANGED
|
@@ -16,8 +16,6 @@ type QueryFunction = <T extends QueryResultRow>(
|
|
|
16
16
|
name?: string
|
|
17
17
|
) => Promise<QueryResult<T>>
|
|
18
18
|
|
|
19
|
-
let queryFn: QueryFunction
|
|
20
|
-
|
|
21
19
|
const pool = new pg.Pool({
|
|
22
20
|
max: 10, // default
|
|
23
21
|
idleTimeoutMillis: 10000,
|
|
@@ -39,7 +37,7 @@ pool.on('error', (err: Error) => {
|
|
|
39
37
|
* @param name - Optional name for the query to use a prepared statement.
|
|
40
38
|
* @returns A promise resolving to the typed QueryResult.
|
|
41
39
|
*/
|
|
42
|
-
queryFn = <T extends QueryResultRow>(
|
|
40
|
+
const queryFn: QueryFunction = <T extends QueryResultRow>(
|
|
43
41
|
sql: string,
|
|
44
42
|
params?: (string | number | boolean | object | null)[],
|
|
45
43
|
name?: string
|
|
@@ -71,7 +69,7 @@ queryFn = <T extends QueryResultRow>(
|
|
|
71
69
|
* );
|
|
72
70
|
* ```
|
|
73
71
|
*/
|
|
74
|
-
export const query = <T extends QueryResultRow =
|
|
72
|
+
export const query = <T extends QueryResultRow = QueryResultRow>(
|
|
75
73
|
sql: string,
|
|
76
74
|
params?: (string | number | boolean | object | null)[],
|
|
77
75
|
name?: string
|
|
@@ -38,7 +38,10 @@ describe('query', () => {
|
|
|
38
38
|
await query('SELECT $1::text AS val', ['hello'])
|
|
39
39
|
|
|
40
40
|
expect(mockPoolQuery).toHaveBeenCalledOnce()
|
|
41
|
-
expect(mockPoolQuery).toHaveBeenCalledWith({
|
|
41
|
+
expect(mockPoolQuery).toHaveBeenCalledWith({
|
|
42
|
+
text: 'SELECT $1::text AS val',
|
|
43
|
+
values: ['hello']
|
|
44
|
+
})
|
|
42
45
|
})
|
|
43
46
|
|
|
44
47
|
it('passes SQL without params to pool.query', async () => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
2
|
|
|
3
|
-
vi.mock('$env/dynamic/private', () => ({
|
|
3
|
+
vi.mock('$env/dynamic/private', () => ({
|
|
4
|
+
env: { EMAIL: 'no-reply@example.com', DOMAIN: 'https://example.com' }
|
|
5
|
+
}))
|
|
4
6
|
|
|
5
7
|
vi.mock('$lib/server/brevo', () => ({ sendMessage: vi.fn() }))
|
|
6
8
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
2
|
|
|
3
|
-
vi.mock('$env/dynamic/private', () => ({
|
|
3
|
+
vi.mock('$env/dynamic/private', () => ({
|
|
4
|
+
env: { EMAIL: 'no-reply@example.com', DOMAIN: 'https://example.com' }
|
|
5
|
+
}))
|
|
4
6
|
|
|
5
7
|
vi.mock('$lib/server/brevo', () => ({ sendMessage: vi.fn() }))
|
|
6
8
|
|
|
@@ -94,10 +94,7 @@ describe('verifyTurnstileToken', () => {
|
|
|
94
94
|
const result = await verifyTurnstileToken('some-token')
|
|
95
95
|
|
|
96
96
|
expect(result).toBe(false)
|
|
97
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
98
|
-
'Turnstile verification error:',
|
|
99
|
-
expect.any(Error)
|
|
100
|
-
)
|
|
97
|
+
expect(consoleSpy).toHaveBeenCalledWith('Turnstile verification error:', expect.any(Error))
|
|
101
98
|
})
|
|
102
99
|
|
|
103
100
|
it('returns false when the response body is not valid JSON', async () => {
|
|
@@ -20,7 +20,7 @@ export const PUT: RequestHandler = async event => {
|
|
|
20
20
|
try {
|
|
21
21
|
const userUpdate = await event.request.json()
|
|
22
22
|
await query(`CALL update_user($1, $2);`, [user.id, JSON.stringify(userUpdate)])
|
|
23
|
-
} catch
|
|
23
|
+
} catch {
|
|
24
24
|
error(503, 'Could not communicate with database.')
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -9,10 +9,7 @@ const mockQuery = vi.mocked(query)
|
|
|
9
9
|
|
|
10
10
|
const adminUser: UserProperties = { id: 42, role: 'admin', email: 'admin@example.com' }
|
|
11
11
|
|
|
12
|
-
function makeEvent({
|
|
13
|
-
noUser = false,
|
|
14
|
-
body = {} as unknown
|
|
15
|
-
} = {}) {
|
|
12
|
+
function makeEvent({ noUser = false, body = {} as unknown } = {}) {
|
|
16
13
|
return {
|
|
17
14
|
locals: { user: noUser ? undefined : adminUser },
|
|
18
15
|
request: { json: vi.fn().mockResolvedValue(body) },
|
|
@@ -43,10 +40,10 @@ describe('PUT /api/v1/user', () => {
|
|
|
43
40
|
|
|
44
41
|
await PUT(makeEvent({ body: update }))
|
|
45
42
|
|
|
46
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
43
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('update_user'), [
|
|
44
|
+
adminUser.id,
|
|
45
|
+
JSON.stringify(update)
|
|
46
|
+
])
|
|
50
47
|
})
|
|
51
48
|
|
|
52
49
|
it('throws 401 when user is not authenticated', async () => {
|
|
@@ -78,10 +75,7 @@ describe('DELETE /api/v1/user', () => {
|
|
|
78
75
|
|
|
79
76
|
await DELETE(makeEvent())
|
|
80
77
|
|
|
81
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
82
|
-
expect.stringContaining('delete_user'),
|
|
83
|
-
[adminUser.id]
|
|
84
|
-
)
|
|
78
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_user'), [adminUser.id])
|
|
85
79
|
})
|
|
86
80
|
|
|
87
81
|
it('deletes the session cookie on success', async () => {
|
|
@@ -107,7 +101,7 @@ describe('DELETE /api/v1/user', () => {
|
|
|
107
101
|
mockQuery.mockRejectedValue(new Error('db down'))
|
|
108
102
|
const event = makeEvent()
|
|
109
103
|
|
|
110
|
-
await DELETE(event).catch(() => {})
|
|
104
|
+
await Promise.resolve(DELETE(event)).catch(() => {})
|
|
111
105
|
|
|
112
106
|
expect(event.cookies.delete).not.toHaveBeenCalled()
|
|
113
107
|
})
|
|
@@ -17,7 +17,9 @@ const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
|
17
17
|
function makeEvent(body: Record<string, unknown> = {}) {
|
|
18
18
|
return {
|
|
19
19
|
request: {
|
|
20
|
-
json: vi
|
|
20
|
+
json: vi
|
|
21
|
+
.fn()
|
|
22
|
+
.mockResolvedValue({ turnstileToken: 'tok', email: 'user@example.com', ...body }),
|
|
21
23
|
headers: { get: vi.fn().mockReturnValue(null) }
|
|
22
24
|
},
|
|
23
25
|
getClientAddress: vi.fn().mockReturnValue('127.0.0.1')
|
|
@@ -13,7 +13,13 @@ import { OAuth2Client } from 'google-auth-library'
|
|
|
13
13
|
const mockQuery = vi.mocked(query)
|
|
14
14
|
const MockOAuth2Client = vi.mocked(OAuth2Client)
|
|
15
15
|
|
|
16
|
-
const mockUser:
|
|
16
|
+
const mockUser: UserProperties = {
|
|
17
|
+
id: 1,
|
|
18
|
+
email: 'jane@example.com',
|
|
19
|
+
firstName: 'Jane',
|
|
20
|
+
lastName: 'Doe',
|
|
21
|
+
role: 'student'
|
|
22
|
+
}
|
|
17
23
|
const mockUserSession: UserSession = { id: 'session-abc', user: mockUser }
|
|
18
24
|
|
|
19
25
|
function makeVerifyIdToken(payload: Record<string, unknown> | null) {
|
|
@@ -23,7 +29,7 @@ function makeVerifyIdToken(payload: Record<string, unknown> | null) {
|
|
|
23
29
|
function setupOAuth2Mock(verifyIdToken: ReturnType<typeof vi.fn>) {
|
|
24
30
|
MockOAuth2Client.mockImplementation(function () {
|
|
25
31
|
return { verifyIdToken }
|
|
26
|
-
} as
|
|
32
|
+
} as any)
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
function makeEvent(body: Record<string, unknown> = { token: 'google-jwt' }) {
|
|
@@ -40,7 +46,9 @@ beforeEach(() => {
|
|
|
40
46
|
|
|
41
47
|
describe('POST /auth/google', () => {
|
|
42
48
|
it('returns 200 with user data on successful sign-in', async () => {
|
|
43
|
-
setupOAuth2Mock(
|
|
49
|
+
setupOAuth2Mock(
|
|
50
|
+
makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
|
|
51
|
+
)
|
|
44
52
|
mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
|
|
45
53
|
|
|
46
54
|
const res = await POST(makeEvent())
|
|
@@ -52,22 +60,30 @@ describe('POST /auth/google', () => {
|
|
|
52
60
|
})
|
|
53
61
|
|
|
54
62
|
it('sets an httpOnly session cookie on success', async () => {
|
|
55
|
-
setupOAuth2Mock(
|
|
63
|
+
setupOAuth2Mock(
|
|
64
|
+
makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
|
|
65
|
+
)
|
|
56
66
|
mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
|
|
57
67
|
const event = makeEvent()
|
|
58
68
|
|
|
59
69
|
await POST(event)
|
|
60
70
|
|
|
61
|
-
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
72
|
+
'session',
|
|
73
|
+
'session-abc',
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
httpOnly: true,
|
|
76
|
+
sameSite: 'lax',
|
|
77
|
+
secure: true,
|
|
78
|
+
path: '/'
|
|
79
|
+
})
|
|
80
|
+
)
|
|
67
81
|
})
|
|
68
82
|
|
|
69
83
|
it('sets event.locals.user on success', async () => {
|
|
70
|
-
setupOAuth2Mock(
|
|
84
|
+
setupOAuth2Mock(
|
|
85
|
+
makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
|
|
86
|
+
)
|
|
71
87
|
mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
|
|
72
88
|
const event = makeEvent()
|
|
73
89
|
|
|
@@ -77,7 +93,11 @@ describe('POST /auth/google', () => {
|
|
|
77
93
|
})
|
|
78
94
|
|
|
79
95
|
it('uses PUBLIC_GOOGLE_CLIENT_ID when verifying the token', async () => {
|
|
80
|
-
const verifyIdToken = makeVerifyIdToken({
|
|
96
|
+
const verifyIdToken = makeVerifyIdToken({
|
|
97
|
+
given_name: 'Jane',
|
|
98
|
+
family_name: 'Doe',
|
|
99
|
+
email: 'jane@example.com'
|
|
100
|
+
})
|
|
81
101
|
setupOAuth2Mock(verifyIdToken)
|
|
82
102
|
mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
|
|
83
103
|
|
|
@@ -88,15 +108,16 @@ describe('POST /auth/google', () => {
|
|
|
88
108
|
})
|
|
89
109
|
|
|
90
110
|
it('passes the Google user data to the DB upsert', async () => {
|
|
91
|
-
setupOAuth2Mock(
|
|
111
|
+
setupOAuth2Mock(
|
|
112
|
+
makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
|
|
113
|
+
)
|
|
92
114
|
mockQuery.mockResolvedValue({ rows: [{ user_session: mockUserSession }] } as any)
|
|
93
115
|
|
|
94
116
|
await POST(makeEvent())
|
|
95
117
|
|
|
96
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
118
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('start_gmail_user_session'), [
|
|
119
|
+
JSON.stringify({ firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' })
|
|
120
|
+
])
|
|
100
121
|
})
|
|
101
122
|
|
|
102
123
|
it('falls back to placeholder names when given_name/family_name are missing', async () => {
|
|
@@ -105,10 +126,13 @@ describe('POST /auth/google', () => {
|
|
|
105
126
|
|
|
106
127
|
await POST(makeEvent())
|
|
107
128
|
|
|
108
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
firstName: 'UnknownFirstName',
|
|
132
|
+
lastName: 'UnknownLastName',
|
|
133
|
+
email: 'noname@example.com'
|
|
134
|
+
})
|
|
135
|
+
])
|
|
112
136
|
})
|
|
113
137
|
|
|
114
138
|
it('throws 401 when the Google token is invalid', async () => {
|
|
@@ -118,7 +142,9 @@ describe('POST /auth/google', () => {
|
|
|
118
142
|
})
|
|
119
143
|
|
|
120
144
|
it('throws 401 when the DB upsert fails', async () => {
|
|
121
|
-
setupOAuth2Mock(
|
|
145
|
+
setupOAuth2Mock(
|
|
146
|
+
makeVerifyIdToken({ given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com' })
|
|
147
|
+
)
|
|
122
148
|
mockQuery.mockRejectedValue(new Error('db error'))
|
|
123
149
|
|
|
124
150
|
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 401 })
|
|
@@ -69,7 +69,7 @@ export const POST: RequestHandler = async event => {
|
|
|
69
69
|
error(503, 'Could not communicate with database.')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
const { authenticationResult }
|
|
72
|
+
const { authenticationResult } = result.rows[0] as { authenticationResult: AuthenticationResult }
|
|
73
73
|
|
|
74
74
|
if (!authenticationResult.user) {
|
|
75
75
|
// Track failed attempt for lockout
|
|
@@ -15,7 +15,13 @@ const mockQuery = vi.mocked(query)
|
|
|
15
15
|
const mockSendMfaCodeEmail = vi.mocked(sendMfaCodeEmail)
|
|
16
16
|
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
17
17
|
|
|
18
|
-
const mockUser:
|
|
18
|
+
const mockUser: UserProperties = {
|
|
19
|
+
id: 7,
|
|
20
|
+
email: 'user@example.com',
|
|
21
|
+
firstName: 'Jane',
|
|
22
|
+
lastName: 'Doe',
|
|
23
|
+
role: 'student'
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
const successResult: AuthenticationResult = {
|
|
21
27
|
user: mockUser,
|
|
@@ -32,7 +38,10 @@ const failResult: AuthenticationResult = {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function makeEvent({
|
|
35
|
-
body = { email: 'user@example.com', password: 'Password1!', turnstileToken: 'tok' } as Record<
|
|
41
|
+
body = { email: 'user@example.com', password: 'Password1!', turnstileToken: 'tok' } as Record<
|
|
42
|
+
string,
|
|
43
|
+
unknown
|
|
44
|
+
>,
|
|
36
45
|
mfaTrustedCookie = undefined as string | undefined
|
|
37
46
|
} = {}) {
|
|
38
47
|
return {
|
|
@@ -61,8 +70,8 @@ describe('POST /auth/login — MFA flow', () => {
|
|
|
61
70
|
beforeEach(() => {
|
|
62
71
|
mockQuery
|
|
63
72
|
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any) // authenticate
|
|
64
|
-
.mockResolvedValueOnce({ rows: [] } as any)
|
|
65
|
-
.mockResolvedValueOnce({ rows: [{ code: '123456' }] } as any)
|
|
73
|
+
.mockResolvedValueOnce({ rows: [] } as any) // delete_session
|
|
74
|
+
.mockResolvedValueOnce({ rows: [{ code: '123456' }] } as any) // create_mfa_code
|
|
66
75
|
})
|
|
67
76
|
|
|
68
77
|
it('returns { mfaRequired: true } when no trusted cookie is present', async () => {
|
|
@@ -114,12 +123,16 @@ describe('POST /auth/login — MFA trusted device', () => {
|
|
|
114
123
|
|
|
115
124
|
await POST(event)
|
|
116
125
|
|
|
117
|
-
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
127
|
+
'session',
|
|
128
|
+
'sess-123',
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
httpOnly: true,
|
|
131
|
+
sameSite: 'lax',
|
|
132
|
+
secure: true,
|
|
133
|
+
path: '/'
|
|
134
|
+
})
|
|
135
|
+
)
|
|
123
136
|
})
|
|
124
137
|
|
|
125
138
|
it('sets event.locals.user when the trusted cookie is valid', async () => {
|
|
@@ -145,7 +158,9 @@ describe('POST /auth/login — MFA trusted device', () => {
|
|
|
145
158
|
})
|
|
146
159
|
|
|
147
160
|
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', {
|
|
161
|
+
const expiredToken = jwt.sign({ userId: mockUser.id, purpose: 'mfa-trusted' }, 'test-secret', {
|
|
162
|
+
expiresIn: -1
|
|
163
|
+
})
|
|
149
164
|
mockQuery
|
|
150
165
|
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any)
|
|
151
166
|
.mockResolvedValueOnce({ rows: [] } as any)
|
|
@@ -195,18 +210,34 @@ describe('POST /auth/login — brute-force lockout', () => {
|
|
|
195
210
|
|
|
196
211
|
// 5 failures to trigger lockout
|
|
197
212
|
for (let i = 0; i < 5; i++) {
|
|
198
|
-
await
|
|
213
|
+
await Promise.resolve(
|
|
214
|
+
POST(
|
|
215
|
+
makeEvent({
|
|
216
|
+
body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' }
|
|
217
|
+
})
|
|
218
|
+
)
|
|
219
|
+
).catch(() => {})
|
|
199
220
|
}
|
|
200
221
|
|
|
201
222
|
await expect(
|
|
202
|
-
POST(
|
|
223
|
+
POST(
|
|
224
|
+
makeEvent({
|
|
225
|
+
body: { email: 'lockout@example.com', password: 'wrong', turnstileToken: 'tok' }
|
|
226
|
+
})
|
|
227
|
+
)
|
|
203
228
|
).rejects.toMatchObject({ status: 429 })
|
|
204
229
|
})
|
|
205
230
|
|
|
206
231
|
it('clears the lockout tracker on successful login', async () => {
|
|
207
232
|
// Register a failed attempt first
|
|
208
233
|
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: failResult }] } as any)
|
|
209
|
-
await
|
|
234
|
+
await Promise.resolve(
|
|
235
|
+
POST(
|
|
236
|
+
makeEvent({
|
|
237
|
+
body: { email: 'clear@example.com', password: 'wrong', turnstileToken: 'tok' }
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
).catch(() => {})
|
|
210
241
|
|
|
211
242
|
// Then succeed — mfa flow needs 3 query responses
|
|
212
243
|
mockQuery
|
|
@@ -215,7 +246,11 @@ describe('POST /auth/login — brute-force lockout', () => {
|
|
|
215
246
|
.mockResolvedValueOnce({ rows: [{ code: '000000' }] } as any)
|
|
216
247
|
|
|
217
248
|
// Should not throw 429
|
|
218
|
-
const res = await POST(
|
|
249
|
+
const res = await POST(
|
|
250
|
+
makeEvent({
|
|
251
|
+
body: { email: 'clear@example.com', password: 'Password1!', turnstileToken: 'tok' }
|
|
252
|
+
})
|
|
253
|
+
)
|
|
219
254
|
expect((await res.json()).mfaRequired).toBe(true)
|
|
220
255
|
})
|
|
221
256
|
})
|
|
@@ -7,7 +7,13 @@ import { query } from '$lib/server/db'
|
|
|
7
7
|
|
|
8
8
|
const mockQuery = vi.mocked(query)
|
|
9
9
|
|
|
10
|
-
const mockUser:
|
|
10
|
+
const mockUser: UserProperties = {
|
|
11
|
+
id: 3,
|
|
12
|
+
email: 'user@example.com',
|
|
13
|
+
firstName: 'Jane',
|
|
14
|
+
lastName: 'Doe',
|
|
15
|
+
role: 'student'
|
|
16
|
+
}
|
|
11
17
|
|
|
12
18
|
function makeEvent({ noUser = false } = {}) {
|
|
13
19
|
return {
|
|
@@ -44,10 +50,7 @@ describe('POST /auth/logout', () => {
|
|
|
44
50
|
|
|
45
51
|
await POST(makeEvent())
|
|
46
52
|
|
|
47
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
48
|
-
expect.stringContaining('delete_session'),
|
|
49
|
-
[mockUser.id]
|
|
50
|
-
)
|
|
53
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_session'), [mockUser.id])
|
|
51
54
|
})
|
|
52
55
|
|
|
53
56
|
it('skips the DB call when no user is authenticated', async () => {
|
|
@@ -12,9 +12,21 @@ import jwt from 'jsonwebtoken'
|
|
|
12
12
|
const mockQuery = vi.mocked(query)
|
|
13
13
|
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
14
14
|
|
|
15
|
-
const mockUser:
|
|
15
|
+
const mockUser: UserProperties = {
|
|
16
|
+
id: 5,
|
|
17
|
+
email: 'user@example.com',
|
|
18
|
+
firstName: 'Jane',
|
|
19
|
+
lastName: 'Doe',
|
|
20
|
+
role: 'student'
|
|
21
|
+
}
|
|
16
22
|
|
|
17
|
-
function makeEvent(
|
|
23
|
+
function makeEvent(
|
|
24
|
+
body: Record<string, unknown> = {
|
|
25
|
+
email: 'user@example.com',
|
|
26
|
+
code: '123456',
|
|
27
|
+
turnstileToken: 'tok'
|
|
28
|
+
}
|
|
29
|
+
) {
|
|
18
30
|
return {
|
|
19
31
|
request: {
|
|
20
32
|
json: vi.fn().mockResolvedValue(body),
|
|
@@ -28,9 +40,9 @@ function makeEvent(body: Record<string, unknown> = { email: 'user@example.com',
|
|
|
28
40
|
/** Set up the three DB calls needed for a successful MFA verification. */
|
|
29
41
|
function setupSuccessQueries() {
|
|
30
42
|
mockQuery
|
|
31
|
-
.mockResolvedValueOnce({ rows: [{ userId: mockUser.id }] } as any)
|
|
32
|
-
.mockResolvedValueOnce({ rows: [{ sessionId: 'sess-xyz' }] } as any)
|
|
33
|
-
.mockResolvedValueOnce({ rows: [{ get_session: mockUser }] } as any)
|
|
43
|
+
.mockResolvedValueOnce({ rows: [{ userId: mockUser.id }] } as any) // verify_mfa_code
|
|
44
|
+
.mockResolvedValueOnce({ rows: [{ sessionId: 'sess-xyz' }] } as any) // create_session
|
|
45
|
+
.mockResolvedValueOnce({ rows: [{ get_session: mockUser }] } as any) // get_session
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
beforeEach(() => {
|
|
@@ -56,12 +68,16 @@ describe('POST /auth/mfa', () => {
|
|
|
56
68
|
|
|
57
69
|
await POST(event)
|
|
58
70
|
|
|
59
|
-
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
72
|
+
'session',
|
|
73
|
+
'sess-xyz',
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
httpOnly: true,
|
|
76
|
+
sameSite: 'lax',
|
|
77
|
+
secure: true,
|
|
78
|
+
path: '/'
|
|
79
|
+
})
|
|
80
|
+
)
|
|
65
81
|
})
|
|
66
82
|
|
|
67
83
|
it('sets an mfa_trusted cookie on success', async () => {
|
|
@@ -70,13 +86,17 @@ describe('POST /auth/mfa', () => {
|
|
|
70
86
|
|
|
71
87
|
await POST(event)
|
|
72
88
|
|
|
73
|
-
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
90
|
+
'mfa_trusted',
|
|
91
|
+
expect.any(String),
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
httpOnly: true,
|
|
94
|
+
sameSite: 'lax',
|
|
95
|
+
secure: true,
|
|
96
|
+
path: '/',
|
|
97
|
+
maxAge: 30 * 24 * 60 * 60
|
|
98
|
+
})
|
|
99
|
+
)
|
|
80
100
|
})
|
|
81
101
|
|
|
82
102
|
it('mfa_trusted cookie is a valid JWT with correct payload', async () => {
|
|
@@ -85,7 +105,9 @@ describe('POST /auth/mfa', () => {
|
|
|
85
105
|
|
|
86
106
|
await POST(event)
|
|
87
107
|
|
|
88
|
-
const [, trustedToken] = vi
|
|
108
|
+
const [, trustedToken] = vi
|
|
109
|
+
.mocked(event.cookies.set)
|
|
110
|
+
.mock.calls.find(([name]) => name === 'mfa_trusted')!
|
|
89
111
|
const payload = jwt.verify(trustedToken as string, 'test-secret') as Record<string, unknown>
|
|
90
112
|
expect(payload.userId).toBe(mockUser.id)
|
|
91
113
|
expect(payload.purpose).toBe('mfa-trusted')
|
|
@@ -96,10 +118,10 @@ describe('POST /auth/mfa', () => {
|
|
|
96
118
|
|
|
97
119
|
await POST(makeEvent({ email: 'User@Example.COM', code: '123456', turnstileToken: 'tok' }))
|
|
98
120
|
|
|
99
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
121
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('verify_mfa_code'), [
|
|
122
|
+
'user@example.com',
|
|
123
|
+
'123456'
|
|
124
|
+
])
|
|
103
125
|
})
|
|
104
126
|
|
|
105
127
|
it('throws 401 when the MFA code is invalid or expired', async () => {
|
|
@@ -109,9 +131,9 @@ describe('POST /auth/mfa', () => {
|
|
|
109
131
|
})
|
|
110
132
|
|
|
111
133
|
it('throws 400 when email is missing', async () => {
|
|
112
|
-
await expect(
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
await expect(POST(makeEvent({ code: '123456', turnstileToken: 'tok' }))).rejects.toMatchObject({
|
|
135
|
+
status: 400
|
|
136
|
+
})
|
|
115
137
|
})
|
|
116
138
|
|
|
117
139
|
it('throws 400 when code is missing', async () => {
|
|
@@ -59,7 +59,7 @@ export const POST: RequestHandler = async event => {
|
|
|
59
59
|
error(503, 'Could not communicate with database.')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const { authenticationResult }
|
|
62
|
+
const { authenticationResult } = result.rows[0] as { authenticationResult: AuthenticationResult }
|
|
63
63
|
|
|
64
64
|
if (!authenticationResult.user)
|
|
65
65
|
error(authenticationResult.statusCode, authenticationResult.status)
|
|
@@ -15,7 +15,13 @@ const mockQuery = vi.mocked(query)
|
|
|
15
15
|
const mockSendVerificationEmail = vi.mocked(sendVerificationEmail)
|
|
16
16
|
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
17
17
|
|
|
18
|
-
const mockUser:
|
|
18
|
+
const mockUser: UserProperties = {
|
|
19
|
+
id: 9,
|
|
20
|
+
email: 'new@example.com',
|
|
21
|
+
firstName: 'Jane',
|
|
22
|
+
lastName: 'Doe',
|
|
23
|
+
role: 'student'
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
const successResult: AuthenticationResult = {
|
|
21
27
|
user: mockUser,
|
|
@@ -46,7 +52,7 @@ function makeEvent(body: Record<string, unknown> = validBody) {
|
|
|
46
52
|
function setupSuccessQueries() {
|
|
47
53
|
mockQuery
|
|
48
54
|
.mockResolvedValueOnce({ rows: [{ authenticationResult: successResult }] } as any) // register
|
|
49
|
-
.mockResolvedValueOnce({ rows: [] } as any)
|
|
55
|
+
.mockResolvedValueOnce({ rows: [] } as any) // delete_session
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
beforeEach(() => {
|
|
@@ -92,10 +98,9 @@ describe('POST /auth/register', () => {
|
|
|
92
98
|
|
|
93
99
|
await POST(makeEvent())
|
|
94
100
|
|
|
95
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
101
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_session'), [
|
|
102
|
+
successResult.sessionId
|
|
103
|
+
])
|
|
99
104
|
})
|
|
100
105
|
|
|
101
106
|
it('calls the register SQL function with the full body', async () => {
|
|
@@ -103,10 +108,9 @@ describe('POST /auth/register', () => {
|
|
|
103
108
|
|
|
104
109
|
await POST(makeEvent())
|
|
105
110
|
|
|
106
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
111
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('register'), [
|
|
112
|
+
JSON.stringify(validBody)
|
|
113
|
+
])
|
|
110
114
|
})
|
|
111
115
|
|
|
112
116
|
it('still sends the verification email when delete_session fails', async () => {
|
|
@@ -143,19 +147,27 @@ describe('POST /auth/register — validation', () => {
|
|
|
143
147
|
})
|
|
144
148
|
|
|
145
149
|
it('throws 400 when password has no uppercase letter', async () => {
|
|
146
|
-
await expect(POST(makeEvent({ ...validBody, password: 'password1!' }))).rejects.toMatchObject({
|
|
150
|
+
await expect(POST(makeEvent({ ...validBody, password: 'password1!' }))).rejects.toMatchObject({
|
|
151
|
+
status: 400
|
|
152
|
+
})
|
|
147
153
|
})
|
|
148
154
|
|
|
149
155
|
it('throws 400 when password has no number', async () => {
|
|
150
|
-
await expect(POST(makeEvent({ ...validBody, password: 'Password!' }))).rejects.toMatchObject({
|
|
156
|
+
await expect(POST(makeEvent({ ...validBody, password: 'Password!' }))).rejects.toMatchObject({
|
|
157
|
+
status: 400
|
|
158
|
+
})
|
|
151
159
|
})
|
|
152
160
|
|
|
153
161
|
it('throws 400 when password has no special character', async () => {
|
|
154
|
-
await expect(POST(makeEvent({ ...validBody, password: 'Password1' }))).rejects.toMatchObject({
|
|
162
|
+
await expect(POST(makeEvent({ ...validBody, password: 'Password1' }))).rejects.toMatchObject({
|
|
163
|
+
status: 400
|
|
164
|
+
})
|
|
155
165
|
})
|
|
156
166
|
|
|
157
167
|
it('throws 400 when password is too short', async () => {
|
|
158
|
-
await expect(POST(makeEvent({ ...validBody, password: 'P1!' }))).rejects.toMatchObject({
|
|
168
|
+
await expect(POST(makeEvent({ ...validBody, password: 'P1!' }))).rejects.toMatchObject({
|
|
169
|
+
status: 400
|
|
170
|
+
})
|
|
159
171
|
})
|
|
160
172
|
|
|
161
173
|
it('throws 400 when Turnstile verification fails', async () => {
|
|
@@ -175,7 +187,12 @@ describe('POST /auth/register — validation', () => {
|
|
|
175
187
|
})
|
|
176
188
|
|
|
177
189
|
it('throws the DB status code on registration failure (e.g. duplicate email)', async () => {
|
|
178
|
-
const dupResult: AuthenticationResult = {
|
|
190
|
+
const dupResult: AuthenticationResult = {
|
|
191
|
+
user: null,
|
|
192
|
+
sessionId: '',
|
|
193
|
+
status: 'Email already registered.',
|
|
194
|
+
statusCode: 409
|
|
195
|
+
}
|
|
179
196
|
mockQuery.mockResolvedValueOnce({ rows: [{ authenticationResult: dupResult }] } as any)
|
|
180
197
|
await expect(POST(makeEvent())).rejects.toMatchObject({ status: 409 })
|
|
181
198
|
})
|
|
@@ -50,8 +50,8 @@ export const PUT: RequestHandler = async event => {
|
|
|
50
50
|
return json({
|
|
51
51
|
message: 'Password successfully reset.'
|
|
52
52
|
})
|
|
53
|
-
} catch
|
|
54
|
-
// Technically,
|
|
53
|
+
} catch {
|
|
54
|
+
// Technically, we should check if it's a DB issue vs an invalid/expired token
|
|
55
55
|
return json(
|
|
56
56
|
{
|
|
57
57
|
message: 'Password reset token expired.'
|
|
@@ -13,7 +13,9 @@ const mockQuery = vi.mocked(query)
|
|
|
13
13
|
const mockVerifyTurnstileToken = vi.mocked(verifyTurnstileToken)
|
|
14
14
|
|
|
15
15
|
function makeResetToken(overrides: Record<string, unknown> = {}) {
|
|
16
|
-
return jwt.sign({ subject: 42, purpose: 'reset-password', ...overrides }, 'test-secret', {
|
|
16
|
+
return jwt.sign({ subject: 42, purpose: 'reset-password', ...overrides }, 'test-secret', {
|
|
17
|
+
expiresIn: '30m'
|
|
18
|
+
})
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
function makeEvent(body: Record<string, unknown> = {}) {
|
|
@@ -58,10 +60,10 @@ describe('PUT /auth/reset', () => {
|
|
|
58
60
|
|
|
59
61
|
await PUT(makeEvent({ password: 'NewPassword1!' }))
|
|
60
62
|
|
|
61
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
63
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('reset_password'), [
|
|
64
|
+
42,
|
|
65
|
+
'NewPassword1!'
|
|
66
|
+
])
|
|
65
67
|
})
|
|
66
68
|
|
|
67
69
|
it('invalidates existing sessions after a successful reset', async () => {
|
|
@@ -69,16 +71,11 @@ describe('PUT /auth/reset', () => {
|
|
|
69
71
|
|
|
70
72
|
await PUT(makeEvent())
|
|
71
73
|
|
|
72
|
-
expect(mockQuery).toHaveBeenCalledWith(
|
|
73
|
-
expect.stringContaining('delete_session'),
|
|
74
|
-
[42]
|
|
75
|
-
)
|
|
74
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('delete_session'), [42])
|
|
76
75
|
})
|
|
77
76
|
|
|
78
77
|
it('still returns 200 when session invalidation fails', async () => {
|
|
79
|
-
mockQuery
|
|
80
|
-
.mockResolvedValueOnce({ rows: [] } as any)
|
|
81
|
-
.mockRejectedValueOnce(new Error('db down'))
|
|
78
|
+
mockQuery.mockResolvedValueOnce({ rows: [] } as any).mockRejectedValueOnce(new Error('db down'))
|
|
82
79
|
|
|
83
80
|
const res = await PUT(makeEvent())
|
|
84
81
|
|
|
@@ -86,7 +83,9 @@ describe('PUT /auth/reset', () => {
|
|
|
86
83
|
})
|
|
87
84
|
|
|
88
85
|
it('returns 403 when the token is expired', async () => {
|
|
89
|
-
const expiredToken = jwt.sign({ subject: 42, purpose: 'reset-password' }, 'test-secret', {
|
|
86
|
+
const expiredToken = jwt.sign({ subject: 42, purpose: 'reset-password' }, 'test-secret', {
|
|
87
|
+
expiresIn: -1
|
|
88
|
+
})
|
|
90
89
|
|
|
91
90
|
const res = await PUT(makeEvent({ token: expiredToken }))
|
|
92
91
|
|
|
@@ -10,7 +10,9 @@ import jwt from 'jsonwebtoken'
|
|
|
10
10
|
const mockQuery = vi.mocked(query)
|
|
11
11
|
|
|
12
12
|
function makeVerifyEmailToken(overrides: Record<string, unknown> = {}) {
|
|
13
|
-
return jwt.sign({ subject: '7', purpose: 'verify-email', ...overrides }, 'test-secret', {
|
|
13
|
+
return jwt.sign({ subject: '7', purpose: 'verify-email', ...overrides }, 'test-secret', {
|
|
14
|
+
expiresIn: '24h'
|
|
15
|
+
})
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
function makeEvent(token: string) {
|
|
@@ -38,20 +40,24 @@ describe('GET /auth/verify/[token]', () => {
|
|
|
38
40
|
mockQuery.mockResolvedValue({ rows: [{ verify_email_and_create_session: 'sess-abc' }] } as any)
|
|
39
41
|
const event = makeEvent(makeVerifyEmailToken())
|
|
40
42
|
|
|
41
|
-
await GET(event).catch(() => {})
|
|
42
|
-
|
|
43
|
-
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
await Promise.resolve(GET(event)).catch(() => {})
|
|
44
|
+
|
|
45
|
+
expect(event.cookies.set).toHaveBeenCalledWith(
|
|
46
|
+
'session',
|
|
47
|
+
'sess-abc',
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
sameSite: 'lax',
|
|
51
|
+
secure: true,
|
|
52
|
+
path: '/'
|
|
53
|
+
})
|
|
54
|
+
)
|
|
49
55
|
})
|
|
50
56
|
|
|
51
57
|
it('calls verify_email_and_create_session with the userId from the token', async () => {
|
|
52
58
|
mockQuery.mockResolvedValue({ rows: [{ verify_email_and_create_session: 'sess-abc' }] } as any)
|
|
53
59
|
|
|
54
|
-
await GET(makeEvent(makeVerifyEmailToken())).catch(() => {})
|
|
60
|
+
await Promise.resolve(GET(makeEvent(makeVerifyEmailToken()))).catch(() => {})
|
|
55
61
|
|
|
56
62
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
57
63
|
expect.stringContaining('verify_email_and_create_session'),
|
|
@@ -60,7 +66,9 @@ describe('GET /auth/verify/[token]', () => {
|
|
|
60
66
|
})
|
|
61
67
|
|
|
62
68
|
it('redirects to /login?error=invalid-token when the token is expired', async () => {
|
|
63
|
-
const expiredToken = jwt.sign({ subject: '7', purpose: 'verify-email' }, 'test-secret', {
|
|
69
|
+
const expiredToken = jwt.sign({ subject: '7', purpose: 'verify-email' }, 'test-secret', {
|
|
70
|
+
expiresIn: -1
|
|
71
|
+
})
|
|
64
72
|
|
|
65
73
|
await expect(GET(makeEvent(expiredToken))).rejects.toMatchObject({
|
|
66
74
|
location: '/login?error=invalid-token',
|
|
@@ -117,7 +125,7 @@ describe('GET /auth/verify/[token]', () => {
|
|
|
117
125
|
mockQuery.mockRejectedValue(new Error('db down'))
|
|
118
126
|
const event = makeEvent(makeVerifyEmailToken())
|
|
119
127
|
|
|
120
|
-
await GET(event).catch(() => {})
|
|
128
|
+
await Promise.resolve(GET(event)).catch(() => {})
|
|
121
129
|
|
|
122
130
|
expect(event.cookies.set).not.toHaveBeenCalled()
|
|
123
131
|
})
|
|
@@ -22,15 +22,17 @@ const mockCaches = {
|
|
|
22
22
|
delete: vi.fn()
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const swHandlers: Record<string,
|
|
25
|
+
const swHandlers: Record<string, (event: any) => void> = {}
|
|
26
26
|
|
|
27
27
|
beforeAll(async () => {
|
|
28
28
|
vi.stubGlobal('caches', mockCaches)
|
|
29
29
|
|
|
30
30
|
// Spy on addEventListener to capture the handlers the SW registers
|
|
31
|
-
vi.spyOn(window, 'addEventListener').mockImplementation(
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
vi.spyOn(window, 'addEventListener').mockImplementation(
|
|
32
|
+
(type: string, handler: EventListener) => {
|
|
33
|
+
swHandlers[type] = handler
|
|
34
|
+
}
|
|
35
|
+
)
|
|
34
36
|
|
|
35
37
|
// Reset module registry so the SW file re-evaluates and re-registers its listeners
|
|
36
38
|
vi.resetModules()
|
|
File without changes
|