opsPanel.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { after, before, test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
  4. import { applyTestConfig } from '../helpers/testConfig.js';
  5. const dbUrl = prepareTestDatabase('test-ops');
  6. const { createApp } = await import('../../src/http/app.js');
  7. const { db, disconnectDb } = await import('../../src/store/db.js');
  8. const { audit } = await import('../../src/store/auditLog.js');
  9. let running: RunningServer;
  10. const ADMIN = 'Bearer test-admin-token';
  11. const ADMIN_ENDPOINTS = [
  12. '/admin/events',
  13. '/admin/jobs',
  14. '/admin/messages',
  15. '/admin/tickets',
  16. '/admin/meta',
  17. ];
  18. before(async () => {
  19. applyTestConfig({ DATABASE_URL: dbUrl });
  20. running = await startServer(createApp());
  21. await db().ticket.create({
  22. data: { conversationId: 4242, ticketNumber: 'EKS-20260820-4242', reason: 'test handoff' },
  23. });
  24. await db().processedMessage.create({
  25. data: { source: 'chatwoot', messageId: 'm-1', conversationId: 4242, status: 'skipped', reason: 'ticket_mode' },
  26. });
  27. await db().processedMessage.create({
  28. data: { source: 'chatwoot', messageId: 'm-2', conversationId: 99, status: 'replied' },
  29. });
  30. await audit({ conversationId: 4242, messageId: 'm-1', jobId: 'job-1', eventType: 'webhook_accepted', summary: 'Queued job job-1' });
  31. await audit({ conversationId: 4242, messageId: 'm-1', eventType: 'skipped_ticket_mode', summary: 'ticket mode' });
  32. await audit({ conversationId: 99, messageId: 'm-2', eventType: 'reply_sent', summary: 'AI reply sent' });
  33. });
  34. after(async () => {
  35. await running.close();
  36. await disconnectDb();
  37. });
  38. async function get(path: string, auth?: string) {
  39. const headers: Record<string, string> = {};
  40. if (auth) headers.Authorization = auth;
  41. const res = await fetch(`${running.baseUrl}${path}`, { headers, redirect: 'manual' });
  42. const text = await res.text();
  43. let json: Record<string, unknown> | null = null;
  44. try {
  45. json = JSON.parse(text) as Record<string, unknown>;
  46. } catch {
  47. json = null;
  48. }
  49. return { status: res.status, text, json, headers: res.headers };
  50. }
  51. // ───────────────────────────────────────────────────────────── panel shell
  52. test('GET /ops serves the panel shell without a token', async () => {
  53. const res = await get('/ops');
  54. assert.equal(res.status, 200);
  55. assert.match(res.headers.get('content-type') ?? '', /text\/html/);
  56. assert.match(res.text, /EKSRelay/);
  57. assert.match(res.text, /ADMIN_TOKEN/); // the prompt label, not a value
  58. });
  59. test('the shell leaks no data and no credential', async () => {
  60. const res = await get('/ops');
  61. // Nothing from the seeded database may appear in the static HTML.
  62. assert.ok(!res.text.includes('EKS-20260820-4242'), 'ticket number must not be inlined');
  63. assert.ok(!res.text.includes('test-admin-token'), 'token must never be inlined');
  64. assert.ok(!res.text.includes('test-shared-secret'));
  65. assert.ok(!res.text.includes('test-chatwoot-token'));
  66. assert.ok(!res.text.includes('4242'), 'no conversation data may be inlined');
  67. });
  68. test('the shell never puts the token in a query string', async () => {
  69. const res = await get('/ops');
  70. assert.ok(!/searchParams\.set\(\s*['"]token/i.test(res.text));
  71. assert.ok(!/[?&]token=/.test(res.text));
  72. // It must authenticate through the header instead.
  73. assert.match(res.text, /Authorization['"]?\s*:\s*['"]Bearer/);
  74. });
  75. test('the shell sends hardening headers', async () => {
  76. const res = await get('/ops');
  77. assert.equal(res.headers.get('cache-control'), 'no-store');
  78. assert.match(res.headers.get('x-robots-tag') ?? '', /noindex/);
  79. const csp = res.headers.get('content-security-policy') ?? '';
  80. assert.match(csp, /default-src 'none'/);
  81. assert.match(csp, /connect-src 'self'/);
  82. assert.match(csp, /frame-ancestors 'none'/);
  83. });
  84. test('the panel is not mounted inside the protected /admin namespace', async () => {
  85. const res = await get('/admin/ui');
  86. assert.equal(res.status, 401, '/admin/* must stay uniformly token-protected');
  87. });
  88. // ───────────────────────────────────────────────────────────────── auth
  89. test('every admin endpoint rejects a missing token', async () => {
  90. for (const path of ADMIN_ENDPOINTS) {
  91. const res = await get(path);
  92. assert.equal(res.status, 401, `${path} must require a token`);
  93. assert.equal(res.json?.code, 'UNAUTHORIZED');
  94. }
  95. });
  96. test('every admin endpoint rejects a wrong token', async () => {
  97. for (const path of ADMIN_ENDPOINTS) {
  98. const res = await get(path, 'Bearer nope');
  99. assert.equal(res.status, 401, `${path} must reject a wrong token`);
  100. }
  101. });
  102. test('the relay shared secret does not open the admin API', async () => {
  103. for (const path of ADMIN_ENDPOINTS) {
  104. const res = await get(path, 'Bearer test-shared-secret');
  105. assert.equal(res.status, 401, `${path} must not accept the tool secret`);
  106. }
  107. });
  108. test('a token is not accepted from the query string', async () => {
  109. const res = await get('/admin/events?token=test-admin-token');
  110. assert.equal(res.status, 401);
  111. });
  112. test('admin endpoints answer with a valid token', async () => {
  113. for (const path of ADMIN_ENDPOINTS) {
  114. const res = await get(path, ADMIN);
  115. assert.equal(res.status, 200, `${path} should answer`);
  116. assert.equal(res.json?.ok, true);
  117. }
  118. });
  119. // ─────────────────────────────────────────────────────────── read-only
  120. test('the admin API exposes no write verbs', async () => {
  121. for (const path of ADMIN_ENDPOINTS) {
  122. for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
  123. const res = await fetch(`${running.baseUrl}${path}`, {
  124. method,
  125. headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
  126. body: method === 'DELETE' ? undefined : '{}',
  127. });
  128. assert.equal(res.status, 404, `${method} ${path} must not exist`);
  129. }
  130. }
  131. });
  132. test('a read call does not mutate stored data', async () => {
  133. const before = {
  134. tickets: await db().ticket.count(),
  135. messages: await db().processedMessage.count(),
  136. events: await db().auditEvent.count(),
  137. };
  138. for (const path of ADMIN_ENDPOINTS) await get(path, ADMIN);
  139. assert.deepEqual(
  140. {
  141. tickets: await db().ticket.count(),
  142. messages: await db().processedMessage.count(),
  143. events: await db().auditEvent.count(),
  144. },
  145. before,
  146. );
  147. });
  148. // ──────────────────────────────────────────────────────────── filters
  149. test('events filter by conversationId', async () => {
  150. const res = await get('/admin/events?conversationId=99', ADMIN);
  151. const events = res.json?.events as { conversationId: number }[];
  152. assert.ok(events.length > 0);
  153. assert.ok(events.every((e) => e.conversationId === 99));
  154. });
  155. test('events filter by eventType', async () => {
  156. const res = await get('/admin/events?eventType=reply_sent', ADMIN);
  157. const events = res.json?.events as { eventType: string }[];
  158. assert.ok(events.length > 0);
  159. assert.ok(events.every((e) => e.eventType === 'reply_sent'));
  160. });
  161. test('events expose jobId as a column', async () => {
  162. const res = await get('/admin/events?eventType=webhook_accepted', ADMIN);
  163. const events = res.json?.events as { jobId: string | null }[];
  164. assert.equal(events[0]?.jobId, 'job-1');
  165. });
  166. test('limit is honoured and clamped to a sane maximum', async () => {
  167. const one = await get('/admin/events?limit=1', ADMIN);
  168. assert.equal((one.json?.events as unknown[]).length, 1);
  169. const clamped = await get('/admin/events?limit=100000', ADMIN);
  170. assert.ok((clamped.json?.events as unknown[]).length <= 200);
  171. const nonsense = await get('/admin/events?limit=abc', ADMIN);
  172. assert.equal(nonsense.status, 200, 'a bad limit must not 500');
  173. });
  174. test('messages filter by status and conversationId', async () => {
  175. const byStatus = await get('/admin/messages?status=replied', ADMIN);
  176. const rows = byStatus.json?.messages as { status: string }[];
  177. assert.ok(rows.length > 0);
  178. assert.ok(rows.every((m) => m.status === 'replied'));
  179. const byConv = await get('/admin/messages?conversationId=4242', ADMIN);
  180. const convRows = byConv.json?.messages as { conversationId: number }[];
  181. assert.ok(convRows.every((m) => m.conversationId === 4242));
  182. });
  183. test('jobs report queue statistics and a dead list', async () => {
  184. const res = await get('/admin/jobs', ADMIN);
  185. assert.ok(res.json?.stats);
  186. assert.ok(Array.isArray(res.json?.jobs));
  187. assert.ok(Array.isArray(res.json?.dead));
  188. });
  189. test('tickets carry the number, conversation, reason and derived activity', async () => {
  190. const res = await get('/admin/tickets', ADMIN);
  191. const tickets = res.json?.tickets as Record<string, unknown>[];
  192. const t = tickets.find((x) => x.conversationId === 4242);
  193. assert.ok(t, 'seeded ticket should be listed');
  194. assert.equal(t.ticketNumber, 'EKS-20260820-4242');
  195. assert.equal(t.reason, 'test handoff');
  196. assert.ok(t.createdAt);
  197. assert.equal(t.messagesSkippedSince, 1);
  198. assert.ok(typeof t.lastEventType === 'string');
  199. });
  200. test('meta supplies the filter vocabulary without secrets', async () => {
  201. const res = await get('/admin/meta', ADMIN);
  202. const body = res.json as Record<string, unknown>;
  203. assert.ok(Array.isArray(body.eventTypes));
  204. assert.ok((body.eventTypes as string[]).includes('reply_sent'));
  205. assert.ok(body.counts);
  206. const serialized = JSON.stringify(body);
  207. assert.ok(!serialized.includes('test-admin-token'));
  208. assert.ok(!serialized.includes('test-shared-secret'));
  209. assert.ok(!serialized.includes('test-chatwoot-token'));
  210. assert.ok(!serialized.includes('test-flowise-key'));
  211. assert.ok(!serialized.includes('ck_'));
  212. });