@oneuptime/common 14.0.9 → 14.0.10

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 (383) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +2 -0
  9. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  10. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  11. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  12. package/Models/DatabaseModels/RunnerJob.ts +144 -0
  13. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  14. package/Server/API/AutoRemediationAPI.ts +392 -1
  15. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  18. package/Server/Infrastructure/Semaphore.ts +22 -0
  19. package/Server/Middleware/TelemetryIngest.ts +15 -5
  20. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  21. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  22. package/Server/Services/CephClusterService.ts +109 -1
  23. package/Server/Services/DatabaseServerService.ts +116 -1
  24. package/Server/Services/DockerHostService.ts +109 -1
  25. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  26. package/Server/Services/HostService.ts +126 -4
  27. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  28. package/Server/Services/PodmanHostService.ts +109 -1
  29. package/Server/Services/ProxmoxClusterService.ts +110 -1
  30. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  31. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  32. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  33. package/Server/Services/RunnerJobService.ts +611 -4
  34. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  35. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  36. package/Server/Services/VMwareVCenterService.ts +110 -1
  37. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
  38. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
  39. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  40. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  41. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  42. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  43. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  44. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  45. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  46. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
  47. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  48. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  49. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
  50. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  51. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  52. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  53. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  54. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  55. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  56. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  57. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  60. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
  61. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  63. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  64. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  65. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  66. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  67. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
  68. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  69. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  70. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  71. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  72. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  73. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  74. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  75. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  76. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  77. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  78. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  79. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  80. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  81. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  82. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  83. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  84. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  85. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  86. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  87. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
  88. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
  89. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
  90. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  91. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  92. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  93. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  94. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  95. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  96. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  97. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  98. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  99. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  100. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  101. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  102. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  103. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  104. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  105. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
  106. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  107. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  108. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  109. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  110. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  111. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  112. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  113. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  114. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  115. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  116. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  117. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  118. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  119. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  120. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  121. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  122. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  123. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  124. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  125. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  126. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  127. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  128. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
  129. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  130. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  131. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  132. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  133. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  134. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  135. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  137. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  138. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  139. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  140. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  141. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  142. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  143. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  144. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  145. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  146. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  147. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  148. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  149. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  150. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  151. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  152. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  153. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  154. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  155. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  156. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  157. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  158. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  159. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  160. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  161. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  162. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  163. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  164. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  165. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  166. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  167. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  168. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  169. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  170. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  171. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  172. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  173. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  174. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  175. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
  176. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  177. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  178. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  179. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  180. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  181. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  182. package/Types/Runbook/RunbookStepType.ts +29 -0
  183. package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
  184. package/Utils/AI/InvestigationReport.ts +121 -15
  185. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  186. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  187. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  188. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  189. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  190. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  191. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  192. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  193. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  194. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  195. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  196. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  197. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  198. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  199. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  200. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  202. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  204. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  206. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  208. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  210. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  211. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  212. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  214. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  216. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  218. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  219. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  220. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  221. package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
  222. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  223. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  224. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  225. package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
  226. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  227. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  228. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  234. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  235. package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
  236. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  237. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  239. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  240. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  241. package/build/dist/Server/Services/CephClusterService.js +104 -0
  242. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  243. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  244. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  245. package/build/dist/Server/Services/DockerHostService.js +104 -0
  246. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  247. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  248. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  249. package/build/dist/Server/Services/HostService.js +114 -2
  250. package/build/dist/Server/Services/HostService.js.map +1 -1
  251. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  252. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  253. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  254. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  255. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  256. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  257. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  258. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  259. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  260. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  261. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  262. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  263. package/build/dist/Server/Services/RunnerJobService.js +344 -5
  264. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  265. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  266. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  267. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  268. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  269. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  270. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  271. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
  272. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  273. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
  274. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  275. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  276. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  277. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  278. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  279. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  280. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  281. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  282. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  283. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  284. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  285. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  286. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  287. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  288. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  289. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
  290. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  291. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  292. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  293. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  294. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  295. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
  296. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  297. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  298. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  299. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  300. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  301. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  302. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  303. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  304. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  314. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  315. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  316. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  317. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
  318. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  319. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  320. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  321. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  322. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  323. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  324. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  325. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  326. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  327. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  328. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  329. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  330. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  331. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  332. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  333. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  334. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  335. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
  336. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  337. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  338. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  339. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  340. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  341. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  342. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  343. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  344. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  345. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  346. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  347. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  348. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  349. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  350. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  351. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
  352. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  353. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  354. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  355. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  356. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  357. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  358. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  359. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  360. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  361. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  362. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  363. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  364. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  365. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  366. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  367. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  368. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  369. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  370. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  371. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  372. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  373. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  374. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  375. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  376. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  377. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  378. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  379. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  380. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  381. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  382. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  383. package/package.json +1 -1
