opsPanel.test.ts 11 KB

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