import Database from 'better-sqlite3'; import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Cookies } from '@sveltejs/kit'; const temporaryDirectories: string[] = []; let directory: string; let databasePath: string; function testCookies(): Cookies { const values = new Map(); return { get: (name: string) => values.get(name), getAll: () => Array.from(values, ([name, value]) => ({ name, value })), set: (name: string, value: string) => { values.set(name, value); }, delete: (name: string) => { values.delete(name); }, serialize: () => '' } as unknown as Cookies; } function tokenFromLatestMail(): string { const outbox = path.join(directory, 'outbox'); const files = readdirSync(outbox).sort(); const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as { text: string; }; const match = message.text.match(/[?&]token=([^\s]+)/); if (!match) throw new Error('Testmail enthält keinen Token.'); return decodeURIComponent(match[1]); } beforeEach(() => { directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-')); temporaryDirectories.push(directory); databasePath = path.join(directory, 'test.sqlite'); process.env.SENDEWERK_DB_PATH = databasePath; process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox'); process.env.SENDEWERK_MAIL_TRANSPORT = 'local'; process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173'; process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32); execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); }); afterEach(async () => { const { closeDatabase } = await import('./database.js'); closeDatabase(); for (const name of [ 'SENDEWERK_DB_PATH', 'SENDEWERK_UPLOAD_DIR', 'SENDEWERK_MAIL_OUTBOX_DIR', 'SENDEWERK_MAIL_TRANSPORT', 'SENDEWERK_MAIL_FROM', 'SENDEWERK_SMTP_HOST', 'SENDEWERK_APP_ORIGIN', 'SENDEWERK_SETUP_TOKEN' ]) { delete process.env[name]; } for (const temporaryDirectory of temporaryDirectories.splice(0)) { rmSync(temporaryDirectory, { recursive: true, force: true }); } }); describe('authentication', () => { it('creates the first Barkeeper once and stores no plaintext secret', async () => { const { createFirstAdmin } = await import('./auth.js'); const password = 'Ein-sehr-langes-Testpasswort'; const user = await createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'Bar@example.org', displayName: 'Erster Barkeeper', password }); const database = new Database(databasePath, { readonly: true }); const row = database.prepare('SELECT * FROM users').get() as { role: string; email_normalized: string; password_hash: string; }; database.close(); expect(user.role).toBe('barkeeper'); expect(row.email_normalized).toBe('bar@example.org'); expect(row.password_hash).not.toContain(password); await expect( createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'second@example.org', displayName: 'Zweiter Admin', password }) ).rejects.toThrow('SETUP_CLOSED'); }); it('accepts an invitation only once and rejects an expired invitation', async () => { const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js'); const admin = await createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'admin@example.org', displayName: 'Admin', password: 'Admin-Passwort-1234' }); await inviteUser(admin, 'crew@example.org', 'thekencrew'); const firstToken = tokenFromLatestMail(); const crew = await acceptInvitation({ token: firstToken, displayName: 'Crew', password: 'Crew-Passwort-1234' }); expect(crew.role).toBe('thekencrew'); await expect( acceptInvitation({ token: firstToken, displayName: 'Crew', password: 'Crew-Passwort-1234' }) ).rejects.toThrow('TOKEN_INVALID'); await inviteUser(admin, 'late@example.org', 'thekencrew'); const expiredToken = tokenFromLatestMail(); const database = new Database(databasePath); database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run(); database.close(); await expect( acceptInvitation({ token: expiredToken, displayName: 'Zu spät', password: 'Crew-Passwort-1234' }) ).rejects.toThrow('TOKEN_INVALID'); }); it('revokes active sessions immediately when a user is locked', async () => { const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } = await import('./auth.js'); const admin = await createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'admin@example.org', displayName: 'Admin', password: 'Admin-Passwort-1234' }); const database = new Database(databasePath); const crewId = crypto.randomUUID(); database .prepare( `INSERT INTO users (id, email, email_normalized, display_name, password_hash, role, password_changed_at_utc, created_at_utc, updated_at_utc) SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew', password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?` ) .run(crewId, admin.id); database.close(); const cookies = testCookies(); createSession(crewId, cookies); const token = cookies.get(SESSION_COOKIE); expect(getSessionUser(token)?.id).toBe(crewId); setUserLocked(admin, crewId, true); expect(getSessionUser(token)).toBeNull(); }); it('uses password reset links once and invalidates prior sessions', async () => { const { authenticate, createFirstAdmin, createSession, getSessionUser, requestPasswordReset, resetPassword, SESSION_COOKIE } = await import('./auth.js'); const admin = await createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'admin@example.org', displayName: 'Admin', password: 'Altes-Passwort-1234' }); const cookies = testCookies(); createSession(admin.id, cookies); const sessionToken = cookies.get(SESSION_COOKIE); await requestPasswordReset(admin.email); const expiredToken = tokenFromLatestMail(); const database = new Database(databasePath); database .prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'") .run(); database.close(); await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow( 'TOKEN_INVALID' ); await requestPasswordReset(admin.email); const resetToken = tokenFromLatestMail(); await resetPassword(resetToken, 'Neues-Passwort-1234'); expect(getSessionUser(sessionToken)).toBeNull(); expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull(); expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull(); await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow( 'TOKEN_INVALID' ); }); it('rate limits repeated failed login attempts without locking the account', async () => { const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } = await import('./auth.js'); await createFirstAdmin({ setupToken: 'a'.repeat(32), email: 'admin@example.org', displayName: 'Admin', password: 'Admin-Passwort-1234' }); for (let attempt = 0; attempt < 5; attempt += 1) { expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true); expect(await authenticate('admin@example.org', 'falsch')).toBeNull(); } expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true); const database = new Database(databasePath, { readonly: true }); const user = database.prepare('SELECT locked_at_utc FROM users').get() as { locked_at_utc: string | null; }; database.close(); expect(user.locked_at_utc).toBeNull(); }); it('denies administrative operations to Thekencrew', async () => { const { requireBarkeeper } = await import('./auth.js'); expect(() => requireBarkeeper({ id: 'crew', email: 'crew@example.org', displayName: 'Crew', role: 'thekencrew', lockedAtUtc: null }) ).toThrow(); }); it('never falls back to local delivery when SMTP was selected', async () => { process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp'; process.env.SENDEWERK_MAIL_FROM = ''; process.env.SENDEWERK_SMTP_HOST = ''; const { sendMail } = await import('./mail.js'); await expect( sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' }) ).rejects.toThrow('SMTP-Konfiguration'); }); });