Add tests for sessions, guards, API error details and the app layout load

This commit is contained in:
2026-09-24 14:03:35 +02:00
parent ba0a2373b5
commit b5c9658d65
4 changed files with 210 additions and 0 deletions
+31
View File
@@ -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();
});
});
+52
View File
@@ -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');
});
});
+63
View File
@@ -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' });
});
});