Coves frontend - a photon fork
1import { describe, it, expect, vi, beforeEach } from 'vitest'
2import type { Cookies, Redirect, RequestEvent } from '@sveltejs/kit'
3
4// Variable to control the mocked instance URL
5let mockPublicInternalInstance: string | undefined = 'http://localhost:4000'
6let mockPublicInstanceUrl: string | undefined = undefined
7
8// Variable to control dev mode (default false to avoid hostname redirects in most tests)
9let mockDev = false
10
11// Mock $app/environment
12vi.mock('$app/environment', () => ({
13 get dev() {
14 return mockDev
15 },
16 browser: false,
17 building: false,
18 version: 'test',
19}))
20
21// Mock environment variables
22vi.mock('$env/dynamic/public', () => ({
23 env: {
24 get PUBLIC_INTERNAL_INSTANCE() {
25 return mockPublicInternalInstance
26 },
27 get PUBLIC_INSTANCE_URL() {
28 return mockPublicInstanceUrl
29 },
30 },
31}))
32
33// Import handle and handleError after mocking
34const { handle, handleError } = await import('./hooks.server')
35
36// Mock global fetch
37const mockFetch = vi.fn()
38vi.stubGlobal('fetch', mockFetch)
39
40// Helper to create mock cookies
41function createMockCookies(
42 initialCookies: Record<string, string> = {},
43): Cookies {
44 const store = new Map(Object.entries(initialCookies))
45 return {
46 get: vi.fn((name: string) => store.get(name)),
47 getAll: vi.fn(() =>
48 Array.from(store.entries()).map(([name, value]) => ({ name, value })),
49 ),
50 set: vi.fn((name: string, value: string) => {
51 store.set(name, value)
52 }),
53 delete: vi.fn((name: string) => {
54 store.delete(name)
55 }),
56 serialize: vi.fn(),
57 } as unknown as Cookies
58}
59
60/**
61 * Creates a mock request event for testing.
62 */
63function createMockEvent(options: {
64 cookies?: Cookies
65 locals?: App.Locals
66 url?: string
67}): RequestEvent {
68 const url = new URL(options.url ?? 'http://localhost:5173/')
69 const defaultLocals: App.Locals = { auth: { authenticated: false } }
70 return {
71 request: new Request(url),
72 cookies: options.cookies ?? createMockCookies(),
73 url,
74 locals: options.locals ?? defaultLocals,
75 params: {},
76 platform: undefined,
77 route: { id: '/' },
78 getClientAddress: () => '127.0.0.1',
79 fetch: vi.fn(),
80 isDataRequest: false,
81 isSubRequest: false,
82 setHeaders: vi.fn(),
83 } as unknown as RequestEvent
84}
85
86/**
87 * Checks if a thrown value is a SvelteKit Redirect.
88 * SvelteKit's `redirect()` throws an object with `status` and `location` properties.
89 */
90function isRedirect(err: unknown): err is Redirect {
91 return (
92 typeof err === 'object' &&
93 err !== null &&
94 'status' in err &&
95 'location' in err
96 )
97}
98
99/**
100 * Creates a mock resolve function that returns a Response
101 */
102function createMockResolve() {
103 return vi.fn().mockResolvedValue(new Response('OK'))
104}
105
106describe('hooks.server handle', () => {
107 beforeEach(() => {
108 vi.clearAllMocks()
109 mockPublicInternalInstance = 'http://localhost:4000'
110 mockPublicInstanceUrl = undefined
111 mockDev = false
112 })
113
114 describe('no coves_session cookie', () => {
115 it('results in unauthenticated state and no fetch called', async () => {
116 const cookies = createMockCookies({})
117 const event = createMockEvent({ cookies })
118 const resolve = createMockResolve()
119
120 await handle({ event, resolve })
121
122 expect(mockFetch).not.toHaveBeenCalled()
123 expect(event.locals.auth.authenticated).toBe(false)
124 expect(event.locals.authError).toBeUndefined()
125 expect(resolve).toHaveBeenCalledWith(event)
126 })
127 })
128
129 describe('empty string coves_session cookie', () => {
130 it('treats empty string as no cookie and returns unauthenticated', async () => {
131 const cookies = createMockCookies({ coves_session: '' })
132 const event = createMockEvent({ cookies })
133 const resolve = createMockResolve()
134
135 await handle({ event, resolve })
136
137 // Empty string is falsy, so it's treated the same as no cookie
138 expect(event.locals.auth.authenticated).toBe(false)
139 expect(mockFetch).not.toHaveBeenCalled()
140 expect(resolve).toHaveBeenCalledWith(event)
141 })
142 })
143
144 describe('valid cookie and /api/me returns 200', () => {
145 it('populates authenticated state with correct account and authToken', async () => {
146 mockFetch.mockResolvedValue(
147 new Response(
148 JSON.stringify({
149 did: 'did:plc:user1',
150 handle: 'user1.example.com',
151 avatar: 'https://example.com/avatar.png',
152 }),
153 { status: 200 },
154 ),
155 )
156
157 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
158 const event = createMockEvent({ cookies })
159 const resolve = createMockResolve()
160
161 await handle({ event, resolve })
162
163 expect(mockFetch).toHaveBeenCalledWith('http://localhost:4000/api/me', {
164 headers: { Cookie: 'coves_session=sealed-token-value' },
165 })
166 expect(event.locals.auth.authenticated).toBe(true)
167 if (event.locals.auth.authenticated) {
168 expect(event.locals.auth.account.did).toBe('did:plc:user1')
169 expect(event.locals.auth.account.handle).toBe('user1.example.com')
170 expect(event.locals.auth.account.instance).toBe('http://localhost:4000')
171 expect(event.locals.auth.account.sealedToken).toBe('sealed-token-value')
172 expect(event.locals.auth.account.avatar).toBe(
173 'https://example.com/avatar.png',
174 )
175 expect(event.locals.auth.authToken).toBe('sealed-token-value')
176 }
177 expect(resolve).toHaveBeenCalledWith(event)
178 })
179 })
180
181 describe('valid cookie and /api/me returns 401', () => {
182 it('results in unauthenticated state without console.warn', async () => {
183 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
184
185 mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
186
187 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
188 const event = createMockEvent({ cookies })
189 const resolve = createMockResolve()
190
191 await handle({ event, resolve })
192
193 expect(event.locals.auth.authenticated).toBe(false)
194 // Should NOT log a warning for 401 (expected case)
195 expect(warnSpy).not.toHaveBeenCalled()
196 expect(resolve).toHaveBeenCalledWith(event)
197
198 warnSpy.mockRestore()
199 })
200
201 it('deletes the stale coves_session cookie on 401', async () => {
202 mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
203
204 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
205 const event = createMockEvent({ cookies })
206 const resolve = createMockResolve()
207
208 await handle({ event, resolve })
209
210 expect(cookies.delete).toHaveBeenCalledWith('coves_session', {
211 path: '/',
212 })
213 })
214
215 it('sets sessionExpired flag on 401', async () => {
216 mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
217
218 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
219 const event = createMockEvent({ cookies })
220 const resolve = createMockResolve()
221
222 await handle({ event, resolve })
223
224 expect(event.locals.sessionExpired).toBe(true)
225 })
226
227 it('does not set authError on 401 (session expiry is expected)', async () => {
228 mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
229
230 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
231 const event = createMockEvent({ cookies })
232 const resolve = createMockResolve()
233
234 await handle({ event, resolve })
235
236 expect(event.locals.authError).toBeUndefined()
237 })
238 })
239
240 describe('valid cookie and /api/me returns 500', () => {
241 it('results in unauthenticated state and logs warning', async () => {
242 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
243
244 mockFetch.mockResolvedValue(
245 new Response('Internal Server Error', { status: 500 }),
246 )
247
248 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
249 const event = createMockEvent({ cookies })
250 const resolve = createMockResolve()
251
252 await handle({ event, resolve })
253
254 expect(event.locals.auth.authenticated).toBe(false)
255 expect(warnSpy).toHaveBeenCalledWith(
256 expect.stringContaining('/api/me returned 500'),
257 )
258 expect(resolve).toHaveBeenCalledWith(event)
259
260 warnSpy.mockRestore()
261 })
262 })
263
264 describe('valid cookie and fetch throws network error', () => {
265 it('sets authError to network_error for connection refused', async () => {
266 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
267
268 mockFetch.mockRejectedValue(
269 new Error('Network error: connection refused'),
270 )
271
272 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
273 const event = createMockEvent({ cookies })
274 const resolve = createMockResolve()
275
276 await handle({ event, resolve })
277
278 expect(event.locals.auth.authenticated).toBe(false)
279 expect(event.locals.authError).toBe('network_error')
280 expect(warnSpy).toHaveBeenCalledWith(
281 expect.stringContaining('Network error calling /api/me'),
282 expect.any(Error),
283 )
284 expect(resolve).toHaveBeenCalledWith(event)
285
286 warnSpy.mockRestore()
287 })
288
289 it('sets authError to network_error for TypeError (fetch failure)', async () => {
290 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
291
292 mockFetch.mockRejectedValue(new TypeError('fetch failed'))
293
294 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
295 const event = createMockEvent({ cookies })
296 const resolve = createMockResolve()
297
298 await handle({ event, resolve })
299
300 expect(event.locals.auth.authenticated).toBe(false)
301 expect(event.locals.authError).toBe('network_error')
302 expect(warnSpy).toHaveBeenCalledWith(
303 expect.stringContaining('Network error calling /api/me'),
304 expect.any(TypeError),
305 )
306 expect(resolve).toHaveBeenCalledWith(event)
307
308 warnSpy.mockRestore()
309 })
310
311 it('does not delete the coves_session cookie on network error', async () => {
312 vi.spyOn(console, 'warn').mockImplementation(() => {})
313
314 mockFetch.mockRejectedValue(new TypeError('fetch failed'))
315
316 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
317 const event = createMockEvent({ cookies })
318 const resolve = createMockResolve()
319
320 await handle({ event, resolve })
321
322 expect(cookies.delete).not.toHaveBeenCalled()
323 })
324
325 it('sets authError to network_error for unexpected non-network errors', async () => {
326 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
327
328 mockFetch.mockRejectedValue(new Error('some completely unexpected error'))
329
330 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
331 const event = createMockEvent({ cookies })
332 const resolve = createMockResolve()
333
334 await handle({ event, resolve })
335
336 expect(event.locals.auth.authenticated).toBe(false)
337 expect(event.locals.authError).toBe('network_error')
338 expect(warnSpy).toHaveBeenCalledWith(
339 expect.stringContaining('Unexpected error calling /api/me'),
340 expect.any(Error),
341 )
342 expect(resolve).toHaveBeenCalledWith(event)
343
344 warnSpy.mockRestore()
345 })
346 })
347
348 describe('valid cookie and /api/me returns invalid JSON', () => {
349 it('sets authError to validation_error and logs warning', async () => {
350 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
351
352 mockFetch.mockResolvedValue(
353 new Response('not json', {
354 status: 200,
355 headers: { 'Content-Type': 'text/plain' },
356 }),
357 )
358
359 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
360 const event = createMockEvent({ cookies })
361 const resolve = createMockResolve()
362
363 await handle({ event, resolve })
364
365 expect(event.locals.auth.authenticated).toBe(false)
366 expect(event.locals.authError).toBe('validation_error')
367 // Invalid JSON triggers response.json() to throw as SyntaxError,
368 // which is categorized as a validation error
369 expect(warnSpy).toHaveBeenCalledWith(
370 expect.stringContaining('/api/me returned invalid JSON'),
371 expect.any(SyntaxError),
372 )
373 expect(resolve).toHaveBeenCalledWith(event)
374
375 warnSpy.mockRestore()
376 })
377 })
378
379 describe('valid cookie and /api/me returns incomplete data', () => {
380 it('sets authError to validation_error when did is missing', async () => {
381 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
382
383 mockFetch.mockResolvedValue(
384 new Response(JSON.stringify({ handle: 'user1.example.com' }), {
385 status: 200,
386 }),
387 )
388
389 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
390 const event = createMockEvent({ cookies })
391 const resolve = createMockResolve()
392
393 await handle({ event, resolve })
394
395 expect(event.locals.auth.authenticated).toBe(false)
396 expect(event.locals.authError).toBe('validation_error')
397 expect(warnSpy).toHaveBeenCalledWith(
398 expect.stringContaining('/api/me response failed validation'),
399 )
400 expect(resolve).toHaveBeenCalledWith(event)
401
402 warnSpy.mockRestore()
403 })
404
405 it('sets authError to validation_error when handle is missing', async () => {
406 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
407
408 mockFetch.mockResolvedValue(
409 new Response(JSON.stringify({ did: 'did:plc:user1' }), { status: 200 }),
410 )
411
412 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
413 const event = createMockEvent({ cookies })
414 const resolve = createMockResolve()
415
416 await handle({ event, resolve })
417
418 expect(event.locals.auth.authenticated).toBe(false)
419 expect(event.locals.authError).toBe('validation_error')
420 expect(resolve).toHaveBeenCalledWith(event)
421
422 warnSpy.mockRestore()
423 })
424 })
425
426 describe('no instance URL configured', () => {
427 it('throws a fatal error when instance URL is missing', async () => {
428 mockPublicInternalInstance = undefined
429 mockPublicInstanceUrl = undefined
430
431 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
432 const event = createMockEvent({ cookies })
433 const resolve = createMockResolve()
434
435 await expect(handle({ event, resolve })).rejects.toThrow(
436 'No instance URL configured',
437 )
438
439 expect(mockFetch).not.toHaveBeenCalled()
440 })
441 })
442
443 describe('authToken equals cookie value', () => {
444 it('authToken is the coves_session cookie value', async () => {
445 mockFetch.mockResolvedValue(
446 new Response(
447 JSON.stringify({
448 did: 'did:plc:user1',
449 handle: 'user1.example.com',
450 }),
451 { status: 200 },
452 ),
453 )
454
455 const cookieValue = 'my-specific-sealed-token-value'
456 const cookies = createMockCookies({ coves_session: cookieValue })
457 const event = createMockEvent({ cookies })
458 const resolve = createMockResolve()
459
460 await handle({ event, resolve })
461
462 expect(event.locals.auth.authenticated).toBe(true)
463 if (event.locals.auth.authenticated) {
464 expect(event.locals.auth.authToken).toBe(cookieValue)
465 }
466 })
467 })
468
469 describe('resolve function behavior', () => {
470 it('always calls resolve with the event', async () => {
471 const cookies = createMockCookies({})
472 const event = createMockEvent({ cookies })
473 const resolve = createMockResolve()
474
475 await handle({ event, resolve })
476
477 expect(resolve).toHaveBeenCalledTimes(1)
478 expect(resolve).toHaveBeenCalledWith(event)
479 })
480
481 it('returns the resolve response', async () => {
482 const expectedResponse = new Response('Test Response')
483 const cookies = createMockCookies({})
484 const event = createMockEvent({ cookies })
485 const resolve = vi.fn().mockResolvedValue(expectedResponse)
486
487 const result = await handle({ event, resolve })
488
489 expect(result).toBe(expectedResponse)
490 })
491 })
492
493 describe('instance URL fallback', () => {
494 it('uses PUBLIC_INSTANCE_URL when PUBLIC_INTERNAL_INSTANCE is not set', async () => {
495 mockPublicInternalInstance = undefined
496 mockPublicInstanceUrl = 'https://coves.example.com'
497
498 mockFetch.mockResolvedValue(
499 new Response(
500 JSON.stringify({
501 did: 'did:plc:user1',
502 handle: 'user1.example.com',
503 }),
504 { status: 200 },
505 ),
506 )
507
508 const cookies = createMockCookies({ coves_session: 'sealed-token-value' })
509 const event = createMockEvent({ cookies })
510 const resolve = createMockResolve()
511
512 await handle({ event, resolve })
513
514 expect(mockFetch).toHaveBeenCalledWith(
515 'https://coves.example.com/api/me',
516 {
517 headers: { Cookie: 'coves_session=sealed-token-value' },
518 },
519 )
520 expect(event.locals.auth.authenticated).toBe(true)
521 })
522 })
523
524 describe('dev mode hostname normalization', () => {
525 it('redirects localhost to 127.0.0.1 when dev=true and PUBLIC_INSTANCE_URL uses 127.0.0.1', async () => {
526 mockDev = true
527 mockPublicInstanceUrl = 'http://127.0.0.1:8080'
528
529 const cookies = createMockCookies({})
530 const event = createMockEvent({
531 cookies,
532 url: 'http://localhost:8080/some/page?q=test',
533 })
534 const resolve = createMockResolve()
535
536 try {
537 await handle({ event, resolve })
538 // Should have thrown a redirect
539 expect.fail('Expected a redirect to be thrown')
540 } catch (err) {
541 expect(isRedirect(err)).toBe(true)
542 if (isRedirect(err)) {
543 expect(err.status).toBe(302)
544 expect(err.location).toBe('http://127.0.0.1:8080/some/page?q=test')
545 }
546 }
547 })
548
549 it('does not redirect when hostname already matches canonical host', async () => {
550 mockDev = true
551 mockPublicInstanceUrl = 'http://127.0.0.1:8080'
552
553 const cookies = createMockCookies({})
554 const event = createMockEvent({
555 cookies,
556 url: 'http://127.0.0.1:8080/some/page',
557 })
558 const resolve = createMockResolve()
559
560 await handle({ event, resolve })
561
562 // Should proceed normally without redirect
563 expect(resolve).toHaveBeenCalledWith(event)
564 })
565
566 it('does not redirect when dev=false even if hostname mismatches', async () => {
567 mockDev = false
568 mockPublicInstanceUrl = 'http://127.0.0.1:8080'
569
570 const cookies = createMockCookies({})
571 const event = createMockEvent({
572 cookies,
573 url: 'http://localhost:8080/',
574 })
575 const resolve = createMockResolve()
576
577 await handle({ event, resolve })
578
579 // Should proceed normally without redirect
580 expect(resolve).toHaveBeenCalledWith(event)
581 })
582
583 it('does not redirect when PUBLIC_INSTANCE_URL is not set', async () => {
584 mockDev = true
585 mockPublicInstanceUrl = undefined
586
587 const cookies = createMockCookies({})
588 const event = createMockEvent({
589 cookies,
590 url: 'http://localhost:8080/',
591 })
592 const resolve = createMockResolve()
593
594 await handle({ event, resolve })
595
596 expect(resolve).toHaveBeenCalledWith(event)
597 })
598 })
599})
600
601describe('hooks.server handleError', () => {
602 beforeEach(() => {
603 vi.clearAllMocks()
604 })
605
606 it('returns "Not found" for 404 errors', async () => {
607 const result = await handleError({
608 error: new Error('Page not found'),
609 event: createMockEvent({ cookies: createMockCookies() }),
610 status: 404,
611 message: 'Not Found',
612 })
613
614 expect(result).toEqual({ message: 'Not found' })
615 })
616
617 it('returns generic error message for non-404 errors', async () => {
618 const result = await handleError({
619 error: new Error(
620 'Internal database connection failed with password xyz123',
621 ),
622 event: createMockEvent({ cookies: createMockCookies() }),
623 status: 500,
624 message: 'Internal Server Error',
625 })
626
627 expect(result).toEqual({ message: 'An unexpected error occurred' })
628 })
629
630 it('does not expose internal error details in response', async () => {
631 const sensitiveError = new Error(
632 'Database password: secret123, API key: abc-def-ghi',
633 )
634 const result = await handleError({
635 error: sensitiveError,
636 event: createMockEvent({ cookies: createMockCookies() }),
637 status: 500,
638 message: 'Internal Server Error',
639 })
640
641 const appError = result as App.Error
642 expect(appError.message).not.toContain('secret123')
643 expect(appError.message).not.toContain('abc-def-ghi')
644 expect(appError.message).not.toContain('password')
645 expect(appError.message).toBe('An unexpected error occurred')
646 })
647
648 it('handles errors without message property', async () => {
649 const result = await handleError({
650 error: 'String error without message property',
651 event: createMockEvent({ cookies: createMockCookies() }),
652 status: 500,
653 message: 'Internal Server Error',
654 })
655
656 expect(result).toEqual({ message: 'An unexpected error occurred' })
657 })
658
659 it('returns generic message for 400 errors', async () => {
660 const result = await handleError({
661 error: new Error('Bad request: invalid JSON'),
662 event: createMockEvent({ cookies: createMockCookies() }),
663 status: 400,
664 message: 'Bad Request',
665 })
666
667 expect(result).toEqual({ message: 'An unexpected error occurred' })
668 })
669
670 it('returns generic message for 403 errors', async () => {
671 const result = await handleError({
672 error: new Error('User not authorized for resource /admin/secrets'),
673 event: createMockEvent({ cookies: createMockCookies() }),
674 status: 403,
675 message: 'Forbidden',
676 })
677
678 expect(result).toEqual({ message: 'An unexpected error occurred' })
679 })
680})