| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 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<string, string> = {};
- if (auth) headers.Authorization = auth;
- const res = await fetch(`${running.baseUrl}${path}`, { headers, redirect: 'manual' });
- const text = await res.text();
- let json: Record<string, unknown> | null = null;
- try {
- json = JSON.parse(text) as Record<string, unknown>;
- } 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, /connect-src 'self'/);
- assert.match(csp, /frame-ancestors 'none'/);
- });
- test('the panel is not mounted inside the protected /admin namespace', async () => {
- 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<string, unknown>[];
- 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<string, unknown>;
- 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_'));
- });
|