@app-vitals/agent-admin 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,705 @@
1
+ /**
2
+ * agent/src/agents-api.integration.test.ts
3
+ * Integration tests for the admin CRUD API against a real PostgreSQL DB.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ *
7
+ * Key assertions:
8
+ * - POST /agents/:id/envs encrypts values at rest
9
+ * - POST /agents/:id/tokens stores hash not raw token
10
+ * - GET /tokens shows hash metadata only (no rawToken)
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14
+ import { sign } from "hono/jwt";
15
+ import type { PrismaClient } from "../prisma/client/client.ts";
16
+ import { AgentChatTokenService } from "./agent-chat-tokens.ts";
17
+ import { AgentCronJobService } from "./agent-cron-jobs.ts";
18
+ import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
19
+ import { AgentCronRunService } from "./agent-cron-runs.ts";
20
+ import { AgentEnvService } from "./agent-envs.ts";
21
+ import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
22
+ import { AgentMemberService } from "./agent-members.ts";
23
+ import { AgentPluginService } from "./agent-plugins.ts";
24
+ import type { AgentProvisioner } from "./agent-provisioner.ts";
25
+ import { NoopAgentProvisioner } from "./agent-provisioner.ts";
26
+ import { AgentTokenService } from "./agent-tokens.ts";
27
+ import { AgentToolService } from "./agent-tools.ts";
28
+ import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
29
+ import type { SpecialtyManifest } from "./specialty-registry.ts";
30
+ import { AgentService } from "./agents.ts";
31
+ import type { AdminDeps } from "./agents-api.ts";
32
+ import { createAdminApp } from "./agents-api.ts";
33
+ import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
34
+ import { createAdminPrismaClient } from "./prisma-client.ts";
35
+ import { makeTokenCrypto } from "./token-crypto.ts";
36
+
37
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
38
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
39
+
40
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
41
+ const REAL_KEY =
42
+ "0000000000000000000000000000000000000000000000000000000000000001";
43
+
44
+ async function makeSessionCookie(): Promise<string> {
45
+ return sign(
46
+ {
47
+ isAdmin: true,
48
+ userId: "user-123",
49
+ email: "admin@example.com",
50
+ name: "Admin User",
51
+ iat: Math.floor(Date.now() / 1000),
52
+ exp: Math.floor(Date.now() / 1000) + 3600,
53
+ },
54
+ SESSION_SECRET,
55
+ "HS256",
56
+ );
57
+ }
58
+
59
+ function makePrisma(): PrismaClient {
60
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
61
+ return createAdminPrismaClient(TEST_DB as string);
62
+ }
63
+
64
+ async function createAgent(
65
+ prisma: PrismaClient,
66
+ name = "Test Agent",
67
+ ): Promise<string> {
68
+ const agent = await prisma.agent.create({ data: { name } });
69
+ return agent.id;
70
+ }
71
+
72
+ /** A minimal valid "coding" manifest used to drive seeding assertions below. */
73
+ const CODING_MANIFEST: SpecialtyManifest = {
74
+ apiVersion: "shipwright.dev/v1alpha1",
75
+ kind: "Specialty",
76
+ metadata: {
77
+ name: "coding",
78
+ displayName: "Coding Agent",
79
+ description: "test manifest",
80
+ version: "1.0.0",
81
+ skills: [],
82
+ },
83
+ identity: { templatesDir: "workspace/" },
84
+ crons: [],
85
+ plugins: ["shipwright"],
86
+ tools: [
87
+ "Read",
88
+ "Write",
89
+ "Edit",
90
+ "Glob",
91
+ "Grep",
92
+ "Bash",
93
+ "WebSearch",
94
+ "WebFetch",
95
+ "Skill",
96
+ "Agent",
97
+ ],
98
+ env: { required: [], optional: [] },
99
+ members: [],
100
+ repos: [],
101
+ chat: true,
102
+ voice: true,
103
+ };
104
+
105
+ /**
106
+ * Fake SpecialtyManifestResolver — resolves a fixed map of manifests by
107
+ * typeName. tryGetManifest() returns undefined (no fallback) for anything
108
+ * not in the map, mirroring SpecialtyRegistry's real contract.
109
+ */
110
+ function fakeSpecialtyRegistry(
111
+ byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
112
+ ): SpecialtyManifestResolver {
113
+ return {
114
+ getManifest(typeName: string): SpecialtyManifest {
115
+ return byType[typeName] ?? (byType.coding as SpecialtyManifest);
116
+ },
117
+ tryGetManifest(typeName: string): SpecialtyManifest | undefined {
118
+ return byType[typeName];
119
+ },
120
+ listTypes() {
121
+ return Object.entries(byType).map(([name, manifest]) => ({
122
+ name,
123
+ displayName: manifest.metadata.displayName,
124
+ }));
125
+ },
126
+ };
127
+ }
128
+
129
+ function makeDeps(
130
+ prisma: PrismaClient,
131
+ provisioner: AgentProvisioner = new NoopAgentProvisioner(),
132
+ specialtyRegistry: SpecialtyManifestResolver = fakeSpecialtyRegistry(),
133
+ ): AdminDeps {
134
+ const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
135
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
136
+ const crypto = makeTokenCrypto();
137
+ if (savedKey === undefined) {
138
+ delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
139
+ } else {
140
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
141
+ }
142
+
143
+ return {
144
+ agentService: new AgentService(prisma),
145
+ agentEnvService: new AgentEnvService(prisma, crypto),
146
+ agentCronJobService: new AgentCronJobService(prisma),
147
+ agentCronRunService: new AgentCronRunService(prisma),
148
+ agentCronRunStatsService: new AgentCronRunStatsService(prisma),
149
+ agentToolService: new AgentToolService(prisma),
150
+ agentTokenService: new AgentTokenService(prisma),
151
+ agentPluginService: new AgentPluginService(prisma),
152
+ agentMemberService: new AgentMemberService(prisma),
153
+ specialtyRegistry,
154
+ agentChatTokenService: new AgentChatTokenService(prisma),
155
+ agentGitHubInstallationsService: new AgentGitHubInstallationsService(
156
+ prisma,
157
+ ),
158
+ prisma,
159
+ provisioner,
160
+ serviceIntegrations: [
161
+ {
162
+ name: "task-store",
163
+ listTokensForAgent: async () => [],
164
+ revokeToken: async () => {},
165
+ },
166
+ ],
167
+ chatService: new NoopChatServiceProvisioningClient(),
168
+ slack: {
169
+ deleteApp: async () => {},
170
+ },
171
+ decrypt: (value: string) => crypto.decrypt(value),
172
+ sessionSecret: SESSION_SECRET,
173
+ };
174
+ }
175
+
176
+ describeOrSkip("admin CRUD API (integration)", () => {
177
+ let prisma: PrismaClient;
178
+ let agentId: string;
179
+ let cookie: string;
180
+ let app: ReturnType<typeof createAdminApp>;
181
+
182
+ beforeEach(async () => {
183
+ prisma = makePrisma();
184
+
185
+ // Clean all tables in FK dependency order
186
+ await prisma.agentWorkQueueSnapshot.deleteMany();
187
+ await prisma.agentPlugin.deleteMany();
188
+ await prisma.agentPhaseMethodology.deleteMany();
189
+ await prisma.agentToken.deleteMany();
190
+ await prisma.agentCronJob.deleteMany();
191
+ await prisma.agentTool.deleteMany();
192
+ await prisma.agentEnv.deleteMany();
193
+ await prisma.agentMember.deleteMany();
194
+ await prisma.agent.deleteMany();
195
+
196
+ agentId = await createAgent(prisma);
197
+ cookie = await makeSessionCookie();
198
+
199
+ app = createAdminApp(makeDeps(prisma));
200
+ });
201
+
202
+ afterEach(async () => {
203
+ await prisma.$disconnect();
204
+ });
205
+
206
+ // ─── Env var encryption ───────────────────────────────────────────────────────
207
+
208
+ it("POST /envs encrypts values at rest — raw DB value differs from input", async () => {
209
+ const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
210
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
211
+
212
+ try {
213
+ const res = await app.request(`/agents/${agentId}/envs`, {
214
+ method: "POST",
215
+ body: JSON.stringify({ SECRET: "my-api-key" }),
216
+ headers: {
217
+ "Content-Type": "application/json",
218
+ Cookie: `admin_session=${cookie}`,
219
+ },
220
+ });
221
+ expect(res.status).toBe(201);
222
+
223
+ // Raw DB value should be encrypted (not plain text)
224
+ const raw = await prisma.agentEnv.findFirst({
225
+ where: { agentId, key: "SECRET" },
226
+ });
227
+ expect(raw?.value).not.toBe("my-api-key");
228
+ expect(raw?.value).toContain(":"); // iv:ciphertext:authTag format
229
+ } finally {
230
+ if (savedKey === undefined) {
231
+ delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
232
+ } else {
233
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
234
+ }
235
+ }
236
+ });
237
+
238
+ it("GET /envs returns decrypted values", async () => {
239
+ const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
240
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
241
+
242
+ try {
243
+ // First write via POST
244
+ await app.request(`/agents/${agentId}/envs`, {
245
+ method: "POST",
246
+ body: JSON.stringify({ SECRET: "my-api-key" }),
247
+ headers: {
248
+ "Content-Type": "application/json",
249
+ Cookie: `admin_session=${cookie}`,
250
+ },
251
+ });
252
+
253
+ // Then read via GET
254
+ const res = await app.request(`/agents/${agentId}/envs`, {
255
+ headers: { Cookie: `admin_session=${cookie}` },
256
+ });
257
+ expect(res.status).toBe(200);
258
+ const body = await res.json();
259
+ expect(body.env.SECRET).toBe("my-api-key");
260
+ } finally {
261
+ if (savedKey === undefined) {
262
+ delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
263
+ } else {
264
+ process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
265
+ }
266
+ }
267
+ });
268
+
269
+ // ─── Token hash storage ───────────────────────────────────────────────────────
270
+
271
+ it("POST /tokens stores hash not raw token; GET /tokens shows hash metadata only", async () => {
272
+ const res = await app.request(`/agents/${agentId}/tokens`, {
273
+ method: "POST",
274
+ body: JSON.stringify({ label: "ci-token" }),
275
+ headers: {
276
+ "Content-Type": "application/json",
277
+ Cookie: `admin_session=${cookie}`,
278
+ },
279
+ });
280
+ expect(res.status).toBe(201);
281
+
282
+ const createBody = await res.json();
283
+ const rawToken: string = createBody.rawToken;
284
+ expect(typeof rawToken).toBe("string");
285
+ expect(rawToken.length).toBeGreaterThan(0);
286
+
287
+ // Verify the stored token is a hash, not the raw token
288
+ const dbRecord = await prisma.agentToken.findFirst({
289
+ where: { agentId },
290
+ });
291
+ expect(dbRecord).not.toBeNull();
292
+ expect(dbRecord?.token).not.toBe(rawToken);
293
+ // SHA-256 hash is 64 hex chars
294
+ expect(dbRecord?.token).toHaveLength(64);
295
+
296
+ // GET /tokens should not expose rawToken
297
+ const listRes = await app.request(`/agents/${agentId}/tokens`, {
298
+ headers: { Cookie: `admin_session=${cookie}` },
299
+ });
300
+ expect(listRes.status).toBe(200);
301
+ const listBody = await listRes.json();
302
+ expect(Array.isArray(listBody.tokens)).toBe(true);
303
+ expect(listBody.tokens).toHaveLength(1);
304
+ // rawToken must NOT be present in the list response
305
+ const listedToken = listBody.tokens[0];
306
+ expect(listedToken.rawToken).toBeUndefined();
307
+ // The stored hash value should also not be exposed
308
+ expect(listedToken.token).toBeUndefined();
309
+ expect(listedToken.id).toBe(dbRecord?.id);
310
+ });
311
+
312
+ // ─── typeName field ─────────────────────────────────────────────────────────
313
+
314
+ it("GET /agents includes typeName:'coding' for an agent created before typeName existed", async () => {
315
+ const res = await app.request("/agents", {
316
+ headers: { Cookie: `admin_session=${cookie}` },
317
+ });
318
+ expect(res.status).toBe(200);
319
+ const body = await res.json();
320
+ const entry = body.find((a: { id: string }) => a.id === agentId);
321
+ expect(entry).toBeDefined();
322
+ expect(entry.typeName).toBe("coding");
323
+ });
324
+
325
+ it("GET /agents/:id includes typeName:'coding' by default", async () => {
326
+ const res = await app.request(`/agents/${agentId}`, {
327
+ headers: { Cookie: `admin_session=${cookie}` },
328
+ });
329
+ expect(res.status).toBe(200);
330
+ const body = await res.json();
331
+ expect(body.typeName).toBe("coding");
332
+ });
333
+
334
+ // ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
335
+ //
336
+ // Simulates the exact scenario the 20260724000000_add_agent_type_name
337
+ // migration must handle: rows that existed *before* the typeName column was
338
+ // added (never received an explicit value) must read back typeName="coding"
339
+ // via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
340
+ // must be completely untouched — same count, same updatedAt — since the
341
+ // migration only ALTERs the Agent table.
342
+
343
+ it("migration backfill: pre-existing agent rows read back typeName='coding' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
344
+ // Seed a second "pre-existing" agent with one row in every child table,
345
+ // simulating data that existed prior to the typeName migration landing.
346
+ const preExisting = await prisma.agent.create({
347
+ data: { name: "Pre-Existing Agent" },
348
+ });
349
+
350
+ const cron = await prisma.agentCronJob.create({
351
+ data: {
352
+ agentId: preExisting.id,
353
+ schedule: "0 9 * * 1-5",
354
+ prompt: "daily standup",
355
+ channel: "C123",
356
+ },
357
+ });
358
+ const plugin = await prisma.agentPlugin.create({
359
+ data: { agentId: preExisting.id, name: "shipwright@shipwright" },
360
+ });
361
+ const tool = await prisma.agentTool.create({
362
+ data: { agentId: preExisting.id, pattern: "Read" },
363
+ });
364
+ const env = await prisma.agentEnv.create({
365
+ data: { agentId: preExisting.id, key: "FOO", value: "encrypted-value" },
366
+ });
367
+ const member = await prisma.agentMember.create({
368
+ data: { agentId: preExisting.id, email: "member@example.com" },
369
+ });
370
+
371
+ // Re-running `prisma migrate deploy` against an already-migrated schema
372
+ // (which is exactly the state this test DB is in — beforeEach only
373
+ // truncates rows, it never re-runs migrations) is the idempotency
374
+ // scenario: the additive ALTER TABLE ... ADD COLUMN has already been
375
+ // applied once when the schema was first provisioned, and asserting the
376
+ // column's default here — without any application-layer backfill code —
377
+ // is the black-box proof that a second `migrate deploy` is a no-op that
378
+ // still leaves every row readable with the column default intact.
379
+
380
+ // Read back via the raw Agent row (bypasses the API layer entirely) —
381
+ // this is the direct DB-level assertion that the column DEFAULT applies
382
+ // to a row that never set typeName explicitly.
383
+ const rawAgent = await prisma.agent.findUniqueOrThrow({
384
+ where: { id: preExisting.id },
385
+ });
386
+ expect(rawAgent.typeName).toBe("coding");
387
+
388
+ // Read back via the HTTP API too — end-to-end confirmation.
389
+ const res = await app.request(`/agents/${preExisting.id}`, {
390
+ headers: { Cookie: `admin_session=${cookie}` },
391
+ });
392
+ expect(res.status).toBe(200);
393
+ const body = await res.json();
394
+ expect(body.typeName).toBe("coding");
395
+
396
+ // Child row counts are unchanged — exactly one of each, still linked to
397
+ // the same agent.
398
+ expect(
399
+ await prisma.agentCronJob.count({ where: { agentId: preExisting.id } }),
400
+ ).toBe(1);
401
+ expect(
402
+ await prisma.agentPlugin.count({ where: { agentId: preExisting.id } }),
403
+ ).toBe(1);
404
+ expect(
405
+ await prisma.agentTool.count({ where: { agentId: preExisting.id } }),
406
+ ).toBe(1);
407
+ expect(
408
+ await prisma.agentEnv.count({ where: { agentId: preExisting.id } }),
409
+ ).toBe(1);
410
+ expect(
411
+ await prisma.agentMember.count({ where: { agentId: preExisting.id } }),
412
+ ).toBe(1);
413
+
414
+ // Child row updatedAt values are unchanged — the typeName column only
415
+ // touches the Agent table, so nothing here should have been rewritten.
416
+ const cronAfter = await prisma.agentCronJob.findUniqueOrThrow({
417
+ where: { id: cron.id },
418
+ });
419
+ const pluginAfter = await prisma.agentPlugin.findUniqueOrThrow({
420
+ where: { id: plugin.id },
421
+ });
422
+ const toolAfter = await prisma.agentTool.findUniqueOrThrow({
423
+ where: { id: tool.id },
424
+ });
425
+ const envAfter = await prisma.agentEnv.findUniqueOrThrow({
426
+ where: { id: env.id },
427
+ });
428
+ const memberAfter = await prisma.agentMember.findUniqueOrThrow({
429
+ where: { id: member.id },
430
+ });
431
+
432
+ expect(cronAfter.updatedAt.getTime()).toBe(cron.updatedAt.getTime());
433
+ expect(pluginAfter.updatedAt.getTime()).toBe(plugin.updatedAt.getTime());
434
+ expect(envAfter.updatedAt.getTime()).toBe(env.updatedAt.getTime());
435
+ // AgentTool and AgentMember have no updatedAt column — createdAt stands
436
+ // in as the "untouched" signal for those two models.
437
+ expect(toolAfter.createdAt.getTime()).toBe(tool.createdAt.getTime());
438
+ expect(memberAfter.createdAt.getTime()).toBe(member.createdAt.getTime());
439
+ });
440
+
441
+ it("prisma migrate deploy is idempotent — running it twice does not error or duplicate the typeName column", async () => {
442
+ // The migration has already been applied once (this test DB was
443
+ // provisioned via `prisma migrate deploy` before the suite ran). Re-run
444
+ // deploy here and confirm the column is still readable with a single,
445
+ // unambiguous default — a duplicate-column error would surface as a
446
+ // thrown exception from execSync, and a broken default would surface as
447
+ // a failed read-back.
448
+ const { execSync } = await import("node:child_process");
449
+ execSync("bunx prisma migrate deploy --schema=prisma/schema", {
450
+ cwd: new URL("..", import.meta.url).pathname,
451
+ env: { ...process.env, DATABASE_URL_SHIPWRIGHT_ADMIN: TEST_DB },
452
+ stdio: "pipe",
453
+ });
454
+
455
+ const rawAgent = await prisma.agent.findUniqueOrThrow({
456
+ where: { id: agentId },
457
+ });
458
+ expect(rawAgent.typeName).toBe("coding");
459
+ });
460
+
461
+ // ─── repos field round-trip ───────────────────────────────────────────────────
462
+
463
+ it("PATCH /agents/:id with repos sets the field; GET /agents/:id returns it", async () => {
464
+ // PATCH to set repos
465
+ const patchRes = await app.request(`/agents/${agentId}`, {
466
+ method: "PATCH",
467
+ body: JSON.stringify({ repos: ["my-org/my-repo"] }),
468
+ headers: {
469
+ "Content-Type": "application/json",
470
+ Cookie: `admin_session=${cookie}`,
471
+ },
472
+ });
473
+ expect(patchRes.status).toBe(200);
474
+ const patchBody = await patchRes.json();
475
+ expect(patchBody.repos).toEqual(["my-org/my-repo"]);
476
+
477
+ // GET to confirm persisted value
478
+ const getRes = await app.request(`/agents/${agentId}`, {
479
+ headers: { Cookie: `admin_session=${cookie}` },
480
+ });
481
+ expect(getRes.status).toBe(200);
482
+ const getBody = await getRes.json();
483
+ expect(getBody.repos).toEqual(["my-org/my-repo"]);
484
+ });
485
+
486
+ it("POST then DELETE /agents/:id/repos persists the expected set and preserves order", async () => {
487
+ const hdr = {
488
+ "Content-Type": "application/json",
489
+ Cookie: `admin_session=${cookie}`,
490
+ };
491
+ const setRes = await app.request(`/agents/${agentId}`, {
492
+ method: "PATCH",
493
+ body: JSON.stringify({ repos: ["org/alpha", "org/beta"] }),
494
+ headers: hdr,
495
+ });
496
+ expect(setRes.status).toBe(200);
497
+
498
+ const addRes = await app.request(`/agents/${agentId}/repos`, {
499
+ method: "POST",
500
+ body: JSON.stringify({ repos: ["org/gamma", "org/alpha"] }),
501
+ headers: hdr,
502
+ });
503
+ expect(addRes.status).toBe(200);
504
+
505
+ const delRes = await app.request(`/agents/${agentId}/repos/org/beta`, {
506
+ method: "DELETE",
507
+ headers: hdr,
508
+ });
509
+ expect(delRes.status).toBe(200);
510
+
511
+ const row = await prisma.agent.findUniqueOrThrow({
512
+ where: { id: agentId },
513
+ });
514
+ expect(row.repos).toEqual(["org/alpha", "org/gamma"]);
515
+ });
516
+
517
+ // ─── reviewAuthorAllowlist field round-trip ────────────────────────────────
518
+
519
+ it("PATCH /agents/:id with reviewAuthorAllowlist sets the field; GET /agents/:id returns it", async () => {
520
+ const patchRes = await app.request(`/agents/${agentId}`, {
521
+ method: "PATCH",
522
+ body: JSON.stringify({ reviewAuthorAllowlist: ["octocat"] }),
523
+ headers: {
524
+ "Content-Type": "application/json",
525
+ Cookie: `admin_session=${cookie}`,
526
+ },
527
+ });
528
+ expect(patchRes.status).toBe(200);
529
+ const patchBody = await patchRes.json();
530
+ expect(patchBody.reviewAuthorAllowlist).toEqual(["octocat"]);
531
+
532
+ // GET to confirm persisted value
533
+ const getRes = await app.request(`/agents/${agentId}`, {
534
+ headers: { Cookie: `admin_session=${cookie}` },
535
+ });
536
+ expect(getRes.status).toBe(200);
537
+ const getBody = await getRes.json();
538
+ expect(getBody.reviewAuthorAllowlist).toEqual(["octocat"]);
539
+
540
+ const rawAgent = await prisma.agent.findUniqueOrThrow({
541
+ where: { id: agentId },
542
+ });
543
+ expect(rawAgent.reviewAuthorAllowlist).toEqual(["octocat"]);
544
+ });
545
+
546
+ // DBR-2.4 regression: the legacy authorAllowlist column is gone for good.
547
+ it("GET /agents/:id response does not include the removed authorAllowlist field", async () => {
548
+ const getRes = await app.request(`/agents/${agentId}`, {
549
+ headers: { Cookie: `admin_session=${cookie}` },
550
+ });
551
+ expect(getRes.status).toBe(200);
552
+ const getBody = await getRes.json();
553
+ expect(getBody).not.toHaveProperty("authorAllowlist");
554
+ });
555
+
556
+ // ─── restrictSlackToMembers field round-trip ───────────────────────────────────
557
+
558
+ it("PATCH /agents/:id with restrictSlackToMembers:true sets the field; GET /agents/:id returns it", async () => {
559
+ const patchRes = await app.request(`/agents/${agentId}`, {
560
+ method: "PATCH",
561
+ body: JSON.stringify({ restrictSlackToMembers: true }),
562
+ headers: {
563
+ "Content-Type": "application/json",
564
+ Cookie: `admin_session=${cookie}`,
565
+ },
566
+ });
567
+ expect(patchRes.status).toBe(200);
568
+ const patchBody = await patchRes.json();
569
+ expect(patchBody.restrictSlackToMembers).toBe(true);
570
+
571
+ const getRes = await app.request(`/agents/${agentId}`, {
572
+ headers: { Cookie: `admin_session=${cookie}` },
573
+ });
574
+ expect(getRes.status).toBe(200);
575
+ const getBody = await getRes.json();
576
+ expect(getBody.restrictSlackToMembers).toBe(true);
577
+ });
578
+
579
+ it("PATCH /agents/:id omitting restrictSlackToMembers leaves the existing value unchanged", async () => {
580
+ // First set it to true.
581
+ await app.request(`/agents/${agentId}`, {
582
+ method: "PATCH",
583
+ body: JSON.stringify({ restrictSlackToMembers: true }),
584
+ headers: {
585
+ "Content-Type": "application/json",
586
+ Cookie: `admin_session=${cookie}`,
587
+ },
588
+ });
589
+
590
+ // A subsequent PATCH that omits the field entirely must not reset it.
591
+ const patchRes = await app.request(`/agents/${agentId}`, {
592
+ method: "PATCH",
593
+ body: JSON.stringify({ selfHosted: false }),
594
+ headers: {
595
+ "Content-Type": "application/json",
596
+ Cookie: `admin_session=${cookie}`,
597
+ },
598
+ });
599
+ expect(patchRes.status).toBe(200);
600
+ const patchBody = await patchRes.json();
601
+ expect(patchBody.restrictSlackToMembers).toBe(true);
602
+ });
603
+
604
+ // ─── restrictSlackToMembers zero-members warning ───────────────────────────────
605
+
606
+ it("PATCH /agents/:id to restrictSlackToMembers:true with zero AgentMember rows returns a warning; with members present returns no warning", async () => {
607
+ // No members on this agent — expect a warning.
608
+ const patchRes = await app.request(`/agents/${agentId}`, {
609
+ method: "PATCH",
610
+ body: JSON.stringify({ restrictSlackToMembers: true }),
611
+ headers: {
612
+ "Content-Type": "application/json",
613
+ Cookie: `admin_session=${cookie}`,
614
+ },
615
+ });
616
+ expect(patchRes.status).toBe(200);
617
+ const patchBody = await patchRes.json();
618
+ expect(typeof patchBody.warning).toBe("string");
619
+
620
+ // Reset, add a member, then flip the flag again — expect no warning.
621
+ await app.request(`/agents/${agentId}`, {
622
+ method: "PATCH",
623
+ body: JSON.stringify({ restrictSlackToMembers: false }),
624
+ headers: {
625
+ "Content-Type": "application/json",
626
+ Cookie: `admin_session=${cookie}`,
627
+ },
628
+ });
629
+ await prisma.agentMember.create({
630
+ data: { agentId, email: "member@example.com" },
631
+ });
632
+
633
+ const secondPatchRes = await app.request(`/agents/${agentId}`, {
634
+ method: "PATCH",
635
+ body: JSON.stringify({ restrictSlackToMembers: true }),
636
+ headers: {
637
+ "Content-Type": "application/json",
638
+ Cookie: `admin_session=${cookie}`,
639
+ },
640
+ });
641
+ expect(secondPatchRes.status).toBe(200);
642
+ const secondPatchBody = await secondPatchRes.json();
643
+ expect(secondPatchBody.warning).toBeUndefined();
644
+ });
645
+
646
+ it("GET /agents/:id/chat-tokens/daily/stats is scoped to the caller's own agent", async () => {
647
+ const otherId = await createAgent(prisma);
648
+ const chatTokens = new AgentChatTokenService(prisma);
649
+ const usage = (n: number) => ({
650
+ inputTokens: n,
651
+ outputTokens: n,
652
+ cacheReadTokens: 0,
653
+ cacheCreationTokens: 0,
654
+ costUsd: 0,
655
+ });
656
+ await chatTokens.upsertDailyByModel(
657
+ agentId,
658
+ "2026-01-10",
659
+ "model-a",
660
+ usage(10),
661
+ );
662
+ await chatTokens.upsertDailyByModel(
663
+ otherId,
664
+ "2026-01-10",
665
+ "model-b",
666
+ usage(500),
667
+ );
668
+ const { rawToken: tokenA } = await new AgentTokenService(prisma).create(
669
+ agentId,
670
+ );
671
+ const own = await app.request(
672
+ `/agents/${agentId}/chat-tokens/daily/stats`,
673
+ {
674
+ headers: { Authorization: `Bearer ${tokenA}` },
675
+ },
676
+ );
677
+ expect(own.status).toBe(200);
678
+ const body = await own.json();
679
+ expect(body.totals.input).toBe(10);
680
+ expect(body.byAgent.map((r: { key: string }) => r.key)).toEqual([agentId]);
681
+ expect(
682
+ body.byModel.map((r: { key1: string; key2: string }) => [r.key1, r.key2]),
683
+ ).toEqual([[agentId, "model-a"]]);
684
+ expect(body.daily).toHaveLength(1);
685
+ expect(body.daily[0].input).toBe(10);
686
+
687
+ const cross = await app.request(
688
+ `/agents/${otherId}/chat-tokens/daily/stats`,
689
+ { headers: { Authorization: `Bearer ${tokenA}` } },
690
+ );
691
+ expect(cross.status).toBe(403);
692
+
693
+ const anon = await app.request(
694
+ `/agents/${agentId}/chat-tokens/daily/stats`,
695
+ );
696
+ expect(anon.status).toBe(401);
697
+
698
+ // Fleet-wide admin route is unchanged: sees both agents.
699
+ const fleet = await app.request("/agents/chat-tokens/daily/stats", {
700
+ headers: { Cookie: `admin_session=${cookie}` },
701
+ });
702
+ expect(fleet.status).toBe(200);
703
+ expect((await fleet.json()).totals.input).toBe(510);
704
+ });
705
+ });