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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
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.4
|
|
6
|
+
|
|
7
|
+
- Fix README: replace outdated SendGrid references with Brevo; update env var names (`BREVO_KEY`, `EMAIL`) and source file paths
|
|
8
|
+
|
|
9
|
+
# 5.8.3
|
|
10
|
+
|
|
11
|
+
- 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]`
|
|
12
|
+
- Switch all server-side env imports from `$env/static/private` to `$env/dynamic/private` so secrets are read at runtime rather than baked into the build
|
|
13
|
+
|
|
5
14
|
# 5.8.2
|
|
6
15
|
|
|
7
16
|
- Add Chrome DevTools
|
package/README.md
CHANGED
|
@@ -8,15 +8,15 @@ A complete, production-ready authentication and authorization starter for **Svel
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
| ✅ Local accounts (email + password)
|
|
14
|
-
| ✅ Multi-factor authentication (MFA via email)
|
|
15
|
-
| ✅ Forgot password / email reset (Brevo)
|
|
16
|
-
| ✅ Session management + timeout
|
|
17
|
-
| ✅ Role-based access control
|
|
18
|
-
| ✅ Content Security Policy (CSP)
|
|
19
|
-
| ✅ Cloudflare Turnstile CAPTCHA (bot protection) |
|
|
11
|
+
| | |
|
|
12
|
+
| ------------------------------------------------ | --------------------------------------- |
|
|
13
|
+
| ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
|
|
14
|
+
| ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
|
|
15
|
+
| ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
|
|
16
|
+
| ✅ Session management + timeout | ✅ Rate limiting |
|
|
17
|
+
| ✅ Role-based access control | ✅ Password complexity enforcement |
|
|
18
|
+
| ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
|
|
19
|
+
| ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
|
|
20
20
|
|
|
21
21
|
## Stack
|
|
22
22
|
|
|
@@ -88,8 +88,8 @@ yarn preview
|
|
|
88
88
|
|
|
89
89
|
The db_create.sql script adds three users to the database with obvious roles:
|
|
90
90
|
|
|
91
|
-
| Email | Password
|
|
92
|
-
| ------------------- |
|
|
91
|
+
| Email | Password | Role |
|
|
92
|
+
| ------------------- | ------------ | ------- |
|
|
93
93
|
| admin@example.com | Admin1234! | admin |
|
|
94
94
|
| teacher@example.com | Teacher1234! | teacher |
|
|
95
95
|
| student@example.com | Student1234! | student |
|
|
@@ -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/db_create.sql
CHANGED
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.4",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -55,10 +55,12 @@
|
|
|
55
55
|
"@types/google.accounts": "^0.0.18",
|
|
56
56
|
"@types/jsonwebtoken": "^9.0.10",
|
|
57
57
|
"@types/pg": "^8.18.0",
|
|
58
|
+
"@vitest/coverage-v8": "4.1.0",
|
|
58
59
|
"eslint": "^10.0.3",
|
|
59
60
|
"eslint-config-prettier": "^10.1.8",
|
|
60
61
|
"eslint-plugin-svelte": "^3.15.2",
|
|
61
62
|
"globals": "^17.4.0",
|
|
63
|
+
"jsdom": "^28.1.0",
|
|
62
64
|
"playwright": "^1.58.2",
|
|
63
65
|
"prettier": "^3.8.1",
|
|
64
66
|
"prettier-plugin-sql": "^0.19.2",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { error } from '@sveltejs/kit'
|
|
3
|
+
|
|
4
|
+
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
5
|
+
|
|
6
|
+
import { handle } from './hooks.server'
|
|
7
|
+
import { query } from '$lib/server/db'
|
|
8
|
+
|
|
9
|
+
const mockQuery = vi.mocked(query)
|
|
10
|
+
|
|
11
|
+
/** Build a minimal event object for the hook. */
|
|
12
|
+
function makeEvent({
|
|
13
|
+
pathname = '/page',
|
|
14
|
+
sessionCookie = undefined as string | undefined,
|
|
15
|
+
ip = '1.2.3.4'
|
|
16
|
+
} = {}) {
|
|
17
|
+
const cookieStore = new Map<string, string>()
|
|
18
|
+
if (sessionCookie) cookieStore.set('session', sessionCookie)
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
url: new URL(`http://localhost${pathname}`),
|
|
22
|
+
cookies: {
|
|
23
|
+
get: (name: string) => cookieStore.get(name),
|
|
24
|
+
delete: vi.fn()
|
|
25
|
+
},
|
|
26
|
+
locals: {} as Record<string, unknown>,
|
|
27
|
+
getClientAddress: () => ip
|
|
28
|
+
} as unknown as Parameters<typeof handle>[0]['event']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolve = vi.fn(async (event: unknown) => new Response('ok', { status: 200 }))
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.resetAllMocks()
|
|
35
|
+
resolve.mockResolvedValue(new Response('ok', { status: 200 }))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── Static asset bypass ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('static asset bypass', () => {
|
|
41
|
+
it('calls resolve immediately for /_app/ paths without touching the db', async () => {
|
|
42
|
+
const event = makeEvent({ pathname: '/_app/immutable/app.js' })
|
|
43
|
+
|
|
44
|
+
await handle({ event, resolve })
|
|
45
|
+
|
|
46
|
+
expect(resolve).toHaveBeenCalledOnce()
|
|
47
|
+
expect(mockQuery).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ── Rate limiting ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('rate limiting', () => {
|
|
54
|
+
it('allows requests under the limit', async () => {
|
|
55
|
+
const event = makeEvent({ pathname: '/auth/login', ip: '10.0.0.1' })
|
|
56
|
+
|
|
57
|
+
await expect(handle({ event, resolve })).resolves.not.toThrow()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws 429 after exceeding the request limit for a rate-limited path', async () => {
|
|
61
|
+
const ip = '10.0.0.99'
|
|
62
|
+
|
|
63
|
+
// Exhaust the 20-request allowance
|
|
64
|
+
for (let i = 0; i < 20; i++) {
|
|
65
|
+
await handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve }).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve })
|
|
70
|
+
).rejects.toMatchObject({ status: 429 })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not rate-limit non-auth paths', async () => {
|
|
74
|
+
const ip = '10.0.0.2'
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < 25; i++) {
|
|
77
|
+
await handle({ event: makeEvent({ pathname: '/about', ip }), resolve })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
expect(resolve).toHaveBeenCalledTimes(25)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── Session handling ───────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('session handling', () => {
|
|
87
|
+
it('attaches user to locals when a valid session cookie is present', async () => {
|
|
88
|
+
const user = { id: 1, role: 'admin' }
|
|
89
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
|
|
90
|
+
|
|
91
|
+
const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
|
|
92
|
+
|
|
93
|
+
await handle({ event, resolve })
|
|
94
|
+
|
|
95
|
+
expect(event.locals.user).toEqual(user)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not call query when no session cookie is present', async () => {
|
|
99
|
+
const event = makeEvent()
|
|
100
|
+
|
|
101
|
+
await handle({ event, resolve })
|
|
102
|
+
|
|
103
|
+
expect(mockQuery).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('deletes the session cookie when the session is not found in the db', async () => {
|
|
107
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: undefined }] } as any)
|
|
108
|
+
|
|
109
|
+
const event = makeEvent({ sessionCookie: 'stale-session-uuid' })
|
|
110
|
+
|
|
111
|
+
await handle({ event, resolve })
|
|
112
|
+
|
|
113
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('deletes the session cookie when no session cookie is present and locals.user is unset', async () => {
|
|
117
|
+
const event = makeEvent()
|
|
118
|
+
|
|
119
|
+
await handle({ event, resolve })
|
|
120
|
+
|
|
121
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not delete the session cookie when a valid session exists', async () => {
|
|
125
|
+
const user = { id: 2, role: 'student' }
|
|
126
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
|
|
127
|
+
|
|
128
|
+
const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
|
|
129
|
+
|
|
130
|
+
await handle({ event, resolve })
|
|
131
|
+
|
|
132
|
+
expect(event.cookies.delete).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('uses the named prepared statement for session lookup', async () => {
|
|
136
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: { id: 3 } }] } as any)
|
|
137
|
+
|
|
138
|
+
const event = makeEvent({ sessionCookie: 'some-uuid' })
|
|
139
|
+
|
|
140
|
+
await handle({ event, resolve })
|
|
141
|
+
|
|
142
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('get_and_update_session'),
|
|
144
|
+
['some-uuid'],
|
|
145
|
+
'get-and-update-session'
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ── Response pass-through ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('response', () => {
|
|
153
|
+
it('returns the response from resolve', async () => {
|
|
154
|
+
const fakeResponse = new Response('hello', { status: 200 })
|
|
155
|
+
resolve.mockResolvedValue(fakeResponse)
|
|
156
|
+
|
|
157
|
+
const event = makeEvent()
|
|
158
|
+
|
|
159
|
+
const result = await handle({ event, resolve })
|
|
160
|
+
|
|
161
|
+
expect(result).toBe(fakeResponse)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { appState } from './app-state.svelte'
|
|
3
|
+
|
|
4
|
+
describe('appState', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Reset to initial state before each test
|
|
7
|
+
appState.user = undefined
|
|
8
|
+
appState.toast = { title: '', body: '', isOpen: false }
|
|
9
|
+
appState.googleInitialized = false
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('initial state', () => {
|
|
13
|
+
it('user is undefined', () => {
|
|
14
|
+
expect(appState.user).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('toast starts closed with empty strings', () => {
|
|
18
|
+
expect(appState.toast).toEqual({ title: '', body: '', isOpen: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('googleInitialized is false', () => {
|
|
22
|
+
expect(appState.googleInitialized).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('user', () => {
|
|
27
|
+
it('can be set to a user object', () => {
|
|
28
|
+
const user: User = {
|
|
29
|
+
id: 1,
|
|
30
|
+
role: 'admin',
|
|
31
|
+
email: 'admin@example.com',
|
|
32
|
+
firstName: 'Jane',
|
|
33
|
+
lastName: 'Doe',
|
|
34
|
+
phone: '412-555-1212',
|
|
35
|
+
optOut: false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
appState.user = user
|
|
39
|
+
|
|
40
|
+
expect(appState.user).toEqual(user)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('can be cleared back to undefined', () => {
|
|
44
|
+
appState.user = { id: 1, role: 'student', email: 'a@b.com', firstName: 'A', lastName: 'B', phone: '', optOut: false }
|
|
45
|
+
appState.user = undefined
|
|
46
|
+
expect(appState.user).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('toast', () => {
|
|
51
|
+
it('can be updated to show a notification', () => {
|
|
52
|
+
appState.toast = { title: 'Success', body: 'Your changes were saved.', isOpen: true }
|
|
53
|
+
|
|
54
|
+
expect(appState.toast).toEqual({ title: 'Success', body: 'Your changes were saved.', isOpen: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('isOpen can be toggled independently', () => {
|
|
58
|
+
appState.toast = { title: 'Hey', body: 'Hello', isOpen: true }
|
|
59
|
+
appState.toast.isOpen = false
|
|
60
|
+
|
|
61
|
+
expect(appState.toast.isOpen).toBe(false)
|
|
62
|
+
expect(appState.toast.title).toBe('Hey')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('googleInitialized', () => {
|
|
67
|
+
it('can be set to true', () => {
|
|
68
|
+
appState.googleInitialized = true
|
|
69
|
+
|
|
70
|
+
expect(appState.googleInitialized).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
|
|
4
|
+
vi.mock('$app/state', () => ({ page: { url: { searchParams: new URLSearchParams() } } }))
|
|
5
|
+
|
|
6
|
+
import { redirectAfterLogin } from './auth-redirect'
|
|
7
|
+
import { goto } from '$app/navigation'
|
|
8
|
+
import { page } from '$app/state'
|
|
9
|
+
|
|
10
|
+
const mockGoto = vi.mocked(goto)
|
|
11
|
+
const mockPage = page as { url: { searchParams: URLSearchParams } }
|
|
12
|
+
|
|
13
|
+
const student: UserProperties = { id: 1, role: 'student' }
|
|
14
|
+
const teacher: UserProperties = { id: 2, role: 'teacher' }
|
|
15
|
+
const admin: UserProperties = { id: 3, role: 'admin' }
|
|
16
|
+
|
|
17
|
+
describe('redirectAfterLogin', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockGoto.mockReset()
|
|
20
|
+
mockPage.url.searchParams = new URLSearchParams()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// ── No-op cases ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
it('does nothing when user is undefined', () => {
|
|
26
|
+
redirectAfterLogin(undefined)
|
|
27
|
+
expect(mockGoto).not.toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('does nothing when user is null', () => {
|
|
31
|
+
redirectAfterLogin(null)
|
|
32
|
+
expect(mockGoto).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ── Role-based defaults ────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
it('redirects a student to /', () => {
|
|
38
|
+
redirectAfterLogin(student)
|
|
39
|
+
expect(mockGoto).toHaveBeenCalledWith('/')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('redirects a teacher to /teachers', () => {
|
|
43
|
+
redirectAfterLogin(teacher)
|
|
44
|
+
expect(mockGoto).toHaveBeenCalledWith('/teachers')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('redirects an admin to /admin', () => {
|
|
48
|
+
redirectAfterLogin(admin)
|
|
49
|
+
expect(mockGoto).toHaveBeenCalledWith('/admin')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── Valid referrer ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
it('redirects to the referrer path instead of the role default', () => {
|
|
55
|
+
mockPage.url.searchParams = new URLSearchParams({ referrer: '/dashboard' })
|
|
56
|
+
redirectAfterLogin(admin)
|
|
57
|
+
expect(mockGoto).toHaveBeenCalledWith('/dashboard')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses the referrer for any role', () => {
|
|
61
|
+
mockPage.url.searchParams = new URLSearchParams({ referrer: '/some/path' })
|
|
62
|
+
redirectAfterLogin(teacher)
|
|
63
|
+
expect(mockGoto).toHaveBeenCalledWith('/some/path')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── Invalid referrer ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
it('ignores a referrer that does not start with /', () => {
|
|
69
|
+
mockPage.url.searchParams = new URLSearchParams({ referrer: 'https://evil.com' })
|
|
70
|
+
redirectAfterLogin(admin)
|
|
71
|
+
expect(mockGoto).toHaveBeenCalledWith('/admin')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('ignores a protocol-relative referrer starting with //', () => {
|
|
75
|
+
mockPage.url.searchParams = new URLSearchParams({ referrer: '//evil.com' })
|
|
76
|
+
redirectAfterLogin(admin)
|
|
77
|
+
expect(mockGoto).toHaveBeenCalledWith('/admin')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ignores an empty referrer', () => {
|
|
81
|
+
mockPage.url.searchParams = new URLSearchParams({ referrer: '' })
|
|
82
|
+
redirectAfterLogin(student)
|
|
83
|
+
expect(mockGoto).toHaveBeenCalledWith('/')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── Single goto call ────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
it('calls goto exactly once', () => {
|
|
89
|
+
redirectAfterLogin(student)
|
|
90
|
+
expect(mockGoto).toHaveBeenCalledOnce()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
|
|
5
|
+
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/dashboard') } }))
|
|
6
|
+
vi.mock('$lib/app-state.svelte', () => ({ appState: { user: undefined } }))
|
|
7
|
+
|
|
8
|
+
import { setupFetchInterceptor } from './fetch-interceptor'
|
|
9
|
+
import { goto } from '$app/navigation'
|
|
10
|
+
import { page } from '$app/state'
|
|
11
|
+
import { appState } from '$lib/app-state.svelte'
|
|
12
|
+
|
|
13
|
+
const mockGoto = vi.mocked(goto)
|
|
14
|
+
const mockPage = page as { url: URL }
|
|
15
|
+
|
|
16
|
+
function makeResponse(status: number) {
|
|
17
|
+
return new Response(null, { status })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('setupFetchInterceptor', () => {
|
|
21
|
+
let originalFetch: typeof fetch
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Save and restore window.fetch around each test
|
|
25
|
+
originalFetch = window.fetch
|
|
26
|
+
mockGoto.mockReset()
|
|
27
|
+
appState.user = { id: 1, role: 'admin', email: 'a@b.com', firstName: 'A', lastName: 'B', phone: '', optOut: false }
|
|
28
|
+
setupFetchInterceptor()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
window.fetch = originalFetch
|
|
33
|
+
appState.user = undefined
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('patches window.fetch', () => {
|
|
37
|
+
expect(window.fetch).not.toBe(originalFetch)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns the response unchanged for non-401 status codes', async () => {
|
|
41
|
+
window.fetch = vi.fn().mockResolvedValue(makeResponse(200))
|
|
42
|
+
setupFetchInterceptor()
|
|
43
|
+
|
|
44
|
+
const res = await window.fetch('/api/data')
|
|
45
|
+
|
|
46
|
+
expect(res.status).toBe(200)
|
|
47
|
+
expect(mockGoto).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not redirect on 401 when no user is logged in', async () => {
|
|
51
|
+
appState.user = undefined
|
|
52
|
+
window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
|
|
53
|
+
setupFetchInterceptor()
|
|
54
|
+
|
|
55
|
+
await window.fetch('/api/data')
|
|
56
|
+
|
|
57
|
+
expect(mockGoto).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('clears appState.user on 401 when a user is logged in', async () => {
|
|
61
|
+
window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
|
|
62
|
+
setupFetchInterceptor()
|
|
63
|
+
|
|
64
|
+
await window.fetch('/api/data')
|
|
65
|
+
|
|
66
|
+
expect(appState.user).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('redirects to /login with the current path as referrer on 401', async () => {
|
|
70
|
+
mockPage.url = new URL('http://localhost/dashboard?tab=profile')
|
|
71
|
+
window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
|
|
72
|
+
setupFetchInterceptor()
|
|
73
|
+
|
|
74
|
+
await window.fetch('/api/data')
|
|
75
|
+
|
|
76
|
+
expect(mockGoto).toHaveBeenCalledWith(
|
|
77
|
+
'/login?referrer=' + encodeURIComponent('/dashboard?tab=profile')
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('still returns the 401 response to the caller', async () => {
|
|
82
|
+
window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
|
|
83
|
+
setupFetchInterceptor()
|
|
84
|
+
|
|
85
|
+
const res = await window.fetch('/api/data')
|
|
86
|
+
|
|
87
|
+
expect(res.status).toBe(401)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('passes all fetch arguments through to the original fetch', async () => {
|
|
91
|
+
const innerFetch = vi.fn().mockResolvedValue(makeResponse(200))
|
|
92
|
+
window.fetch = innerFetch
|
|
93
|
+
setupFetchInterceptor()
|
|
94
|
+
|
|
95
|
+
await window.fetch('/api/endpoint', { method: 'POST', body: 'data' })
|
|
96
|
+
|
|
97
|
+
expect(innerFetch).toHaveBeenCalledWith('/api/endpoint', { method: 'POST', body: 'data' })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
import { focusOnFirstError } from './focus'
|
|
4
|
+
|
|
5
|
+
function makeInput(valid: boolean): HTMLInputElement {
|
|
6
|
+
const input = document.createElement('input')
|
|
7
|
+
input.type = 'text'
|
|
8
|
+
if (!valid) {
|
|
9
|
+
input.required = true
|
|
10
|
+
// leave value empty so checkValidity() returns false
|
|
11
|
+
}
|
|
12
|
+
input.focus = vi.fn()
|
|
13
|
+
return input
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeForm(...inputs: HTMLInputElement[]): HTMLFormElement {
|
|
17
|
+
const form = document.createElement('form')
|
|
18
|
+
for (const input of inputs) form.appendChild(input)
|
|
19
|
+
return form
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('focusOnFirstError', () => {
|
|
23
|
+
it('focuses the first invalid input', () => {
|
|
24
|
+
const invalid = makeInput(false)
|
|
25
|
+
const form = makeForm(invalid)
|
|
26
|
+
|
|
27
|
+
focusOnFirstError(form)
|
|
28
|
+
|
|
29
|
+
expect(invalid.focus).toHaveBeenCalledOnce()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not focus a valid input', () => {
|
|
33
|
+
const valid = makeInput(true)
|
|
34
|
+
const form = makeForm(valid)
|
|
35
|
+
|
|
36
|
+
focusOnFirstError(form)
|
|
37
|
+
|
|
38
|
+
expect(valid.focus).not.toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('focuses only the first invalid input when multiple are invalid', () => {
|
|
42
|
+
const first = makeInput(false)
|
|
43
|
+
const second = makeInput(false)
|
|
44
|
+
const form = makeForm(first, second)
|
|
45
|
+
|
|
46
|
+
focusOnFirstError(form)
|
|
47
|
+
|
|
48
|
+
expect(first.focus).toHaveBeenCalledOnce()
|
|
49
|
+
expect(second.focus).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('skips valid inputs before the first invalid one', () => {
|
|
53
|
+
const valid = makeInput(true)
|
|
54
|
+
const invalid = makeInput(false)
|
|
55
|
+
const form = makeForm(valid, invalid)
|
|
56
|
+
|
|
57
|
+
focusOnFirstError(form)
|
|
58
|
+
|
|
59
|
+
expect(valid.focus).not.toHaveBeenCalled()
|
|
60
|
+
expect(invalid.focus).toHaveBeenCalledOnce()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does nothing when the form has no inputs', () => {
|
|
64
|
+
const form = document.createElement('form')
|
|
65
|
+
// Should not throw
|
|
66
|
+
expect(() => focusOnFirstError(form)).not.toThrow()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does nothing when all inputs are valid', () => {
|
|
70
|
+
const a = makeInput(true)
|
|
71
|
+
const b = makeInput(true)
|
|
72
|
+
const form = makeForm(a, b)
|
|
73
|
+
|
|
74
|
+
focusOnFirstError(form)
|
|
75
|
+
|
|
76
|
+
expect(a.focus).not.toHaveBeenCalled()
|
|
77
|
+
expect(b.focus).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ignores non-input elements (e.g. select, button)', () => {
|
|
81
|
+
const select = document.createElement('select')
|
|
82
|
+
select.required = true
|
|
83
|
+
const focusSpy = vi.spyOn(select, 'focus')
|
|
84
|
+
const form = document.createElement('form')
|
|
85
|
+
form.appendChild(select)
|
|
86
|
+
|
|
87
|
+
focusOnFirstError(form)
|
|
88
|
+
|
|
89
|
+
expect(focusSpy).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
})
|