From b5c9658d658fb0a018615e3d4056cd463adb213f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 14:03:35 +0200 Subject: [PATCH] Add tests for sessions, guards, API error details and the app layout load --- web-player/src/lib/server/api.test.ts | 31 +++++++++ web-player/src/lib/server/guards.test.ts | 52 +++++++++++++++ web-player/src/lib/server/session.test.ts | 63 ++++++++++++++++++ .../src/routes/(app)/layout.server.test.ts | 64 +++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 web-player/src/lib/server/api.test.ts create mode 100644 web-player/src/lib/server/guards.test.ts create mode 100644 web-player/src/lib/server/session.test.ts create mode 100644 web-player/src/routes/(app)/layout.server.test.ts diff --git a/web-player/src/lib/server/api.test.ts b/web-player/src/lib/server/api.test.ts new file mode 100644 index 0000000..d66af6c --- /dev/null +++ b/web-player/src/lib/server/api.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { apiErrorDetail } from './api'; + +describe('apiErrorDetail', () => { + it('joins the validation messages from errors', () => { + expect( + apiErrorDetail({ errors: { Name: ['Too short.', 'Required.'], Genre: ['Invalid.'] } }) + ).toBe('Too short. Required. Invalid.'); + }); + + it('flattens arrays and ignores non-string entries', () => { + expect(apiErrorDetail({ errors: { A: ['one', 2, null], B: 'two' } })).toBe('one two'); + }); + + it('falls back to detail when errors has no text', () => { + expect(apiErrorDetail({ errors: {}, detail: 'Bad request.' })).toBe('Bad request.'); + }); + + it('falls back to title after detail', () => { + expect(apiErrorDetail({ detail: '', title: 'Conflict' })).toBe('Conflict'); + expect(apiErrorDetail({ title: 'Conflict' })).toBe('Conflict'); + }); + + it('returns undefined for non-objects and empty objects', () => { + expect(apiErrorDetail(undefined)).toBeUndefined(); + expect(apiErrorDetail(null)).toBeUndefined(); + expect(apiErrorDetail('boom')).toBeUndefined(); + expect(apiErrorDetail({})).toBeUndefined(); + expect(apiErrorDetail({ detail: 3, title: '' })).toBeUndefined(); + }); +}); diff --git a/web-player/src/lib/server/guards.test.ts b/web-player/src/lib/server/guards.test.ts new file mode 100644 index 0000000..bb88ed9 --- /dev/null +++ b/web-player/src/lib/server/guards.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { loginRedirect, optionalUser, requireUser } from './guards'; + +const user = { sub: 'u1', name: 'Ana' }; + +function redirectOf(fn: () => unknown): { status: number; location: string } { + try { + fn(); + } catch (thrown) { + return thrown as { status: number; location: string }; + } + throw new Error('Expected a redirect'); +} + +describe('loginRedirect', () => { + it('redirects to /auth with the encoded path and query', () => { + const result = redirectOf(() => loginRedirect(new URL('http://x/albums/1?tab=a&b=c'))); + expect(result.status).toBe(302); + expect(result.location).toBe(`/auth?returnTo=${encodeURIComponent('/albums/1?tab=a&b=c')}`); + }); +}); + +describe('requireUser', () => { + it('returns the user when signed in', () => { + const locals = { user, accessToken: 't' } as App.Locals; + expect(requireUser(locals, new URL('http://x/'))).toBe(user); + }); + + it('redirects when there is no user', () => { + const locals = { user: null, accessToken: null } as App.Locals; + const result = redirectOf(() => requireUser(locals, new URL('http://x/library'))); + expect(result.location).toBe('/auth?returnTo=%2Flibrary'); + }); +}); + +describe('optionalUser', () => { + it('returns the user when signed in', () => { + const locals = { user, accessToken: 't' } as App.Locals; + expect(optionalUser(locals, new URL('http://x/'), false)).toBe(user); + }); + + it('returns null for anonymous listening', () => { + const locals = { user: null, accessToken: null } as App.Locals; + expect(optionalUser(locals, new URL('http://x/'), true)).toBeNull(); + }); + + it('redirects anonymous visitors when listening is not allowed', () => { + const locals = { user: null, accessToken: null } as App.Locals; + const result = redirectOf(() => optionalUser(locals, new URL('http://x/albums/1'), false)); + expect(result.location).toBe('/auth?returnTo=%2Falbums%2F1'); + }); +}); diff --git a/web-player/src/lib/server/session.test.ts b/web-player/src/lib/server/session.test.ts new file mode 100644 index 0000000..a6ee25f --- /dev/null +++ b/web-player/src/lib/server/session.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EncryptJWT } from 'jose'; + +const testEnv = vi.hoisted(() => ({ SESSION_SECRET: 'a'.repeat(40) }) as Record); + +vi.mock('$env/dynamic/private', () => ({ env: testEnv })); + +import { decodeSession, encodeSession, type Session } from './auth'; + +const session: Session = { + sub: 'user-1', + name: 'Test User', + email: 'test@example.com', + accessToken: 'access', + refreshToken: 'refresh', + idToken: 'id', + expiresAt: 1_900_000_000 +}; + +async function encryptWith(secret: string, ttlSeconds: number): Promise { + const now = Math.floor(Date.now() / 1000); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret)); + return new EncryptJWT({ ...session }) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .encrypt(new Uint8Array(digest)); +} + +describe('encodeSession / decodeSession', () => { + it('round-trips a session', async () => { + const token = await encodeSession(session); + const decoded = await decodeSession(token); + expect(decoded).toMatchObject(session); + }); + + it('returns null for a missing or empty token', async () => { + expect(await decodeSession(undefined)).toBeNull(); + expect(await decodeSession('')).toBeNull(); + }); + + it('returns null for a tampered token', async () => { + const token = await encodeSession(session); + const tampered = token.slice(0, -4) + (token.endsWith('AAAA') ? 'BBBB' : 'AAAA'); + expect(await decodeSession(tampered)).toBeNull(); + expect(await decodeSession('not.a.token')).toBeNull(); + }); + + it('returns null for a token encrypted with another key', async () => { + const token = await encryptWith('b'.repeat(40), 3600); + expect(await decodeSession(token)).toBeNull(); + }); + + it('returns null for an expired token', async () => { + const token = await encryptWith(testEnv.SESSION_SECRET, -50); + expect(await decodeSession(token)).toBeNull(); + }); + + it('accepts a token built with the configured key', async () => { + const token = await encryptWith(testEnv.SESSION_SECRET, 3600); + expect(await decodeSession(token)).toMatchObject({ sub: 'user-1' }); + }); +}); diff --git a/web-player/src/routes/(app)/layout.server.test.ts b/web-player/src/routes/(app)/layout.server.test.ts new file mode 100644 index 0000000..1822da9 --- /dev/null +++ b/web-player/src/routes/(app)/layout.server.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const allowAnonymous = vi.hoisted(() => vi.fn()); + +vi.mock('$lib/server/playbackConfig', () => ({ getAllowAnonymousListening: allowAnonymous })); +vi.mock('$lib/server/config', () => ({ + authConfig: { issuer: 'https://auth.example.com/' }, + apiConfig: { baseUrl: 'http://api.test' } +})); + +import { load } from './+layout.server'; + +type LoadEvent = Parameters[0]; + +function event(user: { sub: string; name: string } | null, fetchFn = vi.fn()) { + return { + locals: { user, accessToken: user ? 'token' : null }, + url: new URL('http://x/library?q=1'), + fetch: fetchFn + } as unknown as LoadEvent; +} + +beforeEach(() => { + allowAnonymous.mockReset(); +}); + +describe('(app) layout load', () => { + it('redirects to login without a session or anonymous listening', async () => { + allowAnonymous.mockResolvedValue(false); + await expect(load(event(null))).rejects.toMatchObject({ + status: 302, + location: `/auth?returnTo=${encodeURIComponent('/library?q=1')}` + }); + }); + + it('returns empty data in anonymous mode without calling the API', async () => { + allowAnonymous.mockResolvedValue(true); + const fetchFn = vi.fn(); + const data = await load(event(null, fetchFn)); + expect(data).toMatchObject({ + user: null, + allowAnonymousListening: true, + userPlaylists: [], + userPlaylistsTotal: 0, + likedTracks: [], + lastPlayedTrack: null, + accountUrl: 'https://auth.example.com/ui/console' + }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('falls back to empty data when the API fails for a signed in user', async () => { + allowAnonymous.mockResolvedValue(false); + const fetchFn = vi.fn().mockRejectedValue(new Error('down')); + const data = await load(event({ sub: 'u1', name: 'Ana' }, fetchFn)); + expect(data).toMatchObject({ + user: { sub: 'u1' }, + userPlaylists: [], + userPlaylistsTotal: 0, + likedTracks: [], + lastPlayedTrack: null + }); + }); +});