Add tests for sessions, guards, API error details and the app layout load
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>);
|
||||
|
||||
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<string> {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof load>[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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user