Coves frontend - a photon fork
at main 680 lines 22 kB view raw
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})