opsPanel.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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, /script-src-elem/);
  82. assert.match(csp, /img-src 'self'/);
  83. assert.match(csp, /connect-src 'self'/);
  84. assert.match(csp, /frame-ancestors 'none'/);
  85. assert.match(res.text, /data-cfasync="false"/);
  86. assert.ok(!res.text.includes('<form'), 'login must not depend on a form submit that CSP/Cloudflare can block');
  87. });
  88. test('the panel is not mounted inside the protected /admin namespace', async () => {
  89. const res = await get('/admin/ui');
  90. assert.equal(res.status, 401, '/admin/* must stay uniformly token-protected');
  91. });
  92. // ───────────────────────────────────────────────────────────────── auth
  93. test('every admin endpoint rejects a missing token', async () => {
  94. for (const path of ADMIN_ENDPOINTS) {
  95. const res = await get(path);
  96. assert.equal(res.status, 401, `${path} must require a token`);
  97. assert.equal(res.json?.code, 'UNAUTHORIZED');
  98. }
  99. });
  100. test('every admin endpoint rejects a wrong token', async () => {
  101. for (const path of ADMIN_ENDPOINTS) {
  102. const res = await get(path, 'Bearer nope');
  103. assert.equal(res.status, 401, `${path} must reject a wrong token`);
  104. }
  105. });
  106. test('the relay shared secret does not open the admin API', async () => {
  107. for (const path of ADMIN_ENDPOINTS) {
  108. const res = await get(path, 'Bearer test-shared-secret');
  109. assert.equal(res.status, 401, `${path} must not accept the tool secret`);
  110. }
  111. });
  112. test('a token is not accepted from the query string', async () => {
  113. const res = await get('/admin/events?token=test-admin-token');
  114. assert.equal(res.status, 401);
  115. });
  116. test('admin endpoints answer with a valid token', async () => {
  117. for (const path of ADMIN_ENDPOINTS) {
  118. const res = await get(path, ADMIN);
  119. assert.equal(res.status, 200, `${path} should answer`);
  120. assert.equal(res.json?.ok, true);
  121. }
  122. });
  123. // ─────────────────────────────────────────────────────────── read-only
  124. test('the admin API exposes no write verbs', async () => {
  125. for (const path of ADMIN_ENDPOINTS) {
  126. for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
  127. const res = await fetch(`${running.baseUrl}${path}`, {
  128. method,
  129. headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
  130. body: method === 'DELETE' ? undefined : '{}',
  131. });
  132. assert.equal(res.status, 404, `${method} ${path} must not exist`);
  133. }
  134. }
  135. });
  136. test('a read call does not mutate stored data', async () => {
  137. const before = {
  138. tickets: await db().ticket.count(),
  139. messages: await db().processedMessage.count(),
  140. events: await db().auditEvent.count(),
  141. };
  142. for (const path of ADMIN_ENDPOINTS) await get(path, ADMIN);
  143. assert.deepEqual(
  144. {
  145. tickets: await db().ticket.count(),
  146. messages: await db().processedMessage.count(),
  147. events: await db().auditEvent.count(),
  148. },
  149. before,
  150. );
  151. });
  152. // ──────────────────────────────────────────────────────────── filters
  153. test('events filter by conversationId', async () => {
  154. const res = await get('/admin/events?conversationId=99', ADMIN);
  155. const events = res.json?.events as { conversationId: number }[];
  156. assert.ok(events.length > 0);
  157. assert.ok(events.every((e) => e.conversationId === 99));
  158. });
  159. test('events filter by eventType', async () => {
  160. const res = await get('/admin/events?eventType=reply_sent', ADMIN);
  161. const events = res.json?.events as { eventType: string }[];
  162. assert.ok(events.length > 0);
  163. assert.ok(events.every((e) => e.eventType === 'reply_sent'));
  164. });
  165. test('events expose jobId as a column', async () => {
  166. const res = await get('/admin/events?eventType=webhook_accepted', ADMIN);
  167. const events = res.json?.events as { jobId: string | null }[];
  168. assert.equal(events[0]?.jobId, 'job-1');
  169. });
  170. test('limit is honoured and clamped to a sane maximum', async () => {
  171. const one = await get('/admin/events?limit=1', ADMIN);
  172. assert.equal((one.json?.events as unknown[]).length, 1);
  173. const clamped = await get('/admin/events?limit=100000', ADMIN);
  174. assert.ok((clamped.json?.events as unknown[]).length <= 200);
  175. const nonsense = await get('/admin/events?limit=abc', ADMIN);
  176. assert.equal(nonsense.status, 200, 'a bad limit must not 500');
  177. });
  178. test('messages filter by status and conversationId', async () => {
  179. const byStatus = await get('/admin/messages?status=replied', ADMIN);
  180. const rows = byStatus.json?.messages as { status: string }[];
  181. assert.ok(rows.length > 0);
  182. assert.ok(rows.every((m) => m.status === 'replied'));
  183. const byConv = await get('/admin/messages?conversationId=4242', ADMIN);
  184. const convRows = byConv.json?.messages as { conversationId: number }[];
  185. assert.ok(convRows.every((m) => m.conversationId === 4242));
  186. });
  187. test('jobs report queue statistics and a dead list', async () => {
  188. const res = await get('/admin/jobs', ADMIN);
  189. assert.ok(res.json?.stats);
  190. assert.ok(Array.isArray(res.json?.jobs));
  191. assert.ok(Array.isArray(res.json?.dead));
  192. });
  193. test('tickets carry the number, conversation, reason and derived activity', async () => {
  194. const res = await get('/admin/tickets', ADMIN);
  195. const tickets = res.json?.tickets as Record<string, unknown>[];
  196. const t = tickets.find((x) => x.conversationId === 4242);
  197. assert.ok(t, 'seeded ticket should be listed');
  198. assert.equal(t.ticketNumber, 'EKS-20260820-4242');
  199. assert.equal(t.reason, 'test handoff');
  200. assert.ok(t.createdAt);
  201. assert.equal(t.messagesSkippedSince, 1);
  202. assert.ok(typeof t.lastEventType === 'string');
  203. });
  204. test('meta supplies the filter vocabulary without secrets', async () => {
  205. const res = await get('/admin/meta', ADMIN);
  206. const body = res.json as Record<string, unknown>;
  207. assert.ok(Array.isArray(body.eventTypes));
  208. assert.ok((body.eventTypes as string[]).includes('reply_sent'));
  209. assert.ok(body.counts);
  210. const serialized = JSON.stringify(body);
  211. assert.ok(!serialized.includes('test-admin-token'));
  212. assert.ok(!serialized.includes('test-shared-secret'));
  213. assert.ok(!serialized.includes('test-chatwoot-token'));
  214. assert.ok(!serialized.includes('test-flowise-key'));
  215. assert.ok(!serialized.includes('ck_'));
  216. });