sveltekit-auth-example 5.6.1 → 5.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -3
- package/CHANGELOG.md +33 -0
- package/README.md +24 -18
- package/db_create.sql +0 -1
- package/db_schema.sql +3 -3
- package/package.json +8 -5
- package/src/app.d.ts +125 -61
- package/src/app.html +6 -0
- package/src/hooks.server.unit.test.ts +163 -0
- package/src/lib/Turnstile.svelte +53 -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.ts +41 -17
- package/src/lib/google.unit.test.ts +189 -0
- package/src/lib/server/brevo.ts +179 -0
- 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 +6 -6
- package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
- package/src/lib/server/email/password-reset.ts +7 -7
- package/src/lib/server/email/password-reset.unit.test.ts +70 -0
- package/src/lib/server/email/verify-email.ts +7 -7
- package/src/lib/server/email/verify-email.unit.test.ts +79 -0
- package/src/lib/server/turnstile.ts +29 -0
- 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 +9 -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 +8 -3
- 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 +8 -3
- package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
- package/src/routes/auth/register/+server.ts +14 -3
- package/src/routes/auth/register/+server.unit.test.ts +182 -0
- package/src/routes/auth/reset/+server.ts +8 -2
- package/src/routes/auth/reset/+server.unit.test.ts +139 -0
- package/src/routes/auth/reset/[token]/+page.svelte +11 -1
- 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/forgot/+page.svelte +9 -1
- package/src/routes/login/+page.svelte +28 -5
- package/src/routes/register/+page.svelte +11 -1
- package/src/service-worker.unit.test.ts +228 -0
- package/svelte.config.js +4 -2
- package/vite.config.ts +5 -10
- package/src/lib/server/sendgrid.ts +0 -22
|
@@ -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
|
+
})
|
package/src/lib/google.ts
CHANGED
|
@@ -2,6 +2,26 @@ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
|
|
|
2
2
|
import { appState } from '$lib/app-state.svelte'
|
|
3
3
|
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Waits for the Google Identity Services SDK to load, then calls `fn`.
|
|
7
|
+
* Polls every 50 ms; gives up after 10 seconds.
|
|
8
|
+
*/
|
|
9
|
+
function whenGoogleReady(fn: () => void) {
|
|
10
|
+
if (typeof google !== 'undefined') {
|
|
11
|
+
fn()
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const deadline = Date.now() + 10_000
|
|
15
|
+
const id = setInterval(() => {
|
|
16
|
+
if (typeof google !== 'undefined') {
|
|
17
|
+
clearInterval(id)
|
|
18
|
+
fn()
|
|
19
|
+
} else if (Date.now() > deadline) {
|
|
20
|
+
clearInterval(id)
|
|
21
|
+
}
|
|
22
|
+
}, 50)
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
/**
|
|
6
26
|
* Renders the Google Sign-In button inside the element with id `googleButton`.
|
|
7
27
|
*
|
|
@@ -10,16 +30,18 @@ import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
|
10
30
|
* correctly within its container.
|
|
11
31
|
*/
|
|
12
32
|
export function renderGoogleButton() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
whenGoogleReady(() => {
|
|
34
|
+
const btn = document.getElementById('googleButton')
|
|
35
|
+
if (btn) {
|
|
36
|
+
const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
|
|
37
|
+
google.accounts.id.renderButton(btn, {
|
|
38
|
+
type: 'standard',
|
|
39
|
+
theme: 'outline',
|
|
40
|
+
size: 'large',
|
|
41
|
+
width: Math.floor(width)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
})
|
|
23
45
|
}
|
|
24
46
|
|
|
25
47
|
/**
|
|
@@ -33,13 +55,15 @@ export function renderGoogleButton() {
|
|
|
33
55
|
* 3. Redirects the user via {@link redirectAfterLogin}.
|
|
34
56
|
*/
|
|
35
57
|
export function initializeGoogleAccounts() {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
whenGoogleReady(() => {
|
|
59
|
+
if (!appState.googleInitialized) {
|
|
60
|
+
google.accounts.id.initialize({
|
|
61
|
+
client_id: PUBLIC_GOOGLE_CLIENT_ID,
|
|
62
|
+
callback: googleCallback
|
|
63
|
+
})
|
|
64
|
+
appState.googleInitialized = true
|
|
65
|
+
}
|
|
66
|
+
})
|
|
43
67
|
|
|
44
68
|
async function googleCallback(response: google.accounts.id.CredentialResponse) {
|
|
45
69
|
const res = await fetch('/auth/google', {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('$env/static/public', () => ({ PUBLIC_GOOGLE_CLIENT_ID: 'test-client-id' }))
|
|
5
|
+
vi.mock('$lib/app-state.svelte', () => ({ appState: { googleInitialized: false, user: undefined } }))
|
|
6
|
+
vi.mock('$lib/auth-redirect', () => ({ redirectAfterLogin: vi.fn() }))
|
|
7
|
+
|
|
8
|
+
import { renderGoogleButton, initializeGoogleAccounts } from './google'
|
|
9
|
+
import { appState } from '$lib/app-state.svelte'
|
|
10
|
+
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
11
|
+
|
|
12
|
+
const mockRedirectAfterLogin = vi.mocked(redirectAfterLogin)
|
|
13
|
+
|
|
14
|
+
/** Build a minimal google.accounts.id mock and attach it to globalThis. */
|
|
15
|
+
function installGoogleMock() {
|
|
16
|
+
const mock = {
|
|
17
|
+
initialize: vi.fn(),
|
|
18
|
+
renderButton: vi.fn()
|
|
19
|
+
}
|
|
20
|
+
;(globalThis as unknown as Record<string, unknown>).google = {
|
|
21
|
+
accounts: { id: mock }
|
|
22
|
+
}
|
|
23
|
+
return mock
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function removeGoogleMock() {
|
|
27
|
+
delete (globalThis as unknown as Record<string, unknown>).google
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('renderGoogleButton', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
document.body.innerHTML = ''
|
|
33
|
+
appState.googleInitialized = false
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
removeGoogleMock()
|
|
38
|
+
vi.useRealTimers()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('calls google.accounts.id.renderButton with the button element', () => {
|
|
42
|
+
const mock = installGoogleMock()
|
|
43
|
+
const btn = document.createElement('div')
|
|
44
|
+
btn.id = 'googleButton'
|
|
45
|
+
document.body.appendChild(btn)
|
|
46
|
+
|
|
47
|
+
renderGoogleButton()
|
|
48
|
+
|
|
49
|
+
expect(mock.renderButton).toHaveBeenCalledOnce()
|
|
50
|
+
expect(mock.renderButton).toHaveBeenCalledWith(btn, expect.objectContaining({
|
|
51
|
+
type: 'standard',
|
|
52
|
+
theme: 'outline',
|
|
53
|
+
size: 'large'
|
|
54
|
+
}))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('falls back to 400 when the button has no width', () => {
|
|
58
|
+
const mock = installGoogleMock()
|
|
59
|
+
const btn = document.createElement('div')
|
|
60
|
+
btn.id = 'googleButton'
|
|
61
|
+
document.body.appendChild(btn)
|
|
62
|
+
|
|
63
|
+
renderGoogleButton()
|
|
64
|
+
|
|
65
|
+
const [, opts] = mock.renderButton.mock.calls[0]
|
|
66
|
+
expect(opts.width).toBe(400)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does nothing when the googleButton element is absent', () => {
|
|
70
|
+
const mock = installGoogleMock()
|
|
71
|
+
|
|
72
|
+
renderGoogleButton()
|
|
73
|
+
|
|
74
|
+
expect(mock.renderButton).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('polls until google is available, then renders', () => {
|
|
78
|
+
vi.useFakeTimers()
|
|
79
|
+
const btn = document.createElement('div')
|
|
80
|
+
btn.id = 'googleButton'
|
|
81
|
+
document.body.appendChild(btn)
|
|
82
|
+
|
|
83
|
+
renderGoogleButton() // google not yet defined
|
|
84
|
+
|
|
85
|
+
const mock = installGoogleMock()
|
|
86
|
+
vi.advanceTimersByTime(100) // advance past one poll interval
|
|
87
|
+
|
|
88
|
+
expect(mock.renderButton).toHaveBeenCalledOnce()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('gives up polling after 10 seconds without throwing', () => {
|
|
92
|
+
vi.useFakeTimers()
|
|
93
|
+
// google is never installed
|
|
94
|
+
|
|
95
|
+
expect(() => {
|
|
96
|
+
renderGoogleButton()
|
|
97
|
+
vi.advanceTimersByTime(11_000)
|
|
98
|
+
}).not.toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('initializeGoogleAccounts', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
appState.googleInitialized = false
|
|
105
|
+
appState.user = undefined
|
|
106
|
+
mockRedirectAfterLogin.mockReset()
|
|
107
|
+
document.body.innerHTML = ''
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
removeGoogleMock()
|
|
112
|
+
vi.useRealTimers()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('calls google.accounts.id.initialize with the client id', () => {
|
|
116
|
+
const mock = installGoogleMock()
|
|
117
|
+
|
|
118
|
+
initializeGoogleAccounts()
|
|
119
|
+
|
|
120
|
+
expect(mock.initialize).toHaveBeenCalledOnce()
|
|
121
|
+
expect(mock.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
|
122
|
+
client_id: 'test-client-id'
|
|
123
|
+
}))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('sets appState.googleInitialized to true', () => {
|
|
127
|
+
installGoogleMock()
|
|
128
|
+
|
|
129
|
+
initializeGoogleAccounts()
|
|
130
|
+
|
|
131
|
+
expect(appState.googleInitialized).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('does not call initialize a second time if already initialized', () => {
|
|
135
|
+
const mock = installGoogleMock()
|
|
136
|
+
appState.googleInitialized = true
|
|
137
|
+
|
|
138
|
+
initializeGoogleAccounts()
|
|
139
|
+
|
|
140
|
+
expect(mock.initialize).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('callback POSTs the credential token to /auth/google and updates appState', async () => {
|
|
144
|
+
const mock = installGoogleMock()
|
|
145
|
+
const user = { id: 1, role: 'admin' }
|
|
146
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
147
|
+
new Response(JSON.stringify({ user }), { status: 200 })
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
initializeGoogleAccounts()
|
|
151
|
+
|
|
152
|
+
// Extract and invoke the registered callback
|
|
153
|
+
const { callback } = mock.initialize.mock.calls[0][0]
|
|
154
|
+
await callback({ credential: 'test-credential' })
|
|
155
|
+
|
|
156
|
+
expect(fetch).toHaveBeenCalledWith('/auth/google', expect.objectContaining({
|
|
157
|
+
method: 'POST',
|
|
158
|
+
body: JSON.stringify({ token: 'test-credential' })
|
|
159
|
+
}))
|
|
160
|
+
expect(appState.user).toEqual(user)
|
|
161
|
+
expect(mockRedirectAfterLogin).toHaveBeenCalledWith(user)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('callback does not update state when the response is not ok', async () => {
|
|
165
|
+
const mock = installGoogleMock()
|
|
166
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
167
|
+
new Response(null, { status: 401 })
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
initializeGoogleAccounts()
|
|
171
|
+
|
|
172
|
+
const { callback } = mock.initialize.mock.calls[0][0]
|
|
173
|
+
await callback({ credential: 'bad-credential' })
|
|
174
|
+
|
|
175
|
+
expect(appState.user).toBeUndefined()
|
|
176
|
+
expect(mockRedirectAfterLogin).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('polls until google is available, then initializes', () => {
|
|
180
|
+
vi.useFakeTimers()
|
|
181
|
+
|
|
182
|
+
initializeGoogleAccounts() // google not yet defined
|
|
183
|
+
|
|
184
|
+
const mock = installGoogleMock()
|
|
185
|
+
vi.advanceTimersByTime(100)
|
|
186
|
+
|
|
187
|
+
expect(mock.initialize).toHaveBeenCalledOnce()
|
|
188
|
+
})
|
|
189
|
+
})
|