@oneuptime/common 12.0.7 → 12.0.9

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 (873) hide show
  1. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
  2. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
  3. package/Models/DatabaseModels/AIConversation.ts +32 -0
  4. package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
  5. package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
  6. package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
  7. package/Models/DatabaseModels/Index.ts +12 -4
  8. package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +184 -9
  9. package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
  10. package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +33 -7
  11. package/Models/DatabaseModels/NetworkDevice.ts +141 -0
  12. package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
  13. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
  14. package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
  15. package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
  16. package/Models/DatabaseModels/Project.ts +78 -0
  17. package/Models/DatabaseModels/UserCall.ts +42 -0
  18. package/Models/DatabaseModels/UserEmail.ts +44 -0
  19. package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
  20. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
  21. package/Models/DatabaseModels/UserPush.ts +49 -0
  22. package/Models/DatabaseModels/UserSMS.ts +43 -0
  23. package/Models/DatabaseModels/UserTelegram.ts +59 -0
  24. package/Models/DatabaseModels/UserWebhook.ts +52 -0
  25. package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
  26. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  27. package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
  28. package/Server/API/AIChatAPI.ts +315 -1
  29. package/Server/API/DashboardAPI.ts +217 -1
  30. package/Server/API/OnCallReadinessAPI.ts +841 -0
  31. package/Server/API/TeamComplianceAPI.ts +69 -17
  32. package/Server/API/TelemetryAPI.ts +220 -12
  33. package/Server/API/UserAPI.ts +16 -1
  34. package/Server/EnvironmentConfig.ts +52 -0
  35. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
  37. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
  38. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  39. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  40. package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
  41. package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
  42. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
  43. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
  44. package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
  45. package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
  46. package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
  47. package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
  48. package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
  49. package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
  50. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +24 -0
  51. package/Server/Infrastructure/Queue.ts +78 -13
  52. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  53. package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
  54. package/Server/Services/AIService.ts +7 -0
  55. package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
  56. package/Server/Services/AlertSeverityService.ts +63 -0
  57. package/Server/Services/DashboardService.ts +9 -10
  58. package/Server/Services/DatabaseService.ts +32 -2
  59. package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
  60. package/Server/Services/IncidentSeverityService.ts +76 -0
  61. package/Server/Services/Index.ts +12 -4
  62. package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
  63. package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +7 -4
  64. package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +203 -21
  65. package/Server/Services/LogAggregationService.ts +45 -8
  66. package/Server/Services/MetricAggregationService.ts +121 -0
  67. package/Server/Services/MetricService.ts +7 -7
  68. package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
  69. package/Server/Services/NetworkDeviceLinkService.ts +84 -0
  70. package/Server/Services/NetworkDeviceService.ts +140 -0
  71. package/Server/Services/NetworkSiteService.ts +77 -25
  72. package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
  73. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
  74. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
  75. package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
  76. package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
  77. package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
  78. package/Server/Services/OnCallReadinessService.ts +2803 -0
  79. package/Server/Services/OnCallSetupReminderService.ts +955 -0
  80. package/Server/Services/ProfileAggregationService.ts +123 -0
  81. package/Server/Services/StatusPageService.ts +9 -10
  82. package/Server/Services/TeamComplianceService.ts +429 -252
  83. package/Server/Services/UserCallService.ts +26 -1
  84. package/Server/Services/UserEmailService.ts +26 -1
  85. package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
  86. package/Server/Services/UserNotificationRuleService.ts +3812 -333
  87. package/Server/Services/UserOnCallLogService.ts +561 -48
  88. package/Server/Services/UserPushService.ts +29 -0
  89. package/Server/Services/UserService.ts +11 -0
  90. package/Server/Services/UserSmsService.ts +26 -1
  91. package/Server/Services/UserTelegramService.ts +24 -1
  92. package/Server/Services/UserWebhookService.ts +28 -1
  93. package/Server/Services/UserWhatsAppService.ts +24 -1
  94. package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
  95. package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
  96. package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
  97. package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
  98. package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
  99. package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
  100. package/Server/Types/Workflow/Components/API/Get.ts +1 -1
  101. package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
  102. package/Server/Types/Workflow/Components/API/Post.ts +1 -1
  103. package/Server/Types/Workflow/Components/API/Put.ts +1 -1
  104. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  105. package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
  106. package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
  107. package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
  108. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
  109. package/Server/Types/Workflow/Components/Email.ts +25 -7
  110. package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
  111. package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
  112. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  113. package/Server/Types/Workflow/Workflow.ts +5 -0
  114. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
  115. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
  116. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
  117. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
  118. package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
  119. package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
  120. package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
  121. package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
  122. package/Server/Utils/AI/Toolbox/Index.ts +48 -0
  123. package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
  124. package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
  125. package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
  126. package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
  127. package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
  128. package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
  129. package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
  130. package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
  131. package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
  132. package/Server/Utils/ClientIp.ts +221 -0
  133. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
  134. package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
  135. package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
  136. package/Server/Utils/Express.ts +12 -17
  137. package/Server/Utils/LLM/LLMService.ts +85 -8
  138. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
  139. package/Server/Utils/SSRFProtection.ts +98 -23
  140. package/Server/Utils/StartServer.ts +12 -3
  141. package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
  142. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  143. package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
  144. package/Server/Utils/VM/VMAPI.ts +56 -8
  145. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
  146. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
  147. package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
  148. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
  149. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  150. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
  151. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
  152. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
  153. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
  154. package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
  155. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
  156. package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
  157. package/Tests/Models/InventoryItemModel.test.ts +174 -0
  158. package/Tests/Models/InventoryItemNaming.test.ts +302 -0
  159. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
  160. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
  161. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
  162. package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
  163. package/Tests/Server/API/Helpers.ts +24 -15
  164. package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
  165. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
  166. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  167. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
  168. package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
  169. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
  170. package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
  171. package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
  172. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
  173. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
  174. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
  175. package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
  176. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
  177. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +248 -0
  178. package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
  179. package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
  180. package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
  181. package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
  182. package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
  183. package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
  184. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
  185. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
  186. package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
  187. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
  188. package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
  189. package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
  190. package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
  191. package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
  192. package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
  193. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
  194. package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
  195. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
  196. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
  197. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
  198. package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
  199. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
  200. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
  201. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
  202. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
  203. package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
  204. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  205. package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
  206. package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
  207. package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
  208. package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
  209. package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
  210. package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
  211. package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
  212. package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
  213. package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
  214. package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
  215. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
  216. package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
  217. package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
  218. package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
  219. package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
  220. package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
  221. package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
  222. package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
  223. package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
  224. package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
  225. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  226. package/Tests/Server/Utils/ClientIp.test.ts +438 -0
  227. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
  228. package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
  229. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
  230. package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
  231. package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
  232. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
  233. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
  234. package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
  235. package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
  236. package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
  237. package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
  238. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  239. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
  240. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  241. package/Tests/Types/IP/IP.test.ts +263 -0
  242. package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
  243. package/Tests/Types/IP/IPv6.test.ts +12 -1
  244. package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
  245. package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
  246. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
  247. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
  248. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
  249. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
  250. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
  251. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  252. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  253. package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
  254. package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
  255. package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
  256. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  257. package/Tests/Types/Workflow/TemplateSyntax.test.ts +491 -0
  258. package/Tests/Types/Workflow/Templates.test.ts +1218 -0
  259. package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
  260. package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
  261. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  262. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  263. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  264. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  265. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  266. package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
  267. package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
  268. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  269. package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
  270. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  271. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
  272. package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
  273. package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
  274. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  275. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  276. package/Tests/UI/Components/Workflow/GraphLint.test.ts +893 -0
  277. package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
  278. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +522 -0
  279. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +193 -0
  280. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +388 -0
  281. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +210 -0
  282. package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
  283. package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
  284. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  285. package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
  286. package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
  287. package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
  288. package/Tests/UI/EsbuildConfig.test.ts +607 -0
  289. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  290. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  291. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
  292. package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
  293. package/Tests/UI/Utils/Platform.test.ts +147 -0
  294. package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
  295. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
  296. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  297. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +727 -0
  298. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  299. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  300. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
  301. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  302. package/Tests/__mocks__/bullmq.js +55 -0
  303. package/Types/AI/AIChatMessageStatus.ts +8 -1
  304. package/Types/AI/AIChatTypes.ts +12 -0
  305. package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
  306. package/Types/Exception/ExceptionCode.ts +2 -0
  307. package/Types/Exception/ServiceUnavailableException.ts +8 -0
  308. package/Types/Exception/TooManyRequestsException.ts +8 -0
  309. package/Types/IP/IP.ts +93 -47
  310. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +80 -3
  311. package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
  312. package/Types/OnCallDutyPolicy/Layer.ts +203 -149
  313. package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
  314. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
  315. package/Types/Permission.ts +193 -0
  316. package/Types/SerializableObjectDictionary.ts +133 -39
  317. package/Types/Telemetry/EntityRelationshipType.ts +1 -1
  318. package/Types/Telemetry/EntitySource.ts +40 -0
  319. package/Types/Telemetry/EntityType.ts +28 -1
  320. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  321. package/Types/Workflow/Component.ts +29 -0
  322. package/Types/Workflow/Components/API.ts +35 -0
  323. package/Types/Workflow/Components/BaseModel.ts +77 -27
  324. package/Types/Workflow/Components/Discord.ts +1 -0
  325. package/Types/Workflow/Components/Email.ts +12 -3
  326. package/Types/Workflow/Components/JavaScript.ts +7 -0
  327. package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
  328. package/Types/Workflow/Components/Slack.ts +1 -0
  329. package/Types/Workflow/Components/Telegram.ts +1 -0
  330. package/Types/Workflow/StepTrace.ts +181 -0
  331. package/Types/Workflow/TemplateSyntax.ts +543 -0
  332. package/Types/Workflow/Templates.ts +2368 -0
  333. package/UI/Components/Calendar/Calendar.css +43 -0
  334. package/UI/Components/Calendar/Calendar.tsx +8 -0
  335. package/UI/Components/Card/Card.tsx +2 -2
  336. package/UI/Components/Checkbox/Checkbox.tsx +16 -0
  337. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  338. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  339. package/UI/Components/Dictionary/Dictionary.tsx +71 -15
  340. package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
  341. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  342. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  343. package/UI/Components/Forms/Types/Field.ts +7 -0
  344. package/UI/Components/Forms/Validation.ts +63 -1
  345. package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
  346. package/UI/Components/Input/Input.tsx +32 -3
  347. package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
  348. package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
  349. package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
  350. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
  351. package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
  352. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
  353. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  354. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
  355. package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
  356. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
  357. package/UI/Components/LogsViewer/types.ts +23 -0
  358. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
  359. package/UI/Components/Modal/Modal.tsx +37 -4
  360. package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
  361. package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
  362. package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
  363. package/UI/Components/Table/Table.tsx +49 -16
  364. package/UI/Components/Table/TableBody.tsx +53 -28
  365. package/UI/Components/Table/TableHeader.tsx +13 -0
  366. package/UI/Components/Table/TableRow.tsx +58 -26
  367. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  368. package/UI/Components/Workflow/ArgumentsForm.tsx +341 -8
  369. package/UI/Components/Workflow/Component.tsx +28 -26
  370. package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
  371. package/UI/Components/Workflow/ComponentSettingsModal.tsx +79 -9
  372. package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
  373. package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
  374. package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
  375. package/UI/Components/Workflow/GraphLint.ts +628 -0
  376. package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
  377. package/UI/Components/Workflow/ModelColumnEditor.tsx +528 -0
  378. package/UI/Components/Workflow/ModelSchema.ts +278 -0
  379. package/UI/Components/Workflow/RunForm.tsx +41 -7
  380. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  381. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  382. package/UI/Components/Workflow/UseRunWatch.ts +212 -0
  383. package/UI/Components/Workflow/Utils.ts +93 -1
  384. package/UI/Components/Workflow/VariableModal.tsx +6 -2
  385. package/UI/Components/Workflow/Workflow.tsx +126 -7
  386. package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
  387. package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
  388. package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
  389. package/UI/Types/LayeredDismissal.ts +31 -0
  390. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  391. package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
  392. package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
  393. package/UI/Utils/Platform.ts +149 -0
  394. package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
  395. package/UI/esbuild-config.js +22 -1
  396. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
  397. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  398. package/Utils/Monitor/NetworkTopologyUtil.ts +917 -155
  399. package/Utils/Telemetry/CrossSignalScope.ts +502 -0
  400. package/Utils/Telemetry/EntityKey.ts +71 -5
  401. package/Utils/Telemetry/EntityRelationship.ts +1 -1
  402. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
  403. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
  404. package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
  405. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
  406. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
  407. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
  408. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
  409. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  410. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
  411. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  412. package/build/dist/Models/DatabaseModels/Index.js +12 -4
  413. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  414. package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +212 -29
  415. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
  416. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
  417. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
  418. package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +52 -25
  419. package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
  420. package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
  421. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  422. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
  423. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
  424. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
  425. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
  426. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
  427. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
  428. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
  429. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
  430. package/build/dist/Models/DatabaseModels/Project.js +80 -0
  431. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  432. package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
  433. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  434. package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
  435. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  436. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
  437. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  438. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
  439. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  440. package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
  441. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  442. package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
  443. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  444. package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
  445. package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
  446. package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
  447. package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
  448. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
  449. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  450. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  451. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  452. package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
  453. package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
  454. package/build/dist/Server/API/AIChatAPI.js +237 -1
  455. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  456. package/build/dist/Server/API/DashboardAPI.js +165 -13
  457. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  458. package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
  459. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
  460. package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
  461. package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
  462. package/build/dist/Server/API/TelemetryAPI.js +129 -18
  463. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  464. package/build/dist/Server/API/UserAPI.js +16 -1
  465. package/build/dist/Server/API/UserAPI.js.map +1 -1
  466. package/build/dist/Server/EnvironmentConfig.js +45 -0
  467. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  468. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
  469. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  470. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
  471. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  472. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  473. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  474. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  475. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
  476. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
  477. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
  478. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
  479. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
  480. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
  481. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
  482. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
  483. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
  484. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
  485. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
  486. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
  487. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
  488. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
  489. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
  490. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
  491. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
  492. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
  493. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
  494. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
  495. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +24 -0
  496. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  497. package/build/dist/Server/Infrastructure/Queue.js +72 -13
  498. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  499. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  500. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  501. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
  502. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
  503. package/build/dist/Server/Services/AIService.js +1 -0
  504. package/build/dist/Server/Services/AIService.js.map +1 -1
  505. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
  506. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
  507. package/build/dist/Server/Services/AlertSeverityService.js +54 -0
  508. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  509. package/build/dist/Server/Services/DashboardService.js +10 -9
  510. package/build/dist/Server/Services/DashboardService.js.map +1 -1
  511. package/build/dist/Server/Services/DatabaseService.js +24 -2
  512. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  513. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
  514. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  515. package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
  516. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  517. package/build/dist/Server/Services/Index.js +12 -4
  518. package/build/dist/Server/Services/Index.js.map +1 -1
  519. package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
  520. package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
  521. package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +9 -6
  522. package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
  523. package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +158 -23
  524. package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
  525. package/build/dist/Server/Services/LogAggregationService.js +27 -8
  526. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  527. package/build/dist/Server/Services/MetricAggregationService.js +80 -0
  528. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  529. package/build/dist/Server/Services/MetricService.js +6 -6
  530. package/build/dist/Server/Services/MetricService.js.map +1 -1
  531. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
  532. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
  533. package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
  534. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
  535. package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
  536. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  537. package/build/dist/Server/Services/NetworkSiteService.js +53 -13
  538. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  539. package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
  540. package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
  541. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
  542. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  543. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
  544. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  545. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
  546. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
  547. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
  548. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  549. package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
  550. package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
  551. package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
  552. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
  553. package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
  554. package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
  555. package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
  556. package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
  557. package/build/dist/Server/Services/StatusPageService.js +11 -10
  558. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  559. package/build/dist/Server/Services/TeamComplianceService.js +312 -160
  560. package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
  561. package/build/dist/Server/Services/UserCallService.js +24 -1
  562. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  563. package/build/dist/Server/Services/UserEmailService.js +24 -1
  564. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  565. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
  566. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
  567. package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
  568. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  569. package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
  570. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  571. package/build/dist/Server/Services/UserPushService.js +26 -0
  572. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  573. package/build/dist/Server/Services/UserService.js +10 -0
  574. package/build/dist/Server/Services/UserService.js.map +1 -1
  575. package/build/dist/Server/Services/UserSmsService.js +24 -1
  576. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  577. package/build/dist/Server/Services/UserTelegramService.js +22 -1
  578. package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
  579. package/build/dist/Server/Services/UserWebhookService.js +25 -1
  580. package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
  581. package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
  582. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  583. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
  584. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  585. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
  586. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
  587. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
  588. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
  589. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
  590. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
  591. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
  592. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  593. package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
  594. package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
  595. package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
  596. package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
  597. package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
  598. package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
  599. package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
  600. package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
  601. package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
  602. package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
  603. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  604. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  605. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
  606. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
  607. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
  608. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
  609. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
  610. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
  611. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
  612. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  613. package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
  614. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  615. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
  616. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  617. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
  618. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
  619. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  620. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
  621. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  622. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
  623. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
  624. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
  625. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
  626. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
  627. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  628. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
  629. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
  630. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
  631. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
  632. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
  633. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
  634. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
  635. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  636. package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
  637. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  638. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
  639. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
  640. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
  641. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
  642. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
  643. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
  644. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
  645. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
  646. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
  647. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
  648. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
  649. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
  650. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
  651. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
  652. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
  653. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
  654. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
  655. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
  656. package/build/dist/Server/Utils/ClientIp.js +137 -0
  657. package/build/dist/Server/Utils/ClientIp.js.map +1 -0
  658. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
  659. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  660. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
  661. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
  662. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
  663. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
  664. package/build/dist/Server/Utils/Express.js +12 -12
  665. package/build/dist/Server/Utils/Express.js.map +1 -1
  666. package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
  667. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  668. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
  669. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  670. package/build/dist/Server/Utils/SSRFProtection.js +82 -21
  671. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  672. package/build/dist/Server/Utils/StartServer.js +12 -4
  673. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  674. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
  675. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  676. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  677. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  678. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
  679. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  680. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  681. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  682. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
  683. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  684. package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
  685. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
  686. package/build/dist/Types/AI/AIChatTypes.js +12 -0
  687. package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
  688. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
  689. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
  690. package/build/dist/Types/Exception/ExceptionCode.js +2 -0
  691. package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
  692. package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
  693. package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
  694. package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
  695. package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
  696. package/build/dist/Types/IP/IP.js +87 -43
  697. package/build/dist/Types/IP/IP.js.map +1 -1
  698. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
  699. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
  700. package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
  701. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  702. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
  703. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
  704. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
  705. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  706. package/build/dist/Types/Permission.js +174 -0
  707. package/build/dist/Types/Permission.js.map +1 -1
  708. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  709. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  710. package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
  711. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  712. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  713. package/build/dist/Types/Telemetry/EntityType.js +28 -1
  714. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  715. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  716. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  717. package/build/dist/Types/Workflow/Component.js +21 -0
  718. package/build/dist/Types/Workflow/Component.js.map +1 -1
  719. package/build/dist/Types/Workflow/Components/API.js +35 -0
  720. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  721. package/build/dist/Types/Workflow/Components/BaseModel.js +68 -26
  722. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  723. package/build/dist/Types/Workflow/Components/Discord.js +1 -0
  724. package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
  725. package/build/dist/Types/Workflow/Components/Email.js +12 -3
  726. package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
  727. package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
  728. package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
  729. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
  730. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
  731. package/build/dist/Types/Workflow/Components/Slack.js +1 -0
  732. package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
  733. package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
  734. package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
  735. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  736. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  737. package/build/dist/Types/Workflow/TemplateSyntax.js +338 -0
  738. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  739. package/build/dist/Types/Workflow/Templates.js +2111 -0
  740. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  741. package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
  742. package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
  743. package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
  744. package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
  745. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  746. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  747. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  748. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  749. package/build/dist/UI/Components/Dictionary/Dictionary.js +47 -17
  750. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  751. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  752. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  753. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  754. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  755. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  756. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  757. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  758. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
  759. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  760. package/build/dist/UI/Components/Input/Input.js +13 -4
  761. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  762. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
  763. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
  764. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
  765. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
  766. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
  767. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  768. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
  769. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
  770. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
  771. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
  772. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
  773. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  774. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  775. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  776. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
  777. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  778. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
  779. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  780. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
  781. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  782. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  783. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
  784. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  785. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  786. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  787. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
  788. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  789. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
  790. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
  791. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
  792. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
  793. package/build/dist/UI/Components/Table/Table.js +27 -15
  794. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  795. package/build/dist/UI/Components/Table/TableBody.js +24 -18
  796. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  797. package/build/dist/UI/Components/Table/TableHeader.js +9 -1
  798. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  799. package/build/dist/UI/Components/Table/TableRow.js +29 -21
  800. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  801. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  802. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  803. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +261 -14
  804. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  805. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  806. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  807. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
  808. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
  809. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +35 -7
  810. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  811. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
  812. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
  813. package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
  814. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  815. package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
  816. package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
  817. package/build/dist/UI/Components/Workflow/GraphLint.js +425 -0
  818. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  819. package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
  820. package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
  821. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +320 -0
  822. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  823. package/build/dist/UI/Components/Workflow/ModelSchema.js +156 -0
  824. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  825. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  826. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  827. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  828. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  829. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  830. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  831. package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
  832. package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
  833. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  834. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  835. package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
  836. package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
  837. package/build/dist/UI/Components/Workflow/Workflow.js +92 -7
  838. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  839. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
  840. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
  841. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
  842. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
  843. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
  844. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
  845. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  846. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  847. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  848. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  849. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
  850. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
  851. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
  852. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  853. package/build/dist/UI/Utils/Platform.js +118 -0
  854. package/build/dist/UI/Utils/Platform.js.map +1 -0
  855. package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
  856. package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
  857. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
  858. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
  859. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  860. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  861. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +661 -126
  862. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  863. package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
  864. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
  865. package/build/dist/Utils/Telemetry/EntityKey.js +57 -5
  866. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  867. package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
  868. package/jest.config.json +1 -0
  869. package/package.json +1 -1
  870. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
  871. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
  872. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
  873. package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
