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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user