import { after, before, test } from 'node:test'; import assert from 'node:assert/strict'; import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js'; import { applyTestConfig } from '../helpers/testConfig.js'; const dbUrl = prepareTestDatabase('test-ops'); const { createApp } = await import('../../src/http/app.js'); const { db, disconnectDb } = await import('../../src/store/db.js'); const { audit } = await import('../../src/store/auditLog.js'); let running: RunningServer; const ADMIN = 'Bearer test-admin-token'; const ADMIN_ENDPOINTS = [ '/admin/events', '/admin/jobs', '/admin/messages', '/admin/tickets', '/admin/meta', ]; before(async () => { applyTestConfig({ DATABASE_URL: dbUrl }); running = await startServer(createApp()); await db().ticket.create({ data: { conversationId: 4242, ticketNumber: 'EKS-20260820-4242', reason: 'test handoff' }, }); await db().processedMessage.create({ data: { source: 'chatwoot', messageId: 'm-1', conversationId: 4242, status: 'skipped', reason: 'ticket_mode' }, }); await db().processedMessage.create({ data: { source: 'chatwoot', messageId: 'm-2', conversationId: 99, status: 'replied' }, }); await audit({ conversationId: 4242, messageId: 'm-1', jobId: 'job-1', eventType: 'webhook_accepted', summary: 'Queued job job-1' }); await audit({ conversationId: 4242, messageId: 'm-1', eventType: 'skipped_ticket_mode', summary: 'ticket mode' }); await audit({ conversationId: 99, messageId: 'm-2', eventType: 'reply_sent', summary: 'AI reply sent' }); }); after(async () => { await running.close(); await disconnectDb(); }); async function get(path: string, auth?: string) { const headers: Record = {}; if (auth) headers.Authorization = auth; const res = await fetch(`${running.baseUrl}${path}`, { headers, redirect: 'manual' }); const text = await res.text(); let json: Record | null = null; try { json = JSON.parse(text) as Record; } catch { json = null; } return { status: res.status, text, json, headers: res.headers }; } // ───────────────────────────────────────────────────────────── panel shell test('GET /ops serves the panel shell without a token', async () => { const res = await get('/ops'); assert.equal(res.status, 200); assert.match(res.headers.get('content-type') ?? '', /text\/html/); assert.match(res.text, /EKSRelay/); assert.match(res.text, /ADMIN_TOKEN/); // the prompt label, not a value }); test('the shell leaks no data and no credential', async () => { const res = await get('/ops'); // Nothing from the seeded database may appear in the static HTML. assert.ok(!res.text.includes('EKS-20260820-4242'), 'ticket number must not be inlined'); assert.ok(!res.text.includes('test-admin-token'), 'token must never be inlined'); assert.ok(!res.text.includes('test-shared-secret')); assert.ok(!res.text.includes('test-chatwoot-token')); assert.ok(!res.text.includes('4242'), 'no conversation data may be inlined'); }); test('the shell never puts the token in a query string', async () => { const res = await get('/ops'); assert.ok(!/searchParams\.set\(\s*['"]token/i.test(res.text)); assert.ok(!/[?&]token=/.test(res.text)); // It must authenticate through the header instead. assert.match(res.text, /Authorization['"]?\s*:\s*['"]Bearer/); }); test('the shell sends hardening headers', async () => { const res = await get('/ops'); assert.equal(res.headers.get('cache-control'), 'no-store'); assert.match(res.headers.get('x-robots-tag') ?? '', /noindex/); const csp = res.headers.get('content-security-policy') ?? ''; assert.match(csp, /default-src 'none'/); assert.match(csp, /script-src-elem/); assert.match(csp, /img-src 'self'/); assert.match(csp, /connect-src 'self'/); assert.match(csp, /frame-ancestors 'none'/); assert.match(res.text, /data-cfasync="false"/); assert.ok(!res.text.includes(' { const res = await get('/admin/ui'); assert.equal(res.status, 401, '/admin/* must stay uniformly token-protected'); }); // ───────────────────────────────────────────────────────────────── auth test('every admin endpoint rejects a missing token', async () => { for (const path of ADMIN_ENDPOINTS) { const res = await get(path); assert.equal(res.status, 401, `${path} must require a token`); assert.equal(res.json?.code, 'UNAUTHORIZED'); } }); test('every admin endpoint rejects a wrong token', async () => { for (const path of ADMIN_ENDPOINTS) { const res = await get(path, 'Bearer nope'); assert.equal(res.status, 401, `${path} must reject a wrong token`); } }); test('the relay shared secret does not open the admin API', async () => { for (const path of ADMIN_ENDPOINTS) { const res = await get(path, 'Bearer test-shared-secret'); assert.equal(res.status, 401, `${path} must not accept the tool secret`); } }); test('a token is not accepted from the query string', async () => { const res = await get('/admin/events?token=test-admin-token'); assert.equal(res.status, 401); }); test('admin endpoints answer with a valid token', async () => { for (const path of ADMIN_ENDPOINTS) { const res = await get(path, ADMIN); assert.equal(res.status, 200, `${path} should answer`); assert.equal(res.json?.ok, true); } }); // ─────────────────────────────────────────────────────────── read-only test('the admin API exposes no write verbs', async () => { for (const path of ADMIN_ENDPOINTS) { for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { const res = await fetch(`${running.baseUrl}${path}`, { method, headers: { Authorization: ADMIN, 'Content-Type': 'application/json' }, body: method === 'DELETE' ? undefined : '{}', }); assert.equal(res.status, 404, `${method} ${path} must not exist`); } } }); test('a read call does not mutate stored data', async () => { const before = { tickets: await db().ticket.count(), messages: await db().processedMessage.count(), events: await db().auditEvent.count(), }; for (const path of ADMIN_ENDPOINTS) await get(path, ADMIN); assert.deepEqual( { tickets: await db().ticket.count(), messages: await db().processedMessage.count(), events: await db().auditEvent.count(), }, before, ); }); // ──────────────────────────────────────────────────────────── filters test('events filter by conversationId', async () => { const res = await get('/admin/events?conversationId=99', ADMIN); const events = res.json?.events as { conversationId: number }[]; assert.ok(events.length > 0); assert.ok(events.every((e) => e.conversationId === 99)); }); test('events filter by eventType', async () => { const res = await get('/admin/events?eventType=reply_sent', ADMIN); const events = res.json?.events as { eventType: string }[]; assert.ok(events.length > 0); assert.ok(events.every((e) => e.eventType === 'reply_sent')); }); test('events expose jobId as a column', async () => { const res = await get('/admin/events?eventType=webhook_accepted', ADMIN); const events = res.json?.events as { jobId: string | null }[]; assert.equal(events[0]?.jobId, 'job-1'); }); test('limit is honoured and clamped to a sane maximum', async () => { const one = await get('/admin/events?limit=1', ADMIN); assert.equal((one.json?.events as unknown[]).length, 1); const clamped = await get('/admin/events?limit=100000', ADMIN); assert.ok((clamped.json?.events as unknown[]).length <= 200); const nonsense = await get('/admin/events?limit=abc', ADMIN); assert.equal(nonsense.status, 200, 'a bad limit must not 500'); }); test('messages filter by status and conversationId', async () => { const byStatus = await get('/admin/messages?status=replied', ADMIN); const rows = byStatus.json?.messages as { status: string }[]; assert.ok(rows.length > 0); assert.ok(rows.every((m) => m.status === 'replied')); const byConv = await get('/admin/messages?conversationId=4242', ADMIN); const convRows = byConv.json?.messages as { conversationId: number }[]; assert.ok(convRows.every((m) => m.conversationId === 4242)); }); test('jobs report queue statistics and a dead list', async () => { const res = await get('/admin/jobs', ADMIN); assert.ok(res.json?.stats); assert.ok(Array.isArray(res.json?.jobs)); assert.ok(Array.isArray(res.json?.dead)); }); test('tickets carry the number, conversation, reason and derived activity', async () => { const res = await get('/admin/tickets', ADMIN); const tickets = res.json?.tickets as Record[]; const t = tickets.find((x) => x.conversationId === 4242); assert.ok(t, 'seeded ticket should be listed'); assert.equal(t.ticketNumber, 'EKS-20260820-4242'); assert.equal(t.reason, 'test handoff'); assert.ok(t.createdAt); assert.equal(t.messagesSkippedSince, 1); assert.ok(typeof t.lastEventType === 'string'); }); test('meta supplies the filter vocabulary without secrets', async () => { const res = await get('/admin/meta', ADMIN); const body = res.json as Record; assert.ok(Array.isArray(body.eventTypes)); assert.ok((body.eventTypes as string[]).includes('reply_sent')); assert.ok(body.counts); const serialized = JSON.stringify(body); assert.ok(!serialized.includes('test-admin-token')); assert.ok(!serialized.includes('test-shared-secret')); assert.ok(!serialized.includes('test-chatwoot-token')); assert.ok(!serialized.includes('test-flowise-key')); assert.ok(!serialized.includes('ck_')); });