@@ -0,0 +1,2848 @@
1
+ import DatabaseConfig from "../../../Server/DatabaseConfig";
2
+ import AuditLogService from "../../../Server/Services/AuditLogService";
3
+ import MailService from "../../../Server/Services/MailService";
4
+ import ProjectService from "../../../Server/Services/ProjectService";
5
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
6
+ import UserCallService from "../../../Server/Services/UserCallService";
7
+ import UserEmailService from "../../../Server/Services/UserEmailService";
8
+ import UserNotificationRuleAdminService from "../../../Server/Services/UserNotificationRuleAdminService";
9
+ import UserNotificationRuleService, {
10
+ ExecuteNotificationRuleOptions,
11
+ } from "../../../Server/Services/UserNotificationRuleService";
12
+ import UserOnCallLogService from "../../../Server/Services/UserOnCallLogService";
13
+ import UserOnCallLogTimelineService from "../../../Server/Services/UserOnCallLogTimelineService";
14
+ import UserPushService from "../../../Server/Services/UserPushService";
15
+ import UserService from "../../../Server/Services/UserService";
16
+ import UserSmsService from "../../../Server/Services/UserSmsService";
17
+ import UserTelegramService from "../../../Server/Services/UserTelegramService";
18
+ import UserWebhookService from "../../../Server/Services/UserWebhookService";
19
+ import UserWhatsAppService from "../../../Server/Services/UserWhatsAppService";
20
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
21
+ import DeleteBy from "../../../Server/Types/Database/DeleteBy";
22
+ import {
23
+ OnCreate,
24
+ OnDelete,
25
+ OnUpdate,
26
+ } from "../../../Server/Types/Database/Hooks";
27
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
28
+ import logger from "../../../Server/Utils/Logger";
29
+ import Project from "../../../Models/DatabaseModels/Project";
30
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
31
+ import User from "../../../Models/DatabaseModels/User";
32
+ import UserNotificationRule from "../../../Models/DatabaseModels/UserNotificationRule";
33
+ import UserOnCallLogTimeline from "../../../Models/DatabaseModels/UserOnCallLogTimeline";
34
+ import URL from "../../../Types/API/URL";
35
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
36
+ import Email from "../../../Types/Email";
37
+ import Name from "../../../Types/Name";
38
+ import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
39
+ import ObjectID from "../../../Types/ObjectID";
40
+ import Permission from "../../../Types/Permission";
41
+ import UserNotificationEventType from "../../../Types/UserNotification/UserNotificationEventType";
42
+ import UserNotificationStatus from "../../../Types/UserNotification/UserNotificationStatus";
43
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
44
+
45
+ /*
46
+ * WHAT THIS FILE IS DEFENDING, AND WHY IT IS WRITTEN THE WAY IT IS
47
+ *
48
+ * A UserNotificationRule row is two halves that no screen, no ORM relation and
49
+ * no permission rule ever compares against each other:
50
+ *
51
+ * userId -> whose on-call page selects this row
52
+ * user<X>Id -> which address that page is delivered to
53
+ *
54
+ * For as long as every notification model was CurrentUser-only, the two halves
55
+ * could not disagree: a caller could only ever name their own id in both. This
56
+ * phase deliberately lets a project administrator write a row whose userId is
57
+ * somebody else's, because an admin who cannot see or repair a responder's
58
+ * broken notification setup cannot actually fix the outage-shaped problem this
59
+ * epic is about. The moment that is true, "userId = Bob, userWebhookId = the
60
+ * admin's own webhook" becomes an input somebody can send. It routes every one
61
+ * of Bob's pages to an endpoint the sender controls, and it is invisible from
62
+ * both ends: Bob's rules page still lists a rule, the on-call log still records
63
+ * a delivered notification, and the page simply reaches the wrong human during
64
+ * the incident where it mattered.
65
+ *
66
+ * The permission layer structurally cannot catch that. It reasons about tables
67
+ * and columns; this is a relationship between two VALUES in one row. So the
68
+ * invariants live in the service hooks, and this file is their adversarial
69
+ * test: it sends the requests an attacker would send rather than the ones a
70
+ * dashboard would.
71
+ *
72
+ * R1 the rule's owner must be on the acting project's roster, because
73
+ * holding an administrative permission is a claim about ONE project and
74
+ * a user id is global.
75
+ * R3 a rule's method FK must point at a method row owned by the rule's own
76
+ * user - on create, on update, through the FK column and through the
77
+ * relation slot, on every one of the seven channels, and on update with
78
+ * the owner re-read from the DATABASE because the body is written by the
79
+ * party under suspicion.
80
+ * R6 the audit line and the owner's warning email are keyed on the actor the
81
+ * SERVER resolved against the owner the row was PERSISTED with; never on
82
+ * anything the request said, and never at the cost of the write itself.
83
+ * D-i-D a row that is already mismatched - written before these guards
84
+ * existed, or by internal code running as root - must be inert at
85
+ * delivery time rather than quietly obeyed.
86
+ *
87
+ * Every happy path here earns its place by proving a guard does not simply
88
+ * refuse everything. A guard that blocks legitimate repair is deleted by the
89
+ * next engineer who needs to repair something, and then there is no guard.
90
+ *
91
+ * No database is involved: each service the guards consult is a jest.spyOn
92
+ * stub, and the hooks - which are protected, and are ordinary prototype methods
93
+ * at runtime - are reached through a structural cast.
94
+ */
95
+
96
+ const PROJECT_ID: ObjectID = new ObjectID(
97
+ "aaaa1111-1111-4111-8111-111111111111",
98
+ );
99
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
100
+ "bbbb1111-1111-4111-8111-111111111111",
101
+ );
102
+ const ADMIN_USER_ID: ObjectID = new ObjectID(
103
+ "aaaa2222-2222-4222-8222-222222222222",
104
+ );
105
+ const VICTIM_USER_ID: ObjectID = new ObjectID(
106
+ "aaaa3333-3333-4333-8333-333333333333",
107
+ );
108
+ const STRANGER_USER_ID: ObjectID = new ObjectID(
109
+ "aaaa4444-4444-4444-8444-444444444444",
110
+ );
111
+ const VICTIM_METHOD_ID: ObjectID = new ObjectID(
112
+ "aaaa5555-5555-4555-8555-555555555555",
113
+ );
114
+ const ADMIN_METHOD_ID: ObjectID = new ObjectID(
115
+ "aaaa6666-6666-4666-8666-666666666666",
116
+ );
117
+ const RULE_ID: ObjectID = new ObjectID("aaaa7777-7777-4777-8777-777777777777");
118
+ const SECOND_RULE_ID: ObjectID = new ObjectID(
119
+ "aaaa8888-8888-4888-8888-888888888888",
120
+ );
121
+ const TEAM_MEMBER_ID: ObjectID = new ObjectID(
122
+ "aaaa9999-9999-4999-8999-999999999999",
123
+ );
124
+ const ON_CALL_LOG_ID: ObjectID = new ObjectID(
125
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
126
+ );
127
+ const TIMELINE_ID: ObjectID = new ObjectID(
128
+ "aaaabbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
129
+ );
130
+ const INCIDENT_ID: ObjectID = new ObjectID(
131
+ "aaaacccc-cccc-4ccc-8ccc-cccccccccccc",
132
+ );
133
+
134
+ const ADMIN_EMAIL: string = "ada.admin@example.test";
135
+ const VICTIM_EMAIL: string = "vic.responder@example.test";
136
+
137
+ /*
138
+ * The seven channels, each named by the FK column that carries it, the relation
139
+ * slot that is the same write spelled differently, the service the guard has to
140
+ * consult for that channel's owner, and the word that must appear in the
141
+ * refusal so an operator can tell WHICH method was wrong.
142
+ *
143
+ * Everything about method ownership is driven off this table on purpose. The
144
+ * one realistic way for the guard to be wrong is to cover six channels and
145
+ * forget the seventh, and an attacker choosing between seven doors will always
146
+ * choose the unlocked one - so a per-channel loop is the only shape of test
147
+ * that means anything here.
148
+ */
149
+ interface MethodServiceLike {
150
+ findOneById: (...args: Array<never>) => unknown;
151
+ }
152
+
153
+ interface ChannelFixture {
154
+ idColumn: string;
155
+ relationColumn: string;
156
+ label: string;
157
+ service: MethodServiceLike;
158
+ }
159
+
160
+ const CHANNELS: Array<ChannelFixture> = [
161
+ {
162
+ idColumn: "userEmailId",
163
+ relationColumn: "userEmail",
164
+ label: "Email",
165
+ service: UserEmailService as unknown as MethodServiceLike,
166
+ },
167
+ {
168
+ idColumn: "userSmsId",
169
+ relationColumn: "userSms",
170
+ label: "SMS",
171
+ service: UserSmsService as unknown as MethodServiceLike,
172
+ },
173
+ {
174
+ idColumn: "userCallId",
175
+ relationColumn: "userCall",
176
+ label: "Call",
177
+ service: UserCallService as unknown as MethodServiceLike,
178
+ },
179
+ {
180
+ idColumn: "userWhatsAppId",
181
+ relationColumn: "userWhatsApp",
182
+ label: "WhatsApp",
183
+ service: UserWhatsAppService as unknown as MethodServiceLike,
184
+ },
185
+ {
186
+ idColumn: "userTelegramId",
187
+ relationColumn: "userTelegram",
188
+ label: "Telegram",
189
+ service: UserTelegramService as unknown as MethodServiceLike,
190
+ },
191
+ {
192
+ idColumn: "userPushId",
193
+ relationColumn: "userPush",
194
+ label: "Push",
195
+ service: UserPushService as unknown as MethodServiceLike,
196
+ },
197
+ {
198
+ idColumn: "userWebhookId",
199
+ relationColumn: "userWebhook",
200
+ label: "Webhook",
201
+ service: UserWebhookService as unknown as MethodServiceLike,
202
+ },
203
+ ];
204
+
205
+ /*
206
+ * The four write hooks and the private delivery half, named through a
207
+ * structural cast. TypeScript forbids a test naming a protected member; the
208
+ * runtime does not care, and testing these through `create()` / `updateBy()`
209
+ * instead would drag in the entire ORM for no extra assurance about the one
210
+ * comparison under test.
211
+ */
212
+ interface RuleServiceInternals {
213
+ onBeforeCreate: (
214
+ createBy: CreateBy<UserNotificationRule>,
215
+ ) => Promise<OnCreate<UserNotificationRule>>;
216
+ onCreateSuccess: (
217
+ onCreate: OnCreate<UserNotificationRule>,
218
+ createdItem: UserNotificationRule,
219
+ ) => Promise<UserNotificationRule>;
220
+ onBeforeUpdate: (
221
+ updateBy: UpdateBy<UserNotificationRule>,
222
+ ) => Promise<OnUpdate<UserNotificationRule>>;
223
+ onUpdateSuccess: (
224
+ onUpdate: OnUpdate<UserNotificationRule>,
225
+ updatedItemIds: Array<ObjectID>,
226
+ ) => Promise<OnUpdate<UserNotificationRule>>;
227
+ onBeforeDelete: (
228
+ deleteBy: DeleteBy<UserNotificationRule>,
229
+ ) => Promise<OnDelete<UserNotificationRule>>;
230
+ onDeleteSuccess: (
231
+ onDelete: OnDelete<UserNotificationRule>,
232
+ deletedItemIds: Array<ObjectID>,
233
+ ) => Promise<OnDelete<UserNotificationRule>>;
234
+ deliverNotificationForRule: (
235
+ notificationRuleItem: UserNotificationRule,
236
+ options: ExecuteNotificationRuleOptions,
237
+ ) => Promise<boolean>;
238
+ }
239
+
240
+ function ruleService(): RuleServiceInternals {
241
+ return UserNotificationRuleService as unknown as RuleServiceInternals;
242
+ }
243
+
244
+ /*
245
+ * A member session: a real actor id and a real tenant, exactly like the API
246
+ * builds. It carries no ROLE permission, which makes it the ordinary-member
247
+ * case - Permission.CurrentUser is auto-granted to every authenticated caller
248
+ * and is the only thing letting this session through the model's write lists.
249
+ */
250
+ function adminProps(
251
+ overrides: Partial<DatabaseCommonInteractionProps> = {},
252
+ ): DatabaseCommonInteractionProps {
253
+ return {
254
+ userId: ADMIN_USER_ID,
255
+ tenantId: PROJECT_ID,
256
+ ...overrides,
257
+ };
258
+ }
259
+
260
+ /*
261
+ * A session that holds a genuine ProjectAdmin row on this project, which is
262
+ * what a real administrator's session looks like once the API has resolved
263
+ * their team memberships.
264
+ *
265
+ * The distinction from adminProps above is exactly the distinction
266
+ * TenantPermission.isAccessGrantedOnlyByCurrentUser draws, and the whole
267
+ * feature turns on it: a session holding only auto-granted permissions is
268
+ * confined to its own rows, while one holding a role permission that appears in
269
+ * the model's list is not - which is what lets an administrator see and repair
270
+ * somebody else's paging in the first place.
271
+ */
272
+ function projectAdminProps(
273
+ overrides: Partial<DatabaseCommonInteractionProps> = {},
274
+ ): DatabaseCommonInteractionProps {
275
+ return {
276
+ userId: ADMIN_USER_ID,
277
+ tenantId: PROJECT_ID,
278
+ userTenantAccessPermission: {
279
+ [PROJECT_ID.toString()]: {
280
+ _type: "UserTenantAccessPermission",
281
+ projectId: PROJECT_ID,
282
+ permissions: [
283
+ {
284
+ _type: "UserPermission",
285
+ permission: Permission.ProjectAdmin,
286
+ labelIds: [],
287
+ isBlockPermission: false,
288
+ },
289
+ ],
290
+ },
291
+ },
292
+ ...overrides,
293
+ };
294
+ }
295
+
296
+ /*
297
+ * A create payload as the hook receives it: a real model instance, not a
298
+ * literal, because the guard reads relation slots as well as FK columns and the
299
+ * hook ahead of it reads the model's own accessors.
300
+ */
301
+ function createPayload(data: {
302
+ ownerUserId?: ObjectID | undefined;
303
+ props?: DatabaseCommonInteractionProps | undefined;
304
+ columns?: Record<string, unknown> | undefined;
305
+ isOptOut?: boolean | undefined;
306
+ }): CreateBy<UserNotificationRule> {
307
+ const rule: UserNotificationRule = new UserNotificationRule();
308
+ rule.projectId = PROJECT_ID;
309
+ rule.ruleType = NotificationRuleType.ON_CALL_EXECUTED_INCIDENT;
310
+ rule.notifyAfterMinutes = 0;
311
+
312
+ if (data.ownerUserId) {
313
+ rule.userId = data.ownerUserId;
314
+ }
315
+
316
+ if (data.isOptOut) {
317
+ rule.isOptOut = true;
318
+ }
319
+
320
+ Object.assign(rule, data.columns || {});
321
+
322
+ return {
323
+ data: rule,
324
+ props: data.props || adminProps(),
325
+ };
326
+ }
327
+
328
+ function updatePayload(data: {
329
+ patch: Record<string, unknown>;
330
+ query?: Record<string, unknown> | undefined;
331
+ props?: DatabaseCommonInteractionProps | undefined;
332
+ }): UpdateBy<UserNotificationRule> {
333
+ return {
334
+ query: (data.query || {
335
+ _id: RULE_ID.toString(),
336
+ }) as unknown as UpdateBy<UserNotificationRule>["query"],
337
+ data: data.patch as unknown as UpdateBy<UserNotificationRule>["data"],
338
+ props: data.props || adminProps(),
339
+ limit: 10,
340
+ skip: 0,
341
+ };
342
+ }
343
+
344
+ function deletePayload(
345
+ data: {
346
+ query?: Record<string, unknown> | undefined;
347
+ props?: DatabaseCommonInteractionProps | undefined;
348
+ } = {},
349
+ ): DeleteBy<UserNotificationRule> {
350
+ return {
351
+ query: (data.query || {
352
+ _id: RULE_ID.toString(),
353
+ }) as unknown as DeleteBy<UserNotificationRule>["query"],
354
+ props: data.props || adminProps(),
355
+ limit: 10,
356
+ skip: 0,
357
+ };
358
+ }
359
+
360
+ /* The query the guard actually read the affected rows with. */
361
+ function guardReadQuery(findBySpy: jest.SpyInstance): Record<string, unknown> {
362
+ return (
363
+ findBySpy.mock.calls[0]![0] as {
364
+ query: Record<string, unknown>;
365
+ }
366
+ ).query;
367
+ }
368
+
369
+ /*
370
+ * A rule as the database has it. Deliberately built as a plain shape rather
371
+ * than a model instance: these stand in for rows read back out of Postgres, and
372
+ * making them structurally different from the payloads above keeps "which of
373
+ * these two did the guard actually read" an honest question.
374
+ */
375
+ function persistedRule(
376
+ overrides: Record<string, unknown> = {},
377
+ ): UserNotificationRule {
378
+ return {
379
+ id: RULE_ID,
380
+ _id: RULE_ID.toString(),
381
+ projectId: PROJECT_ID,
382
+ userId: VICTIM_USER_ID,
383
+ ...overrides,
384
+ } as unknown as UserNotificationRule;
385
+ }
386
+
387
+ /*
388
+ * A notification-method row as its service hands it back. It carries the SAME
389
+ * projectId as the rule in every ownership test below, because project scoping
390
+ * is the check a reader instinctively assumes is already covering this - and it
391
+ * is not. Both rows being in one project is the normal case for this attack:
392
+ * the admin and their victim are colleagues.
393
+ */
394
+ function methodRow(data: {
395
+ ownerUserId: ObjectID;
396
+ methodId: ObjectID;
397
+ }): Record<string, unknown> {
398
+ return {
399
+ id: data.methodId,
400
+ _id: data.methodId.toString(),
401
+ projectId: PROJECT_ID,
402
+ userId: data.ownerUserId,
403
+ };
404
+ }
405
+
406
+ function executeOptions(): ExecuteNotificationRuleOptions {
407
+ return {
408
+ projectId: PROJECT_ID,
409
+ userNotificationEventType: UserNotificationEventType.IncidentCreated,
410
+ triggeredByIncidentId: INCIDENT_ID,
411
+ onCallPolicyId: undefined,
412
+ userNotificationLogId: ON_CALL_LOG_ID,
413
+ };
414
+ }
415
+
416
+ /*
417
+ * The owner's warning email is fire-and-forget by construction: the row is
418
+ * already committed when it is sent, so recordAdminRuleChange attaches a
419
+ * .catch and deliberately does not await it. Asserting on it therefore means
420
+ * letting the microtask queue drain first, and a macrotask boundary is the only
421
+ * reliable way to do that.
422
+ */
423
+ function flushAsync(): Promise<void> {
424
+ return new Promise<void>((resolve: () => void): void => {
425
+ setTimeout(resolve, 0);
426
+ });
427
+ }
428
+
429
+ /* The shape of the log attributes recordAdminRuleChange stamps its trail with. */
430
+ interface AdminChangeLogAttributes {
431
+ projectId?: string | undefined;
432
+ userId?: string | undefined;
433
+ actorUserId?: string | undefined;
434
+ userNotificationRuleId?: string | undefined;
435
+ action?: string | undefined;
436
+ }
437
+
438
+ function adminChangeLogAttributes(
439
+ infoSpy: jest.SpyInstance,
440
+ ): Array<AdminChangeLogAttributes> {
441
+ return infoSpy.mock.calls
442
+ .map((call: Array<unknown>): AdminChangeLogAttributes | undefined => {
443
+ return call[1] as AdminChangeLogAttributes | undefined;
444
+ })
445
+ .filter((attributes: AdminChangeLogAttributes | undefined): boolean => {
446
+ return Boolean(attributes?.actorUserId);
447
+ }) as Array<AdminChangeLogAttributes>;
448
+ }
449
+
450
+ /* A timeline row snapshotted at create() time. */
451
+ interface TimelineSnapshot {
452
+ status: UserNotificationStatus | undefined;
453
+ statusMessage: string | undefined;
454
+ userId: string | undefined;
455
+ userNotificationRuleId: string | undefined;
456
+ userNotificationLogId: string | undefined;
457
+ isRoot: boolean | undefined;
458
+ }
459
+
460
+ interface Stubs {
461
+ teamMemberFindOneBy: jest.SpyInstance;
462
+ ruleFindBy: jest.SpyInstance;
463
+ ruleFindOneById: jest.SpyInstance;
464
+ deliver: jest.SpyInstance;
465
+ claim: jest.SpyInstance;
466
+ timelineCreate: jest.SpyInstance;
467
+ sendMail: jest.SpyInstance;
468
+ userFindOneById: jest.SpyInstance;
469
+ projectFindOneById: jest.SpyInstance;
470
+ dashboardUrl: jest.SpyInstance;
471
+ auditRecordCreate: jest.SpyInstance;
472
+ auditRecordUpdate: jest.SpyInstance;
473
+ auditRecordDelete: jest.SpyInstance;
474
+ loggerInfo: jest.SpyInstance;
475
+ loggerError: jest.SpyInstance;
476
+ methodFindOneById: Map<string, jest.SpyInstance>;
477
+ }
478
+
479
+ describe("Administrative notification rule edit guards", () => {
480
+ let stubs: Stubs;
481
+ let timelineRows: Array<TimelineSnapshot>;
482
+
483
+ beforeEach(() => {
484
+ timelineRows = [];
485
+
486
+ const methodFindOneById: Map<string, jest.SpyInstance> = new Map<
487
+ string,
488
+ jest.SpyInstance
489
+ >();
490
+
491
+ for (const channel of CHANNELS) {
492
+ /*
493
+ * Default: every method row in the world belongs to the victim, i.e. to
494
+ * the user the rules under test belong to. A test that cares about a
495
+ * mismatch has to say so for its own channel, which means a guard that
496
+ * consults the WRONG service's rows fails rather than passes by luck.
497
+ */
498
+ methodFindOneById.set(
499
+ channel.idColumn,
500
+ jest.spyOn(channel.service, "findOneById").mockResolvedValue(
501
+ methodRow({
502
+ ownerUserId: VICTIM_USER_ID,
503
+ methodId: VICTIM_METHOD_ID,
504
+ }) as never,
505
+ ),
506
+ );
507
+ }
508
+
509
+ stubs = {
510
+ teamMemberFindOneBy: jest
511
+ .spyOn(TeamMemberService, "findOneBy")
512
+ .mockResolvedValue({
513
+ id: TEAM_MEMBER_ID,
514
+ _id: TEAM_MEMBER_ID.toString(),
515
+ } as unknown as TeamMember as never),
516
+ ruleFindBy: jest
517
+ .spyOn(UserNotificationRuleService, "findBy")
518
+ .mockResolvedValue([persistedRule()] as never),
519
+ ruleFindOneById: jest
520
+ .spyOn(UserNotificationRuleService, "findOneById")
521
+ .mockResolvedValue(null as never),
522
+ deliver: jest
523
+ .spyOn(ruleService(), "deliverNotificationForRule")
524
+ .mockResolvedValue(true as never),
525
+ claim: jest
526
+ .spyOn(UserOnCallLogService, "claimNotificationRuleExecution")
527
+ .mockResolvedValue(true as never),
528
+ timelineCreate: jest
529
+ .spyOn(UserOnCallLogTimelineService, "create")
530
+ .mockImplementation(
531
+ (
532
+ createBy: CreateBy<UserOnCallLogTimeline>,
533
+ ): Promise<UserOnCallLogTimeline> => {
534
+ timelineRows.push({
535
+ status: createBy.data.status,
536
+ statusMessage: createBy.data.statusMessage,
537
+ userId: createBy.data.userId?.toString(),
538
+ userNotificationRuleId:
539
+ createBy.data.userNotificationRuleId?.toString(),
540
+ userNotificationLogId:
541
+ createBy.data.userNotificationLogId?.toString(),
542
+ isRoot: createBy.props.isRoot,
543
+ });
544
+
545
+ return Promise.resolve({
546
+ id: TIMELINE_ID,
547
+ _id: TIMELINE_ID.toString(),
548
+ } as unknown as UserOnCallLogTimeline);
549
+ },
550
+ ) as unknown as jest.SpyInstance,
551
+ sendMail: jest
552
+ .spyOn(MailService, "sendMail")
553
+ .mockResolvedValue(undefined as never),
554
+ userFindOneById: jest
555
+ .spyOn(UserService, "findOneById")
556
+ .mockImplementation((findBy: { id: ObjectID }): Promise<User> => {
557
+ const isAdmin: boolean =
558
+ findBy.id.toString() === ADMIN_USER_ID.toString();
559
+
560
+ return Promise.resolve({
561
+ id: findBy.id,
562
+ _id: findBy.id.toString(),
563
+ name: new Name(isAdmin ? "Ada Admin" : "Vic Responder"),
564
+ email: new Email(isAdmin ? ADMIN_EMAIL : VICTIM_EMAIL),
565
+ } as unknown as User);
566
+ }) as unknown as jest.SpyInstance,
567
+ projectFindOneById: jest
568
+ .spyOn(ProjectService, "findOneById")
569
+ .mockResolvedValue({
570
+ id: PROJECT_ID,
571
+ _id: PROJECT_ID.toString(),
572
+ name: "Acme Ops",
573
+ } as unknown as Project as never),
574
+ dashboardUrl: jest
575
+ .spyOn(DatabaseConfig, "getDashboardUrl")
576
+ .mockResolvedValue(URL.fromString("https://oneuptime.test") as never),
577
+ auditRecordCreate: jest
578
+ .spyOn(AuditLogService, "recordCreate")
579
+ .mockResolvedValue(undefined as never),
580
+ auditRecordUpdate: jest
581
+ .spyOn(AuditLogService, "recordUpdate")
582
+ .mockResolvedValue(undefined as never),
583
+ auditRecordDelete: jest
584
+ .spyOn(AuditLogService, "recordDelete")
585
+ .mockResolvedValue(undefined as never),
586
+ loggerInfo: jest.spyOn(logger, "info").mockImplementation((): void => {
587
+ return undefined;
588
+ }),
589
+ loggerError: jest.spyOn(logger, "error").mockImplementation((): void => {
590
+ return undefined;
591
+ }),
592
+ methodFindOneById: methodFindOneById,
593
+ };
594
+
595
+ jest.spyOn(logger, "warn").mockImplementation((): void => {
596
+ return undefined;
597
+ });
598
+ });
599
+
600
+ afterEach(() => {
601
+ jest.restoreAllMocks();
602
+ });
603
+
604
+ /*
605
+ * ------------------------------------------------------------------
606
+ * R1 - an on-behalf-of write is confined to the acting project's roster.
607
+ * ------------------------------------------------------------------
608
+ */
609
+ describe("R1: the rule's owner must be a member of the acting project", () => {
610
+ test("a project admin cannot write a rule for a user who is not on this project's roster", async () => {
611
+ /*
612
+ * The attack in one request. The caller is a genuine administrator of
613
+ * PROJECT_ID - the permission layer has already waved them through, which
614
+ * is precisely why this check has to exist below it - and the user id
615
+ * they name belongs to a project they have nothing to do with. A user id
616
+ * is global; an administrative permission is not.
617
+ */
618
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
619
+
620
+ await expect(
621
+ ruleService().onBeforeCreate(
622
+ createPayload({
623
+ ownerUserId: STRANGER_USER_ID,
624
+ columns: { userEmailId: VICTIM_METHOD_ID },
625
+ }),
626
+ ),
627
+ ).rejects.toThrow(
628
+ `Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
629
+ );
630
+
631
+ /*
632
+ * And the refusal comes before any method lookup. Not a correctness
633
+ * requirement so much as evidence that the roster check really is the
634
+ * thing that rejected this, rather than some later guard tripping over
635
+ * the same fixture.
636
+ */
637
+ expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
638
+ });
639
+
640
+ test("a project admin CAN write a rule for a user who is on the roster", async () => {
641
+ /*
642
+ * The other half, and the more important one to keep passing. An admin
643
+ * who cannot repair a colleague's notification setup is the whole problem
644
+ * this phase exists to solve, so a guard that refuses this case has not
645
+ * been made safe - it has been made useless, and the next engineer will
646
+ * remove it.
647
+ */
648
+ const payload: CreateBy<UserNotificationRule> = createPayload({
649
+ ownerUserId: VICTIM_USER_ID,
650
+ columns: { userEmailId: VICTIM_METHOD_ID },
651
+ });
652
+
653
+ const result: OnCreate<UserNotificationRule> =
654
+ await ruleService().onBeforeCreate(payload);
655
+
656
+ expect(stubs.teamMemberFindOneBy).toHaveBeenCalledTimes(1);
657
+ // The guard validates ownership; it does not quietly re-point it.
658
+ expect(result.createBy.data.userId?.toString()).toBe(
659
+ VICTIM_USER_ID.toString(),
660
+ );
661
+ });
662
+
663
+ test("membership is asked of the session's tenant, with root props, and only counts an accepted invitation", async () => {
664
+ await ruleService().onBeforeCreate(
665
+ createPayload({
666
+ ownerUserId: VICTIM_USER_ID,
667
+ columns: { userEmailId: VICTIM_METHOD_ID },
668
+ }),
669
+ );
670
+
671
+ const rosterQuery: {
672
+ query: {
673
+ userId: ObjectID;
674
+ projectId: ObjectID;
675
+ hasAcceptedInvitation: boolean;
676
+ };
677
+ props: { isRoot: boolean };
678
+ } = stubs.teamMemberFindOneBy.mock.calls[0]![0] as {
679
+ query: {
680
+ userId: ObjectID;
681
+ projectId: ObjectID;
682
+ hasAcceptedInvitation: boolean;
683
+ };
684
+ props: { isRoot: boolean };
685
+ };
686
+
687
+ expect(rosterQuery.query.userId.toString()).toBe(
688
+ VICTIM_USER_ID.toString(),
689
+ );
690
+ /*
691
+ * props.tenantId, never a projectId out of the body. A body-supplied
692
+ * project would let the caller nominate the roster they wish to be
693
+ * checked against, which is the same as not checking.
694
+ */
695
+ expect(rosterQuery.query.projectId.toString()).toBe(
696
+ PROJECT_ID.toString(),
697
+ );
698
+ /*
699
+ * A pending invitation is not membership. Any admin can invite any email
700
+ * address, so counting an unaccepted invitation would hand back most of
701
+ * what this guard just took away.
702
+ */
703
+ expect(rosterQuery.query.hasAcceptedInvitation).toBe(true);
704
+ /*
705
+ * isRoot, because this is a question about the database's state. Asked
706
+ * through the caller's own scope, a caller who cannot READ the roster
707
+ * would get "no rows" and any lenient reading of that answer opens the
708
+ * door this closes.
709
+ */
710
+ expect(rosterQuery.props.isRoot).toBe(true);
711
+ });
712
+
713
+ test("the roster consulted is the session's project, not the project named in the body", async () => {
714
+ /*
715
+ * The body says the rule lives in PROJECT_ID while the session is scoped
716
+ * to OTHER_PROJECT_ID. Membership must be judged where the caller is
717
+ * actually acting, or an admin of a throwaway project could authorise
718
+ * themselves by writing somebody else's project id into the payload.
719
+ */
720
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
721
+
722
+ await expect(
723
+ ruleService().onBeforeCreate(
724
+ createPayload({
725
+ ownerUserId: VICTIM_USER_ID,
726
+ props: adminProps({ tenantId: OTHER_PROJECT_ID }),
727
+ columns: { userEmailId: VICTIM_METHOD_ID },
728
+ }),
729
+ ),
730
+ ).rejects.toThrow("are not a member of this project");
731
+
732
+ const rosterQuery: { query: { projectId: ObjectID } } = stubs
733
+ .teamMemberFindOneBy.mock.calls[0]![0] as {
734
+ query: { projectId: ObjectID };
735
+ };
736
+
737
+ expect(rosterQuery.query.projectId.toString()).toBe(
738
+ OTHER_PROJECT_ID.toString(),
739
+ );
740
+ });
741
+
742
+ test("a session with no tenant at all cannot write for another user", async () => {
743
+ await expect(
744
+ ruleService().onBeforeCreate(
745
+ createPayload({
746
+ ownerUserId: VICTIM_USER_ID,
747
+ props: adminProps({ tenantId: undefined }),
748
+ columns: { userEmailId: VICTIM_METHOD_ID },
749
+ }),
750
+ ),
751
+ ).rejects.toThrow(
752
+ "A project is required to create a notification rule for another user.",
753
+ );
754
+
755
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
756
+ });
757
+
758
+ test("configuring your own rules is not an administrative act and is not roster-checked", async () => {
759
+ stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
760
+ methodRow({
761
+ ownerUserId: ADMIN_USER_ID,
762
+ methodId: ADMIN_METHOD_ID,
763
+ }) as never,
764
+ );
765
+
766
+ await ruleService().onBeforeCreate(
767
+ createPayload({
768
+ ownerUserId: ADMIN_USER_ID,
769
+ columns: { userEmailId: ADMIN_METHOD_ID },
770
+ }),
771
+ );
772
+
773
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
774
+ });
775
+
776
+ test("an omitted owner column is a self-write, because the ownership stamp happens after this hook", async () => {
777
+ /*
778
+ * A first-party client has no reason to echo its own user id back, so the
779
+ * ordinary self-service create arrives with userId unset and
780
+ * CreatePermission stamps it later. Reading data.userId alone here would
781
+ * treat every one of those as an ownerless on-behalf-of write.
782
+ */
783
+ stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
784
+ methodRow({
785
+ ownerUserId: ADMIN_USER_ID,
786
+ methodId: ADMIN_METHOD_ID,
787
+ }) as never,
788
+ );
789
+
790
+ await ruleService().onBeforeCreate(
791
+ createPayload({
792
+ columns: { userEmailId: ADMIN_METHOD_ID },
793
+ }),
794
+ );
795
+
796
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
797
+ // ...and the method is still checked, against the actor as implied owner.
798
+ expect(stubs.methodFindOneById.get("userEmailId")).toHaveBeenCalled();
799
+ });
800
+
801
+ test("an opt-out row for another user is roster-checked too - silence is also a change to their paging", async () => {
802
+ /*
803
+ * An opt-out rule carries no method at all, so R3 has nothing to say
804
+ * about it. It still decides that a named human is NOT paged for a
805
+ * severity, which is the more dangerous half of the feature, so R1 has to
806
+ * cover it independently of whether a method is present.
807
+ */
808
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
809
+
810
+ await expect(
811
+ ruleService().onBeforeCreate(
812
+ createPayload({
813
+ ownerUserId: STRANGER_USER_ID,
814
+ isOptOut: true,
815
+ }),
816
+ ),
817
+ ).rejects.toThrow("are not a member of this project");
818
+ });
819
+
820
+ test("an actor-less caller - an API key holding the permission - is still roster-checked", async () => {
821
+ /*
822
+ * An API key is authenticated but is nobody: props.userId is undefined.
823
+ * That makes every write it does an on-behalf-of write by definition, and
824
+ * it must not be able to slip past the roster because the guard could not
825
+ * work out who it "really" is. A key scoped to one project is still only
826
+ * a claim about that project.
827
+ */
828
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
829
+
830
+ await expect(
831
+ ruleService().onBeforeCreate(
832
+ createPayload({
833
+ ownerUserId: STRANGER_USER_ID,
834
+ props: { tenantId: PROJECT_ID },
835
+ columns: { userEmailId: VICTIM_METHOD_ID },
836
+ }),
837
+ ),
838
+ ).rejects.toThrow("are not a member of this project");
839
+ });
840
+
841
+ test("root writes are exempt - internal seeding builds both halves from one user id", async () => {
842
+ await ruleService().onBeforeCreate(
843
+ createPayload({
844
+ ownerUserId: VICTIM_USER_ID,
845
+ props: { isRoot: true },
846
+ columns: { userEmailId: VICTIM_METHOD_ID },
847
+ }),
848
+ );
849
+
850
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
851
+ expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
852
+ });
853
+ });
854
+
855
+ /*
856
+ * ------------------------------------------------------------------
857
+ * R3 on create - a rule may only name methods its own owner owns.
858
+ * ------------------------------------------------------------------
859
+ */
860
+ describe("R3 on create: a rule's method must belong to the rule's user", () => {
861
+ test("the guard covers exactly the notification method columns the model declares", () => {
862
+ /*
863
+ * Derived from the model rather than restated, so that an eighth channel
864
+ * added to UserNotificationRule and forgotten in the guard fails HERE,
865
+ * loudly, instead of quietly becoming the one unlocked door.
866
+ */
867
+ const methodIdColumnPattern: RegExp = /^user[A-Z][A-Za-z]*Id$/;
868
+
869
+ const columnsOnModel: Array<string> = Object.keys(
870
+ new UserNotificationRule(),
871
+ ).filter((column: string): boolean => {
872
+ return methodIdColumnPattern.test(column);
873
+ });
874
+
875
+ // If the derivation itself ever breaks, fail rather than pass vacuously.
876
+ expect(columnsOnModel).toHaveLength(7);
877
+
878
+ expect(
879
+ [
880
+ ...UserNotificationRuleAdminService.getNotificationMethodIdColumns(),
881
+ ].sort(),
882
+ ).toEqual([...columnsOnModel].sort());
883
+ expect(
884
+ [
885
+ ...CHANNELS.map((c: ChannelFixture): string => {
886
+ return c.idColumn;
887
+ }),
888
+ ].sort(),
889
+ ).toEqual([...columnsOnModel].sort());
890
+ });
891
+
892
+ /*
893
+ * forEach rather than a bare for-of: the per-channel tests close over the
894
+ * `stubs` that beforeEach rebuilds, and a function declared directly inside
895
+ * a loop statement capturing a reassigned binding is exactly what
896
+ * no-loop-func exists to catch. The callback gives each channel its own
897
+ * scope and says so.
898
+ */
899
+ CHANNELS.forEach((channel: ChannelFixture): void => {
900
+ test(`${channel.label}: a rule owned by one user may not point at another user's ${channel.idColumn}, even inside one project`, async () => {
901
+ /*
902
+ * Both rows live in PROJECT_ID. That is the point of the fixture:
903
+ * project scoping is the check everyone assumes already covers this,
904
+ * and it does not - the admin and the responder they are hijacking are
905
+ * colleagues in the same project by construction.
906
+ */
907
+ stubs.methodFindOneById.get(channel.idColumn)!.mockResolvedValue(
908
+ methodRow({
909
+ ownerUserId: ADMIN_USER_ID,
910
+ methodId: ADMIN_METHOD_ID,
911
+ }) as never,
912
+ );
913
+
914
+ await expect(
915
+ ruleService().onBeforeCreate(
916
+ createPayload({
917
+ ownerUserId: VICTIM_USER_ID,
918
+ columns: { [channel.idColumn]: ADMIN_METHOD_ID },
919
+ }),
920
+ ),
921
+ ).rejects.toThrow(
922
+ `The ${channel.label} notification method referenced by this rule belongs to a different user.`,
923
+ );
924
+ });
925
+
926
+ test(`${channel.label}: the ${channel.relationColumn} relation slot is the same write and is refused the same way`, async () => {
927
+ /*
928
+ * The REST layer accepts a relation object as well as a bare FK, so a
929
+ * guard that only reads the id column is bypassed by sending the other
930
+ * spelling of the identical write.
931
+ */
932
+ stubs.methodFindOneById.get(channel.idColumn)!.mockResolvedValue(
933
+ methodRow({
934
+ ownerUserId: ADMIN_USER_ID,
935
+ methodId: ADMIN_METHOD_ID,
936
+ }) as never,
937
+ );
938
+
939
+ await expect(
940
+ ruleService().onBeforeCreate(
941
+ createPayload({
942
+ ownerUserId: VICTIM_USER_ID,
943
+ columns: {
944
+ [channel.relationColumn]: {
945
+ _id: ADMIN_METHOD_ID.toString(),
946
+ },
947
+ },
948
+ }),
949
+ ),
950
+ ).rejects.toThrow(
951
+ `The ${channel.label} notification method referenced by this rule belongs to a different user.`,
952
+ );
953
+ });
954
+
955
+ test(`${channel.label}: a rule pointing at its own owner's ${channel.idColumn} is written`, async () => {
956
+ await ruleService().onBeforeCreate(
957
+ createPayload({
958
+ ownerUserId: VICTIM_USER_ID,
959
+ columns: { [channel.idColumn]: VICTIM_METHOD_ID },
960
+ }),
961
+ );
962
+
963
+ const lookedUpId: ObjectID = stubs.methodFindOneById.get(
964
+ channel.idColumn,
965
+ )!.mock.calls[0]![0].id as ObjectID;
966
+
967
+ expect(lookedUpId.toString()).toBe(VICTIM_METHOD_ID.toString());
968
+ });
969
+ });
970
+
971
+ test("a method id that matches no row is refused rather than waved through", async () => {
972
+ /*
973
+ * "Not found" and "not yours" are the same answer here - in both cases
974
+ * the caller named a row they have no business naming - and a guard whose
975
+ * unknown case resolves to `undefined === undefined` opens on exactly the
976
+ * input it was written to reject.
977
+ */
978
+ stubs.methodFindOneById
979
+ .get("userWebhookId")!
980
+ .mockResolvedValue(null as never);
981
+
982
+ await expect(
983
+ ruleService().onBeforeCreate(
984
+ createPayload({
985
+ ownerUserId: VICTIM_USER_ID,
986
+ columns: { userWebhookId: ADMIN_METHOD_ID },
987
+ }),
988
+ ),
989
+ ).rejects.toThrow(
990
+ "The Webhook notification method referenced by this rule does not exist.",
991
+ );
992
+ });
993
+
994
+ test("the method's owner is read with root props, so the caller's own visibility cannot shape the answer", async () => {
995
+ await ruleService().onBeforeCreate(
996
+ createPayload({
997
+ ownerUserId: VICTIM_USER_ID,
998
+ columns: { userEmailId: VICTIM_METHOD_ID },
999
+ }),
1000
+ );
1001
+
1002
+ const lookup: { props: { isRoot: boolean } } =
1003
+ stubs.methodFindOneById.get("userEmailId")!.mock.calls[0]![0] as {
1004
+ props: { isRoot: boolean };
1005
+ };
1006
+
1007
+ expect(lookup.props.isRoot).toBe(true);
1008
+ });
1009
+
1010
+ test("the mirror hijack is refused too: my own rule may not deliver to your address", async () => {
1011
+ /*
1012
+ * userId = me, userEmailId = yours. It steals nobody's pages; it COPIES
1013
+ * mine to an address I do not own, which is how you page-bomb a colleague
1014
+ * or exfiltrate incident titles. It was writable long before this phase
1015
+ * widened anything, and it is the same comparison, so it is refused by
1016
+ * the same guard.
1017
+ */
1018
+ stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
1019
+ methodRow({
1020
+ ownerUserId: VICTIM_USER_ID,
1021
+ methodId: VICTIM_METHOD_ID,
1022
+ }) as never,
1023
+ );
1024
+
1025
+ await expect(
1026
+ ruleService().onBeforeCreate(
1027
+ createPayload({
1028
+ ownerUserId: ADMIN_USER_ID,
1029
+ columns: { userEmailId: VICTIM_METHOD_ID },
1030
+ }),
1031
+ ),
1032
+ ).rejects.toThrow(
1033
+ "The Email notification method referenced by this rule belongs to a different user.",
1034
+ );
1035
+ });
1036
+
1037
+ test("naming one method in the FK column and a DIFFERENT one in the relation slot does not smuggle the second one past the guard", async () => {
1038
+ /*
1039
+ * KNOWN FAILURE - a real bypass of R3, not a test artefact. Read this
1040
+ * before "fixing" the test.
1041
+ *
1042
+ * `userEmailId` and `userEmail` are two spellings of ONE database column,
1043
+ * and a request may carry both. collectNotificationMethodReferences
1044
+ * resolves them with `carrier[idColumn] || carrier[relationColumn]`, so
1045
+ * when both are present it validates the FK COLUMN and ignores the
1046
+ * relation.
1047
+ *
1048
+ * TypeORM resolves the same conflict the other way round. The declared
1049
+ * @Column("userEmailId") and the @JoinColumn of the userEmail relation
1050
+ * share one ColumnMetadata (RelationJoinColumnBuilder attaches
1051
+ * relationMetadata to the column the model already declares), and
1052
+ * ColumnMetadata.getEntityValue reads the RELATION first, falling back to
1053
+ * the scalar property only when the relation slot holds no object.
1054
+ *
1055
+ * So the id that is validated and the id that is written are different
1056
+ * ids, and the caller chooses both. Send the rule owner's own method in
1057
+ * userEmailId to satisfy the guard, and your own row in userEmail to be
1058
+ * persisted. Same for update: a patch carrying a relation object takes
1059
+ * DatabaseService's `hasRelationUpdates` branch and is written with
1060
+ * save(), which uses the same precedence.
1061
+ *
1062
+ * The delivery-time check in executeNotificationRuleItem is what stops
1063
+ * this being a live page-redirection - it compares the LOADED relation's
1064
+ * owner and refuses - so the blast radius today is a rule that silently
1065
+ * fails to page rather than one that pages the wrong person. That is a
1066
+ * backstop, not the guard, and R3 is the guard.
1067
+ *
1068
+ * The fix is to validate every method reference the payload carries
1069
+ * rather than the first one found: collect both spellings, and refuse a
1070
+ * payload whose two spellings disagree.
1071
+ */
1072
+ stubs.methodFindOneById
1073
+ .get("userEmailId")!
1074
+ .mockImplementation(
1075
+ (findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
1076
+ return Promise.resolve(
1077
+ methodRow({
1078
+ ownerUserId:
1079
+ findBy.id.toString() === ADMIN_METHOD_ID.toString()
1080
+ ? ADMIN_USER_ID
1081
+ : VICTIM_USER_ID,
1082
+ methodId: findBy.id,
1083
+ }),
1084
+ );
1085
+ },
1086
+ );
1087
+
1088
+ await expect(
1089
+ ruleService().onBeforeCreate(
1090
+ createPayload({
1091
+ ownerUserId: VICTIM_USER_ID,
1092
+ columns: {
1093
+ // Innocent, owned by the rule's owner, and the only one checked.
1094
+ userEmailId: VICTIM_METHOD_ID,
1095
+ // The attacker's own row, and the one that reaches the database.
1096
+ userEmail: { _id: ADMIN_METHOD_ID.toString() },
1097
+ },
1098
+ }),
1099
+ ),
1100
+ ).rejects.toThrow(
1101
+ "The Email notification method referenced by this rule belongs to a different user.",
1102
+ );
1103
+ });
1104
+
1105
+ test("an actor-less caller gets no benefit of the doubt about method ownership either", async () => {
1106
+ /*
1107
+ * The companion to the roster test above. An API key has no user id to
1108
+ * compare a method's owner against, so a guard that resolved the rule's
1109
+ * owner from the SESSION rather than from the payload would have nothing
1110
+ * to check and would let this through.
1111
+ */
1112
+ stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
1113
+ methodRow({
1114
+ ownerUserId: ADMIN_USER_ID,
1115
+ methodId: ADMIN_METHOD_ID,
1116
+ }) as never,
1117
+ );
1118
+
1119
+ await expect(
1120
+ ruleService().onBeforeCreate(
1121
+ createPayload({
1122
+ ownerUserId: VICTIM_USER_ID,
1123
+ props: { tenantId: PROJECT_ID },
1124
+ columns: { userEmailId: ADMIN_METHOD_ID },
1125
+ }),
1126
+ ),
1127
+ ).rejects.toThrow(
1128
+ "The Email notification method referenced by this rule belongs to a different user.",
1129
+ );
1130
+ });
1131
+
1132
+ test("a rule naming several methods is refused when any single one of them is foreign", async () => {
1133
+ stubs.methodFindOneById.get("userPushId")!.mockResolvedValue(
1134
+ methodRow({
1135
+ ownerUserId: ADMIN_USER_ID,
1136
+ methodId: ADMIN_METHOD_ID,
1137
+ }) as never,
1138
+ );
1139
+
1140
+ await expect(
1141
+ ruleService().onBeforeCreate(
1142
+ createPayload({
1143
+ ownerUserId: VICTIM_USER_ID,
1144
+ columns: {
1145
+ userEmailId: VICTIM_METHOD_ID,
1146
+ userSmsId: VICTIM_METHOD_ID,
1147
+ userPushId: ADMIN_METHOD_ID,
1148
+ },
1149
+ }),
1150
+ ),
1151
+ ).rejects.toThrow(
1152
+ "The Push notification method referenced by this rule belongs to a different user.",
1153
+ );
1154
+ });
1155
+ });
1156
+
1157
+ /*
1158
+ * ------------------------------------------------------------------
1159
+ * R3 on update - the owner is whatever the DATABASE says it is.
1160
+ * ------------------------------------------------------------------
1161
+ */
1162
+ describe("R3 on update: the rule's owner is re-read from the database", () => {
1163
+ test("an admin repointing a victim's rule at the admin's own webhook is refused", async () => {
1164
+ stubs.ruleFindBy.mockResolvedValue([
1165
+ persistedRule({ userId: VICTIM_USER_ID }),
1166
+ ] as never);
1167
+ stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
1168
+ methodRow({
1169
+ ownerUserId: ADMIN_USER_ID,
1170
+ methodId: ADMIN_METHOD_ID,
1171
+ }) as never,
1172
+ );
1173
+
1174
+ await expect(
1175
+ ruleService().onBeforeUpdate(
1176
+ updatePayload({ patch: { userWebhookId: ADMIN_METHOD_ID } }),
1177
+ ),
1178
+ ).rejects.toThrow(
1179
+ "The Webhook notification method referenced by this rule belongs to a different user.",
1180
+ );
1181
+ });
1182
+
1183
+ test("a body that CLAIMS the rule belongs to the caller does not change the answer", async () => {
1184
+ /*
1185
+ * The load-bearing test of the update path. The caller writes the body,
1186
+ * so a userId in it is a claim made by exactly the party the guard exists
1187
+ * to doubt: "this row is mine, so pointing it at my webhook is fine". The
1188
+ * database says otherwise, and the database is what decides whose pages
1189
+ * select the row.
1190
+ */
1191
+ stubs.ruleFindBy.mockResolvedValue([
1192
+ persistedRule({ userId: VICTIM_USER_ID }),
1193
+ ] as never);
1194
+ stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
1195
+ methodRow({
1196
+ ownerUserId: ADMIN_USER_ID,
1197
+ methodId: ADMIN_METHOD_ID,
1198
+ }) as never,
1199
+ );
1200
+
1201
+ await expect(
1202
+ ruleService().onBeforeUpdate(
1203
+ updatePayload({
1204
+ patch: {
1205
+ userId: ADMIN_USER_ID,
1206
+ userWebhookId: ADMIN_METHOD_ID,
1207
+ },
1208
+ }),
1209
+ ),
1210
+ ).rejects.toThrow(
1211
+ "The Webhook notification method referenced by this rule belongs to a different user.",
1212
+ );
1213
+ });
1214
+
1215
+ test("the database's answer is authoritative in BOTH directions, so the guard is not merely refusing every mismatched body", async () => {
1216
+ /*
1217
+ * The converse of the test above, and the one that proves the mechanism
1218
+ * rather than the outcome. Here the row really IS the caller's own and
1219
+ * the body lies in the other direction, naming the victim. A guard that
1220
+ * compared the body's userId against the method's owner would refuse this
1221
+ * legitimate self-edit; one that re-reads the row allows it.
1222
+ */
1223
+ stubs.ruleFindBy.mockResolvedValue([
1224
+ persistedRule({ userId: ADMIN_USER_ID }),
1225
+ ] as never);
1226
+ stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
1227
+ methodRow({
1228
+ ownerUserId: ADMIN_USER_ID,
1229
+ methodId: ADMIN_METHOD_ID,
1230
+ }) as never,
1231
+ );
1232
+
1233
+ const result: OnUpdate<UserNotificationRule> =
1234
+ await ruleService().onBeforeUpdate(
1235
+ updatePayload({
1236
+ patch: {
1237
+ userId: VICTIM_USER_ID,
1238
+ userWebhookId: ADMIN_METHOD_ID,
1239
+ },
1240
+ }),
1241
+ );
1242
+
1243
+ expect(result.carryForward.affectedRules).toHaveLength(1);
1244
+ });
1245
+
1246
+ test("an admin repointing a victim's rule at the VICTIM's own method is allowed - this is the repair the phase exists for", async () => {
1247
+ stubs.ruleFindBy.mockResolvedValue([
1248
+ persistedRule({ userId: VICTIM_USER_ID }),
1249
+ ] as never);
1250
+ stubs.methodFindOneById.get("userSmsId")!.mockResolvedValue(
1251
+ methodRow({
1252
+ ownerUserId: VICTIM_USER_ID,
1253
+ methodId: VICTIM_METHOD_ID,
1254
+ }) as never,
1255
+ );
1256
+
1257
+ const result: OnUpdate<UserNotificationRule> =
1258
+ await ruleService().onBeforeUpdate(
1259
+ updatePayload({ patch: { userSmsId: VICTIM_METHOD_ID } }),
1260
+ );
1261
+
1262
+ expect(result.updateBy.data).toEqual({ userSmsId: VICTIM_METHOD_ID });
1263
+ });
1264
+
1265
+ test("the relation slot is checked on update as well as the FK column", async () => {
1266
+ stubs.ruleFindBy.mockResolvedValue([
1267
+ persistedRule({ userId: VICTIM_USER_ID }),
1268
+ ] as never);
1269
+ stubs.methodFindOneById.get("userTelegramId")!.mockResolvedValue(
1270
+ methodRow({
1271
+ ownerUserId: ADMIN_USER_ID,
1272
+ methodId: ADMIN_METHOD_ID,
1273
+ }) as never,
1274
+ );
1275
+
1276
+ await expect(
1277
+ ruleService().onBeforeUpdate(
1278
+ updatePayload({
1279
+ patch: {
1280
+ userTelegram: { _id: ADMIN_METHOD_ID.toString() },
1281
+ },
1282
+ }),
1283
+ ),
1284
+ ).rejects.toThrow(
1285
+ "The Telegram notification method referenced by this rule belongs to a different user.",
1286
+ );
1287
+ });
1288
+
1289
+ test("the affected rows are read with root props, so a caller who cannot see a row cannot edit it unchecked", async () => {
1290
+ /*
1291
+ * Scoping this read to the caller's READ permission would be the subtlest
1292
+ * possible hole: a caller who cannot read the row gets an empty result,
1293
+ * the validation loop has nothing to iterate, and the write sails through
1294
+ * unvalidated. Read permission and write permission are different lists,
1295
+ * and it is the write one this hook answers for.
1296
+ *
1297
+ * Note the division of labour with the test below: root props remove the
1298
+ * caller's VISIBILITY from the answer, and the query narrowing removes
1299
+ * their lack of ENTITLEMENT from it. Neither substitutes for the other.
1300
+ */
1301
+ await ruleService().onBeforeUpdate(
1302
+ updatePayload({ patch: { userEmailId: VICTIM_METHOD_ID } }),
1303
+ );
1304
+
1305
+ const findByArgs: {
1306
+ query: Record<string, unknown>;
1307
+ props: { isRoot: boolean; ignoreHooks: boolean };
1308
+ } = stubs.ruleFindBy.mock.calls[0]![0] as {
1309
+ query: Record<string, unknown>;
1310
+ props: { isRoot: boolean; ignoreHooks: boolean };
1311
+ };
1312
+
1313
+ expect(findByArgs.props.isRoot).toBe(true);
1314
+ expect(findByArgs.query["_id"]).toBe(RULE_ID.toString());
1315
+ });
1316
+
1317
+ test("every distinct owner in a bulk update is validated, not just the first", async () => {
1318
+ /*
1319
+ * A query that matches rules belonging to two people is one request that
1320
+ * changes two people's paging. Validating only the first row would make
1321
+ * "match my own rule plus yours" a one-line bypass.
1322
+ */
1323
+ stubs.ruleFindBy.mockResolvedValue([
1324
+ persistedRule({ userId: VICTIM_USER_ID }),
1325
+ persistedRule({
1326
+ id: SECOND_RULE_ID,
1327
+ _id: SECOND_RULE_ID.toString(),
1328
+ userId: STRANGER_USER_ID,
1329
+ }),
1330
+ ] as never);
1331
+ stubs.methodFindOneById.get("userCallId")!.mockResolvedValue(
1332
+ methodRow({
1333
+ ownerUserId: VICTIM_USER_ID,
1334
+ methodId: VICTIM_METHOD_ID,
1335
+ }) as never,
1336
+ );
1337
+
1338
+ await expect(
1339
+ ruleService().onBeforeUpdate(
1340
+ updatePayload({ patch: { userCallId: VICTIM_METHOD_ID } }),
1341
+ ),
1342
+ ).rejects.toThrow(
1343
+ "The Call notification method referenced by this rule belongs to a different user.",
1344
+ );
1345
+ });
1346
+
1347
+ test("a patch carrying both spellings of one method column is refused when they disagree", async () => {
1348
+ /*
1349
+ * KNOWN FAILURE, and the same bypass as its create-path twin above - see
1350
+ * the long note there for the ORM precedence that makes the unvalidated
1351
+ * spelling the one that is written.
1352
+ *
1353
+ * It is repeated here because update is the verb an admin actually uses
1354
+ * to "repair" a colleague's rules, and because the update path reaches
1355
+ * the same precedence by a different road: an Entity (many-to-one)
1356
+ * column does not take DatabaseService's save() branch - only
1357
+ * EntityArray columns do - so this goes through repository.update(),
1358
+ * where UpdateQueryBuilder.createUpdateExpression maps both keys onto
1359
+ * the one shared ColumnMetadata, dedupes them, and computes the value
1360
+ * with that same relation-first getEntityValue.
1361
+ */
1362
+ stubs.ruleFindBy.mockResolvedValue([
1363
+ persistedRule({ userId: VICTIM_USER_ID }),
1364
+ ] as never);
1365
+ stubs.methodFindOneById
1366
+ .get("userWebhookId")!
1367
+ .mockImplementation(
1368
+ (findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
1369
+ return Promise.resolve(
1370
+ methodRow({
1371
+ ownerUserId:
1372
+ findBy.id.toString() === ADMIN_METHOD_ID.toString()
1373
+ ? ADMIN_USER_ID
1374
+ : VICTIM_USER_ID,
1375
+ methodId: findBy.id,
1376
+ }),
1377
+ );
1378
+ },
1379
+ );
1380
+
1381
+ await expect(
1382
+ ruleService().onBeforeUpdate(
1383
+ updatePayload({
1384
+ patch: {
1385
+ userWebhookId: VICTIM_METHOD_ID,
1386
+ userWebhook: { _id: ADMIN_METHOD_ID.toString() },
1387
+ },
1388
+ }),
1389
+ ),
1390
+ ).rejects.toThrow(
1391
+ "The Webhook notification method referenced by this rule belongs to a different user.",
1392
+ );
1393
+ });
1394
+
1395
+ test("an actor-less update is validated too - the guard is not gated on there being a user to blame", async () => {
1396
+ /*
1397
+ * The audit half of this phase legitimately gives up when there is no
1398
+ * actor to name, and it would be an easy mistake to give up on the
1399
+ * SECURITY half in the same breath. An API key repointing somebody's rule
1400
+ * at a foreign method is the same hijack with a less attributable caller,
1401
+ * which makes it worse rather than exempt.
1402
+ */
1403
+ stubs.ruleFindBy.mockResolvedValue([
1404
+ persistedRule({ userId: VICTIM_USER_ID }),
1405
+ ] as never);
1406
+ stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
1407
+ methodRow({
1408
+ ownerUserId: ADMIN_USER_ID,
1409
+ methodId: ADMIN_METHOD_ID,
1410
+ }) as never,
1411
+ );
1412
+
1413
+ await expect(
1414
+ ruleService().onBeforeUpdate(
1415
+ updatePayload({
1416
+ patch: { userWebhookId: ADMIN_METHOD_ID },
1417
+ props: { tenantId: PROJECT_ID },
1418
+ }),
1419
+ ),
1420
+ ).rejects.toThrow(
1421
+ "The Webhook notification method referenced by this rule belongs to a different user.",
1422
+ );
1423
+ });
1424
+
1425
+ test("an update that names no method at all does no ownership lookups", async () => {
1426
+ await ruleService().onBeforeUpdate(
1427
+ updatePayload({ patch: { notifyAfterMinutes: 15 } }),
1428
+ );
1429
+
1430
+ for (const channel of CHANNELS) {
1431
+ expect(
1432
+ stubs.methodFindOneById.get(channel.idColumn),
1433
+ ).not.toHaveBeenCalled();
1434
+ }
1435
+ });
1436
+ });
1437
+
1438
+ /*
1439
+ * ------------------------------------------------------------------
1440
+ * The OWNERSHIP column has the same two spellings as every method column,
1441
+ * and it is the more dangerous of the two to get wrong: a method read from
1442
+ * the wrong spelling sends a page to the wrong address, while an owner read
1443
+ * from the wrong spelling means every check in this file - the roster check,
1444
+ * the method-ownership check, the audit line - was answered about a person
1445
+ * the row does not belong to.
1446
+ * ------------------------------------------------------------------
1447
+ */
1448
+ describe("the rule's owner may be spelled once, not twice", () => {
1449
+ test("an owner named ONLY in the user relation is the owner the roster check is asked about", async () => {
1450
+ /*
1451
+ * THE BYPASS THIS CLOSES, in one request.
1452
+ *
1453
+ * `userId` and `user` are two decorated members over one join column.
1454
+ * The guard used to resolve the owner as `data.userId || props.userId`
1455
+ * and never look at the relation, so a payload that spells the owner
1456
+ * ONLY as `user: { _id: <stranger> }` left the scalar empty, fell
1457
+ * through to the ACTOR, and was validated as an ordinary self-write -
1458
+ * no roster check, and the method check run against the wrong person -
1459
+ * while TypeORM wrote the relation's id and the row belonged to the
1460
+ * stranger.
1461
+ */
1462
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
1463
+
1464
+ await expect(
1465
+ ruleService().onBeforeCreate(
1466
+ createPayload({
1467
+ columns: {
1468
+ user: { _id: STRANGER_USER_ID.toString() },
1469
+ userEmailId: VICTIM_METHOD_ID,
1470
+ },
1471
+ }),
1472
+ ),
1473
+ ).rejects.toThrow(
1474
+ `Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
1475
+ );
1476
+ });
1477
+
1478
+ test("an owner named only in the relation is also the owner R3 compares methods against", async () => {
1479
+ /*
1480
+ * The other half of the same bypass. With the owner resolved from the
1481
+ * actor rather than from the relation, "userId = the victim, email = my
1482
+ * own" reads as a perfectly ordinary self-write and the anti-hijack
1483
+ * check has nothing to object to.
1484
+ */
1485
+ stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
1486
+ methodRow({
1487
+ ownerUserId: ADMIN_USER_ID,
1488
+ methodId: ADMIN_METHOD_ID,
1489
+ }) as never,
1490
+ );
1491
+
1492
+ await expect(
1493
+ ruleService().onBeforeCreate(
1494
+ createPayload({
1495
+ columns: {
1496
+ user: { _id: VICTIM_USER_ID.toString() },
1497
+ userEmailId: ADMIN_METHOD_ID,
1498
+ },
1499
+ }),
1500
+ ),
1501
+ ).rejects.toThrow(
1502
+ "The Email notification method referenced by this rule belongs to a different user.",
1503
+ );
1504
+ });
1505
+
1506
+ test("a hydrated User entity in the relation slot is read the same way as a bare _id", async () => {
1507
+ /*
1508
+ * DatabaseService.sanitizeCreateOrUpdate turns the relation slot into a
1509
+ * real model instance before the write, so the guard must not be reading
1510
+ * a plain-object shape only. ObjectID lives on `id`; `_id` is the string
1511
+ * beneath it, and resolveReferenceId has to find the owner through
1512
+ * either.
1513
+ */
1514
+ const owner: User = new User();
1515
+ owner.id = STRANGER_USER_ID;
1516
+
1517
+ stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
1518
+
1519
+ await expect(
1520
+ ruleService().onBeforeCreate(
1521
+ createPayload({
1522
+ columns: {
1523
+ user: owner,
1524
+ userEmailId: VICTIM_METHOD_ID,
1525
+ },
1526
+ }),
1527
+ ),
1528
+ ).rejects.toThrow(
1529
+ `Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
1530
+ );
1531
+ });
1532
+
1533
+ test("two spellings naming two different users are refused rather than silently reconciled", async () => {
1534
+ /*
1535
+ * Neither half of the guard can catch this on its own: the scalar names
1536
+ * a roster member whose own method is being pointed at, so the roster
1537
+ * check and the method check both pass on the value they were shown.
1538
+ * Which user the row ends up belonging to would be decided by a TypeORM
1539
+ * precedence rule that no reader of this codebase should have to know
1540
+ * and that an upgrade is free to change - so the contradiction is
1541
+ * refused in its own right, and the correctness of the guards behind it
1542
+ * never rests on our reading of that rule.
1543
+ */
1544
+ await expect(
1545
+ ruleService().onBeforeCreate(
1546
+ createPayload({
1547
+ ownerUserId: VICTIM_USER_ID,
1548
+ columns: {
1549
+ user: { _id: STRANGER_USER_ID.toString() },
1550
+ userEmailId: VICTIM_METHOD_ID,
1551
+ },
1552
+ }),
1553
+ ),
1554
+ ).rejects.toThrow(
1555
+ `This notification rule names two different users: userId says ${VICTIM_USER_ID.toString()} and user says ${STRANGER_USER_ID.toString()}.`,
1556
+ );
1557
+
1558
+ // And it is refused before anything is looked up on the strength of it.
1559
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
1560
+ expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
1561
+ });
1562
+
1563
+ test("the same user in both spellings is not a contradiction and is accepted", async () => {
1564
+ await expect(
1565
+ ruleService().onBeforeCreate(
1566
+ createPayload({
1567
+ ownerUserId: VICTIM_USER_ID,
1568
+ columns: {
1569
+ user: { _id: VICTIM_USER_ID.toString() },
1570
+ userEmailId: VICTIM_METHOD_ID,
1571
+ },
1572
+ }),
1573
+ ),
1574
+ ).resolves.toBeDefined();
1575
+ });
1576
+
1577
+ test("on create the user relation is folded into userId, so the ORM is left with nothing to choose between", async () => {
1578
+ /*
1579
+ * The same discipline as CreatePermission.checkCreateOwnership, which
1580
+ * validates the `user` relation identically to the `userId` scalar and
1581
+ * then clears it. What CreatePermission cannot do is cover the
1582
+ * administrator - it returns early for a caller holding a real role
1583
+ * permission in the model's create list, which is exactly the caller
1584
+ * this phase introduced - so the fold has to happen here as well, before
1585
+ * anything downstream has to re-derive which of two sources of truth
1586
+ * actually reaches the database.
1587
+ */
1588
+ const payload: CreateBy<UserNotificationRule> = createPayload({
1589
+ props: projectAdminProps(),
1590
+ columns: {
1591
+ user: { _id: VICTIM_USER_ID.toString() },
1592
+ userEmailId: VICTIM_METHOD_ID,
1593
+ },
1594
+ });
1595
+
1596
+ await ruleService().onBeforeCreate(payload);
1597
+
1598
+ expect(payload.data.userId?.toString()).toBe(VICTIM_USER_ID.toString());
1599
+ expect(payload.data.user).toBeUndefined();
1600
+ });
1601
+
1602
+ test("a relation slot carrying no id is the NULL the ORM would write, and the scalar stops being credited", async () => {
1603
+ /*
1604
+ * `user: {}` is an object, so TypeORM prefers it over the scalar and
1605
+ * writes NULL into the join column. A guard that kept reading the scalar
1606
+ * would validate the whole row against a user who does not own it - here
1607
+ * it would happily accept the victim's email address onto a row that
1608
+ * lands unowned. Folding reproduces the ORM's answer, the owner falls
1609
+ * back to the actor, and the method check sees the mismatch for what it
1610
+ * is.
1611
+ */
1612
+ const payload: CreateBy<UserNotificationRule> = createPayload({
1613
+ ownerUserId: VICTIM_USER_ID,
1614
+ columns: {
1615
+ user: {},
1616
+ userEmailId: VICTIM_METHOD_ID,
1617
+ },
1618
+ });
1619
+
1620
+ await expect(ruleService().onBeforeCreate(payload)).rejects.toThrow(
1621
+ "The Email notification method referenced by this rule belongs to a different user.",
1622
+ );
1623
+
1624
+ expect(payload.data.userId).toBeUndefined();
1625
+ });
1626
+
1627
+ test("root writes are reduced too - an ambiguous row is ambiguous whoever wrote it", async () => {
1628
+ /*
1629
+ * The reduction is a statement about the payload, not a permission
1630
+ * decision, so it sits in front of the root short-circuit that exempts
1631
+ * internal writes from R1 and R3. No seeder sends both spellings; if one
1632
+ * ever starts, it should find out here rather than in the delivery log.
1633
+ */
1634
+ const payload: CreateBy<UserNotificationRule> = createPayload({
1635
+ props: { isRoot: true },
1636
+ columns: {
1637
+ user: { _id: VICTIM_USER_ID.toString() },
1638
+ userEmailId: VICTIM_METHOD_ID,
1639
+ },
1640
+ });
1641
+
1642
+ await ruleService().onBeforeCreate(payload);
1643
+
1644
+ expect(payload.data.userId?.toString()).toBe(VICTIM_USER_ID.toString());
1645
+ expect(payload.data.user).toBeUndefined();
1646
+ // ...and still without paying for the guards root is exempt from.
1647
+ expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
1648
+ expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
1649
+ });
1650
+ });
1651
+
1652
+ /*
1653
+ * ------------------------------------------------------------------
1654
+ * One column, one spelling - the ambiguity that made R3 bypassable is
1655
+ * refused on the way in and folded away where it safely can be.
1656
+ * ------------------------------------------------------------------
1657
+ */
1658
+ describe("a method column may be spelled once, not twice", () => {
1659
+ test("two spellings naming two different methods are refused even when BOTH belong to the rule's owner", async () => {
1660
+ /*
1661
+ * The ownership check cannot catch this one - both ids pass it - and that
1662
+ * is exactly why the ambiguity has to be refused in its own right. Which
1663
+ * address the rule ends up delivering to would otherwise be decided by a
1664
+ * TypeORM precedence rule (relation slot first, scalar as fallback) that
1665
+ * no reader of this codebase should have to know and that a TypeORM
1666
+ * upgrade is free to change.
1667
+ */
1668
+ stubs.methodFindOneById
1669
+ .get("userEmailId")!
1670
+ .mockImplementation(
1671
+ (findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
1672
+ return Promise.resolve(
1673
+ methodRow({
1674
+ ownerUserId: VICTIM_USER_ID,
1675
+ methodId: findBy.id,
1676
+ }),
1677
+ );
1678
+ },
1679
+ );
1680
+
1681
+ await expect(
1682
+ ruleService().onBeforeCreate(
1683
+ createPayload({
1684
+ ownerUserId: VICTIM_USER_ID,
1685
+ columns: {
1686
+ userEmailId: VICTIM_METHOD_ID,
1687
+ userEmail: { _id: ADMIN_METHOD_ID.toString() },
1688
+ },
1689
+ }),
1690
+ ),
1691
+ ).rejects.toThrow(
1692
+ "This notification rule names two different Email notification methods",
1693
+ );
1694
+ });
1695
+
1696
+ test("the refusal names WHICH spelling carried the offending id", async () => {
1697
+ /*
1698
+ * Both spellings are one column, so "your Email method belongs to
1699
+ * somebody else" is unactionable to a caller looking at a payload whose
1700
+ * `userEmailId` is perfectly correct. Naming the slot is the difference
1701
+ * between a message that ends the investigation and one that starts it.
1702
+ */
1703
+ stubs.methodFindOneById
1704
+ .get("userEmailId")!
1705
+ .mockImplementation(
1706
+ (findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
1707
+ return Promise.resolve(
1708
+ methodRow({
1709
+ ownerUserId:
1710
+ findBy.id.toString() === ADMIN_METHOD_ID.toString()
1711
+ ? ADMIN_USER_ID
1712
+ : VICTIM_USER_ID,
1713
+ methodId: findBy.id,
1714
+ }),
1715
+ );
1716
+ },
1717
+ );
1718
+
1719
+ await expect(
1720
+ ruleService().onBeforeCreate(
1721
+ createPayload({
1722
+ ownerUserId: VICTIM_USER_ID,
1723
+ columns: {
1724
+ userEmailId: VICTIM_METHOD_ID,
1725
+ userEmail: { _id: ADMIN_METHOD_ID.toString() },
1726
+ },
1727
+ }),
1728
+ ),
1729
+ ).rejects.toThrow("The offending value was sent in userEmail.");
1730
+ });
1731
+
1732
+ test("the same method in both spellings is not ambiguous and is accepted", async () => {
1733
+ const payload: CreateBy<UserNotificationRule> = createPayload({
1734
+ ownerUserId: VICTIM_USER_ID,
1735
+ columns: {
1736
+ userEmailId: VICTIM_METHOD_ID,
1737
+ userEmail: { _id: VICTIM_METHOD_ID.toString() },
1738
+ },
1739
+ });
1740
+
1741
+ await expect(
1742
+ ruleService().onBeforeCreate(payload),
1743
+ ).resolves.toBeDefined();
1744
+ });
1745
+
1746
+ test("on create the relation slot is folded into the FK column, so the ORM is left with nothing to choose between", async () => {
1747
+ /*
1748
+ * The same discipline as CreatePermission.checkCreateOwnership, which
1749
+ * validates the `user` relation identically to the `userId` scalar and
1750
+ * then clears it. Checking both spellings closes the hijack; clearing one
1751
+ * of them is what stops a future reader having to re-derive which of two
1752
+ * agreeing sources of truth actually reaches the database.
1753
+ */
1754
+ const payload: CreateBy<UserNotificationRule> = createPayload({
1755
+ ownerUserId: VICTIM_USER_ID,
1756
+ columns: { userEmail: { _id: VICTIM_METHOD_ID.toString() } },
1757
+ });
1758
+
1759
+ await ruleService().onBeforeCreate(payload);
1760
+
1761
+ expect(payload.data.userEmailId?.toString()).toBe(
1762
+ VICTIM_METHOD_ID.toString(),
1763
+ );
1764
+ expect(payload.data.userEmail).toBeUndefined();
1765
+ });
1766
+
1767
+ test("a relation slot carrying no id is the NULL the ORM would write, and a rule left with no method at all is refused", async () => {
1768
+ /*
1769
+ * `userEmail: {}` is an object, so TypeORM prefers it over the scalar and
1770
+ * writes NULL into the join column. Reading only the scalar would report
1771
+ * a rule that names an email; the row that lands names nothing and pages
1772
+ * nobody. Folding reproduces the ORM's answer, and the create invariant
1773
+ * then sees the rule for what it is.
1774
+ */
1775
+ await expect(
1776
+ ruleService().onBeforeCreate(
1777
+ createPayload({
1778
+ ownerUserId: VICTIM_USER_ID,
1779
+ columns: {
1780
+ userEmailId: VICTIM_METHOD_ID,
1781
+ userEmail: {},
1782
+ },
1783
+ }),
1784
+ ),
1785
+ ).rejects.toThrow(
1786
+ "Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
1787
+ );
1788
+ });
1789
+
1790
+ test("on update the relation slot is NOT folded into the FK column - that would launder a denied column write", async () => {
1791
+ /*
1792
+ * The two spellings do not have the same access control on update: the
1793
+ * relation members are `update: []` on UserNotificationRule while the
1794
+ * `*Id` members are open to an administrator. Moving a value from the
1795
+ * former into the latter would carry it across a permission boundary,
1796
+ * past the ColumnPermission check that runs immediately after this hook.
1797
+ * On this path ambiguity is answered by refusal instead.
1798
+ */
1799
+ stubs.ruleFindBy.mockResolvedValue([
1800
+ persistedRule({ userId: VICTIM_USER_ID }),
1801
+ ] as never);
1802
+
1803
+ const payload: UpdateBy<UserNotificationRule> = updatePayload({
1804
+ patch: { userTelegram: { _id: VICTIM_METHOD_ID.toString() } },
1805
+ });
1806
+
1807
+ await ruleService().onBeforeUpdate(payload);
1808
+
1809
+ const patch: Record<string, unknown> = payload.data as unknown as Record<
1810
+ string,
1811
+ unknown
1812
+ >;
1813
+
1814
+ expect(patch["userTelegram"]).toEqual({
1815
+ _id: VICTIM_METHOD_ID.toString(),
1816
+ });
1817
+ expect(patch["userTelegramId"]).toBeUndefined();
1818
+ });
1819
+
1820
+ test("an ambiguous patch is refused on update as well", async () => {
1821
+ stubs.ruleFindBy.mockResolvedValue([
1822
+ persistedRule({ userId: VICTIM_USER_ID }),
1823
+ ] as never);
1824
+ stubs.methodFindOneById
1825
+ .get("userWebhookId")!
1826
+ .mockImplementation(
1827
+ (findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
1828
+ return Promise.resolve(
1829
+ methodRow({
1830
+ ownerUserId: VICTIM_USER_ID,
1831
+ methodId: findBy.id,
1832
+ }),
1833
+ );
1834
+ },
1835
+ );
1836
+
1837
+ await expect(
1838
+ ruleService().onBeforeUpdate(
1839
+ updatePayload({
1840
+ patch: {
1841
+ userWebhookId: VICTIM_METHOD_ID,
1842
+ userWebhook: { _id: ADMIN_METHOD_ID.toString() },
1843
+ },
1844
+ }),
1845
+ ),
1846
+ ).rejects.toThrow(
1847
+ "This notification rule names two different Webhook notification methods",
1848
+ );
1849
+ });
1850
+ });
1851
+
1852
+ /*
1853
+ * ------------------------------------------------------------------
1854
+ * The guard's row set. DatabaseService runs these hooks BEFORE
1855
+ * ModelPermission narrows the query, so the narrowing has to be reproduced
1856
+ * here or the guard reasons about rows the caller cannot reach.
1857
+ * ------------------------------------------------------------------
1858
+ */
1859
+ describe("the guard reads only rows the caller is entitled to write", () => {
1860
+ test("an ordinary member's guard read is confined to their own rows", async () => {
1861
+ /*
1862
+ * Permission.CurrentUser is auto-granted to every authenticated caller,
1863
+ * so this session holds nothing else that appears in the model's update
1864
+ * list. TenantPermission would rewrite their query to `userId = me`; if
1865
+ * the hook read the raw query with root props instead, a member could
1866
+ * hand it a colleague's rule id and have the guard - and the audit trail
1867
+ * behind it - answer about somebody else's row entirely.
1868
+ */
1869
+ await ruleService().onBeforeUpdate(
1870
+ updatePayload({
1871
+ patch: { notifyAfterMinutes: 15 },
1872
+ query: { _id: RULE_ID.toString() },
1873
+ }),
1874
+ );
1875
+
1876
+ expect(guardReadQuery(stubs.ruleFindBy)["userId"]).toBe(ADMIN_USER_ID);
1877
+ });
1878
+
1879
+ test("a query naming another project is pulled back to the session's own project", async () => {
1880
+ await ruleService().onBeforeUpdate(
1881
+ updatePayload({
1882
+ patch: { notifyAfterMinutes: 15 },
1883
+ query: {
1884
+ _id: RULE_ID.toString(),
1885
+ projectId: OTHER_PROJECT_ID.toString(),
1886
+ },
1887
+ }),
1888
+ );
1889
+
1890
+ expect(guardReadQuery(stubs.ruleFindBy)["projectId"]).toBe(PROJECT_ID);
1891
+ });
1892
+
1893
+ test("an administrator is scoped to their project but NOT to their own rows - that is the repair capability", async () => {
1894
+ /*
1895
+ * The converse test, and the one that proves the narrowing is the
1896
+ * permission layer's rather than a blanket lock. A session holding a real
1897
+ * ProjectAdmin row is no longer "here purely as some logged-in user", the
1898
+ * ownership predicate does not apply to it, and reading a colleague's
1899
+ * rules is precisely what this phase exists to allow.
1900
+ */
1901
+ await ruleService().onBeforeUpdate(
1902
+ updatePayload({
1903
+ patch: { notifyAfterMinutes: 15 },
1904
+ props: projectAdminProps(),
1905
+ }),
1906
+ );
1907
+
1908
+ const query: Record<string, unknown> = guardReadQuery(stubs.ruleFindBy);
1909
+
1910
+ expect(query["projectId"]).toBe(PROJECT_ID);
1911
+ expect(query["userId"]).toBeUndefined();
1912
+ });
1913
+
1914
+ test("a root write is not narrowed - it is entitled to every row, and reading fewer than the write touches is the one direction this must never be wrong in", async () => {
1915
+ await ruleService().onBeforeUpdate(
1916
+ updatePayload({
1917
+ patch: { notifyAfterMinutes: 15 },
1918
+ props: { isRoot: true, userId: ADMIN_USER_ID },
1919
+ }),
1920
+ );
1921
+
1922
+ expect(guardReadQuery(stubs.ruleFindBy)).toEqual({
1923
+ _id: RULE_ID.toString(),
1924
+ });
1925
+ });
1926
+
1927
+ test("the same narrowing is applied on the delete path", async () => {
1928
+ await ruleService().onBeforeDelete(
1929
+ deletePayload({
1930
+ query: {
1931
+ _id: RULE_ID.toString(),
1932
+ projectId: OTHER_PROJECT_ID.toString(),
1933
+ },
1934
+ }),
1935
+ );
1936
+
1937
+ const query: Record<string, unknown> = guardReadQuery(stubs.ruleFindBy);
1938
+
1939
+ expect(query["projectId"]).toBe(PROJECT_ID);
1940
+ expect(query["userId"]).toBe(ADMIN_USER_ID);
1941
+ });
1942
+
1943
+ test("narrowing does not mutate the caller's query - the permission layer must still see what was actually asked for", async () => {
1944
+ const payload: UpdateBy<UserNotificationRule> = updatePayload({
1945
+ patch: { notifyAfterMinutes: 15 },
1946
+ });
1947
+
1948
+ await ruleService().onBeforeUpdate(payload);
1949
+
1950
+ expect(payload.query).toEqual({ _id: RULE_ID.toString() });
1951
+ });
1952
+ });
1953
+
1954
+ /*
1955
+ * ------------------------------------------------------------------
1956
+ * The row-level invariants, which update could break until now.
1957
+ * ------------------------------------------------------------------
1958
+ */
1959
+ describe("update cannot leave a rule in a state create would have refused", () => {
1960
+ test("opting a rule out while it still carries a method is refused", async () => {
1961
+ /*
1962
+ * "Reach me here; also never reach me." Create has always refused it;
1963
+ * update reached it in one field, and the resulting row is not merely
1964
+ * untidy - the fallback treats an opt-out row as a deliberate choice to
1965
+ * stay silent, so this is a rule that suppresses paging while looking
1966
+ * like one that delivers it.
1967
+ */
1968
+ stubs.ruleFindBy.mockResolvedValue([
1969
+ persistedRule({
1970
+ userId: VICTIM_USER_ID,
1971
+ userEmailId: VICTIM_METHOD_ID,
1972
+ }),
1973
+ ] as never);
1974
+
1975
+ await expect(
1976
+ ruleService().onBeforeUpdate(
1977
+ updatePayload({ patch: { isOptOut: true } }),
1978
+ ),
1979
+ ).rejects.toThrow(
1980
+ "An opt-out notification rule cannot have a notification method.",
1981
+ );
1982
+ });
1983
+
1984
+ test("clearing the last method on a rule that still pages is refused", async () => {
1985
+ stubs.ruleFindBy.mockResolvedValue([
1986
+ persistedRule({
1987
+ userId: VICTIM_USER_ID,
1988
+ isOptOut: false,
1989
+ userEmailId: VICTIM_METHOD_ID,
1990
+ }),
1991
+ ] as never);
1992
+
1993
+ await expect(
1994
+ ruleService().onBeforeUpdate(
1995
+ updatePayload({ patch: { userEmailId: null } }),
1996
+ ),
1997
+ ).rejects.toThrow(
1998
+ "Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
1999
+ );
2000
+ });
2001
+
2002
+ test("clearing the method and opting out in the same patch is coherent and allowed", async () => {
2003
+ stubs.ruleFindBy.mockResolvedValue([
2004
+ persistedRule({
2005
+ userId: VICTIM_USER_ID,
2006
+ userEmailId: VICTIM_METHOD_ID,
2007
+ }),
2008
+ ] as never);
2009
+
2010
+ await expect(
2011
+ ruleService().onBeforeUpdate(
2012
+ updatePayload({ patch: { isOptOut: true, userEmailId: null } }),
2013
+ ),
2014
+ ).resolves.toBeDefined();
2015
+ });
2016
+
2017
+ test("turning opt-out off while naming a method in the same patch is allowed", async () => {
2018
+ stubs.ruleFindBy.mockResolvedValue([
2019
+ persistedRule({ userId: VICTIM_USER_ID, isOptOut: true }),
2020
+ ] as never);
2021
+
2022
+ await expect(
2023
+ ruleService().onBeforeUpdate(
2024
+ updatePayload({
2025
+ patch: { isOptOut: false, userSmsId: VICTIM_METHOD_ID },
2026
+ }),
2027
+ ),
2028
+ ).resolves.toBeDefined();
2029
+ });
2030
+
2031
+ test("swapping one method for another leaves the rule coherent", async () => {
2032
+ stubs.ruleFindBy.mockResolvedValue([
2033
+ persistedRule({
2034
+ userId: VICTIM_USER_ID,
2035
+ userEmailId: VICTIM_METHOD_ID,
2036
+ }),
2037
+ ] as never);
2038
+
2039
+ await expect(
2040
+ ruleService().onBeforeUpdate(
2041
+ updatePayload({
2042
+ patch: { userEmailId: null, userSmsId: VICTIM_METHOD_ID },
2043
+ }),
2044
+ ),
2045
+ ).resolves.toBeDefined();
2046
+ });
2047
+
2048
+ test("a patch that touches neither opt-out nor any method leaves an already-broken legacy row alone", async () => {
2049
+ /*
2050
+ * Rows written before these invariants existed are out there. Refusing to
2051
+ * let anyone edit the notify delay on one would make it unrepairable
2052
+ * through the API that has to repair it, so the check answers for the
2053
+ * state the PATCH produces rather than policing the row it lands on.
2054
+ */
2055
+ stubs.ruleFindBy.mockResolvedValue([
2056
+ persistedRule({ userId: VICTIM_USER_ID, isOptOut: false }),
2057
+ ] as never);
2058
+
2059
+ await expect(
2060
+ ruleService().onBeforeUpdate(
2061
+ updatePayload({ patch: { notifyAfterMinutes: 20 } }),
2062
+ ),
2063
+ ).resolves.toBeDefined();
2064
+ });
2065
+
2066
+ test("the invariant is checked against EVERY affected row, not just the first", async () => {
2067
+ stubs.ruleFindBy.mockResolvedValue([
2068
+ persistedRule({ userId: VICTIM_USER_ID }),
2069
+ persistedRule({
2070
+ id: SECOND_RULE_ID,
2071
+ _id: SECOND_RULE_ID.toString(),
2072
+ userId: VICTIM_USER_ID,
2073
+ userEmailId: VICTIM_METHOD_ID,
2074
+ }),
2075
+ ] as never);
2076
+
2077
+ await expect(
2078
+ ruleService().onBeforeUpdate(
2079
+ updatePayload({ patch: { isOptOut: true } }),
2080
+ ),
2081
+ ).rejects.toThrow(
2082
+ "An opt-out notification rule cannot have a notification method.",
2083
+ );
2084
+ });
2085
+ });
2086
+
2087
+ /*
2088
+ * ------------------------------------------------------------------
2089
+ * Delete - the most destructive of the three verbs, and the last one to
2090
+ * get a guard.
2091
+ * ------------------------------------------------------------------
2092
+ */
2093
+ describe("deleting somebody else's notification rules leaves a record and a warning", () => {
2094
+ test("the rows are read BEFORE the delete, with root props, because afterwards there is nothing to read", async () => {
2095
+ await ruleService().onBeforeDelete(deletePayload());
2096
+
2097
+ const findByArgs: {
2098
+ query: Record<string, unknown>;
2099
+ props: { isRoot: boolean; ignoreHooks: boolean };
2100
+ } = stubs.ruleFindBy.mock.calls[0]![0] as {
2101
+ query: Record<string, unknown>;
2102
+ props: { isRoot: boolean; ignoreHooks: boolean };
2103
+ };
2104
+
2105
+ expect(findByArgs.props.isRoot).toBe(true);
2106
+ expect(findByArgs.query["_id"]).toBe(RULE_ID.toString());
2107
+ });
2108
+
2109
+ test("the snapshot is carried forward, because the audit entry cannot be rebuilt from a row that no longer exists", async () => {
2110
+ stubs.ruleFindBy.mockResolvedValue([
2111
+ persistedRule({ userId: VICTIM_USER_ID }),
2112
+ ] as never);
2113
+
2114
+ const onDelete: OnDelete<UserNotificationRule> =
2115
+ await ruleService().onBeforeDelete(deletePayload());
2116
+
2117
+ expect(onDelete.carryForward.deletedRules).toHaveLength(1);
2118
+ });
2119
+
2120
+ test("an administrative delete is logged against the actor and the row's PERSISTED owner", async () => {
2121
+ await ruleService().onDeleteSuccess(
2122
+ {
2123
+ deleteBy: deletePayload(),
2124
+ carryForward: {
2125
+ deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
2126
+ },
2127
+ },
2128
+ [RULE_ID],
2129
+ );
2130
+
2131
+ await flushAsync();
2132
+
2133
+ const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
2134
+ stubs.loggerInfo,
2135
+ );
2136
+
2137
+ expect(trail).toHaveLength(1);
2138
+ expect(trail[0]!.action).toBe("Delete");
2139
+ expect(trail[0]!.actorUserId).toBe(ADMIN_USER_ID.toString());
2140
+ expect(trail[0]!.userId).toBe(VICTIM_USER_ID.toString());
2141
+ expect(trail[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
2142
+
2143
+ expect(stubs.auditRecordDelete).toHaveBeenCalledTimes(1);
2144
+
2145
+ const auditArgs: { deletedItem: UserNotificationRule; itemId: ObjectID } =
2146
+ stubs.auditRecordDelete.mock.calls[0]![0] as {
2147
+ deletedItem: UserNotificationRule;
2148
+ itemId: ObjectID;
2149
+ };
2150
+
2151
+ expect(auditArgs.itemId.toString()).toBe(RULE_ID.toString());
2152
+ expect(auditArgs.deletedItem.userId?.toString()).toBe(
2153
+ VICTIM_USER_ID.toString(),
2154
+ );
2155
+ });
2156
+
2157
+ test("the owner is told, and told what it means rather than merely that it happened", async () => {
2158
+ /*
2159
+ * Deletion is the one action that can leave a responder silently
2160
+ * unreachable, so the mail says so. "An administrator changed your rules"
2161
+ * is a prompt to go and look; "you may no longer be notified when you are
2162
+ * on call" is the consequence, and it is the reason this epic exists.
2163
+ */
2164
+ await ruleService().onDeleteSuccess(
2165
+ {
2166
+ deleteBy: deletePayload(),
2167
+ carryForward: {
2168
+ deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
2169
+ },
2170
+ },
2171
+ [RULE_ID],
2172
+ );
2173
+
2174
+ await flushAsync();
2175
+
2176
+ expect(stubs.sendMail).toHaveBeenCalledTimes(1);
2177
+
2178
+ const mail: {
2179
+ toEmail: Email;
2180
+ subject: string;
2181
+ vars: { message: string };
2182
+ } = stubs.sendMail.mock.calls[0]![0] as {
2183
+ toEmail: Email;
2184
+ subject: string;
2185
+ vars: { message: string };
2186
+ };
2187
+
2188
+ expect(mail.toEmail.toString()).toBe(VICTIM_EMAIL);
2189
+ expect(mail.subject).toContain("deleted");
2190
+ expect(mail.vars.message).toContain("no longer be notified");
2191
+ expect(mail.vars.message).toContain(ADMIN_EMAIL);
2192
+ });
2193
+
2194
+ test("deleting your own rules is neither audited as administrative nor announced", async () => {
2195
+ await ruleService().onDeleteSuccess(
2196
+ {
2197
+ deleteBy: deletePayload(),
2198
+ carryForward: {
2199
+ deletedRules: [persistedRule({ userId: ADMIN_USER_ID })],
2200
+ },
2201
+ },
2202
+ [RULE_ID],
2203
+ );
2204
+
2205
+ await flushAsync();
2206
+
2207
+ expect(stubs.sendMail).not.toHaveBeenCalled();
2208
+ expect(stubs.auditRecordDelete).not.toHaveBeenCalled();
2209
+ expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
2210
+ });
2211
+
2212
+ test("only rows the delete actually removed are reported", async () => {
2213
+ /*
2214
+ * The hook reads every row the narrowed query matches; _deleteBy then
2215
+ * applies the caller's own skip/limit on top. A warning about a rule that
2216
+ * still exists is a false alarm, and false alarms are how the true one
2217
+ * gets ignored.
2218
+ */
2219
+ await ruleService().onDeleteSuccess(
2220
+ {
2221
+ deleteBy: deletePayload(),
2222
+ carryForward: {
2223
+ deletedRules: [
2224
+ persistedRule({ userId: VICTIM_USER_ID }),
2225
+ persistedRule({
2226
+ id: SECOND_RULE_ID,
2227
+ _id: SECOND_RULE_ID.toString(),
2228
+ userId: STRANGER_USER_ID,
2229
+ }),
2230
+ ],
2231
+ },
2232
+ },
2233
+ [RULE_ID],
2234
+ );
2235
+
2236
+ await flushAsync();
2237
+
2238
+ expect(stubs.sendMail).toHaveBeenCalledTimes(1);
2239
+ expect(
2240
+ (
2241
+ stubs.sendMail.mock.calls[0]![0] as { toEmail: Email }
2242
+ ).toEmail.toString(),
2243
+ ).toBe(VICTIM_EMAIL);
2244
+ });
2245
+
2246
+ test("a sweep across several of one person's rules audits each row but mails them once", async () => {
2247
+ await ruleService().onDeleteSuccess(
2248
+ {
2249
+ deleteBy: deletePayload(),
2250
+ carryForward: {
2251
+ deletedRules: [
2252
+ persistedRule({ userId: VICTIM_USER_ID }),
2253
+ persistedRule({
2254
+ id: SECOND_RULE_ID,
2255
+ _id: SECOND_RULE_ID.toString(),
2256
+ userId: VICTIM_USER_ID,
2257
+ }),
2258
+ ],
2259
+ },
2260
+ },
2261
+ [RULE_ID, SECOND_RULE_ID],
2262
+ );
2263
+
2264
+ await flushAsync();
2265
+
2266
+ expect(stubs.auditRecordDelete).toHaveBeenCalledTimes(2);
2267
+ expect(stubs.sendMail).toHaveBeenCalledTimes(1);
2268
+ });
2269
+
2270
+ test("a delete with no actor - a worker, a cascade, a migration - reads nothing and reports nothing", async () => {
2271
+ const onDelete: OnDelete<UserNotificationRule> =
2272
+ await ruleService().onBeforeDelete(
2273
+ deletePayload({ props: { isRoot: true } }),
2274
+ );
2275
+
2276
+ await ruleService().onDeleteSuccess(onDelete, [RULE_ID]);
2277
+
2278
+ await flushAsync();
2279
+
2280
+ expect(stubs.ruleFindBy).not.toHaveBeenCalled();
2281
+ expect(stubs.sendMail).not.toHaveBeenCalled();
2282
+ expect(stubs.auditRecordDelete).not.toHaveBeenCalled();
2283
+ });
2284
+
2285
+ test("a mail server that is down cannot fail a delete that already happened", async () => {
2286
+ stubs.sendMail.mockRejectedValue(new Error("smtp is down") as never);
2287
+
2288
+ await expect(
2289
+ ruleService().onDeleteSuccess(
2290
+ {
2291
+ deleteBy: deletePayload(),
2292
+ carryForward: {
2293
+ deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
2294
+ },
2295
+ },
2296
+ [RULE_ID],
2297
+ ),
2298
+ ).resolves.toBeDefined();
2299
+
2300
+ await flushAsync();
2301
+
2302
+ expect(stubs.loggerError).toHaveBeenCalled();
2303
+ });
2304
+ });
2305
+
2306
+ /*
2307
+ * ------------------------------------------------------------------
2308
+ * R6 - the trail is keyed on the server's actor and the persisted owner.
2309
+ * ------------------------------------------------------------------
2310
+ */
2311
+ describe("R6: audit and notification are keyed on props.userId versus the persisted owner", () => {
2312
+ test("creating somebody else's rule is logged against the actor and the row's PERSISTED owner", async () => {
2313
+ /*
2314
+ * The body lies about ownership in the direction that would flatter the
2315
+ * caller - it claims the row is the admin's own, which would make the
2316
+ * change unremarkable and unreported. The persisted row says otherwise,
2317
+ * and only the persisted row is consulted.
2318
+ */
2319
+ const payload: CreateBy<UserNotificationRule> = createPayload({
2320
+ ownerUserId: ADMIN_USER_ID,
2321
+ columns: { userEmailId: VICTIM_METHOD_ID },
2322
+ });
2323
+
2324
+ const createdItem: UserNotificationRule = persistedRule({
2325
+ userId: VICTIM_USER_ID,
2326
+ });
2327
+
2328
+ const returned: UserNotificationRule =
2329
+ await ruleService().onCreateSuccess(
2330
+ { createBy: payload, carryForward: null },
2331
+ createdItem,
2332
+ );
2333
+
2334
+ await flushAsync();
2335
+
2336
+ expect(returned).toBe(createdItem);
2337
+
2338
+ const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
2339
+ stubs.loggerInfo,
2340
+ );
2341
+
2342
+ expect(trail).toHaveLength(1);
2343
+ expect(trail[0]!.actorUserId).toBe(ADMIN_USER_ID.toString());
2344
+ expect(trail[0]!.userId).toBe(VICTIM_USER_ID.toString());
2345
+ expect(trail[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
2346
+
2347
+ expect(stubs.auditRecordCreate).toHaveBeenCalledTimes(1);
2348
+
2349
+ const auditArgs: {
2350
+ createdItem: UserNotificationRule;
2351
+ props: DatabaseCommonInteractionProps;
2352
+ } = stubs.auditRecordCreate.mock.calls[0]![0] as {
2353
+ createdItem: UserNotificationRule;
2354
+ props: DatabaseCommonInteractionProps;
2355
+ };
2356
+
2357
+ expect(auditArgs.createdItem).toBe(createdItem);
2358
+ expect(auditArgs.props.userId?.toString()).toBe(ADMIN_USER_ID.toString());
2359
+ });
2360
+
2361
+ test("the owner is emailed, and the mail names the actor by address rather than by display name alone", async () => {
2362
+ await ruleService().onCreateSuccess(
2363
+ {
2364
+ createBy: createPayload({
2365
+ ownerUserId: VICTIM_USER_ID,
2366
+ columns: { userEmailId: VICTIM_METHOD_ID },
2367
+ }),
2368
+ carryForward: null,
2369
+ },
2370
+ persistedRule({ userId: VICTIM_USER_ID }),
2371
+ );
2372
+
2373
+ await flushAsync();
2374
+
2375
+ expect(stubs.sendMail).toHaveBeenCalledTimes(1);
2376
+
2377
+ const mail: { toEmail: Email; vars: { message: string } } = stubs.sendMail
2378
+ .mock.calls[0]![0] as {
2379
+ toEmail: Email;
2380
+ vars: { message: string };
2381
+ };
2382
+ const mailOptions: { userId: ObjectID } = stubs.sendMail.mock
2383
+ .calls[0]![1] as { userId: ObjectID };
2384
+
2385
+ // The person whose pages changed is the one told about it.
2386
+ expect(mail.toEmail.toString()).toBe(VICTIM_EMAIL);
2387
+ expect(mailOptions.userId.toString()).toBe(VICTIM_USER_ID.toString());
2388
+ /*
2389
+ * Display names are neither unique nor trustworthy - a user can set their
2390
+ * own - so "Ada Admin changed your rules" is not something the reader can
2391
+ * act on. An address is.
2392
+ */
2393
+ expect(mail.vars.message).toContain(ADMIN_EMAIL);
2394
+ });
2395
+
2396
+ test("a body naming a victim cannot fabricate a report about a row the caller actually owns", async () => {
2397
+ /*
2398
+ * The inverse forgery: the body claims the row belongs to the victim so
2399
+ * that the trail records an administrative change that never happened.
2400
+ * An audit line an attacker can author is worse than none, because it
2401
+ * reads as evidence.
2402
+ */
2403
+ await ruleService().onCreateSuccess(
2404
+ {
2405
+ createBy: createPayload({
2406
+ ownerUserId: VICTIM_USER_ID,
2407
+ columns: { userEmailId: ADMIN_METHOD_ID },
2408
+ }),
2409
+ carryForward: null,
2410
+ },
2411
+ persistedRule({ userId: ADMIN_USER_ID }),
2412
+ );
2413
+
2414
+ await flushAsync();
2415
+
2416
+ expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
2417
+ expect(stubs.auditRecordCreate).not.toHaveBeenCalled();
2418
+ expect(stubs.sendMail).not.toHaveBeenCalled();
2419
+ });
2420
+
2421
+ test("a self-edit is neither audited as administrative nor announced", async () => {
2422
+ await ruleService().onUpdateSuccess(
2423
+ {
2424
+ updateBy: updatePayload({ patch: { notifyAfterMinutes: 5 } }),
2425
+ carryForward: {
2426
+ affectedRules: [persistedRule({ userId: ADMIN_USER_ID })],
2427
+ },
2428
+ },
2429
+ [RULE_ID],
2430
+ );
2431
+
2432
+ await flushAsync();
2433
+
2434
+ expect(stubs.sendMail).not.toHaveBeenCalled();
2435
+ expect(stubs.auditRecordUpdate).not.toHaveBeenCalled();
2436
+ expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
2437
+ });
2438
+
2439
+ test("a mail server that is down cannot fail a write that already happened", async () => {
2440
+ /*
2441
+ * By the time any of this runs the row is committed. Surfacing an SMTP
2442
+ * failure as a failed write would tell the caller their change did not
2443
+ * happen when it did - a worse outcome than a missing warning email.
2444
+ */
2445
+ stubs.sendMail.mockRejectedValue(new Error("smtp is down") as never);
2446
+
2447
+ const createdItem: UserNotificationRule = persistedRule({
2448
+ userId: VICTIM_USER_ID,
2449
+ });
2450
+
2451
+ await expect(
2452
+ ruleService().onCreateSuccess(
2453
+ {
2454
+ createBy: createPayload({
2455
+ ownerUserId: VICTIM_USER_ID,
2456
+ columns: { userEmailId: VICTIM_METHOD_ID },
2457
+ }),
2458
+ carryForward: null,
2459
+ },
2460
+ createdItem,
2461
+ ),
2462
+ ).resolves.toBe(createdItem);
2463
+
2464
+ await flushAsync();
2465
+
2466
+ // ...and it is not swallowed silently either.
2467
+ expect(stubs.loggerError).toHaveBeenCalled();
2468
+ });
2469
+
2470
+ test("an audit sink that throws cannot fail the write either", async () => {
2471
+ stubs.auditRecordCreate.mockRejectedValue(
2472
+ new Error("clickhouse unreachable") as never,
2473
+ );
2474
+
2475
+ const createdItem: UserNotificationRule = persistedRule({
2476
+ userId: VICTIM_USER_ID,
2477
+ });
2478
+
2479
+ await expect(
2480
+ ruleService().onCreateSuccess(
2481
+ {
2482
+ createBy: createPayload({
2483
+ ownerUserId: VICTIM_USER_ID,
2484
+ columns: { userEmailId: VICTIM_METHOD_ID },
2485
+ }),
2486
+ carryForward: null,
2487
+ },
2488
+ createdItem,
2489
+ ),
2490
+ ).resolves.toBe(createdItem);
2491
+
2492
+ await flushAsync();
2493
+
2494
+ /*
2495
+ * The dependable half of the trail is the log line, which is written
2496
+ * BEFORE the sink is called precisely so that a sink outage cannot erase
2497
+ * the record that this happened. Audit-log rows only exist on enterprise
2498
+ * builds with audit logging switched on for the project; the log line
2499
+ * exists everywhere.
2500
+ */
2501
+ expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(1);
2502
+ });
2503
+
2504
+ test("updating somebody else's rules audits each row against the owner the DATABASE had", async () => {
2505
+ await ruleService().onUpdateSuccess(
2506
+ {
2507
+ updateBy: updatePayload({ patch: { notifyAfterMinutes: 30 } }),
2508
+ carryForward: {
2509
+ affectedRules: [
2510
+ persistedRule({ userId: VICTIM_USER_ID }),
2511
+ persistedRule({
2512
+ id: SECOND_RULE_ID,
2513
+ _id: SECOND_RULE_ID.toString(),
2514
+ userId: VICTIM_USER_ID,
2515
+ }),
2516
+ ],
2517
+ },
2518
+ },
2519
+ [RULE_ID, SECOND_RULE_ID],
2520
+ );
2521
+
2522
+ await flushAsync();
2523
+
2524
+ const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
2525
+ stubs.loggerInfo,
2526
+ );
2527
+
2528
+ // One audit entry per ROW: that is what an investigator reconstructs from.
2529
+ expect(trail).toHaveLength(2);
2530
+ expect(stubs.auditRecordUpdate).toHaveBeenCalledTimes(2);
2531
+
2532
+ const auditArgs: { before: UserNotificationRule; itemId: ObjectID } =
2533
+ stubs.auditRecordUpdate.mock.calls[0]![0] as {
2534
+ before: UserNotificationRule;
2535
+ itemId: ObjectID;
2536
+ };
2537
+
2538
+ expect(auditArgs.itemId.toString()).toBe(RULE_ID.toString());
2539
+ expect(auditArgs.before.userId?.toString()).toBe(
2540
+ VICTIM_USER_ID.toString(),
2541
+ );
2542
+
2543
+ /*
2544
+ * ...but ONE mail per person. Twenty copies of "an admin changed your
2545
+ * rules" is how a warning becomes a filter rule, and then the one that
2546
+ * mattered is never read.
2547
+ */
2548
+ expect(stubs.sendMail).toHaveBeenCalledTimes(1);
2549
+ expect(
2550
+ (
2551
+ stubs.sendMail.mock.calls[0]![0] as { toEmail: Email }
2552
+ ).toEmail.toString(),
2553
+ ).toBe(VICTIM_EMAIL);
2554
+ });
2555
+
2556
+ test("in one update touching two people's rules, each person is told", async () => {
2557
+ stubs.userFindOneById.mockImplementation(
2558
+ (findBy: { id: ObjectID }): Promise<User> => {
2559
+ return Promise.resolve({
2560
+ id: findBy.id,
2561
+ _id: findBy.id.toString(),
2562
+ name: new Name("Somebody"),
2563
+ email: new Email(`${findBy.id.toString()}@example.test`),
2564
+ } as unknown as User);
2565
+ },
2566
+ );
2567
+
2568
+ await ruleService().onUpdateSuccess(
2569
+ {
2570
+ updateBy: updatePayload({ patch: { notifyAfterMinutes: 30 } }),
2571
+ carryForward: {
2572
+ affectedRules: [
2573
+ persistedRule({ userId: VICTIM_USER_ID }),
2574
+ persistedRule({
2575
+ id: SECOND_RULE_ID,
2576
+ _id: SECOND_RULE_ID.toString(),
2577
+ userId: STRANGER_USER_ID,
2578
+ }),
2579
+ ],
2580
+ },
2581
+ },
2582
+ [RULE_ID, SECOND_RULE_ID],
2583
+ );
2584
+
2585
+ await flushAsync();
2586
+
2587
+ const recipients: Array<string> = stubs.sendMail.mock.calls.map(
2588
+ (call: Array<unknown>): string => {
2589
+ return (call[0] as { toEmail: Email }).toEmail.toString();
2590
+ },
2591
+ );
2592
+
2593
+ expect(recipients.sort()).toEqual(
2594
+ [
2595
+ `${VICTIM_USER_ID.toString()}@example.test`,
2596
+ `${STRANGER_USER_ID.toString()}@example.test`,
2597
+ ].sort(),
2598
+ );
2599
+ });
2600
+
2601
+ test("a write with no actor - a worker, a migration - reports nothing", async () => {
2602
+ /*
2603
+ * There is no human to name, so there is nothing to say. Reporting
2604
+ * "somebody changed your rules" for every internal seeding pass would
2605
+ * train people to ignore the message that matters.
2606
+ */
2607
+ await ruleService().onUpdateSuccess(
2608
+ {
2609
+ updateBy: updatePayload({
2610
+ patch: { notifyAfterMinutes: 5 },
2611
+ props: { isRoot: true },
2612
+ }),
2613
+ carryForward: {
2614
+ affectedRules: [persistedRule({ userId: VICTIM_USER_ID })],
2615
+ },
2616
+ },
2617
+ [RULE_ID],
2618
+ );
2619
+
2620
+ await flushAsync();
2621
+
2622
+ expect(stubs.sendMail).not.toHaveBeenCalled();
2623
+ expect(stubs.auditRecordUpdate).not.toHaveBeenCalled();
2624
+ });
2625
+ });
2626
+
2627
+ /*
2628
+ * ------------------------------------------------------------------
2629
+ * Defence in depth - a mismatched row must be inert at delivery time.
2630
+ * ------------------------------------------------------------------
2631
+ */
2632
+ describe("delivery refuses a rule whose method belongs to somebody else", () => {
2633
+ function loadedRule(data: {
2634
+ ruleOwnerUserId: ObjectID | undefined;
2635
+ relationColumn: string;
2636
+ methodOwnerUserId: ObjectID | undefined;
2637
+ }): UserNotificationRule {
2638
+ const rule: UserNotificationRule = new UserNotificationRule();
2639
+ rule.id = RULE_ID;
2640
+ rule.projectId = PROJECT_ID;
2641
+
2642
+ if (data.ruleOwnerUserId) {
2643
+ rule.userId = data.ruleOwnerUserId;
2644
+ }
2645
+
2646
+ (rule as unknown as Record<string, unknown>)[data.relationColumn] = {
2647
+ _id: VICTIM_METHOD_ID.toString(),
2648
+ id: VICTIM_METHOD_ID,
2649
+ isVerified: true,
2650
+ userId: data.methodOwnerUserId,
2651
+ };
2652
+
2653
+ return rule;
2654
+ }
2655
+
2656
+ test("a rule whose email method belongs to another user is not delivered at all", async () => {
2657
+ stubs.ruleFindOneById.mockResolvedValue(
2658
+ loadedRule({
2659
+ ruleOwnerUserId: VICTIM_USER_ID,
2660
+ relationColumn: "userEmail",
2661
+ methodOwnerUserId: ADMIN_USER_ID,
2662
+ }) as never,
2663
+ );
2664
+
2665
+ await UserNotificationRuleService.executeNotificationRuleItem(
2666
+ RULE_ID,
2667
+ executeOptions(),
2668
+ );
2669
+
2670
+ expect(stubs.deliver).not.toHaveBeenCalled();
2671
+ });
2672
+
2673
+ test("the refusal is recorded on the on-call log, naming the channel, so the page is not silently dropped", async () => {
2674
+ /*
2675
+ * A dropped page with no trace is the failure this whole epic exists to
2676
+ * end. The timeline row is the surface both the responder and the
2677
+ * operator read, so the mismatch has to arrive there in words that point
2678
+ * at the rule to repair.
2679
+ */
2680
+ stubs.ruleFindOneById.mockResolvedValue(
2681
+ loadedRule({
2682
+ ruleOwnerUserId: VICTIM_USER_ID,
2683
+ relationColumn: "userWebhook",
2684
+ methodOwnerUserId: ADMIN_USER_ID,
2685
+ }) as never,
2686
+ );
2687
+
2688
+ await UserNotificationRuleService.executeNotificationRuleItem(
2689
+ RULE_ID,
2690
+ executeOptions(),
2691
+ );
2692
+
2693
+ expect(timelineRows).toHaveLength(1);
2694
+ expect(timelineRows[0]!.status).toBe(UserNotificationStatus.Error);
2695
+ expect(timelineRows[0]!.statusMessage).toContain("Webhook");
2696
+ expect(timelineRows[0]!.statusMessage).toContain(
2697
+ "belongs to a different user",
2698
+ );
2699
+ expect(timelineRows[0]!.userId).toBe(VICTIM_USER_ID.toString());
2700
+ expect(timelineRows[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
2701
+ expect(timelineRows[0]!.userNotificationLogId).toBe(
2702
+ ON_CALL_LOG_ID.toString(),
2703
+ );
2704
+ expect(timelineRows[0]!.isRoot).toBe(true);
2705
+ });
2706
+
2707
+ test("one foreign method poisons the whole rule, including the channels that are honest", async () => {
2708
+ /*
2709
+ * Delivering the untainted channels would let a hijacked row keep working
2710
+ * well enough that nobody investigates. The row is not a rule with one
2711
+ * bad field; it is a row somebody wrote in order to redirect a page.
2712
+ */
2713
+ const rule: UserNotificationRule = loadedRule({
2714
+ ruleOwnerUserId: VICTIM_USER_ID,
2715
+ relationColumn: "userSms",
2716
+ methodOwnerUserId: ADMIN_USER_ID,
2717
+ });
2718
+
2719
+ (rule as unknown as Record<string, unknown>)["userEmail"] = {
2720
+ _id: VICTIM_METHOD_ID.toString(),
2721
+ isVerified: true,
2722
+ email: new Email(VICTIM_EMAIL),
2723
+ userId: VICTIM_USER_ID,
2724
+ };
2725
+
2726
+ stubs.ruleFindOneById.mockResolvedValue(rule as never);
2727
+
2728
+ await UserNotificationRuleService.executeNotificationRuleItem(
2729
+ RULE_ID,
2730
+ executeOptions(),
2731
+ );
2732
+
2733
+ expect(stubs.deliver).not.toHaveBeenCalled();
2734
+ expect(timelineRows[0]!.statusMessage).toContain("SMS");
2735
+ });
2736
+
2737
+ test("a rule whose method belongs to its own owner is delivered normally", async () => {
2738
+ const rule: UserNotificationRule = loadedRule({
2739
+ ruleOwnerUserId: VICTIM_USER_ID,
2740
+ relationColumn: "userEmail",
2741
+ methodOwnerUserId: VICTIM_USER_ID,
2742
+ });
2743
+
2744
+ stubs.ruleFindOneById.mockResolvedValue(rule as never);
2745
+
2746
+ await UserNotificationRuleService.executeNotificationRuleItem(
2747
+ RULE_ID,
2748
+ executeOptions(),
2749
+ );
2750
+
2751
+ expect(stubs.deliver).toHaveBeenCalledTimes(1);
2752
+ expect(timelineRows).toHaveLength(0);
2753
+ });
2754
+
2755
+ test("a method whose owner column was never loaded is NOT read as a mismatch", async () => {
2756
+ /*
2757
+ * The most dangerous way to get this check wrong. An unselected column
2758
+ * arrives as undefined, and treating absence as disagreement would turn
2759
+ * the guard into a page-suppression machine for every caller that does
2760
+ * not select userId - which is the exact failure mode the guard was added
2761
+ * to prevent. Silence means "no evidence of a mismatch", and for a check
2762
+ * that can cancel a page that is the only safe default.
2763
+ */
2764
+ const rule: UserNotificationRule = loadedRule({
2765
+ ruleOwnerUserId: VICTIM_USER_ID,
2766
+ relationColumn: "userEmail",
2767
+ methodOwnerUserId: undefined,
2768
+ });
2769
+
2770
+ stubs.ruleFindOneById.mockResolvedValue(rule as never);
2771
+
2772
+ await UserNotificationRuleService.executeNotificationRuleItem(
2773
+ RULE_ID,
2774
+ executeOptions(),
2775
+ );
2776
+
2777
+ expect(stubs.deliver).toHaveBeenCalledTimes(1);
2778
+ expect(timelineRows).toHaveLength(0);
2779
+ });
2780
+
2781
+ test("the rule load asks every method relation for its owner", async () => {
2782
+ /*
2783
+ * The check above is only as good as the select that feeds it: drop
2784
+ * userId from one relation and that channel silently becomes
2785
+ * un-checkable, which is indistinguishable from having no guard on it.
2786
+ */
2787
+ stubs.ruleFindOneById.mockResolvedValue(
2788
+ loadedRule({
2789
+ ruleOwnerUserId: VICTIM_USER_ID,
2790
+ relationColumn: "userEmail",
2791
+ methodOwnerUserId: VICTIM_USER_ID,
2792
+ }) as never,
2793
+ );
2794
+
2795
+ await UserNotificationRuleService.executeNotificationRuleItem(
2796
+ RULE_ID,
2797
+ executeOptions(),
2798
+ );
2799
+
2800
+ const select: Record<string, Record<string, boolean>> = (
2801
+ stubs.ruleFindOneById.mock.calls[0]![0] as {
2802
+ select: Record<string, Record<string, boolean>>;
2803
+ }
2804
+ ).select;
2805
+
2806
+ expect(select["userId"]).toBe(true);
2807
+
2808
+ for (const channel of CHANNELS) {
2809
+ expect(select[channel.relationColumn]!["userId"]).toBe(true);
2810
+ }
2811
+ });
2812
+
2813
+ test("an unowned rule is still delivered - there is no owner to compare a method against", async () => {
2814
+ /*
2815
+ * Not an endorsement of unowned rows; simply that this guard has nothing
2816
+ * to say about them. Inventing a mismatch out of a missing owner would
2817
+ * drop pages for rows the create-path guard already refuses to write.
2818
+ */
2819
+ const rule: UserNotificationRule = loadedRule({
2820
+ ruleOwnerUserId: undefined,
2821
+ relationColumn: "userEmail",
2822
+ methodOwnerUserId: ADMIN_USER_ID,
2823
+ });
2824
+
2825
+ stubs.ruleFindOneById.mockResolvedValue(rule as never);
2826
+
2827
+ await UserNotificationRuleService.executeNotificationRuleItem(
2828
+ RULE_ID,
2829
+ executeOptions(),
2830
+ );
2831
+
2832
+ expect(stubs.deliver).toHaveBeenCalledTimes(1);
2833
+ });
2834
+
2835
+ test("the execution claim is still taken before any of this - the mismatch check must not become a second page", async () => {
2836
+ stubs.claim.mockResolvedValue(false as never);
2837
+
2838
+ await UserNotificationRuleService.executeNotificationRuleItem(
2839
+ RULE_ID,
2840
+ executeOptions(),
2841
+ );
2842
+
2843
+ expect(stubs.ruleFindOneById).not.toHaveBeenCalled();
2844
+ expect(stubs.deliver).not.toHaveBeenCalled();
2845
+ expect(timelineRows).toHaveLength(0);
2846
+ });
2847
+ });
2848
+ });