import { describe, expect, it } from 'vitest' import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db' import { buildApp } from '../src/app.js' import { loadConfig } from '../src/config.js' async function buildTestApp() { const config = loadConfig({ ...process.env, JWT_SECRET: 'test-secret-at-least-8', ADMIN_EMAIL: 'admin@test.local', ADMIN_PASSWORD: 'adminpass', DATABASE_URL: 'sqlite::memory:', NODE_ENV: 'test', AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret', }) return buildApp({ config, databaseUrl: 'sqlite::memory:' }) } async function adminToken(app: Awaited>) { const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@test.local', password: 'adminpass' }, }) expect(login.statusCode).toBe(200) return (login.json() as { access_token: string }).access_token } describe('audit log API', () => { it('ingests external events with secret and dedupes by event_id', async () => { const app = await buildTestApp() const denied = await app.inject({ method: 'POST', url: '/api/v1/ingest/audit', payload: { events: [] }, }) expect(denied.statusCode).toBe(401) const eventId = 'evt-test-1' const ok = await app.inject({ method: 'POST', url: '/api/v1/ingest/audit', headers: { authorization: 'Bearer dev-audit-ingest-secret' }, payload: { events: [ { event_id: eventId, source_app: 'vps', action: 'vps.vps.create', summary: 'Создан VPS', actor_email: 'ops@test.local', }, ], }, }) expect(ok.statusCode).toBe(200) expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 }) const dup = await app.inject({ method: 'POST', url: '/api/v1/ingest/audit', headers: { authorization: 'Bearer dev-audit-ingest-secret' }, payload: { events: [ { event_id: eventId, source_app: 'vps', action: 'vps.vps.create', summary: 'Создан VPS', }, ], }, }) expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 }) const token = await adminToken(app) const list = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?source_app=vps', headers: { authorization: `Bearer ${token}` }, }) expect(list.statusCode).toBe(200) const entries = list.json() as { source_app: string; action: string }[] expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true) await app.close() }) it('records login and lists for admin', async () => { const app = await buildTestApp() const token = await adminToken(app) const list = await app.inject({ method: 'GET', url: '/api/v1/admin/audit', headers: { authorization: `Bearer ${token}` }, }) expect(list.statusCode).toBe(200) const entries = list.json() as { action: string }[] expect(entries.some((e) => e.action === 'auth.login')).toBe(true) const denied = await app.inject({ method: 'GET', url: '/api/v1/admin/audit', }) expect(denied.statusCode).toBe(401) await app.close() }) it('updates retention and purges old rows', async () => { const app = await buildTestApp() const token = await adminToken(app) appendAudit(app.db, { action: 'user.create', summary: 'old event', }) // Backdate the last inserted row app.sqlite .prepare( `UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`, ) .run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString()) const settings = await app.inject({ method: 'PUT', url: '/api/v1/admin/audit/settings', headers: { authorization: `Bearer ${token}` }, payload: { retention_days: 30 }, }) expect(settings.statusCode).toBe(200) expect(settings.json()).toEqual({ retention_days: 30 }) const before = listAudit(app.db, { limit: 500 }) expect(before.some((e) => e.summary === 'old event')).toBe(true) const deleted = purgeAuditOlderThan(app.db, 30) expect(deleted).toBeGreaterThanOrEqual(1) expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe( false, ) const purge = await app.inject({ method: 'POST', url: '/api/v1/admin/audit/purge', headers: { authorization: `Bearer ${token}` }, }) expect(purge.statusCode).toBe(200) expect((purge.json() as { retention_days: number }).retention_days).toBe(30) await app.close() }) it('records user.create from admin mutation', async () => { const app = await buildTestApp() const token = await adminToken(app) const create = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: { authorization: `Bearer ${token}` }, payload: { email: 'new@test.local', name: 'New User', password: 'secret12', is_admin: false, apps: ['cfdm'], permissions: [], }, }) expect(create.statusCode).toBe(201) const list = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?action=user.create', headers: { authorization: `Bearer ${token}` }, }) expect(list.statusCode).toBe(200) const entries = list.json() as { action: string; summary: string }[] expect(entries.length).toBeGreaterThanOrEqual(1) expect(entries[0]?.summary).toContain('new@test.local') await app.close() }) it('records login with IP, UA and last_login_ip', async () => { const app = await buildTestApp() const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', headers: { 'x-forwarded-for': '203.0.113.10', 'user-agent': 'VitestBrowser/1.0', }, payload: { email: 'admin@test.local', password: 'adminpass', return_to: 'https://vps.example.test/dashboard', }, }) expect(login.statusCode).toBe(200) const token = (login.json() as { access_token: string }).access_token const users = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: { authorization: `Bearer ${token}` }, }) const admin = ( users.json() as { email: string; last_login_ip: string | null }[] ).find((u) => u.email === 'admin@test.local') expect(admin?.last_login_ip).toBe('203.0.113.10') const list = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?kind=logins', headers: { authorization: `Bearer ${token}` }, }) const entries = list.json() as { action: string ip: string | null details: Record | null }[] const loginEvt = entries.find((e) => e.action === 'auth.login') expect(loginEvt?.ip).toBe('203.0.113.10') expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0') expect(loginEvt?.details?.target_app).toBe('vps') await app.close() }) it('records failed login with email and IP', async () => { const app = await buildTestApp() const fail = await app.inject({ method: 'POST', url: '/api/v1/auth/login', headers: { 'x-forwarded-for': '198.51.100.7' }, payload: { email: 'nobody@test.local', password: 'wrong' }, }) expect(fail.statusCode).toBe(401) const token = await adminToken(app) const list = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local', headers: { authorization: `Bearer ${token}` }, }) const entries = list.json() as { action: string actor_email: string | null ip: string | null }[] expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true) expect(entries[0]?.actor_email).toBe('nobody@test.local') expect(entries[0]?.ip).toBe('198.51.100.7') await app.close() }) it('records sso-access and separates kind=changes', async () => { const app = await buildTestApp() const token = await adminToken(app) const sso = await app.inject({ method: 'POST', url: '/api/v1/auth/sso-access', headers: { authorization: `Bearer ${token}` }, payload: { return_to: 'https://bgp.example.test/' }, }) expect(sso.statusCode).toBe(200) expect((sso.json() as { target_app: string }).target_app).toBe('bgp') const logins = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?kind=logins', headers: { authorization: `Bearer ${token}` }, }) const loginEntries = logins.json() as { action: string }[] expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true) expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true) const changes = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?kind=changes', headers: { authorization: `Bearer ${token}` }, }) const changeEntries = changes.json() as { action: string }[] expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true) await app.close() }) it('lists and revokes refresh sessions', async () => { const app = await buildTestApp() const token = await adminToken(app) const sessions = await app.inject({ method: 'GET', url: '/api/v1/admin/sessions', headers: { authorization: `Bearer ${token}` }, }) expect(sessions.statusCode).toBe(200) const rows = sessions.json() as { id: string; user_id: string }[] expect(rows.length).toBeGreaterThanOrEqual(1) const sessionId = rows[0]!.id const revoke = await app.inject({ method: 'DELETE', url: `/api/v1/admin/sessions/${sessionId}`, headers: { authorization: `Bearer ${token}` }, }) expect(revoke.statusCode).toBe(200) const after = await app.inject({ method: 'GET', url: '/api/v1/admin/sessions', headers: { authorization: `Bearer ${token}` }, }) expect( (after.json() as { id: string }[]).some((s) => s.id === sessionId), ).toBe(false) await app.close() }) })