@@ -0,0 +1,2730 @@
1
+ import CommonAPI from "../../../Server/API/CommonAPI";
2
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
3
+ import Semaphore, {
4
+ SemaphoreMutex,
5
+ } from "../../../Server/Infrastructure/Semaphore";
6
+ import AIRunService from "../../../Server/Services/AIRunService";
7
+ import AlertService from "../../../Server/Services/AlertService";
8
+ import AutoRemediationSuggestionService from "../../../Server/Services/AutoRemediationSuggestionService";
9
+ import CephClusterService from "../../../Server/Services/CephClusterService";
10
+ import DatabaseServerService from "../../../Server/Services/DatabaseServerService";
11
+ import DockerHostService from "../../../Server/Services/DockerHostService";
12
+ import DockerSwarmClusterService from "../../../Server/Services/DockerSwarmClusterService";
13
+ import HostService from "../../../Server/Services/HostService";
14
+ import IncidentService from "../../../Server/Services/IncidentService";
15
+ import PodmanHostService from "../../../Server/Services/PodmanHostService";
16
+ import ProxmoxClusterService from "../../../Server/Services/ProxmoxClusterService";
17
+ import ResourceAiAccessService from "../../../Server/Services/ResourceAiAccessService";
18
+ import ResourceAiAgentService from "../../../Server/Services/ResourceAiAgentService";
19
+ import RunnerJobService from "../../../Server/Services/RunnerJobService";
20
+ import VMwareVCenterService from "../../../Server/Services/VMwareVCenterService";
21
+ import ResourceCommandJobRunner, {
22
+ RESOURCE_COMMAND_CLAIM_TIMEOUT_MS,
23
+ ResourceCommandJobOutcome,
24
+ } from "../../../Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner";
25
+ import {
26
+ ExpressRequest,
27
+ ExpressResponse,
28
+ NextFunction,
29
+ } from "../../../Server/Utils/Express";
30
+ import Response from "../../../Server/Utils/Response";
31
+ import AIRun from "../../../Models/DatabaseModels/AIRun";
32
+ import Alert from "../../../Models/DatabaseModels/Alert";
33
+ import AutoRemediationSuggestion from "../../../Models/DatabaseModels/AutoRemediationSuggestion";
34
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
35
+ import DatabaseServer from "../../../Models/DatabaseModels/DatabaseServer";
36
+ import DockerHost from "../../../Models/DatabaseModels/DockerHost";
37
+ import Host from "../../../Models/DatabaseModels/Host";
38
+ import Incident from "../../../Models/DatabaseModels/Incident";
39
+ import Label from "../../../Models/DatabaseModels/Label";
40
+ import ResourceAiAgent from "../../../Models/DatabaseModels/ResourceAiAgent";
41
+ import RunnerJob from "../../../Models/DatabaseModels/RunnerJob";
42
+ import {
43
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
44
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
45
+ RESOURCE_AI_ACCESS_STATUS_PATH,
46
+ RESOURCE_AI_ACCESS_TEST_PATH,
47
+ RESOURCE_AI_INSIGHTS_LIMIT,
48
+ ResourceAiInsights,
49
+ } from "../../../Types/AI/ResourceAiAccessApi";
50
+ import { getResourceAiAgentResetRefusal } from "../../../Types/AI/ResourceAiAccessPermissions";
51
+ import AIRunStatus from "../../../Types/AI/AIRunStatus";
52
+ import AIRunType from "../../../Types/AI/AIRunType";
53
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
54
+ import Dictionary from "../../../Types/Dictionary";
55
+ import BadDataException from "../../../Types/Exception/BadDataException";
56
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
57
+ import ServiceUnavailableException from "../../../Types/Exception/ServiceUnavailableException";
58
+ import TooManyRequestsException from "../../../Types/Exception/TooManyRequestsException";
59
+ import { JSONObject } from "../../../Types/JSON";
60
+ import ObjectID from "../../../Types/ObjectID";
61
+ import Permission, {
62
+ UserPermission,
63
+ UserTenantAccessPermission,
64
+ } from "../../../Types/Permission";
65
+ import PositiveNumber from "../../../Types/PositiveNumber";
66
+ import AiResourceType, {
67
+ AI_RESOURCE_TYPE_INFO,
68
+ ALL_AI_RESOURCE_TYPES,
69
+ } from "../../../Types/ResourceAiAgent/AiResourceType";
70
+ import {
71
+ DEFAULT_RESOURCE_COMMAND_TIMEOUT_MS,
72
+ ResourceAiAccessGap,
73
+ ResourceAiAccessGapCode,
74
+ ResourceAiAccessStatus,
75
+ ResourceAiRemediationMode,
76
+ } from "../../../Types/ResourceAiAgent/ResourceAiAccess";
77
+ import RunbookStepType from "../../../Types/Runbook/RunbookStepType";
78
+ import RunnerJobOrigin from "../../../Types/Runbook/RunnerJobOrigin";
79
+ import UserType from "../../../Types/UserType";
80
+ import {
81
+ afterEach,
82
+ beforeAll,
83
+ beforeEach,
84
+ describe,
85
+ expect,
86
+ test,
87
+ } from "@jest/globals";
88
+
89
+ /*
90
+ * ---------------------------------------------------------------------------
91
+ * Common/Server/API/ResourceAiAccessAPI.ts — the calls behind the AI pages of
92
+ * every resource a resource AI agent serves, the resource-agnostic sibling
93
+ * of KubernetesClusterAiAccessAPI (whose tests these mirror):
94
+ *
95
+ * - every route reads the resource through ITS OWN service, under the
96
+ * user's own props and scoped to the tenant: another project's id reads
97
+ * exactly like a missing one, and nothing past the lookup runs — which
98
+ * is what guards the agent data (the ResourceAiAgent table is readable by
99
+ * anyone who may read any one of the eight types);
100
+ * - /status needs only read access; /test needs edit access to the resource
101
+ * (a block row is a denial, not a grant); /reset-agent needs one of
102
+ * RESOURCE_AI_ACCESS_ADMIN_PERMISSIONS;
103
+ * - /test runs the type's test commands through ResourceCommandJobRunner.run
104
+ * as an access test with no AI run, through the resource's CURRENT agent,
105
+ * and keeps going only while commands succeed; a gap that blocks the
106
+ * transport (no agent, an offline one) stops it before anything runs,
107
+ * and a gap about the project's AI never does;
108
+ * - /test has its own limits (one at a time per resource, a few per minute
109
+ * and a ceiling per hour per resource, a few per minute per user) on
110
+ * step ids of its own, so it never counts against a cluster's tests;
111
+ * - /insights returns summaries only, read as root after the read gate.
112
+ * ---------------------------------------------------------------------------
113
+ */
114
+
115
+ type RouterFunction = (
116
+ req: ExpressRequest,
117
+ res: ExpressResponse,
118
+ next: NextFunction,
119
+ ) => void | Promise<void>;
120
+
121
+ type MockRoute = {
122
+ method: string;
123
+ uri: string;
124
+ middleware: RouterFunction;
125
+ handlerFunction: RouterFunction;
126
+ };
127
+
128
+ type MockRouter = {
129
+ get: jest.Mock;
130
+ post: jest.Mock;
131
+ put: jest.Mock;
132
+ delete: jest.Mock;
133
+ };
134
+
135
+ const mockRoutes: Array<MockRoute> = [];
136
+
137
+ type RegisterRouteFunction = (
138
+ method: string,
139
+ ) => (
140
+ uri: string,
141
+ middleware: RouterFunction,
142
+ handlerFunction: RouterFunction,
143
+ ) => void;
144
+
145
+ const registerRoute: RegisterRouteFunction = (method: string) => {
146
+ return (
147
+ uri: string,
148
+ middleware: RouterFunction,
149
+ handlerFunction: RouterFunction,
150
+ ): void => {
151
+ mockRoutes.push({
152
+ method: method.toUpperCase(),
153
+ uri,
154
+ middleware,
155
+ handlerFunction,
156
+ });
157
+ };
158
+ };
159
+
160
+ const mockRouter: MockRouter = {
161
+ get: jest.fn().mockImplementation(registerRoute("get")),
162
+ post: jest.fn().mockImplementation(registerRoute("post")),
163
+ put: jest.fn().mockImplementation(registerRoute("put")),
164
+ delete: jest.fn().mockImplementation(registerRoute("delete")),
165
+ };
166
+
167
+ jest.mock("../../../Server/Utils/Express", () => {
168
+ const actual: Record<string, unknown> = jest.requireActual(
169
+ "../../../Server/Utils/Express",
170
+ ) as Record<string, unknown>;
171
+
172
+ return {
173
+ ...actual,
174
+ __esModule: true,
175
+ default: {
176
+ ...((actual["default"] as Record<string, unknown>) || {}),
177
+ getRouter: (): MockRouter => {
178
+ return mockRouter;
179
+ },
180
+ },
181
+ };
182
+ });
183
+
184
+ jest.mock("../../../Server/Utils/Response", () => {
185
+ return {
186
+ __esModule: true,
187
+ default: {
188
+ sendJsonObjectResponse: jest.fn(),
189
+ sendErrorResponse: jest.fn(),
190
+ sendEmptySuccessResponse: jest.fn(),
191
+ sendEntityResponse: jest.fn(),
192
+ },
193
+ };
194
+ });
195
+
196
+ const sendJsonObjectResponseMock: jest.Mock =
197
+ Response.sendJsonObjectResponse as unknown as jest.Mock;
198
+
199
+ const PROJECT_ID: ObjectID = new ObjectID(
200
+ "11111111-1111-4111-8111-111111111111",
201
+ );
202
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
203
+ "99999999-9999-4999-8999-999999999999",
204
+ );
205
+ const USER_ID: ObjectID = new ObjectID("22222222-2222-4222-8222-222222222222");
206
+ const RESOURCE_ID: ObjectID = new ObjectID(
207
+ "33333333-3333-4333-8333-333333333333",
208
+ );
209
+ const AGENT_ID: ObjectID = new ObjectID("66666666-6666-4666-8666-666666666666");
210
+
211
+ // Every resource type's service and the permissions that read and edit it.
212
+ interface Kind {
213
+ resourceType: AiResourceType;
214
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
215
+ service: any;
216
+ readPermission: Permission;
217
+ editPermission: Permission;
218
+ subjectRelation: string;
219
+ sentenceName: string;
220
+ }
221
+
222
+ const KINDS: Array<Kind> = [
223
+ {
224
+ resourceType: AiResourceType.DockerHost,
225
+ service: DockerHostService,
226
+ readPermission: Permission.ReadDockerHost,
227
+ editPermission: Permission.EditDockerHost,
228
+ subjectRelation: "dockerHosts",
229
+ sentenceName: "Docker host",
230
+ },
231
+ {
232
+ resourceType: AiResourceType.PodmanHost,
233
+ service: PodmanHostService,
234
+ readPermission: Permission.ReadPodmanHost,
235
+ editPermission: Permission.EditPodmanHost,
236
+ subjectRelation: "podmanHosts",
237
+ sentenceName: "Podman host",
238
+ },
239
+ {
240
+ resourceType: AiResourceType.DockerSwarmCluster,
241
+ service: DockerSwarmClusterService,
242
+ readPermission: Permission.ReadDockerSwarmCluster,
243
+ editPermission: Permission.EditDockerSwarmCluster,
244
+ subjectRelation: "dockerSwarmClusters",
245
+ sentenceName: "Docker Swarm cluster",
246
+ },
247
+ {
248
+ resourceType: AiResourceType.ProxmoxCluster,
249
+ service: ProxmoxClusterService,
250
+ readPermission: Permission.ReadProxmoxCluster,
251
+ editPermission: Permission.EditProxmoxCluster,
252
+ subjectRelation: "proxmoxClusters",
253
+ sentenceName: "Proxmox cluster",
254
+ },
255
+ {
256
+ resourceType: AiResourceType.VMwareVCenter,
257
+ service: VMwareVCenterService,
258
+ readPermission: Permission.ReadVMwareVCenter,
259
+ editPermission: Permission.EditVMwareVCenter,
260
+ subjectRelation: "vmwareVCenters",
261
+ sentenceName: "VMware vCenter",
262
+ },
263
+ {
264
+ resourceType: AiResourceType.CephCluster,
265
+ service: CephClusterService,
266
+ readPermission: Permission.ReadCephCluster,
267
+ editPermission: Permission.EditCephCluster,
268
+ subjectRelation: "cephClusters",
269
+ sentenceName: "Ceph cluster",
270
+ },
271
+ {
272
+ resourceType: AiResourceType.DatabaseServer,
273
+ service: DatabaseServerService,
274
+ readPermission: Permission.ReadDatabaseServer,
275
+ editPermission: Permission.EditDatabaseServer,
276
+ subjectRelation: "databaseServers",
277
+ sentenceName: "database server",
278
+ },
279
+ {
280
+ resourceType: AiResourceType.Host,
281
+ service: HostService,
282
+ readPermission: Permission.ReadHost,
283
+ editPermission: Permission.EditHost,
284
+ subjectRelation: "hosts",
285
+ sentenceName: "host",
286
+ },
287
+ ];
288
+
289
+ // The full route behaviour runs against these three; the table covers all eight.
290
+ const COVERED: Array<Kind> = KINDS.filter((kind: Kind) => {
291
+ return [
292
+ AiResourceType.DockerHost,
293
+ AiResourceType.DatabaseServer,
294
+ AiResourceType.Host,
295
+ ].includes(kind.resourceType);
296
+ });
297
+
298
+ // The models of the covered types, for rows that carry their labels.
299
+ const LABELLED_MODELS: Partial<Record<AiResourceType, { new (): BaseModel }>> =
300
+ {
301
+ [AiResourceType.DockerHost]: DockerHost,
302
+ [AiResourceType.DatabaseServer]: DatabaseServer,
303
+ [AiResourceType.Host]: Host,
304
+ };
305
+
306
+ function kindOf(resourceType: AiResourceType): Kind {
307
+ return KINDS.find((kind: Kind) => {
308
+ return kind.resourceType === resourceType;
309
+ })!;
310
+ }
311
+
312
+ type RouteCallResult = {
313
+ thrownToNext: unknown;
314
+ nextCallCount: number;
315
+ };
316
+
317
+ function matchRoute(uri: string): MockRoute {
318
+ const route: MockRoute | undefined = mockRoutes.find((route: MockRoute) => {
319
+ return route.method === "POST" && route.uri === uri;
320
+ });
321
+
322
+ if (!route) {
323
+ throw new Error(`Route POST ${uri} was never registered`);
324
+ }
325
+
326
+ return route;
327
+ }
328
+
329
+ function body(
330
+ resourceType: AiResourceType | string,
331
+ resourceId: string = RESOURCE_ID.toString(),
332
+ ): JSONObject {
333
+ return { resourceType, resourceId };
334
+ }
335
+
336
+ async function callRoute(
337
+ uri: string,
338
+ requestBody: JSONObject,
339
+ ): Promise<RouteCallResult> {
340
+ const req: ExpressRequest = {
341
+ params: {},
342
+ query: {},
343
+ body: requestBody,
344
+ headers: {},
345
+ } as unknown as ExpressRequest;
346
+
347
+ const res: ExpressResponse = {
348
+ send: jest.fn(),
349
+ json: jest.fn(),
350
+ status: jest.fn().mockReturnThis(),
351
+ } as unknown as ExpressResponse;
352
+
353
+ const next: jest.Mock = jest.fn();
354
+
355
+ await matchRoute(uri).handlerFunction(
356
+ req,
357
+ res,
358
+ next as unknown as NextFunction,
359
+ );
360
+
361
+ return {
362
+ thrownToNext: next.mock.calls[0] ? next.mock.calls[0][0] : undefined,
363
+ nextCallCount: next.mock.calls.length,
364
+ };
365
+ }
366
+
367
+ /*
368
+ * The per-resource hourly count and the per-minute count differ only in how
369
+ * far back createdAt reaches: an hour, or a minute.
370
+ */
371
+ function isWithinTheLastHourOnly(query: Record<string, unknown>): boolean {
372
+ const filter: { objectLiteralParameters?: Record<string, unknown> } =
373
+ (query["createdAt"] as {
374
+ objectLiteralParameters?: Record<string, unknown>;
375
+ }) || {};
376
+ const since: unknown = Object.values(filter.objectLiteralParameters || {})[0];
377
+
378
+ return since instanceof Date && Date.now() - since.getTime() > 10 * 60 * 1000;
379
+ }
380
+
381
+ // The ids a QueryHelper.any(...) filter asks for.
382
+ function idsIn(filter: unknown): Array<string> {
383
+ const parameters: Record<string, unknown> =
384
+ (filter as { objectLiteralParameters?: Record<string, unknown> })
385
+ ?.objectLiteralParameters || {};
386
+ const values: unknown = Object.values(parameters)[0];
387
+
388
+ return Array.isArray(values)
389
+ ? values.map((value: unknown) => {
390
+ return String(value);
391
+ })
392
+ : [];
393
+ }
394
+
395
+ function lastResponse(): JSONObject {
396
+ const calls: Array<Array<unknown>> = sendJsonObjectResponseMock.mock
397
+ .calls as Array<Array<unknown>>;
398
+ expect(calls.length).toBeGreaterThan(0);
399
+ return calls[calls.length - 1]![2] as JSONObject;
400
+ }
401
+
402
+ /*
403
+ * A caller's permission rows as an access token carries them: every row is
404
+ * a grant or a block (isBlockPermission is always set), optionally limited
405
+ * to some labels (`scoped`).
406
+ */
407
+ function userProps(data: {
408
+ permissions: Array<Permission>;
409
+ blocked?: Array<Permission> | undefined;
410
+ scoped?:
411
+ | Array<{
412
+ permission: Permission;
413
+ labelIds: Array<ObjectID>;
414
+ isBlockPermission?: boolean | undefined;
415
+ }>
416
+ | undefined;
417
+ userId?: ObjectID | undefined | null;
418
+ isMasterAdmin?: boolean | undefined;
419
+ }): DatabaseCommonInteractionProps {
420
+ const permissionMap: Dictionary<UserTenantAccessPermission> = {};
421
+
422
+ permissionMap[PROJECT_ID.toString()] = {
423
+ _type: "UserTenantAccessPermission",
424
+ projectId: PROJECT_ID,
425
+ permissions: [
426
+ ...data.permissions.map((permission: Permission): UserPermission => {
427
+ return {
428
+ _type: "UserPermission",
429
+ permission,
430
+ labelIds: [],
431
+ isBlockPermission: false,
432
+ } as UserPermission;
433
+ }),
434
+ ...(data.blocked || []).map((permission: Permission): UserPermission => {
435
+ return {
436
+ _type: "UserPermission",
437
+ permission,
438
+ labelIds: [],
439
+ isBlockPermission: true,
440
+ } as UserPermission;
441
+ }),
442
+ ...(data.scoped || []).map(
443
+ (row: {
444
+ permission: Permission;
445
+ labelIds: Array<ObjectID>;
446
+ isBlockPermission?: boolean | undefined;
447
+ }): UserPermission => {
448
+ return {
449
+ _type: "UserPermission",
450
+ permission: row.permission,
451
+ labelIds: row.labelIds,
452
+ isBlockPermission: Boolean(row.isBlockPermission),
453
+ } as UserPermission;
454
+ },
455
+ ),
456
+ ],
457
+ };
458
+
459
+ return {
460
+ tenantId: PROJECT_ID,
461
+ userId: data.userId === undefined ? USER_ID : data.userId || undefined,
462
+ userType: UserType.User,
463
+ userTenantAccessPermission: permissionMap,
464
+ ...(data.isMasterAdmin ? { isMasterAdmin: true } : {}),
465
+ } as DatabaseCommonInteractionProps;
466
+ }
467
+
468
+ function gap(
469
+ code: ResourceAiAccessGapCode,
470
+ blocks: { investigation: boolean; remediation: boolean } = {
471
+ investigation: true,
472
+ remediation: true,
473
+ },
474
+ ): ResourceAiAccessGap {
475
+ return {
476
+ code,
477
+ title: `${code} title`,
478
+ nextStep: `${code} next step`,
479
+ blocksInvestigation: blocks.investigation,
480
+ blocksRemediation: blocks.remediation,
481
+ };
482
+ }
483
+
484
+ function readyStatus(
485
+ resourceType: AiResourceType,
486
+ overrides: Partial<ResourceAiAccessStatus> = {},
487
+ ): ResourceAiAccessStatus {
488
+ return {
489
+ resourceType,
490
+ resourceId: RESOURCE_ID.toString(),
491
+ resourceName: "prod-1",
492
+ isAiInvestigationEnabled: false,
493
+ aiRemediationMode: ResourceAiRemediationMode.Disabled,
494
+ aiCommandAllowlist: [],
495
+ agent: {
496
+ agentId: AGENT_ID.toString(),
497
+ connectionStatus: "connected",
498
+ isOnline: true,
499
+ },
500
+ // The switches are off: the test must run anyway.
501
+ gaps: [
502
+ gap("investigation_disabled", {
503
+ investigation: true,
504
+ remediation: false,
505
+ }),
506
+ gap("remediation_disabled", { investigation: false, remediation: true }),
507
+ ],
508
+ isInvestigationReady: false,
509
+ isRemediationReady: false,
510
+ ...overrides,
511
+ };
512
+ }
513
+
514
+ function resourceRow(
515
+ projectId: ObjectID = PROJECT_ID,
516
+ ): Record<string, unknown> {
517
+ return {
518
+ id: RESOURCE_ID,
519
+ _id: RESOURCE_ID.toString(),
520
+ projectId,
521
+ name: "prod-1",
522
+ };
523
+ }
524
+
525
+ function outcome(
526
+ command: string,
527
+ overrides: Partial<ResourceCommandJobOutcome> = {},
528
+ ): ResourceCommandJobOutcome {
529
+ return {
530
+ jobId: ObjectID.generate().toString(),
531
+ succeeded: true,
532
+ exitCode: 0,
533
+ output: `output of ${command}`,
534
+ displayCommand: command,
535
+ executed: true,
536
+ ...overrides,
537
+ };
538
+ }
539
+
540
+ describe("ResourceAiAccessAPI", () => {
541
+ let propsSpy: jest.SpyInstance;
542
+ let findSpies: Map<AiResourceType, jest.SpyInstance>;
543
+ let statusSpy: jest.SpyInstance;
544
+ let runSpy: jest.SpyInstance;
545
+ let countSpy: jest.SpyInstance;
546
+
547
+ // What the access-test counters answer, by which query asks.
548
+ let inFlightTests: number;
549
+ let recentTestsForResource: number;
550
+ let testsForResourceThisHour: number;
551
+ let recentTestsByUser: number;
552
+
553
+ /*
554
+ * An in-memory stand-in for Redis: SET NX and compare-and-delete with the
555
+ * semantics GlobalCache gives them, and a mutex per key that queues its
556
+ * waiters, as redis-semaphore's does.
557
+ */
558
+ let cacheKeys: Map<string, string>;
559
+ let reserveSpy: jest.SpyInstance;
560
+ let releaseSpy: jest.SpyInstance;
561
+ let lockSpy: jest.SpyInstance;
562
+ let lockReleaseSpy: jest.SpyInstance;
563
+ let heldLocks: Map<string, Promise<void>>;
564
+
565
+ beforeAll(async () => {
566
+ /*
567
+ * The router registers its routes at module scope, so the import is
568
+ * deferred until the mock router above is fully constructed.
569
+ */
570
+ await import("../../../Server/API/ResourceAiAccessAPI");
571
+ });
572
+
573
+ beforeEach(() => {
574
+ jest.clearAllMocks();
575
+
576
+ inFlightTests = 0;
577
+ recentTestsForResource = 0;
578
+ testsForResourceThisHour = 0;
579
+ recentTestsByUser = 0;
580
+
581
+ cacheKeys = new Map<string, string>();
582
+ reserveSpy = jest
583
+ .spyOn(GlobalCache, "setStringIfNotExists")
584
+ .mockImplementation(
585
+ async (
586
+ namespace: string,
587
+ key: string,
588
+ value: string,
589
+ ): Promise<boolean> => {
590
+ const cacheKey: string = `${namespace}-${key}`;
591
+ if (cacheKeys.has(cacheKey)) {
592
+ return false;
593
+ }
594
+ cacheKeys.set(cacheKey, value);
595
+ return true;
596
+ },
597
+ );
598
+ releaseSpy = jest
599
+ .spyOn(GlobalCache, "deleteKeyIfValue")
600
+ .mockImplementation(
601
+ async (
602
+ namespace: string,
603
+ key: string,
604
+ value: string,
605
+ ): Promise<boolean> => {
606
+ const cacheKey: string = `${namespace}-${key}`;
607
+ if (cacheKeys.get(cacheKey) !== value) {
608
+ return false;
609
+ }
610
+ cacheKeys.delete(cacheKey);
611
+ return true;
612
+ },
613
+ );
614
+
615
+ heldLocks = new Map<string, Promise<void>>();
616
+ lockSpy = jest
617
+ .spyOn(Semaphore, "lock")
618
+ .mockImplementation(
619
+ async (data: {
620
+ key: string;
621
+ namespace: string;
622
+ }): Promise<SemaphoreMutex> => {
623
+ const lockKey: string = `${data.namespace}-${data.key}`;
624
+
625
+ while (heldLocks.has(lockKey)) {
626
+ await heldLocks.get(lockKey);
627
+ }
628
+
629
+ let unlock: () => void = (): void => {
630
+ return undefined;
631
+ };
632
+ heldLocks.set(
633
+ lockKey,
634
+ new Promise<void>((resolve: () => void) => {
635
+ unlock = resolve;
636
+ }),
637
+ );
638
+
639
+ return {
640
+ lockKey,
641
+ unlock: (): void => {
642
+ heldLocks.delete(lockKey);
643
+ unlock();
644
+ },
645
+ } as unknown as SemaphoreMutex;
646
+ },
647
+ );
648
+ lockReleaseSpy = jest
649
+ .spyOn(Semaphore, "release")
650
+ .mockImplementation(async (mutex: SemaphoreMutex): Promise<void> => {
651
+ (mutex as unknown as { unlock: () => void }).unlock();
652
+ });
653
+
654
+ propsSpy = jest
655
+ .spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
656
+ .mockResolvedValue(
657
+ userProps({ permissions: [Permission.ProjectMember] }),
658
+ );
659
+
660
+ findSpies = new Map<AiResourceType, jest.SpyInstance>();
661
+ for (const kind of KINDS) {
662
+ findSpies.set(
663
+ kind.resourceType,
664
+ jest.spyOn(kind.service, "findOneBy").mockResolvedValue(resourceRow()),
665
+ );
666
+ }
667
+
668
+ statusSpy = jest
669
+ .spyOn(ResourceAiAccessService, "getStatusForResource")
670
+ .mockImplementation(async (data: unknown) => {
671
+ return readyStatus(
672
+ (data as { resourceType: AiResourceType }).resourceType,
673
+ );
674
+ });
675
+
676
+ runSpy = jest
677
+ .spyOn(ResourceCommandJobRunner, "run")
678
+ .mockImplementation(async (data: unknown) => {
679
+ return outcome((data as { command: string }).command);
680
+ });
681
+
682
+ countSpy = jest
683
+ .spyOn(RunnerJobService, "countBy")
684
+ .mockImplementation(async (args: unknown): Promise<PositiveNumber> => {
685
+ const query: Record<string, unknown> = (
686
+ args as { query: Record<string, unknown> }
687
+ ).query;
688
+
689
+ if (query["status"]) {
690
+ return new PositiveNumber(inFlightTests);
691
+ }
692
+
693
+ if (query["resourceId"]) {
694
+ return new PositiveNumber(
695
+ isWithinTheLastHourOnly(query)
696
+ ? testsForResourceThisHour
697
+ : recentTestsForResource,
698
+ );
699
+ }
700
+
701
+ return new PositiveNumber(recentTestsByUser);
702
+ });
703
+ });
704
+
705
+ afterEach(() => {
706
+ jest.restoreAllMocks();
707
+ });
708
+
709
+ test("registers the four routes, each behind the user middleware", () => {
710
+ for (const path of [
711
+ RESOURCE_AI_ACCESS_STATUS_PATH,
712
+ RESOURCE_AI_ACCESS_TEST_PATH,
713
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
714
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
715
+ ]) {
716
+ const route: MockRoute = matchRoute(path);
717
+ expect(route.middleware).toBeDefined();
718
+ }
719
+
720
+ expect([
721
+ RESOURCE_AI_ACCESS_STATUS_PATH,
722
+ RESOURCE_AI_ACCESS_TEST_PATH,
723
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
724
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
725
+ ]).toEqual([
726
+ "/resource-ai-access/status",
727
+ "/resource-ai-access/test",
728
+ "/resource-ai-access/reset-agent",
729
+ "/resource-ai-access/insights",
730
+ ]);
731
+ });
732
+
733
+ describe.each(KINDS)(
734
+ "$resourceType: the resource lookup goes through its own service",
735
+ (kind: Kind) => {
736
+ test("status reads the resource through its service, under the user's props, scoped to the tenant", async () => {
737
+ propsSpy.mockResolvedValue(
738
+ userProps({ permissions: [kind.readPermission] }),
739
+ );
740
+
741
+ const result: RouteCallResult = await callRoute(
742
+ RESOURCE_AI_ACCESS_STATUS_PATH,
743
+ body(kind.resourceType),
744
+ );
745
+
746
+ expect(result.nextCallCount).toBe(0);
747
+
748
+ const spy: jest.SpyInstance = findSpies.get(kind.resourceType)!;
749
+ expect(spy).toHaveBeenCalledTimes(1);
750
+ const args: {
751
+ query: Record<string, unknown>;
752
+ props: DatabaseCommonInteractionProps;
753
+ } = spy.mock.calls[0]![0] as {
754
+ query: Record<string, unknown>;
755
+ props: DatabaseCommonInteractionProps;
756
+ };
757
+ expect(args.query["_id"]).toBe(RESOURCE_ID.toString());
758
+ expect(String(args.query["projectId"])).toBe(PROJECT_ID.toString());
759
+ expect(args.props.isRoot).toBeFalsy();
760
+ expect(String(args.props.userId)).toBe(USER_ID.toString());
761
+
762
+ // No other type's table was read.
763
+ for (const other of KINDS) {
764
+ if (other.resourceType !== kind.resourceType) {
765
+ expect(findSpies.get(other.resourceType)).not.toHaveBeenCalled();
766
+ }
767
+ }
768
+
769
+ const statusCall: Record<string, unknown> = statusSpy.mock
770
+ .calls[0]![0] as Record<string, unknown>;
771
+ expect(statusCall["resourceType"]).toBe(kind.resourceType);
772
+ expect(String(statusCall["resourceId"])).toBe(RESOURCE_ID.toString());
773
+ expect(String(statusCall["projectId"])).toBe(PROJECT_ID.toString());
774
+ expect(lastResponse()["resourceType"]).toBe(kind.resourceType);
775
+ });
776
+
777
+ test("a resource the user cannot read is refused in the type's own words", async () => {
778
+ findSpies.get(kind.resourceType)!.mockResolvedValue(null);
779
+
780
+ const result: RouteCallResult = await callRoute(
781
+ RESOURCE_AI_ACCESS_STATUS_PATH,
782
+ body(kind.resourceType),
783
+ );
784
+
785
+ expect(result.thrownToNext).toBeInstanceOf(BadDataException);
786
+ expect((result.thrownToNext as Error).message).toBe(
787
+ `${AI_RESOURCE_TYPE_INFO[kind.resourceType].displayName} not found (or you do not have access to it).`,
788
+ );
789
+ expect(statusSpy).not.toHaveBeenCalled();
790
+ });
791
+
792
+ test("the access test is for the people who may edit this type", async () => {
793
+ propsSpy.mockResolvedValue(
794
+ userProps({ permissions: [kind.readPermission] }),
795
+ );
796
+
797
+ const refused: RouteCallResult = await callRoute(
798
+ RESOURCE_AI_ACCESS_TEST_PATH,
799
+ body(kind.resourceType),
800
+ );
801
+
802
+ expect(refused.thrownToNext).toBeInstanceOf(NotAuthorizedException);
803
+ expect((refused.thrownToNext as Error).message).toBe(
804
+ `You need permission to edit this ${kind.sentenceName} to run its AI access test.`,
805
+ );
806
+ expect(runSpy).not.toHaveBeenCalled();
807
+
808
+ // A custom role that may read and edit this type (the route reads it first).
809
+ propsSpy.mockResolvedValue(
810
+ userProps({
811
+ permissions: [kind.readPermission, kind.editPermission],
812
+ }),
813
+ );
814
+
815
+ const admitted: RouteCallResult = await callRoute(
816
+ RESOURCE_AI_ACCESS_TEST_PATH,
817
+ body(kind.resourceType),
818
+ );
819
+
820
+ expect(admitted.nextCallCount).toBe(0);
821
+ expect(
822
+ runSpy.mock.calls.map((call: Array<unknown>) => {
823
+ return (call[0] as { command: string }).command;
824
+ }),
825
+ ).toEqual([...AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands]);
826
+ });
827
+
828
+ test("links incidents and alerts through the type's own relation for insights", async () => {
829
+ const incidentFind: jest.SpyInstance = jest
830
+ .spyOn(IncidentService, "findBy")
831
+ .mockResolvedValue([]);
832
+ const alertFind: jest.SpyInstance = jest
833
+ .spyOn(AlertService, "findBy")
834
+ .mockResolvedValue([]);
835
+ jest.spyOn(RunnerJobService, "findBy").mockResolvedValue([]);
836
+ jest
837
+ .spyOn(AutoRemediationSuggestionService, "findBy")
838
+ .mockResolvedValue([]);
839
+
840
+ await callRoute(
841
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
842
+ body(kind.resourceType),
843
+ );
844
+
845
+ for (const spy of [incidentFind, alertFind]) {
846
+ const query: Record<string, unknown> = (
847
+ spy.mock.calls[0]![0] as { query: Record<string, unknown> }
848
+ ).query;
849
+ expect(query[kind.subjectRelation]).toBeDefined();
850
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
851
+ }
852
+ });
853
+ },
854
+ );
855
+
856
+ test("the kinds table covers every resource type", () => {
857
+ expect(
858
+ KINDS.map((kind: Kind) => {
859
+ return kind.resourceType;
860
+ }),
861
+ ).toEqual([...ALL_AI_RESOURCE_TYPES]);
862
+ });
863
+
864
+ describe("reading the request", () => {
865
+ test("accepts a type in any case and an agent alias", async () => {
866
+ for (const [spelled, type] of [
867
+ ["dockerhost", AiResourceType.DockerHost],
868
+ ["docker", AiResourceType.DockerHost],
869
+ ["db", AiResourceType.DatabaseServer],
870
+ ["HOST", AiResourceType.Host],
871
+ ] as Array<[string, AiResourceType]>) {
872
+ jest.clearAllMocks();
873
+
874
+ const result: RouteCallResult = await callRoute(
875
+ RESOURCE_AI_ACCESS_STATUS_PATH,
876
+ body(spelled),
877
+ );
878
+
879
+ expect(result.nextCallCount).toBe(0);
880
+ expect(findSpies.get(type)).toHaveBeenCalledTimes(1);
881
+ }
882
+ });
883
+
884
+ test.each([
885
+ [RESOURCE_AI_ACCESS_STATUS_PATH],
886
+ [RESOURCE_AI_ACCESS_TEST_PATH],
887
+ [RESOURCE_AI_ACCESS_RESET_AGENT_PATH],
888
+ [RESOURCE_AI_ACCESS_INSIGHTS_PATH],
889
+ ])(
890
+ "%s rejects a missing or unknown type, and a missing or malformed id, before any lookup",
891
+ async (uri: string) => {
892
+ for (const requestBody of [
893
+ {},
894
+ { resourceId: RESOURCE_ID.toString() },
895
+ body("KubernetesCluster"),
896
+ body("serverless"),
897
+ body(AiResourceType.DockerHost, ""),
898
+ body(AiResourceType.DockerHost, "not-a-uuid"),
899
+ { resourceType: AiResourceType.DockerHost, resourceId: 42 },
900
+ ] as Array<JSONObject>) {
901
+ const result: RouteCallResult = await callRoute(uri, requestBody);
902
+ expect(result.thrownToNext).toBeInstanceOf(BadDataException);
903
+ }
904
+
905
+ for (const spy of findSpies.values()) {
906
+ expect(spy).not.toHaveBeenCalled();
907
+ }
908
+ },
909
+ );
910
+
911
+ test("names the valid types when the type is wrong", async () => {
912
+ const result: RouteCallResult = await callRoute(
913
+ RESOURCE_AI_ACCESS_STATUS_PATH,
914
+ body("KubernetesCluster"),
915
+ );
916
+
917
+ expect((result.thrownToNext as Error).message).toBe(
918
+ "resourceType is required and must be one of DockerHost, PodmanHost, DockerSwarmCluster, ProxmoxCluster, VMwareVCenter, CephCluster, DatabaseServer, Host.",
919
+ );
920
+ });
921
+ });
922
+
923
+ describe.each(COVERED)(
924
+ "$resourceType: tenant isolation (every route)",
925
+ (kind: Kind) => {
926
+ test.each([
927
+ [RESOURCE_AI_ACCESS_STATUS_PATH],
928
+ [RESOURCE_AI_ACCESS_TEST_PATH],
929
+ [RESOURCE_AI_ACCESS_RESET_AGENT_PATH],
930
+ [RESOURCE_AI_ACCESS_INSIGHTS_PATH],
931
+ ])(
932
+ "%s: another project's id reads exactly like a missing one, and nothing else runs",
933
+ async (uri: string) => {
934
+ propsSpy.mockResolvedValue(
935
+ userProps({ permissions: [Permission.ProjectOwner] }),
936
+ );
937
+ const resetSpy: jest.SpyInstance = jest
938
+ .spyOn(ResourceAiAgentService, "resetAgent")
939
+ .mockResolvedValue(undefined);
940
+ const jobFind: jest.SpyInstance = jest
941
+ .spyOn(RunnerJobService, "findBy")
942
+ .mockResolvedValue([]);
943
+
944
+ findSpies.get(kind.resourceType)!.mockResolvedValue(null);
945
+ const missing: RouteCallResult = await callRoute(
946
+ uri,
947
+ body(kind.resourceType),
948
+ );
949
+
950
+ expect(missing.thrownToNext).toBeInstanceOf(BadDataException);
951
+ const missingMessage: string = (missing.thrownToNext as Error)
952
+ .message;
953
+
954
+ // Belt and braces: a row that somehow came back from elsewhere.
955
+ findSpies
956
+ .get(kind.resourceType)!
957
+ .mockResolvedValue(resourceRow(OTHER_PROJECT_ID));
958
+
959
+ const foreign: RouteCallResult = await callRoute(
960
+ uri,
961
+ body(kind.resourceType),
962
+ );
963
+
964
+ expect(foreign.thrownToNext).toBeInstanceOf(BadDataException);
965
+ expect((foreign.thrownToNext as Error).message).toBe(missingMessage);
966
+ expect(missingMessage).toContain("not found");
967
+
968
+ expect(statusSpy).not.toHaveBeenCalled();
969
+ expect(countSpy).not.toHaveBeenCalled();
970
+ expect(runSpy).not.toHaveBeenCalled();
971
+ expect(resetSpy).not.toHaveBeenCalled();
972
+ expect(jobFind).not.toHaveBeenCalled();
973
+ expect(sendJsonObjectResponseMock).not.toHaveBeenCalled();
974
+ },
975
+ );
976
+
977
+ test.each([
978
+ [RESOURCE_AI_ACCESS_STATUS_PATH],
979
+ [RESOURCE_AI_ACCESS_TEST_PATH],
980
+ [RESOURCE_AI_ACCESS_RESET_AGENT_PATH],
981
+ [RESOURCE_AI_ACCESS_INSIGHTS_PATH],
982
+ ])("%s: requires a logged-in user", async (uri: string) => {
983
+ propsSpy.mockResolvedValue(
984
+ userProps({ permissions: [Permission.ProjectOwner], userId: null }),
985
+ );
986
+
987
+ const result: RouteCallResult = await callRoute(
988
+ uri,
989
+ body(kind.resourceType),
990
+ );
991
+
992
+ expect(result.nextCallCount).toBe(1);
993
+ expect(findSpies.get(kind.resourceType)).not.toHaveBeenCalled();
994
+ });
995
+ },
996
+ );
997
+
998
+ describe.each(COVERED)("$resourceType: POST /status", (kind: Kind) => {
999
+ test("returns the status for a user who may only read the resource, and runs nothing", async () => {
1000
+ propsSpy.mockResolvedValue(
1001
+ userProps({ permissions: [kind.readPermission] }),
1002
+ );
1003
+
1004
+ const result: RouteCallResult = await callRoute(
1005
+ RESOURCE_AI_ACCESS_STATUS_PATH,
1006
+ body(kind.resourceType),
1007
+ );
1008
+
1009
+ expect(result.nextCallCount).toBe(0);
1010
+ expect(lastResponse()).toEqual(
1011
+ readyStatus(kind.resourceType) as unknown as JSONObject,
1012
+ );
1013
+ expect(runSpy).not.toHaveBeenCalled();
1014
+ expect(countSpy).not.toHaveBeenCalled();
1015
+ });
1016
+
1017
+ test("a status that cannot be computed (the resource went away meanwhile) is not found", async () => {
1018
+ statusSpy.mockResolvedValue(null);
1019
+
1020
+ const result: RouteCallResult = await callRoute(
1021
+ RESOURCE_AI_ACCESS_STATUS_PATH,
1022
+ body(kind.resourceType),
1023
+ );
1024
+
1025
+ expect(result.thrownToNext).toBeInstanceOf(BadDataException);
1026
+ expect((result.thrownToNext as Error).message).toBe(
1027
+ `${AI_RESOURCE_TYPE_INFO[kind.resourceType].displayName} not found.`,
1028
+ );
1029
+ });
1030
+ });
1031
+
1032
+ describe.each(COVERED)(
1033
+ "$resourceType: POST /test — who may run it",
1034
+ (kind: Kind) => {
1035
+ test("refuses a user who may only read the resource, before any count or command", async () => {
1036
+ propsSpy.mockResolvedValue(
1037
+ userProps({ permissions: [kind.readPermission] }),
1038
+ );
1039
+
1040
+ const result: RouteCallResult = await callRoute(
1041
+ RESOURCE_AI_ACCESS_TEST_PATH,
1042
+ body(kind.resourceType),
1043
+ );
1044
+
1045
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1046
+ expect(countSpy).not.toHaveBeenCalled();
1047
+ expect(reserveSpy).not.toHaveBeenCalled();
1048
+ expect(runSpy).not.toHaveBeenCalled();
1049
+ });
1050
+
1051
+ test("a block row is a denial, not a grant", async () => {
1052
+ propsSpy.mockResolvedValue(
1053
+ userProps({
1054
+ permissions: [kind.readPermission],
1055
+ blocked: [kind.editPermission],
1056
+ }),
1057
+ );
1058
+
1059
+ const result: RouteCallResult = await callRoute(
1060
+ RESOURCE_AI_ACCESS_TEST_PATH,
1061
+ body(kind.resourceType),
1062
+ );
1063
+
1064
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1065
+ expect(runSpy).not.toHaveBeenCalled();
1066
+ });
1067
+
1068
+ test("admits every role that may edit the resource, and a master admin", async () => {
1069
+ for (const props of [
1070
+ userProps({ permissions: [Permission.ProjectMember] }),
1071
+ userProps({ permissions: [Permission.SettingsMember] }),
1072
+ userProps({
1073
+ permissions: [kind.readPermission, kind.editPermission],
1074
+ }),
1075
+ userProps({ permissions: [], isMasterAdmin: true }),
1076
+ ]) {
1077
+ jest.clearAllMocks();
1078
+ propsSpy.mockResolvedValue(props);
1079
+
1080
+ const result: RouteCallResult = await callRoute(
1081
+ RESOURCE_AI_ACCESS_TEST_PATH,
1082
+ body(kind.resourceType),
1083
+ );
1084
+
1085
+ expect(result.nextCallCount).toBe(0);
1086
+ expect(runSpy).toHaveBeenCalledTimes(
1087
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands.length,
1088
+ );
1089
+ }
1090
+ });
1091
+
1092
+ test("a table-wide block row on the edit permission refuses even a project member", async () => {
1093
+ propsSpy.mockResolvedValue(
1094
+ userProps({
1095
+ permissions: [Permission.ProjectMember],
1096
+ blocked: [kind.editPermission],
1097
+ }),
1098
+ );
1099
+
1100
+ const result: RouteCallResult = await callRoute(
1101
+ RESOURCE_AI_ACCESS_TEST_PATH,
1102
+ body(kind.resourceType),
1103
+ );
1104
+
1105
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1106
+ expect(reserveSpy).not.toHaveBeenCalled();
1107
+ expect(runSpy).not.toHaveBeenCalled();
1108
+ });
1109
+ },
1110
+ );
1111
+
1112
+ /*
1113
+ * Edit access is decided for THIS resource, from its own labels, as a CRUD
1114
+ * update of it would be — not "may the caller edit some resource of this
1115
+ * type somewhere in the project".
1116
+ */
1117
+ describe.each(COVERED)(
1118
+ "$resourceType: POST /test — edit access to THIS resource, labels included",
1119
+ (kind: Kind) => {
1120
+ const PROD_LABEL_ID: ObjectID = new ObjectID(
1121
+ "44444444-4444-4444-8444-444444444444",
1122
+ );
1123
+ const STAGING_LABEL_ID: ObjectID = new ObjectID(
1124
+ "55555555-5555-4555-8555-555555555555",
1125
+ );
1126
+
1127
+ // The resource as its table holds it: a model with its labels.
1128
+ function serveResourceLabelled(labelId: ObjectID, name: string): void {
1129
+ const label: Label = new Label();
1130
+ label.id = labelId;
1131
+ label.name = name;
1132
+
1133
+ const row: BaseModel = new LABELLED_MODELS[kind.resourceType]!();
1134
+ row.id = RESOURCE_ID;
1135
+ const record: Record<string, unknown> = row as unknown as Record<
1136
+ string,
1137
+ unknown
1138
+ >;
1139
+ record["projectId"] = PROJECT_ID;
1140
+ record["name"] = "prod-1";
1141
+ record["labels"] = [label];
1142
+
1143
+ findSpies.get(kind.resourceType)!.mockResolvedValue(row);
1144
+ }
1145
+
1146
+ function refusedBeforeAnythingRan(result: RouteCallResult): void {
1147
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1148
+ expect((result.thrownToNext as Error).message).toBe(
1149
+ `You need permission to edit this ${kind.sentenceName} to run its AI access test.`,
1150
+ );
1151
+ expect(reserveSpy).not.toHaveBeenCalled();
1152
+ expect(countSpy).not.toHaveBeenCalled();
1153
+ expect(statusSpy).not.toHaveBeenCalled();
1154
+ expect(runSpy).not.toHaveBeenCalled();
1155
+ }
1156
+
1157
+ test("an edit grant limited to another label does not reach this resource", async () => {
1158
+ serveResourceLabelled(PROD_LABEL_ID, "prod");
1159
+ propsSpy.mockResolvedValue(
1160
+ userProps({
1161
+ permissions: [kind.readPermission],
1162
+ scoped: [
1163
+ { permission: kind.editPermission, labelIds: [STAGING_LABEL_ID] },
1164
+ ],
1165
+ }),
1166
+ );
1167
+
1168
+ refusedBeforeAnythingRan(
1169
+ await callRoute(
1170
+ RESOURCE_AI_ACCESS_TEST_PATH,
1171
+ body(kind.resourceType),
1172
+ ),
1173
+ );
1174
+ });
1175
+
1176
+ test("negative control: the same grant reaches a resource with its label", async () => {
1177
+ serveResourceLabelled(STAGING_LABEL_ID, "staging");
1178
+ propsSpy.mockResolvedValue(
1179
+ userProps({
1180
+ permissions: [kind.readPermission],
1181
+ scoped: [
1182
+ { permission: kind.editPermission, labelIds: [STAGING_LABEL_ID] },
1183
+ ],
1184
+ }),
1185
+ );
1186
+
1187
+ const result: RouteCallResult = await callRoute(
1188
+ RESOURCE_AI_ACCESS_TEST_PATH,
1189
+ body(kind.resourceType),
1190
+ );
1191
+
1192
+ expect(result.nextCallCount).toBe(0);
1193
+ expect(runSpy).toHaveBeenCalledTimes(
1194
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands.length,
1195
+ );
1196
+ });
1197
+
1198
+ test("a block row on the edit permission for this resource's label refuses a project member", async () => {
1199
+ serveResourceLabelled(PROD_LABEL_ID, "prod");
1200
+ propsSpy.mockResolvedValue(
1201
+ userProps({
1202
+ permissions: [Permission.ProjectMember],
1203
+ scoped: [
1204
+ {
1205
+ permission: kind.editPermission,
1206
+ labelIds: [PROD_LABEL_ID],
1207
+ isBlockPermission: true,
1208
+ },
1209
+ ],
1210
+ }),
1211
+ );
1212
+
1213
+ refusedBeforeAnythingRan(
1214
+ await callRoute(
1215
+ RESOURCE_AI_ACCESS_TEST_PATH,
1216
+ body(kind.resourceType),
1217
+ ),
1218
+ );
1219
+ });
1220
+
1221
+ test("negative control: that block does not touch a resource without the label", async () => {
1222
+ serveResourceLabelled(STAGING_LABEL_ID, "staging");
1223
+ propsSpy.mockResolvedValue(
1224
+ userProps({
1225
+ permissions: [Permission.ProjectMember],
1226
+ scoped: [
1227
+ {
1228
+ permission: kind.editPermission,
1229
+ labelIds: [PROD_LABEL_ID],
1230
+ isBlockPermission: true,
1231
+ },
1232
+ ],
1233
+ }),
1234
+ );
1235
+
1236
+ const result: RouteCallResult = await callRoute(
1237
+ RESOURCE_AI_ACCESS_TEST_PATH,
1238
+ body(kind.resourceType),
1239
+ );
1240
+
1241
+ expect(result.nextCallCount).toBe(0);
1242
+ expect(runSpy).toHaveBeenCalledTimes(
1243
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands.length,
1244
+ );
1245
+ });
1246
+ },
1247
+ );
1248
+
1249
+ describe.each(COVERED)(
1250
+ "$resourceType: POST /test — the happy path",
1251
+ (kind: Kind) => {
1252
+ test("runs each test command through ResourceCommandJobRunner.run as an access test, through the resource's current agent", async () => {
1253
+ const result: RouteCallResult = await callRoute(
1254
+ RESOURCE_AI_ACCESS_TEST_PATH,
1255
+ body(kind.resourceType),
1256
+ );
1257
+
1258
+ expect(result.nextCallCount).toBe(0);
1259
+
1260
+ const commands: ReadonlyArray<string> =
1261
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands;
1262
+ expect(runSpy).toHaveBeenCalledTimes(commands.length);
1263
+
1264
+ runSpy.mock.calls.forEach((call: Array<unknown>, index: number) => {
1265
+ const data: Record<string, unknown> = call[0] as Record<
1266
+ string,
1267
+ unknown
1268
+ >;
1269
+
1270
+ expect(data["command"]).toBe(commands[index]);
1271
+ expect(data["origin"]).toBe(RunnerJobOrigin.AiInvestigation);
1272
+ expect(data["aiRunId"]).toBeUndefined();
1273
+ expect(data["autoRemediationSuggestionId"]).toBeUndefined();
1274
+ expect(data["isAccessTest"]).toBe(true);
1275
+ expect(data["resourceType"]).toBe(kind.resourceType);
1276
+ expect(String(data["resourceId"])).toBe(RESOURCE_ID.toString());
1277
+ expect(String(data["projectId"])).toBe(PROJECT_ID.toString());
1278
+ expect(String(data["targetResourceAiAgentId"])).toBe(
1279
+ AGENT_ID.toString(),
1280
+ );
1281
+ expect(data["timeoutInMs"]).toBe(DEFAULT_RESOURCE_COMMAND_TIMEOUT_MS);
1282
+ expect(data["claimTimeoutInMs"]).toBe(
1283
+ RESOURCE_COMMAND_CLAIM_TIMEOUT_MS,
1284
+ );
1285
+ expect(data["stepId"]).toBe(
1286
+ `resource-ai-access-test-${index + 1}-${USER_ID.toString()}`,
1287
+ );
1288
+ });
1289
+
1290
+ const response: JSONObject = lastResponse();
1291
+ expect(response["ok"]).toBe(true);
1292
+ expect(response["message"]).toBe(
1293
+ `OneUptime AI can run commands on "prod-1" through the ${
1294
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].agentDisplayName
1295
+ }.`,
1296
+ );
1297
+ expect(response["results"]).toEqual(
1298
+ commands.map((command: string) => {
1299
+ return {
1300
+ command,
1301
+ succeeded: true,
1302
+ exitCode: 0,
1303
+ output: `output of ${command}`,
1304
+ errorMessage: null,
1305
+ };
1306
+ }),
1307
+ );
1308
+ // The status is read again after the test: it may have changed "Last verified".
1309
+ expect(statusSpy).toHaveBeenCalledTimes(2);
1310
+ expect(response["status"]).toBeDefined();
1311
+ });
1312
+
1313
+ test("keeps the response shape: five fields per command, nothing more", async () => {
1314
+ runSpy.mockImplementation(async (data: unknown) => {
1315
+ return outcome((data as { command: string }).command, {
1316
+ redactionCount: 3,
1317
+ isTruncated: true,
1318
+ runState: undefined,
1319
+ jobId: "job",
1320
+ });
1321
+ });
1322
+
1323
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1324
+
1325
+ for (const row of lastResponse()["results"] as Array<JSONObject>) {
1326
+ expect(Object.keys(row).sort()).toEqual([
1327
+ "command",
1328
+ "errorMessage",
1329
+ "exitCode",
1330
+ "output",
1331
+ "succeeded",
1332
+ ]);
1333
+ }
1334
+ });
1335
+
1336
+ test("still runs with the switches off, the project's AI gaps, and an agent that could not reach the resource at its last probe", async () => {
1337
+ statusSpy.mockResolvedValue(
1338
+ readyStatus(kind.resourceType, {
1339
+ gaps: [
1340
+ gap("investigation_disabled", {
1341
+ investigation: true,
1342
+ remediation: false,
1343
+ }),
1344
+ gap("remediation_disabled", {
1345
+ investigation: false,
1346
+ remediation: true,
1347
+ }),
1348
+ gap("remediation_write_access_missing", {
1349
+ investigation: false,
1350
+ remediation: true,
1351
+ }),
1352
+ gap("auto_remediation_disabled_for_project", {
1353
+ investigation: false,
1354
+ remediation: true,
1355
+ }),
1356
+ gap("ai_disabled_for_project"),
1357
+ gap("llm_provider_missing"),
1358
+ gap("ai_balance_insufficient"),
1359
+ gap("ai_agent_unreachable_resource"),
1360
+ ],
1361
+ }),
1362
+ );
1363
+
1364
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1365
+
1366
+ expect(runSpy).toHaveBeenCalledTimes(
1367
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands.length,
1368
+ );
1369
+ expect(lastResponse()["ok"]).toBe(true);
1370
+ });
1371
+
1372
+ test.each([
1373
+ ["ai_agent_offline" as const],
1374
+ ["ai_agent_not_connected" as const],
1375
+ ])(
1376
+ "stops before anything runs on %s, with the gap's own title and next step",
1377
+ async (code: ResourceAiAccessGapCode) => {
1378
+ statusSpy.mockResolvedValue(
1379
+ readyStatus(kind.resourceType, { gaps: [gap(code)] }),
1380
+ );
1381
+
1382
+ await callRoute(
1383
+ RESOURCE_AI_ACCESS_TEST_PATH,
1384
+ body(kind.resourceType),
1385
+ );
1386
+
1387
+ expect(runSpy).not.toHaveBeenCalled();
1388
+ const response: JSONObject = lastResponse();
1389
+ expect(response["ok"]).toBe(false);
1390
+ expect(response["message"]).toBe(`${code} title. ${code} next step`);
1391
+ expect(response["results"]).toEqual([]);
1392
+ expect(response["status"]).toBeDefined();
1393
+ },
1394
+ );
1395
+
1396
+ test("with no agent at all it says to install the type's agent", async () => {
1397
+ statusSpy.mockResolvedValue(
1398
+ readyStatus(kind.resourceType, { agent: null, gaps: [] }),
1399
+ );
1400
+
1401
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1402
+
1403
+ expect(runSpy).not.toHaveBeenCalled();
1404
+ expect(lastResponse()["message"]).toBe(
1405
+ `Nothing can reach this ${kind.sentenceName} yet: install the ${
1406
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].agentDisplayName
1407
+ }.`,
1408
+ );
1409
+ });
1410
+
1411
+ test("with an offline agent and no gap saying so it still does not run", async () => {
1412
+ statusSpy.mockResolvedValue(
1413
+ readyStatus(kind.resourceType, {
1414
+ agent: {
1415
+ agentId: AGENT_ID.toString(),
1416
+ connectionStatus: "disconnected",
1417
+ isOnline: false,
1418
+ },
1419
+ gaps: [],
1420
+ }),
1421
+ );
1422
+
1423
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1424
+
1425
+ expect(runSpy).not.toHaveBeenCalled();
1426
+ expect(lastResponse()["ok"]).toBe(false);
1427
+ expect(lastResponse()["message"]).toContain("is offline");
1428
+ });
1429
+
1430
+ test("stops after the first command that did not succeed, and reports it", async () => {
1431
+ runSpy.mockImplementation(async (data: unknown) => {
1432
+ return outcome((data as { command: string }).command, {
1433
+ succeeded: false,
1434
+ exitCode: 1,
1435
+ output: "",
1436
+ errorMessage: "Cannot connect to the server",
1437
+ isAccessFailure: true,
1438
+ });
1439
+ });
1440
+
1441
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1442
+
1443
+ expect(runSpy).toHaveBeenCalledTimes(1);
1444
+ const response: JSONObject = lastResponse();
1445
+ expect(response["ok"]).toBe(false);
1446
+ expect(response["message"]).toBe(
1447
+ `The ${
1448
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].agentDisplayName
1449
+ } could not run the test commands successfully — see the command output below.`,
1450
+ );
1451
+ expect(response["results"]).toEqual([
1452
+ {
1453
+ command: AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands[0],
1454
+ succeeded: false,
1455
+ exitCode: 1,
1456
+ output: "",
1457
+ errorMessage: "Cannot connect to the server",
1458
+ },
1459
+ ]);
1460
+ });
1461
+
1462
+ test("reports a refusal from the chokepoint (a thrown error) as a failed command, and stops", async () => {
1463
+ runSpy.mockRejectedValue(
1464
+ new BadDataException("The agent is read-only for this command."),
1465
+ );
1466
+
1467
+ const result: RouteCallResult = await callRoute(
1468
+ RESOURCE_AI_ACCESS_TEST_PATH,
1469
+ body(kind.resourceType),
1470
+ );
1471
+
1472
+ expect(result.nextCallCount).toBe(0);
1473
+ expect(runSpy).toHaveBeenCalledTimes(1);
1474
+ expect(lastResponse()["results"]).toEqual([
1475
+ {
1476
+ command: AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands[0],
1477
+ succeeded: false,
1478
+ exitCode: null,
1479
+ output: "",
1480
+ errorMessage: "The agent is read-only for this command.",
1481
+ },
1482
+ ]);
1483
+ });
1484
+
1485
+ test("a later command that throws is reported in its turn", async () => {
1486
+ const commands: ReadonlyArray<string> =
1487
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].testCommands;
1488
+
1489
+ if (commands.length < 2) {
1490
+ return;
1491
+ }
1492
+
1493
+ runSpy
1494
+ .mockImplementationOnce(async (data: unknown) => {
1495
+ return outcome((data as { command: string }).command);
1496
+ })
1497
+ .mockRejectedValueOnce("boom");
1498
+
1499
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1500
+
1501
+ const results: Array<JSONObject> = lastResponse()[
1502
+ "results"
1503
+ ] as Array<JSONObject>;
1504
+ expect(results).toHaveLength(2);
1505
+ expect(results[1]).toEqual({
1506
+ command: commands[1],
1507
+ succeeded: false,
1508
+ exitCode: null,
1509
+ output: "",
1510
+ errorMessage: "boom",
1511
+ });
1512
+ expect(lastResponse()["ok"]).toBe(false);
1513
+ });
1514
+
1515
+ test("never uses a Kubernetes cluster's step ids, so its tests never count against a cluster's", async () => {
1516
+ await callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1517
+
1518
+ for (const call of runSpy.mock.calls) {
1519
+ const stepId: string = (call[0] as { stepId: string }).stepId;
1520
+ expect(stepId.startsWith("ai-access-test-")).toBe(false);
1521
+ expect(stepId.startsWith("resource-ai-access-test-")).toBe(true);
1522
+ }
1523
+ });
1524
+ },
1525
+ );
1526
+
1527
+ describe.each(COVERED)(
1528
+ "$resourceType: POST /test — its own limits",
1529
+ (kind: Kind) => {
1530
+ async function runTest(): Promise<RouteCallResult> {
1531
+ return callRoute(RESOURCE_AI_ACCESS_TEST_PATH, body(kind.resourceType));
1532
+ }
1533
+
1534
+ test("refuses a second test while one is still running for the resource", async () => {
1535
+ inFlightTests = 1;
1536
+
1537
+ const result: RouteCallResult = await runTest();
1538
+
1539
+ expect(result.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1540
+ expect((result.thrownToNext as Error).message).toBe(
1541
+ `An AI access test is already running for this ${kind.sentenceName}. Wait for it to finish, then run it again.`,
1542
+ );
1543
+ expect(runSpy).not.toHaveBeenCalled();
1544
+ });
1545
+
1546
+ test("refuses once the resource was tested 3 times in the last minute; admits at 2", async () => {
1547
+ recentTestsForResource = 3;
1548
+ const refused: RouteCallResult = await runTest();
1549
+ expect(refused.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1550
+ expect((refused.thrownToNext as Error).message).toContain(
1551
+ "3 times in the last minute",
1552
+ );
1553
+ expect(runSpy).not.toHaveBeenCalled();
1554
+
1555
+ recentTestsForResource = 2;
1556
+ const admitted: RouteCallResult = await runTest();
1557
+ expect(admitted.nextCallCount).toBe(0);
1558
+ expect(runSpy).toHaveBeenCalled();
1559
+ });
1560
+
1561
+ test("refuses once the resource was tested 30 times in the last hour; admits at 29", async () => {
1562
+ testsForResourceThisHour = 30;
1563
+ const refused: RouteCallResult = await runTest();
1564
+ expect(refused.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1565
+ expect((refused.thrownToNext as Error).message).toContain(
1566
+ "30 times in the last hour",
1567
+ );
1568
+
1569
+ testsForResourceThisHour = 29;
1570
+ const admitted: RouteCallResult = await runTest();
1571
+ expect(admitted.nextCallCount).toBe(0);
1572
+ });
1573
+
1574
+ test("refuses once the user ran 6 tests in the last minute; admits at 5", async () => {
1575
+ recentTestsByUser = 6;
1576
+ const refused: RouteCallResult = await runTest();
1577
+ expect(refused.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1578
+ expect((refused.thrownToNext as Error).message).toBe(
1579
+ "You ran 6 AI access tests in the last minute, which is the limit (6). Try again in a minute.",
1580
+ );
1581
+ expect(runSpy).not.toHaveBeenCalled();
1582
+
1583
+ recentTestsByUser = 5;
1584
+ const admitted: RouteCallResult = await runTest();
1585
+ expect(admitted.nextCallCount).toBe(0);
1586
+ });
1587
+
1588
+ test("counts this resource's tests (their first command) on its own rows, and the user's across resources", async () => {
1589
+ await runTest();
1590
+
1591
+ const queries: Array<Record<string, unknown>> = countSpy.mock.calls.map(
1592
+ (call: Array<unknown>) => {
1593
+ return (call[0] as { query: Record<string, unknown> }).query;
1594
+ },
1595
+ );
1596
+
1597
+ const resourceQueries: Array<Record<string, unknown>> = queries.filter(
1598
+ (query: Record<string, unknown>) => {
1599
+ return Boolean(query["resourceId"]);
1600
+ },
1601
+ );
1602
+ // In flight, per minute and per hour.
1603
+ expect(resourceQueries).toHaveLength(3);
1604
+ for (const query of resourceQueries) {
1605
+ expect(query["resourceType"]).toBe(kind.resourceType);
1606
+ expect(String(query["resourceId"])).toBe(RESOURCE_ID.toString());
1607
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
1608
+ expect(query["stepId"]).toBeDefined();
1609
+ }
1610
+
1611
+ const userQueries: Array<Record<string, unknown>> = queries.filter(
1612
+ (query: Record<string, unknown>) => {
1613
+ return !query["resourceId"];
1614
+ },
1615
+ );
1616
+ expect(userQueries).toHaveLength(1);
1617
+ expect(JSON.stringify(userQueries[0]!["stepId"])).toContain(
1618
+ `resource-ai-access-test-1-${USER_ID.toString()}`,
1619
+ );
1620
+
1621
+ for (const call of countSpy.mock.calls) {
1622
+ expect((call[0] as { props: unknown }).props).toEqual({
1623
+ isRoot: true,
1624
+ });
1625
+ }
1626
+ });
1627
+
1628
+ test("one test at a time per resource: a reservation keyed by type and id, released when the test ends", async () => {
1629
+ await runTest();
1630
+
1631
+ expect(reserveSpy).toHaveBeenCalledTimes(1);
1632
+ const [namespace, key]: Array<unknown> = reserveSpy.mock.calls[0]!;
1633
+ expect(namespace).toBe("resource-ai-access-test");
1634
+ expect(key).toBe(`${kind.resourceType}:${RESOURCE_ID.toString()}`);
1635
+ expect(releaseSpy).toHaveBeenCalledTimes(1);
1636
+ expect(cacheKeys.size).toBe(0);
1637
+
1638
+ const next: RouteCallResult = await runTest();
1639
+ expect(next.nextCallCount).toBe(0);
1640
+ });
1641
+
1642
+ test("a held reservation refuses the test before anything is counted", async () => {
1643
+ cacheKeys.set(
1644
+ `resource-ai-access-test-${kind.resourceType}:${RESOURCE_ID.toString()}`,
1645
+ "someone-else",
1646
+ );
1647
+
1648
+ const result: RouteCallResult = await runTest();
1649
+
1650
+ expect(result.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1651
+ expect(countSpy).not.toHaveBeenCalled();
1652
+ expect(runSpy).not.toHaveBeenCalled();
1653
+ // Not ours to release.
1654
+ expect(
1655
+ cacheKeys.get(
1656
+ `resource-ai-access-test-${kind.resourceType}:${RESOURCE_ID.toString()}`,
1657
+ ),
1658
+ ).toBe("someone-else");
1659
+ });
1660
+
1661
+ test("two tests started together for one resource: exactly one runs", async () => {
1662
+ let releaseFirst: () => void = (): void => {
1663
+ return undefined;
1664
+ };
1665
+ const firstStarted: Promise<void> = new Promise<void>(
1666
+ (resolve: () => void) => {
1667
+ runSpy.mockImplementationOnce(async (data: unknown) => {
1668
+ resolve();
1669
+ await new Promise<void>((done: () => void) => {
1670
+ releaseFirst = done;
1671
+ });
1672
+ return outcome((data as { command: string }).command);
1673
+ });
1674
+ },
1675
+ );
1676
+
1677
+ const first: Promise<RouteCallResult> = runTest();
1678
+ await firstStarted;
1679
+ const second: RouteCallResult = await runTest();
1680
+ releaseFirst();
1681
+ const firstResult: RouteCallResult = await first;
1682
+
1683
+ expect(firstResult.nextCallCount).toBe(0);
1684
+ expect(second.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1685
+ });
1686
+
1687
+ test("releases the reservation after a failed command, a refused limit and a transport gap too", async () => {
1688
+ runSpy.mockRejectedValueOnce(new Error("nope"));
1689
+ await runTest();
1690
+ expect(cacheKeys.size).toBe(0);
1691
+
1692
+ recentTestsForResource = 3;
1693
+ await runTest();
1694
+ expect(cacheKeys.size).toBe(0);
1695
+ recentTestsForResource = 0;
1696
+
1697
+ statusSpy.mockResolvedValue(
1698
+ readyStatus(kind.resourceType, { gaps: [gap("ai_agent_offline")] }),
1699
+ );
1700
+ await runTest();
1701
+ expect(cacheKeys.size).toBe(0);
1702
+ });
1703
+
1704
+ test("a reservation that cannot be checked fails closed (503), and nothing runs", async () => {
1705
+ reserveSpy.mockRejectedValue(new Error("redis down"));
1706
+
1707
+ const result: RouteCallResult = await runTest();
1708
+
1709
+ expect(result.thrownToNext).toBeInstanceOf(ServiceUnavailableException);
1710
+ expect(runSpy).not.toHaveBeenCalled();
1711
+ });
1712
+
1713
+ test("a user lock that cannot be had refuses with 429, and nothing runs", async () => {
1714
+ lockSpy.mockRejectedValue(new Error("timeout"));
1715
+
1716
+ const result: RouteCallResult = await runTest();
1717
+
1718
+ expect(result.thrownToNext).toBeInstanceOf(TooManyRequestsException);
1719
+ expect((result.thrownToNext as Error).message).toBe(
1720
+ "Another AI access test of yours is starting right now. Try again in a moment.",
1721
+ );
1722
+ expect(runSpy).not.toHaveBeenCalled();
1723
+ expect(cacheKeys.size).toBe(0);
1724
+ });
1725
+
1726
+ test("holds the user's lock (its own namespace) until the first command finished, then releases it", async () => {
1727
+ const events: Array<string> = [];
1728
+ lockSpy.mockImplementation(async (data: { namespace: string }) => {
1729
+ events.push(`lock:${data.namespace}`);
1730
+ return {} as SemaphoreMutex;
1731
+ });
1732
+ lockReleaseSpy.mockImplementation(async () => {
1733
+ events.push("release");
1734
+ });
1735
+ runSpy.mockImplementation(async (data: unknown) => {
1736
+ events.push(
1737
+ `run:${(data as { stepId: string }).stepId.split("-")[4]}`,
1738
+ );
1739
+ return outcome((data as { command: string }).command);
1740
+ });
1741
+
1742
+ await runTest();
1743
+
1744
+ expect(events[0]).toBe("lock:resource-ai-access-test-user");
1745
+ expect(events[1]).toBe("run:1");
1746
+ expect(events[2]).toBe("release");
1747
+ });
1748
+
1749
+ test("releases the user's lock after a first command that could not start", async () => {
1750
+ runSpy.mockRejectedValue(new Error("refused"));
1751
+
1752
+ await runTest();
1753
+
1754
+ expect(lockReleaseSpy).toHaveBeenCalledTimes(1);
1755
+ expect(heldLocks.size).toBe(0);
1756
+ });
1757
+ },
1758
+ );
1759
+
1760
+ describe.each(COVERED)("$resourceType: POST /reset-agent", (kind: Kind) => {
1761
+ let resetSpy: jest.SpyInstance;
1762
+ let agentLookup: jest.SpyInstance;
1763
+
1764
+ beforeEach(() => {
1765
+ resetSpy = jest
1766
+ .spyOn(ResourceAiAgentService, "resetAgent")
1767
+ .mockResolvedValue(undefined);
1768
+ agentLookup = jest
1769
+ .spyOn(ResourceAiAgentService, "findAgentForResource")
1770
+ .mockResolvedValue({ id: AGENT_ID } as unknown as ResourceAiAgent);
1771
+ });
1772
+
1773
+ test.each([
1774
+ Permission.ProjectOwner,
1775
+ Permission.ProjectAdmin,
1776
+ Permission.EditAutoRemediationRule,
1777
+ ])("resets this resource's agent for %s", async (role: Permission) => {
1778
+ propsSpy.mockResolvedValue(userProps({ permissions: [role] }));
1779
+
1780
+ const result: RouteCallResult = await callRoute(
1781
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1782
+ body(kind.resourceType),
1783
+ );
1784
+
1785
+ expect(result.thrownToNext).toBeUndefined();
1786
+ expect(agentLookup).toHaveBeenCalledWith({
1787
+ projectId: PROJECT_ID,
1788
+ resourceType: kind.resourceType,
1789
+ resourceId: RESOURCE_ID,
1790
+ });
1791
+ expect(resetSpy).toHaveBeenCalledTimes(1);
1792
+ const call: Record<string, unknown> = resetSpy.mock
1793
+ .calls[0]![0] as Record<string, unknown>;
1794
+ expect(String(call["projectId"])).toBe(PROJECT_ID.toString());
1795
+ expect(call["resourceType"]).toBe(kind.resourceType);
1796
+ expect(String(call["resourceId"])).toBe(RESOURCE_ID.toString());
1797
+ expect(String(call["userId"])).toBe(USER_ID.toString());
1798
+
1799
+ const response: JSONObject = lastResponse();
1800
+ expect(response["ok"]).toBe(true);
1801
+ expect(response["message"]).toBe(
1802
+ `The ${
1803
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].agentDisplayName
1804
+ } was reset. It reconnects on its own within a few minutes.`,
1805
+ );
1806
+ expect(response["status"]).toBeDefined();
1807
+ });
1808
+
1809
+ test("lets a master admin reset without any project permission", async () => {
1810
+ propsSpy.mockResolvedValue(
1811
+ userProps({ permissions: [], isMasterAdmin: true }),
1812
+ );
1813
+
1814
+ await callRoute(
1815
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1816
+ body(kind.resourceType),
1817
+ );
1818
+
1819
+ expect(resetSpy).toHaveBeenCalledTimes(1);
1820
+ });
1821
+
1822
+ test("answers without a status when it can no longer be computed", async () => {
1823
+ propsSpy.mockResolvedValue(
1824
+ userProps({ permissions: [Permission.ProjectAdmin] }),
1825
+ );
1826
+ statusSpy.mockResolvedValue(null);
1827
+
1828
+ await callRoute(
1829
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1830
+ body(kind.resourceType),
1831
+ );
1832
+
1833
+ expect(lastResponse()["ok"]).toBe(true);
1834
+ expect(lastResponse()["status"]).toBeUndefined();
1835
+ });
1836
+
1837
+ test.each([
1838
+ [Permission.ProjectMember],
1839
+ [Permission.SettingsAdmin],
1840
+ [Permission.SettingsMember],
1841
+ [kind.editPermission],
1842
+ [kind.readPermission],
1843
+ ])(
1844
+ "refuses %s: editing the resource is not enough",
1845
+ async (role: Permission) => {
1846
+ propsSpy.mockResolvedValue(userProps({ permissions: [role] }));
1847
+
1848
+ const result: RouteCallResult = await callRoute(
1849
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1850
+ body(kind.resourceType),
1851
+ );
1852
+
1853
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1854
+ expect((result.thrownToNext as Error).message).toBe(
1855
+ getResourceAiAgentResetRefusal(kind.resourceType),
1856
+ );
1857
+ expect(agentLookup).not.toHaveBeenCalled();
1858
+ expect(resetSpy).not.toHaveBeenCalled();
1859
+ },
1860
+ );
1861
+
1862
+ test("does not read a block row as a grant", async () => {
1863
+ propsSpy.mockResolvedValue(
1864
+ userProps({
1865
+ permissions: [Permission.ProjectAdmin],
1866
+ blocked: [Permission.ProjectAdmin],
1867
+ }),
1868
+ );
1869
+
1870
+ const result: RouteCallResult = await callRoute(
1871
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1872
+ body(kind.resourceType),
1873
+ );
1874
+
1875
+ expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
1876
+ expect(resetSpy).not.toHaveBeenCalled();
1877
+ });
1878
+
1879
+ test("checks the resource first, under the user's own props", async () => {
1880
+ propsSpy.mockResolvedValue(
1881
+ userProps({ permissions: [Permission.ProjectAdmin] }),
1882
+ );
1883
+ findSpies.get(kind.resourceType)!.mockResolvedValue(null);
1884
+
1885
+ const result: RouteCallResult = await callRoute(
1886
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1887
+ body(kind.resourceType),
1888
+ );
1889
+
1890
+ expect(result.thrownToNext).toBeInstanceOf(BadDataException);
1891
+ expect(agentLookup).not.toHaveBeenCalled();
1892
+ expect(resetSpy).not.toHaveBeenCalled();
1893
+ });
1894
+
1895
+ test("says so when the resource has no agent to reset", async () => {
1896
+ propsSpy.mockResolvedValue(
1897
+ userProps({ permissions: [Permission.ProjectAdmin] }),
1898
+ );
1899
+ agentLookup.mockResolvedValue(null);
1900
+
1901
+ const result: RouteCallResult = await callRoute(
1902
+ RESOURCE_AI_ACCESS_RESET_AGENT_PATH,
1903
+ body(kind.resourceType),
1904
+ );
1905
+
1906
+ expect(result.thrownToNext).toBeInstanceOf(BadDataException);
1907
+ expect((result.thrownToNext as Error).message).toBe(
1908
+ `This ${kind.sentenceName} has no ${
1909
+ AI_RESOURCE_TYPE_INFO[kind.resourceType].agentDisplayName
1910
+ } to reset.`,
1911
+ );
1912
+ expect(resetSpy).not.toHaveBeenCalled();
1913
+ });
1914
+ });
1915
+
1916
+ describe.each(COVERED)("$resourceType: POST /insights", (kind: Kind) => {
1917
+ const INCIDENT_ID: ObjectID = ObjectID.generate();
1918
+ const ALERT_ID: ObjectID = ObjectID.generate();
1919
+
1920
+ let jobFind: jest.SpyInstance;
1921
+ let incidentFind: jest.SpyInstance;
1922
+ let alertFind: jest.SpyInstance;
1923
+ let runFind: jest.SpyInstance;
1924
+ let suggestionFind: jest.SpyInstance;
1925
+
1926
+ function run(
1927
+ id: ObjectID,
1928
+ minutesAgo: number,
1929
+ overrides: Record<string, unknown> = {},
1930
+ ): AIRun {
1931
+ return {
1932
+ id,
1933
+ status: AIRunStatus.Completed,
1934
+ analysisTldr: `tldr ${id.toString()}`,
1935
+ createdAt: new Date(Date.now() - minutesAgo * 60 * 1000),
1936
+ completedAt: new Date(Date.now() - (minutesAgo - 1) * 60 * 1000),
1937
+ ...overrides,
1938
+ } as unknown as AIRun;
1939
+ }
1940
+
1941
+ function suggestion(
1942
+ id: ObjectID,
1943
+ minutesAgo: number,
1944
+ overrides: Record<string, unknown> = {},
1945
+ ): AutoRemediationSuggestion {
1946
+ return {
1947
+ id,
1948
+ status: "Suggested",
1949
+ executionMode: "Suggest",
1950
+ suggestionType: "Commands",
1951
+ rationaleMarkdown: "Restart the web container.",
1952
+ commandPlan: { commands: [] },
1953
+ createdAt: new Date(Date.now() - minutesAgo * 60 * 1000),
1954
+ ...overrides,
1955
+ } as unknown as AutoRemediationSuggestion;
1956
+ }
1957
+
1958
+ beforeEach(() => {
1959
+ jobFind = jest.spyOn(RunnerJobService, "findBy").mockResolvedValue([]);
1960
+ incidentFind = jest
1961
+ .spyOn(IncidentService, "findBy")
1962
+ .mockResolvedValue([]);
1963
+ alertFind = jest.spyOn(AlertService, "findBy").mockResolvedValue([]);
1964
+ runFind = jest.spyOn(AIRunService, "findBy").mockResolvedValue([]);
1965
+ suggestionFind = jest
1966
+ .spyOn(AutoRemediationSuggestionService, "findBy")
1967
+ .mockResolvedValue([]);
1968
+ countSpy.mockResolvedValue(new PositiveNumber(0));
1969
+ // Someone who may read everything summarised here.
1970
+ propsSpy.mockResolvedValue(
1971
+ userProps({ permissions: [Permission.ProjectMember] }),
1972
+ );
1973
+ });
1974
+
1975
+ function isCallerProps(call: Array<unknown>): boolean {
1976
+ const props: DatabaseCommonInteractionProps = (
1977
+ call[0] as { props: DatabaseCommonInteractionProps }
1978
+ ).props;
1979
+ return !props.isRoot && String(props.userId) === USER_ID.toString();
1980
+ }
1981
+
1982
+ function isRootProps(call: Array<unknown>): boolean {
1983
+ return (
1984
+ JSON.stringify(
1985
+ (call[0] as { props: DatabaseCommonInteractionProps }).props,
1986
+ ) === JSON.stringify({ isRoot: true })
1987
+ );
1988
+ }
1989
+
1990
+ test("needs only read access to the resource", async () => {
1991
+ propsSpy.mockResolvedValue(
1992
+ userProps({ permissions: [kind.readPermission] }),
1993
+ );
1994
+
1995
+ const ok: RouteCallResult = await callRoute(
1996
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
1997
+ body(kind.resourceType),
1998
+ );
1999
+ expect(ok.thrownToNext).toBeUndefined();
2000
+ });
2001
+
2002
+ test("reads the resource, incidents and alerts under the USER's props, and the jobs, runs, suggestions and counts as root", async () => {
2003
+ await callRoute(
2004
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2005
+ body(kind.resourceType),
2006
+ );
2007
+
2008
+ expect(
2009
+ isCallerProps(findSpies.get(kind.resourceType)!.mock.calls[0]!),
2010
+ ).toBe(true);
2011
+
2012
+ for (const spy of [incidentFind, alertFind]) {
2013
+ expect(spy).toHaveBeenCalled();
2014
+ expect(spy.mock.calls.every(isCallerProps)).toBe(true);
2015
+ }
2016
+
2017
+ for (const spy of [jobFind, suggestionFind, countSpy]) {
2018
+ expect(spy).toHaveBeenCalled();
2019
+ expect(spy.mock.calls.every(isRootProps)).toBe(true);
2020
+ }
2021
+ });
2022
+
2023
+ test("an empty resource: empty lists and zero counts, and no AI run read", async () => {
2024
+ await callRoute(
2025
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2026
+ body(kind.resourceType),
2027
+ );
2028
+
2029
+ expect(lastResponse()).toEqual({
2030
+ resourceType: kind.resourceType,
2031
+ resourceId: RESOURCE_ID.toString(),
2032
+ investigations: [],
2033
+ fixes: [],
2034
+ commandCounts: { investigation: 0, remediation: 0 },
2035
+ });
2036
+ expect(runFind).not.toHaveBeenCalled();
2037
+ });
2038
+
2039
+ test("investigations: runs that ran commands here UNION runs of linked incidents and alerts, deduplicated, newest first", async () => {
2040
+ const fromJobs: ObjectID = ObjectID.generate();
2041
+ const fromBoth: ObjectID = ObjectID.generate();
2042
+ const fromIncident: ObjectID = ObjectID.generate();
2043
+ const fromAlert: ObjectID = ObjectID.generate();
2044
+
2045
+ jobFind.mockResolvedValue([
2046
+ { aiRunId: fromJobs },
2047
+ { aiRunId: fromBoth },
2048
+ { aiRunId: fromBoth },
2049
+ // A connection test: no AI run, no suggestion.
2050
+ {},
2051
+ ] as unknown as Array<RunnerJob>);
2052
+ incidentFind.mockImplementation(async (args: unknown) => {
2053
+ const query: Record<string, unknown> = (
2054
+ args as { query: Record<string, unknown> }
2055
+ ).query;
2056
+ return query[kind.subjectRelation]
2057
+ ? [{ id: INCIDENT_ID }]
2058
+ : [{ id: INCIDENT_ID, title: "Web down", incidentNumber: 42 }];
2059
+ });
2060
+ alertFind.mockImplementation(async (args: unknown) => {
2061
+ const query: Record<string, unknown> = (
2062
+ args as { query: Record<string, unknown> }
2063
+ ).query;
2064
+ return query[kind.subjectRelation]
2065
+ ? [{ id: ALERT_ID }]
2066
+ : [{ id: ALERT_ID, title: "High memory" }];
2067
+ });
2068
+ runFind.mockImplementation(async (args: unknown) => {
2069
+ const query: Record<string, unknown> = (
2070
+ args as { query: Record<string, unknown> }
2071
+ ).query;
2072
+
2073
+ if (query["triggeredByIncidentId"]) {
2074
+ return [
2075
+ run(fromIncident, 5, { triggeredByIncidentId: INCIDENT_ID }),
2076
+ run(fromBoth, 30),
2077
+ ];
2078
+ }
2079
+
2080
+ if (query["triggeredByAlertId"]) {
2081
+ return [run(fromAlert, 1, { triggeredByAlertId: ALERT_ID })];
2082
+ }
2083
+
2084
+ return [run(fromJobs, 60), run(fromBoth, 30)];
2085
+ });
2086
+
2087
+ await callRoute(
2088
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2089
+ body(kind.resourceType),
2090
+ );
2091
+
2092
+ const insights: ResourceAiInsights =
2093
+ lastResponse() as unknown as ResourceAiInsights;
2094
+
2095
+ expect(
2096
+ insights.investigations.map((row: { aiRunId: string }) => {
2097
+ return row.aiRunId;
2098
+ }),
2099
+ ).toEqual([
2100
+ fromAlert.toString(),
2101
+ fromIncident.toString(),
2102
+ fromBoth.toString(),
2103
+ fromJobs.toString(),
2104
+ ]);
2105
+ expect(insights.investigations[0]!.alert).toEqual({
2106
+ id: ALERT_ID.toString(),
2107
+ title: "High memory",
2108
+ });
2109
+ expect(insights.investigations[1]!.incident).toEqual({
2110
+ id: INCIDENT_ID.toString(),
2111
+ title: "Web down",
2112
+ number: 42,
2113
+ });
2114
+
2115
+ for (const call of runFind.mock.calls) {
2116
+ const query: Record<string, unknown> = (
2117
+ call[0] as { query: Record<string, unknown> }
2118
+ ).query;
2119
+ expect(query["runType"]).toBe(AIRunType.Investigation);
2120
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
2121
+ }
2122
+
2123
+ // The jobs read are this resource's agent jobs.
2124
+ const jobQuery: Record<string, unknown> = (
2125
+ jobFind.mock.calls[0]![0] as { query: Record<string, unknown> }
2126
+ ).query;
2127
+ expect(jobQuery["resourceType"]).toBe(kind.resourceType);
2128
+ expect(String(jobQuery["resourceId"])).toBe(RESOURCE_ID.toString());
2129
+ expect(jobQuery["stepType"]).toBe(RunbookStepType.ResourceCommand);
2130
+ expect(String(jobQuery["projectId"])).toBe(PROJECT_ID.toString());
2131
+ });
2132
+
2133
+ test(`returns at most ${RESOURCE_AI_INSIGHTS_LIMIT} investigations and fixes`, async () => {
2134
+ const ids: Array<ObjectID> = Array.from({ length: 40 }, () => {
2135
+ return ObjectID.generate();
2136
+ });
2137
+ jobFind.mockResolvedValue(
2138
+ ids.map((id: ObjectID) => {
2139
+ return { aiRunId: id, autoRemediationSuggestionId: id };
2140
+ }) as unknown as Array<RunnerJob>,
2141
+ );
2142
+ runFind.mockResolvedValue(
2143
+ ids.map((id: ObjectID, index: number) => {
2144
+ return run(id, index + 1);
2145
+ }),
2146
+ );
2147
+ suggestionFind.mockResolvedValue(
2148
+ ids.map((id: ObjectID, index: number) => {
2149
+ return suggestion(id, index + 1);
2150
+ }),
2151
+ );
2152
+
2153
+ await callRoute(
2154
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2155
+ body(kind.resourceType),
2156
+ );
2157
+
2158
+ const insights: ResourceAiInsights =
2159
+ lastResponse() as unknown as ResourceAiInsights;
2160
+ expect(insights.investigations).toHaveLength(RESOURCE_AI_INSIGHTS_LIMIT);
2161
+ expect(insights.fixes).toHaveLength(RESOURCE_AI_INSIGHTS_LIMIT);
2162
+ expect(insights.investigations[0]!.aiRunId).toBe(ids[0]!.toString());
2163
+ });
2164
+
2165
+ test("fixes: the resource's own rounds UNION suggestions whose commands ran here, summarised", async () => {
2166
+ const round: ObjectID = ObjectID.generate();
2167
+ const ruleRound: ObjectID = ObjectID.generate();
2168
+
2169
+ jobFind.mockResolvedValue([
2170
+ { autoRemediationSuggestionId: ruleRound },
2171
+ ] as unknown as Array<RunnerJob>);
2172
+
2173
+ const rows: Array<AutoRemediationSuggestion> = [
2174
+ suggestion(round, 10, {
2175
+ rationaleMarkdown: "x".repeat(1000),
2176
+ incidentId: INCIDENT_ID,
2177
+ approvedAt: new Date("2026-09-01T00:00:00.000Z"),
2178
+ }),
2179
+ suggestion(ruleRound, 2, { alertId: ALERT_ID }),
2180
+ ];
2181
+
2182
+ suggestionFind.mockImplementation(async (args: unknown) => {
2183
+ const query: Record<string, unknown> = (
2184
+ args as { query: Record<string, unknown> }
2185
+ ).query;
2186
+
2187
+ if (query["resourceId"]) {
2188
+ expect(query["resourceType"]).toBe(kind.resourceType);
2189
+ return [rows[0]!];
2190
+ }
2191
+
2192
+ const ids: Array<string> = idsIn(query["_id"]);
2193
+
2194
+ return rows.filter((row: AutoRemediationSuggestion) => {
2195
+ return ids.includes(row.id!.toString());
2196
+ });
2197
+ });
2198
+
2199
+ await callRoute(
2200
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2201
+ body(kind.resourceType),
2202
+ );
2203
+
2204
+ const insights: ResourceAiInsights =
2205
+ lastResponse() as unknown as ResourceAiInsights;
2206
+
2207
+ expect(
2208
+ insights.fixes.map((fix: { id: string }) => {
2209
+ return fix.id;
2210
+ }),
2211
+ ).toEqual([ruleRound.toString(), round.toString()]);
2212
+ expect(insights.fixes[0]).toMatchObject({
2213
+ status: "Suggested",
2214
+ executionMode: "Suggest",
2215
+ suggestionType: "Commands",
2216
+ rationale: "Restart the web container.",
2217
+ alertId: ALERT_ID.toString(),
2218
+ });
2219
+ expect(insights.fixes[1]!.rationale).toHaveLength(300);
2220
+ expect(insights.fixes[1]!.incidentId).toBe(INCIDENT_ID.toString());
2221
+ expect(insights.fixes[1]!.approvedAt).toBe("2026-09-01T00:00:00.000Z");
2222
+
2223
+ // Summaries only: no plan, no output.
2224
+ const serialized: string = JSON.stringify(insights);
2225
+ expect(serialized).not.toContain("commandPlan");
2226
+ expect(serialized).not.toContain("output");
2227
+
2228
+ for (const call of suggestionFind.mock.calls) {
2229
+ const select: Record<string, unknown> = (
2230
+ call[0] as { select: Record<string, unknown> }
2231
+ ).select;
2232
+ expect(select["commandPlan"]).toBeUndefined();
2233
+ }
2234
+ });
2235
+
2236
+ test("counts commands of the last 30 days by kind, leaving the connection tests out", async () => {
2237
+ countSpy.mockImplementation(async (args: unknown) => {
2238
+ const query: Record<string, unknown> = (
2239
+ args as { query: Record<string, unknown> }
2240
+ ).query;
2241
+ return new PositiveNumber(
2242
+ query["origin"] === RunnerJobOrigin.AiInvestigation ? 17 : 3,
2243
+ );
2244
+ });
2245
+
2246
+ await callRoute(
2247
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2248
+ body(kind.resourceType),
2249
+ );
2250
+
2251
+ expect(lastResponse()["commandCounts"]).toEqual({
2252
+ investigation: 17,
2253
+ remediation: 3,
2254
+ });
2255
+
2256
+ const queries: Array<Record<string, unknown>> = countSpy.mock.calls.map(
2257
+ (call: Array<unknown>) => {
2258
+ return (call[0] as { query: Record<string, unknown> }).query;
2259
+ },
2260
+ );
2261
+ const investigation: Record<string, unknown> = queries.find(
2262
+ (query: Record<string, unknown>) => {
2263
+ return query["origin"] === RunnerJobOrigin.AiInvestigation;
2264
+ },
2265
+ )!;
2266
+ const remediation: Record<string, unknown> = queries.find(
2267
+ (query: Record<string, unknown>) => {
2268
+ return query["origin"] === RunnerJobOrigin.AiRemediation;
2269
+ },
2270
+ )!;
2271
+
2272
+ // A connection test is the only resource command without an AI run.
2273
+ expect(investigation["aiRunId"]).toBeDefined();
2274
+ expect(remediation["aiRunId"]).toBeUndefined();
2275
+
2276
+ for (const query of [investigation, remediation]) {
2277
+ expect(query["resourceType"]).toBe(kind.resourceType);
2278
+ expect(String(query["resourceId"])).toBe(RESOURCE_ID.toString());
2279
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
2280
+ expect(query["stepType"]).toBe(RunbookStepType.ResourceCommand);
2281
+ expect(query["createdAt"]).toBeDefined();
2282
+ }
2283
+ });
2284
+
2285
+ test("links incidents and alerts through the resource's relation, in this project", async () => {
2286
+ await callRoute(
2287
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2288
+ body(kind.resourceType),
2289
+ );
2290
+
2291
+ for (const spy of [incidentFind, alertFind]) {
2292
+ const query: Record<string, unknown> = (
2293
+ spy.mock.calls[0]![0] as { query: Record<string, unknown> }
2294
+ ).query;
2295
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
2296
+ expect(
2297
+ (query[kind.subjectRelation] as Array<ObjectID>).map(
2298
+ (id: ObjectID) => {
2299
+ return id.toString();
2300
+ },
2301
+ ),
2302
+ ).toEqual([RESOURCE_ID.toString()]);
2303
+ }
2304
+ });
2305
+
2306
+ /*
2307
+ * ---------------------------------------------------------------------
2308
+ * What the insights say about incidents, alerts, AI runs and suggestions
2309
+ * follows the caller's own read access to them. The world below is one
2310
+ * resource's AI history, served the way the permission layer would serve
2311
+ * it: as root everything; under the caller's props a table the caller
2312
+ * may not read is refused outright (the table-level check), and only the
2313
+ * incidents and suggestions the caller's labels and private-incident
2314
+ * membership reach come back.
2315
+ * ---------------------------------------------------------------------
2316
+ */
2317
+ describe("reading what the caller may read", () => {
2318
+ const OTHER_INCIDENT_ID: ObjectID = ObjectID.generate();
2319
+ const INSIGHT_ID: ObjectID = ObjectID.generate();
2320
+ const TRIAGE_RUN: ObjectID = ObjectID.generate();
2321
+ const INCIDENT_RUN: ObjectID = ObjectID.generate();
2322
+ const OTHER_INCIDENT_RUN: ObjectID = ObjectID.generate();
2323
+ const ALERT_RUN: ObjectID = ObjectID.generate();
2324
+ const OWN_FIX: ObjectID = ObjectID.generate();
2325
+ const RULE_FIX: ObjectID = ObjectID.generate();
2326
+ const STAGING_LABEL_ID: ObjectID = ObjectID.generate();
2327
+
2328
+ const INCIDENT_TITLES: Dictionary<{ title: string; number: number }> = {
2329
+ [INCIDENT_ID.toString()]: { title: "Web down", number: 42 },
2330
+ [OTHER_INCIDENT_ID.toString()]: {
2331
+ title: "Payroll database breach",
2332
+ number: 7,
2333
+ },
2334
+ };
2335
+
2336
+ function refuseUnlessTableReadable(
2337
+ props: DatabaseCommonInteractionProps,
2338
+ modelType: { new (): BaseModel },
2339
+ ): void {
2340
+ if (props.isRoot || props.isMasterAdmin) {
2341
+ return;
2342
+ }
2343
+
2344
+ const allowed: Array<Permission> = new modelType().getReadPermissions();
2345
+ const rows: Array<UserPermission> =
2346
+ props.userTenantAccessPermission?.[PROJECT_ID.toString()]
2347
+ ?.permissions || [];
2348
+
2349
+ if (
2350
+ !rows.some((row: UserPermission) => {
2351
+ return !row.isBlockPermission && allowed.includes(row.permission);
2352
+ })
2353
+ ) {
2354
+ throw new NotAuthorizedException(
2355
+ `You do not have permissions to read ${new modelType().singularName}.`,
2356
+ );
2357
+ }
2358
+ }
2359
+
2360
+ function serveHistory(
2361
+ reach: {
2362
+ incidentIds?: Array<ObjectID> | undefined;
2363
+ suggestionIds?: Array<ObjectID> | undefined;
2364
+ } = {},
2365
+ ): void {
2366
+ const reaches: (
2367
+ props: DatabaseCommonInteractionProps,
2368
+ id: string,
2369
+ reachable: Array<ObjectID> | undefined,
2370
+ ) => boolean = (
2371
+ props: DatabaseCommonInteractionProps,
2372
+ id: string,
2373
+ reachable: Array<ObjectID> | undefined,
2374
+ ): boolean => {
2375
+ return (
2376
+ Boolean(props.isRoot) ||
2377
+ !reachable ||
2378
+ reachable.some((reachableId: ObjectID) => {
2379
+ return reachableId.toString() === id;
2380
+ })
2381
+ );
2382
+ };
2383
+
2384
+ jobFind.mockResolvedValue([
2385
+ { aiRunId: TRIAGE_RUN },
2386
+ { autoRemediationSuggestionId: RULE_FIX },
2387
+ ] as unknown as Array<RunnerJob>);
2388
+
2389
+ incidentFind.mockImplementation(async (args: unknown) => {
2390
+ const { query, props } = args as {
2391
+ query: Record<string, unknown>;
2392
+ props: DatabaseCommonInteractionProps;
2393
+ };
2394
+ refuseUnlessTableReadable(props, Incident);
2395
+
2396
+ const ids: Array<string> = query[kind.subjectRelation]
2397
+ ? [INCIDENT_ID.toString(), OTHER_INCIDENT_ID.toString()]
2398
+ : idsIn(query["_id"]);
2399
+
2400
+ return ids
2401
+ .filter((id: string) => {
2402
+ return reaches(props, id, reach.incidentIds);
2403
+ })
2404
+ .map((id: string) => {
2405
+ return query[kind.subjectRelation]
2406
+ ? { id: new ObjectID(id) }
2407
+ : {
2408
+ id: new ObjectID(id),
2409
+ title: INCIDENT_TITLES[id]!.title,
2410
+ incidentNumber: INCIDENT_TITLES[id]!.number,
2411
+ };
2412
+ });
2413
+ });
2414
+
2415
+ alertFind.mockImplementation(async (args: unknown) => {
2416
+ const { query, props } = args as {
2417
+ query: Record<string, unknown>;
2418
+ props: DatabaseCommonInteractionProps;
2419
+ };
2420
+ refuseUnlessTableReadable(props, Alert);
2421
+
2422
+ return query[kind.subjectRelation]
2423
+ ? [{ id: ALERT_ID }]
2424
+ : [{ id: ALERT_ID, title: "High memory" }];
2425
+ });
2426
+
2427
+ // AI runs are only ever read as root: they are private to their author.
2428
+ runFind.mockImplementation(async (args: unknown) => {
2429
+ const { query } = args as { query: Record<string, unknown> };
2430
+
2431
+ if (query["triggeredByIncidentId"]) {
2432
+ const ids: Array<string> = idsIn(query["triggeredByIncidentId"]);
2433
+ return [
2434
+ run(INCIDENT_RUN, 2, { triggeredByIncidentId: INCIDENT_ID }),
2435
+ run(OTHER_INCIDENT_RUN, 3, {
2436
+ triggeredByIncidentId: OTHER_INCIDENT_ID,
2437
+ }),
2438
+ ].filter((row: AIRun) => {
2439
+ return ids.includes(row.triggeredByIncidentId!.toString());
2440
+ });
2441
+ }
2442
+
2443
+ if (query["triggeredByAlertId"]) {
2444
+ return [run(ALERT_RUN, 4, { triggeredByAlertId: ALERT_ID })];
2445
+ }
2446
+
2447
+ // An AI insight's triage ran commands here: no incident, no alert.
2448
+ return [run(TRIAGE_RUN, 1, { triggeredByAiInsightId: INSIGHT_ID })];
2449
+ });
2450
+
2451
+ const suggestions: Array<AutoRemediationSuggestion> = [
2452
+ suggestion(OWN_FIX, 5, {
2453
+ rationaleMarkdown: "Restart the web container.",
2454
+ incidentId: INCIDENT_ID,
2455
+ }),
2456
+ suggestion(RULE_FIX, 6, {
2457
+ rationaleMarkdown: "Free memory on the host.",
2458
+ alertId: ALERT_ID,
2459
+ }),
2460
+ ];
2461
+
2462
+ // Only the columns asked for come back, as from the database.
2463
+ suggestionFind.mockImplementation(async (args: unknown) => {
2464
+ const { query, select, props } = args as {
2465
+ query: Record<string, unknown>;
2466
+ select: Record<string, unknown>;
2467
+ props: DatabaseCommonInteractionProps;
2468
+ };
2469
+ refuseUnlessTableReadable(props, AutoRemediationSuggestion);
2470
+
2471
+ const ids: Array<string> = query["resourceId"]
2472
+ ? [OWN_FIX.toString()]
2473
+ : idsIn(query["_id"]);
2474
+
2475
+ return suggestions
2476
+ .filter((row: AutoRemediationSuggestion) => {
2477
+ return (
2478
+ ids.includes(row.id!.toString()) &&
2479
+ reaches(props, row.id!.toString(), reach.suggestionIds)
2480
+ );
2481
+ })
2482
+ .map((row: AutoRemediationSuggestion) => {
2483
+ return select["rationaleMarkdown"]
2484
+ ? row
2485
+ : ({
2486
+ ...row,
2487
+ rationaleMarkdown: undefined,
2488
+ } as unknown as AutoRemediationSuggestion);
2489
+ });
2490
+ });
2491
+ }
2492
+
2493
+ async function insightsFor(
2494
+ props: DatabaseCommonInteractionProps,
2495
+ ): Promise<ResourceAiInsights> {
2496
+ propsSpy.mockResolvedValue(props);
2497
+
2498
+ const result: RouteCallResult = await callRoute(
2499
+ RESOURCE_AI_ACCESS_INSIGHTS_PATH,
2500
+ body(kind.resourceType),
2501
+ );
2502
+
2503
+ expect(result.nextCallCount).toBe(0);
2504
+ return lastResponse() as unknown as ResourceAiInsights;
2505
+ }
2506
+
2507
+ function investigationIds(insights: ResourceAiInsights): Array<string> {
2508
+ return insights.investigations.map((row: { aiRunId: string }) => {
2509
+ return row.aiRunId;
2510
+ });
2511
+ }
2512
+
2513
+ function fixFor(
2514
+ insights: ResourceAiInsights,
2515
+ id: ObjectID,
2516
+ ): ResourceAiInsights["fixes"][number] {
2517
+ return insights.fixes.find((fix: { id: string }) => {
2518
+ return fix.id === id.toString();
2519
+ })!;
2520
+ }
2521
+
2522
+ test("a role that may read only the resource gets no incident or alert, no TL;DR and no rationale", async () => {
2523
+ serveHistory();
2524
+
2525
+ const insights: ResourceAiInsights = await insightsFor(
2526
+ userProps({ permissions: [kind.readPermission] }),
2527
+ );
2528
+
2529
+ // Only the run with no subject is left, and without its TL;DR.
2530
+ expect(investigationIds(insights)).toEqual([TRIAGE_RUN.toString()]);
2531
+ expect(insights.investigations[0]!.analysisTldr).toBeUndefined();
2532
+ expect(insights.investigations[0]!.incident).toBeUndefined();
2533
+ expect(insights.investigations[0]!.alert).toBeUndefined();
2534
+
2535
+ // The fixes on the resource are listed, their rationale is not.
2536
+ expect(
2537
+ insights.fixes.map((fix: { id: string }) => {
2538
+ return fix.id;
2539
+ }),
2540
+ ).toEqual([OWN_FIX.toString(), RULE_FIX.toString()]);
2541
+ expect(fixFor(insights, OWN_FIX).rationale).toBeUndefined();
2542
+ expect(fixFor(insights, RULE_FIX).rationale).toBeUndefined();
2543
+
2544
+ const serialized: string = JSON.stringify(insights);
2545
+ for (const secret of [
2546
+ "Web down",
2547
+ "High memory",
2548
+ "Payroll database breach",
2549
+ "tldr ",
2550
+ "Restart the web container.",
2551
+ "Free memory on the host.",
2552
+ ]) {
2553
+ expect(serialized).not.toContain(secret);
2554
+ }
2555
+ expect(serialized).not.toContain(INCIDENT_RUN.toString());
2556
+ expect(serialized).not.toContain(ALERT_RUN.toString());
2557
+ });
2558
+
2559
+ test("a caller whose incident read is label-scoped sees only the incidents their labels reach", async () => {
2560
+ serveHistory({ incidentIds: [INCIDENT_ID] });
2561
+
2562
+ const insights: ResourceAiInsights = await insightsFor(
2563
+ userProps({
2564
+ permissions: [kind.readPermission, Permission.AlertViewer],
2565
+ scoped: [
2566
+ {
2567
+ permission: Permission.ReadProjectIncident,
2568
+ labelIds: [STAGING_LABEL_ID],
2569
+ },
2570
+ ],
2571
+ }),
2572
+ );
2573
+
2574
+ expect(investigationIds(insights)).toEqual([
2575
+ TRIAGE_RUN.toString(),
2576
+ INCIDENT_RUN.toString(),
2577
+ ALERT_RUN.toString(),
2578
+ ]);
2579
+ expect(JSON.stringify(insights)).not.toContain(
2580
+ "Payroll database breach",
2581
+ );
2582
+ expect(JSON.stringify(insights)).not.toContain(
2583
+ OTHER_INCIDENT_RUN.toString(),
2584
+ );
2585
+
2586
+ /*
2587
+ * With a subject the caller may read, the TL;DR is shown — the
2588
+ * incident's own AI panel shows them the whole analysis.
2589
+ */
2590
+ expect(insights.investigations[1]!.incident).toEqual({
2591
+ id: INCIDENT_ID.toString(),
2592
+ title: "Web down",
2593
+ number: 42,
2594
+ });
2595
+ expect(insights.investigations[1]!.analysisTldr).toBe(
2596
+ `tldr ${INCIDENT_RUN.toString()}`,
2597
+ );
2598
+ expect(insights.investigations[2]!.alert).toEqual({
2599
+ id: ALERT_ID.toString(),
2600
+ title: "High memory",
2601
+ });
2602
+ expect(insights.investigations[2]!.analysisTldr).toBe(
2603
+ `tldr ${ALERT_RUN.toString()}`,
2604
+ );
2605
+
2606
+ // A run with no subject: only for those who may read AIRun.
2607
+ expect(insights.investigations[0]!.analysisTldr).toBeUndefined();
2608
+
2609
+ // Suggestions are not theirs to read.
2610
+ expect(fixFor(insights, OWN_FIX).rationale).toBeUndefined();
2611
+ expect(fixFor(insights, RULE_FIX).rationale).toBeUndefined();
2612
+ });
2613
+
2614
+ test("a viewer (incidents, alerts and AI runs, not suggestions) gets every TL;DR but no rationale", async () => {
2615
+ serveHistory();
2616
+
2617
+ const insights: ResourceAiInsights = await insightsFor(
2618
+ userProps({ permissions: [Permission.Viewer] }),
2619
+ );
2620
+
2621
+ expect(investigationIds(insights)).toEqual([
2622
+ TRIAGE_RUN.toString(),
2623
+ INCIDENT_RUN.toString(),
2624
+ OTHER_INCIDENT_RUN.toString(),
2625
+ ALERT_RUN.toString(),
2626
+ ]);
2627
+ for (const investigation of insights.investigations) {
2628
+ expect(investigation.analysisTldr).toBe(
2629
+ `tldr ${investigation.aiRunId}`,
2630
+ );
2631
+ }
2632
+ expect(fixFor(insights, OWN_FIX).rationale).toBeUndefined();
2633
+ expect(fixFor(insights, RULE_FIX).rationale).toBeUndefined();
2634
+ });
2635
+
2636
+ test("a project member gets everything; a suggestion hidden by its incident's privacy loses only its rationale", async () => {
2637
+ serveHistory();
2638
+
2639
+ const all: ResourceAiInsights = await insightsFor(
2640
+ userProps({ permissions: [Permission.ProjectMember] }),
2641
+ );
2642
+
2643
+ expect(investigationIds(all)).toHaveLength(4);
2644
+ expect(all.investigations[2]!.incident).toEqual({
2645
+ id: OTHER_INCIDENT_ID.toString(),
2646
+ title: "Payroll database breach",
2647
+ number: 7,
2648
+ });
2649
+ expect(all.investigations[0]!.analysisTldr).toBe(
2650
+ `tldr ${TRIAGE_RUN.toString()}`,
2651
+ );
2652
+ expect(fixFor(all, OWN_FIX).rationale).toBe(
2653
+ "Restart the web container.",
2654
+ );
2655
+ expect(fixFor(all, RULE_FIX).rationale).toBe(
2656
+ "Free memory on the host.",
2657
+ );
2658
+
2659
+ jest.clearAllMocks();
2660
+ serveHistory({ suggestionIds: [RULE_FIX] });
2661
+
2662
+ const partly: ResourceAiInsights = await insightsFor(
2663
+ userProps({ permissions: [Permission.ProjectMember] }),
2664
+ );
2665
+
2666
+ expect(fixFor(partly, OWN_FIX).rationale).toBeUndefined();
2667
+ expect(fixFor(partly, RULE_FIX).rationale).toBe(
2668
+ "Free memory on the host.",
2669
+ );
2670
+ });
2671
+
2672
+ test("a master admin gets everything", async () => {
2673
+ serveHistory();
2674
+
2675
+ const insights: ResourceAiInsights = await insightsFor(
2676
+ userProps({ permissions: [], isMasterAdmin: true }),
2677
+ );
2678
+
2679
+ expect(investigationIds(insights)).toHaveLength(4);
2680
+ expect(insights.investigations[0]!.analysisTldr).toBe(
2681
+ `tldr ${TRIAGE_RUN.toString()}`,
2682
+ );
2683
+ expect(fixFor(insights, OWN_FIX).rationale).toBe(
2684
+ "Restart the web container.",
2685
+ );
2686
+ });
2687
+
2688
+ test("titles, numbers and rationales are only ever read under the caller's props", async () => {
2689
+ serveHistory();
2690
+
2691
+ await insightsFor(
2692
+ userProps({ permissions: [Permission.ProjectMember] }),
2693
+ );
2694
+
2695
+ for (const [spy, columns] of [
2696
+ [incidentFind, ["title", "incidentNumber"]],
2697
+ [alertFind, ["title"]],
2698
+ [suggestionFind, ["rationaleMarkdown"]],
2699
+ ] as Array<[jest.SpyInstance, Array<string>]>) {
2700
+ const reading: Array<Array<unknown>> = spy.mock.calls.filter(
2701
+ (call: Array<unknown>) => {
2702
+ const select: Record<string, unknown> = (
2703
+ call[0] as { select: Record<string, unknown> }
2704
+ ).select;
2705
+ return columns.some((column: string) => {
2706
+ return Boolean(select[column]);
2707
+ });
2708
+ },
2709
+ );
2710
+
2711
+ expect(reading.length).toBeGreaterThan(0);
2712
+ expect(reading.every(isCallerProps)).toBe(true);
2713
+ }
2714
+
2715
+ // The runs (private to their author) are read as root.
2716
+ expect(runFind).toHaveBeenCalled();
2717
+ expect(runFind.mock.calls.every(isRootProps)).toBe(true);
2718
+ });
2719
+ });
2720
+ });
2721
+
2722
+ test("the Kubernetes-only kinds are not served here", () => {
2723
+ expect(kindOf(AiResourceType.Host).subjectRelation).toBe("hosts");
2724
+ expect(
2725
+ (ALL_AI_RESOURCE_TYPES as ReadonlyArray<string>).includes(
2726
+ "KubernetesCluster",
2727
+ ),
2728
+ ).toBe(false);
2729
+ });
2730
+ });