@oneuptime/common 14.0.7 → 14.0.8

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 (440) hide show
  1. package/Models/DatabaseModels/Incident.ts +267 -1
  2. package/Models/DatabaseModels/IncidentCustomField.ts +202 -0
  3. package/Models/DatabaseModels/IncidentEpisode.ts +30 -0
  4. package/Models/DatabaseModels/IncidentTemplate.ts +126 -1
  5. package/Models/DatabaseModels/StatusPage.ts +55 -0
  6. package/Models/DatabaseModels/StatusPageSubscriber.ts +128 -0
  7. package/Server/API/IncidentAPI.ts +162 -0
  8. package/Server/API/StatusPageAPI.ts +487 -171
  9. package/Server/API/StatusPageSubscriberAPI.ts +63 -15
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.ts +49 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.ts +108 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.ts +33 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.ts +51 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.ts +33 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.ts +168 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.ts +44 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -0
  18. package/Server/Infrastructure/Semaphore.ts +10 -0
  19. package/Server/Middleware/IdentityRateLimit.ts +108 -3
  20. package/Server/Middleware/SubscriberNotificationPreviewRateLimit.ts +237 -0
  21. package/Server/Middleware/SubscriberNotificationTestSendRateLimit.ts +304 -0
  22. package/Server/Services/DatabaseService.ts +93 -10
  23. package/Server/Services/IncidentCustomFieldService.ts +276 -1
  24. package/Server/Services/IncidentEpisodePublicNoteService.ts +28 -0
  25. package/Server/Services/IncidentEpisodeService.ts +15 -0
  26. package/Server/Services/IncidentEpisodeStateTimelineService.ts +27 -1
  27. package/Server/Services/IncidentPublicNoteService.ts +30 -1
  28. package/Server/Services/IncidentService.ts +1535 -65
  29. package/Server/Services/IncidentStateTimelineService.ts +13 -0
  30. package/Server/Services/IncidentTemplateService.ts +109 -0
  31. package/Server/Services/ScheduledMaintenancePublicNoteService.ts +30 -1
  32. package/Server/Services/ScheduledMaintenanceService.ts +68 -18
  33. package/Server/Services/SmsService.ts +10 -0
  34. package/Server/Services/StatusPageService.ts +65 -8
  35. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +299 -37
  36. package/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.ts +129 -0
  37. package/Server/Services/StatusPageSubscriberService.ts +1161 -64
  38. package/Server/Types/Markdown.ts +153 -6
  39. package/Server/Types/Workflow/Components/Email.ts +61 -2
  40. package/Server/Utils/CustomField/CustomFieldRename.ts +378 -0
  41. package/Server/Utils/DataSource/EgressGuard.ts +66 -21
  42. package/Server/Utils/Mail/PinnedSmtpSocket.ts +130 -0
  43. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +10 -0
  44. package/Server/Utils/SSRFProtection.ts +76 -16
  45. package/Server/Utils/StatusPage/IncidentStatusPageScope.ts +948 -0
  46. package/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.ts +656 -0
  47. package/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.ts +805 -0
  48. package/Server/Utils/StatusPage/StatusPageExclusion.ts +18 -0
  49. package/Server/Utils/StatusPage/StatusPageReadAccess.ts +125 -0
  50. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.ts +151 -0
  51. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.ts +70 -0
  52. package/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.ts +421 -0
  53. package/Server/Utils/StatusPage/SubscriberNotificationClaim.ts +107 -0
  54. package/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.ts +1049 -0
  55. package/Server/Utils/StatusPage/SubscriberNotificationFanOut.ts +180 -0
  56. package/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.ts +839 -0
  57. package/Server/Utils/StatusPage/SubscriberNotificationResendAccess.ts +393 -0
  58. package/Server/Utils/StatusPage/SubscriberNotificationRunLimit.ts +246 -0
  59. package/Server/Utils/StatusPage/SubscriberNotificationTiming.ts +133 -0
  60. package/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.ts +215 -0
  61. package/Server/Utils/StatusPageResource.ts +51 -20
  62. package/Server/Utils/VM/VMAPI.ts +1 -0
  63. package/Server/Utils/VM/VMRunner.ts +10 -0
  64. package/Server/Utils/Workflow/OAuth2TokenClient.ts +10 -0
  65. package/Server/Views/Partials/SensitiveUrlToken.ejs +8 -5
  66. package/Tests/App/Accounts/RegisterVerifyEmail.test.tsx +748 -0
  67. package/Tests/App/Accounts/ResendVerificationEmail.test.tsx +1222 -0
  68. package/Tests/App/Accounts/VerificationEmailResendUtils.test.ts +797 -0
  69. package/Tests/App/Accounts/VerifyEmailPending.test.tsx +538 -0
  70. package/Tests/App/Accounts/VerifyEmailResend.test.tsx +779 -0
  71. package/Tests/App/Dashboard/CustomFieldsSettingsIncidentOptions.test.tsx +357 -0
  72. package/Tests/App/Dashboard/EventNotes.test.tsx +776 -0
  73. package/Tests/App/Dashboard/EventNotesPages.test.tsx +119 -0
  74. package/Tests/App/Dashboard/EventNotesUtil.test.ts +116 -17
  75. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +242 -5
  76. package/Tests/App/Dashboard/IncidentChangeStateNotifyDefault.test.tsx +91 -1
  77. package/Tests/App/Dashboard/IncidentCreateDetailsStep.test.tsx +643 -0
  78. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +8 -1
  79. package/Tests/App/Dashboard/IncidentCreateStatusPageScope.test.tsx +601 -0
  80. package/Tests/App/Dashboard/IncidentCustomFieldTemplateVariables.test.tsx +386 -0
  81. package/Tests/App/Dashboard/IncidentNoteTemplatePlaceholders.test.tsx +124 -0
  82. package/Tests/App/Dashboard/IncidentNoteTemplateVariablesLoader.test.ts +305 -0
  83. package/Tests/App/Dashboard/IncidentOverviewNotifyDefault.test.tsx +112 -0
  84. package/Tests/App/Dashboard/IncidentSettingsRenotifyOnPublish.test.tsx +674 -0
  85. package/Tests/App/Dashboard/IncidentSettingsStatusPageScope.test.tsx +1051 -0
  86. package/Tests/App/Dashboard/IncidentTemplateCustomFields.test.tsx +384 -0
  87. package/Tests/App/Dashboard/IncidentTemplateStatusPageScopeCard.test.tsx +215 -0
  88. package/Tests/App/Dashboard/PublicNotePagesNotifyDefault.test.tsx +113 -1
  89. package/Tests/App/Dashboard/SubscriberAudienceSummary.test.tsx +512 -0
  90. package/Tests/App/Dashboard/SubscriberNotificationPreviewModal.test.tsx +837 -0
  91. package/Tests/App/Dashboard/SubscriberNotificationStatusLabel.test.tsx +88 -0
  92. package/Tests/App/Dashboard/SubscriberNotificationStatusResend.test.tsx +473 -0
  93. package/Tests/App/Dashboard/SubscriberTemplateLivePreview.test.tsx +196 -0
  94. package/Tests/App/Dashboard/TeamAddedSubscribersUnsubscribedNotice.test.tsx +248 -0
  95. package/Tests/App/SensitiveUrlTokenBootstrap.test.ts +64 -3
  96. package/Tests/App/StatusPage/StatusPageUnsubscribePage.test.tsx +412 -0
  97. package/Tests/Models/IncidentCustomFieldApiSchemaContract.test.ts +217 -0
  98. package/Tests/Models/IncidentCustomFieldCreateSettingsColumns.test.ts +252 -0
  99. package/Tests/Models/StatusPageSubscriberUnsubscribeColumns.test.ts +218 -0
  100. package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +7 -0
  101. package/Tests/Server/API/IncidentSubscriberAudienceAPI.test.ts +1408 -0
  102. package/Tests/Server/API/StatusPageIncidentScope.test.ts +1868 -0
  103. package/Tests/Server/API/StatusPageIncidentScopePostgres.test.ts +790 -0
  104. package/Tests/Server/API/StatusPageManageSubscriptionRecipient.test.ts +10 -1
  105. package/Tests/Server/API/StatusPageManageSubscriptionTemplateVariables.test.ts +206 -18
  106. package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +14 -2
  107. package/Tests/Server/API/StatusPageSubscriberCreateResponse.test.ts +217 -0
  108. package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +103 -2
  109. package/Tests/Server/API/StatusPageUnsubscribeAPI.test.ts +396 -0
  110. package/Tests/Server/Infrastructure/Postgres/AddIncidentStatusPageScopeMigration.test.ts +395 -0
  111. package/Tests/Server/Infrastructure/Postgres/AddIncidentTemplateStatusPageScopeFlagMigration.test.ts +150 -0
  112. package/Tests/Server/Infrastructure/Postgres/AddSubscriberNotificationClaimedAtMigration.test.ts +156 -0
  113. package/Tests/Server/Infrastructure/Postgres/SkipHiddenIncidentCreatedNotificationsMigration.test.ts +410 -0
  114. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +551 -1
  115. package/Tests/Server/Middleware/SubscriberNotificationPreviewRateLimit.test.ts +238 -0
  116. package/Tests/Server/Middleware/SubscriberNotificationTestSendRateLimit.test.ts +245 -0
  117. package/Tests/Server/Services/AddStatusPageSubscriberUnsubscribeTokenMigration.test.ts +152 -0
  118. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +221 -36
  119. package/Tests/Server/Services/DatabaseServiceCompareAndSetColumns.test.ts +191 -0
  120. package/Tests/Server/Services/IncidentCreatedRenotifyOnPublish.test.ts +681 -0
  121. package/Tests/Server/Services/IncidentCreatedResend.test.ts +775 -0
  122. package/Tests/Server/Services/IncidentCustomFieldRenamePostgres.test.ts +713 -0
  123. package/Tests/Server/Services/IncidentCustomFieldService.test.ts +751 -0
  124. package/Tests/Server/Services/IncidentCustomFieldValueValidation.test.ts +556 -0
  125. package/Tests/Server/Services/IncidentStatusPageScope.test.ts +2971 -0
  126. package/Tests/Server/Services/IncidentStatusPageScopeFeed.test.ts +131 -0
  127. package/Tests/Server/Services/IncidentStatusPageScopePostgres.test.ts +625 -0
  128. package/Tests/Server/Services/IncidentTemplateCustomFieldsCopy.test.ts +311 -0
  129. package/Tests/Server/Services/IncidentTemplateStatusPageScope.test.ts +363 -0
  130. package/Tests/Server/Services/PublicNoteEditFeedEventType.test.ts +213 -0
  131. package/Tests/Server/Services/PublicNoteResendAccess.test.ts +994 -0
  132. package/Tests/Server/Services/ScheduledMaintenanceSubscriberTemplateFormats.test.ts +289 -9
  133. package/Tests/Server/Services/SmsServiceFailIfNotSent.test.ts +67 -0
  134. package/Tests/Server/Services/StatusPageReportIncidentCounts.test.ts +610 -0
  135. package/Tests/Server/Services/StatusPageSubscriberAudienceCounts.test.ts +201 -0
  136. package/Tests/Server/Services/StatusPageSubscriberAudienceCountsPostgres.test.ts +271 -0
  137. package/Tests/Server/Services/StatusPageSubscriberConfirmationTemplateEscaping.test.ts +200 -0
  138. package/Tests/Server/Services/StatusPageSubscriberNotificationLogRedaction.test.ts +54 -0
  139. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateCompile.test.ts +37 -0
  140. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateIncidentRecordAccess.test.ts +772 -0
  141. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookup.test.ts +506 -0
  142. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookupPostgres.test.ts +261 -0
  143. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +21 -5
  144. package/Tests/Server/Services/StatusPageSubscriberUnsubscribe.test.ts +1618 -0
  145. package/Tests/Server/Services/StatusPageSubscriberUnsubscribeLinkSenders.test.ts +373 -0
  146. package/Tests/Server/Services/StatusPageSubscriberUnsubscribePostgres.test.ts +388 -0
  147. package/Tests/Server/Services/SubscriberNotificationInFlightRequeue.test.ts +500 -0
  148. package/Tests/Server/Types/MarkdownEmailLinks.test.ts +320 -0
  149. package/Tests/Server/Types/Workflow/Components/Email.test.ts +491 -1
  150. package/Tests/Server/Types/Workflow/Components/EmailSmtpPinning.test.ts +398 -0
  151. package/Tests/Server/Utils/CustomField/CustomFieldRename.test.ts +586 -0
  152. package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +246 -10
  153. package/Tests/Server/Utils/DataSource/EgressGuardDeploymentConfig.test.ts +226 -0
  154. package/Tests/Server/Utils/DataSource/EgressGuardPinning.test.ts +131 -0
  155. package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +4 -0
  156. package/Tests/Server/Utils/EgressPolicyEnvironment.ts +45 -0
  157. package/Tests/Server/Utils/Mail/PinnedSmtpSocket.test.ts +409 -0
  158. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +23 -0
  159. package/Tests/Server/Utils/SSRFProtection.test.ts +4 -0
  160. package/Tests/Server/Utils/SSRFProtectionCloudServiceAddresses.test.ts +4 -0
  161. package/Tests/Server/Utils/SSRFProtectionPrivateNetwork.test.ts +4 -0
  162. package/Tests/Server/Utils/SSRFProtectionResolutionDetail.test.ts +314 -0
  163. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScope.test.ts +1699 -0
  164. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScopeQueriesPostgres.test.ts +508 -0
  165. package/Tests/Server/Utils/StatusPage/IncidentSubscriberAudienceCallerPermission.test.ts +246 -0
  166. package/Tests/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.test.ts +1348 -0
  167. package/Tests/Server/Utils/StatusPage/StatusPageReadAccess.test.ts +221 -0
  168. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.test.ts +132 -0
  169. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.test.ts +155 -0
  170. package/Tests/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.test.ts +708 -0
  171. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaim.test.ts +255 -0
  172. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaimPostgres.test.ts +528 -0
  173. package/Tests/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.test.ts +1238 -0
  174. package/Tests/Server/Utils/StatusPage/SubscriberNotificationFanOut.test.ts +472 -0
  175. package/Tests/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.test.ts +1168 -0
  176. package/Tests/Server/Utils/StatusPage/SubscriberNotificationRunLimit.test.ts +275 -0
  177. package/Tests/Server/Utils/StatusPage/SubscriberNotificationTiming.test.ts +131 -0
  178. package/Tests/Server/Utils/StatusPageResource.test.ts +98 -0
  179. package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +17 -0
  180. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +75 -0
  181. package/Tests/Types/CustomField/CustomFieldSavedViews.test.ts +247 -0
  182. package/Tests/Types/CustomField/CustomFieldTemplateValues.test.ts +186 -0
  183. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +48 -0
  184. package/Tests/Types/CustomField/CustomFieldValueFormat.test.ts +215 -0
  185. package/Tests/Types/CustomField/CustomFieldValueValidator.test.ts +343 -0
  186. package/Tests/Types/CustomField/CustomFieldVariableKey.test.ts +364 -0
  187. package/Tests/Types/SafeHtml.test.ts +102 -0
  188. package/Tests/Types/StatusPage/IncidentCreatedRenotify.test.ts +419 -0
  189. package/Tests/Types/StatusPage/IncidentCreatedResend.test.ts +118 -0
  190. package/Tests/Types/StatusPage/IncidentScopeAddedPagesNotification.test.ts +558 -0
  191. package/Tests/Types/StatusPage/IncidentSubscriberAudience.test.ts +284 -0
  192. package/Tests/Types/StatusPage/StatusPageSubscriberUnsubscribe.test.ts +341 -0
  193. package/Tests/Types/StatusPage/SubscriberNotificationPreview.test.ts +196 -0
  194. package/Tests/Types/StatusPage/SubscriberNotificationResend.test.ts +201 -0
  195. package/Tests/Types/StatusPage/SubscriberNotificationTemplateCompiler.test.ts +277 -0
  196. package/Tests/Types/StatusPage/SubscriberNotificationTemplatePreview.test.ts +179 -0
  197. package/Tests/Types/StatusPage/SubscriberNotificationTemplateVariables.test.ts +324 -0
  198. package/Tests/Types/StatusPage/SubscriberUpdateNotification.test.ts +60 -13
  199. package/Tests/UI/Components/CustomFields/CustomFieldFormFields.test.ts +432 -0
  200. package/Tests/UI/Components/CustomFields/CustomFieldModelFormFields.test.tsx +685 -0
  201. package/Tests/UI/Components/CustomFields/CustomFieldsDetailOrder.test.tsx +469 -0
  202. package/Tests/UI/Components/Forms/ModelFormCustomFieldInputs.test.tsx +325 -0
  203. package/Tests/UI/Components/Forms/ModelFormDropdownOptionsRefused.test.tsx +358 -0
  204. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +22 -0
  205. package/Tests/UI/Monitor/NetworkPathView.test.tsx +289 -0
  206. package/Tests/Utils/Incident/IncidentNoteTemplateVariables.test.ts +623 -0
  207. package/Tests/Utils/Markdown/MarkdownEscape.test.ts +89 -0
  208. package/Tests/Utils/ProbeNetworkFailureUtil.test.ts +992 -0
  209. package/Tests/Utils/RequestFailedDetails.test.ts +575 -0
  210. package/Types/CustomField/CustomFieldOrder.ts +55 -0
  211. package/Types/CustomField/CustomFieldSavedViews.ts +225 -0
  212. package/Types/CustomField/CustomFieldTemplateMerge.ts +71 -0
  213. package/Types/CustomField/CustomFieldType.ts +14 -0
  214. package/Types/CustomField/CustomFieldValueFormat.ts +161 -0
  215. package/Types/CustomField/CustomFieldValueValidator.ts +488 -0
  216. package/Types/CustomField/CustomFieldVariableKey.ts +211 -0
  217. package/Types/SafeHtml.ts +93 -0
  218. package/Types/StatusPage/IncidentCreatedRenotify.ts +224 -0
  219. package/Types/StatusPage/IncidentCreatedResend.ts +108 -0
  220. package/Types/StatusPage/IncidentScopeAddedPagesNotification.ts +343 -0
  221. package/Types/StatusPage/IncidentSubscriberAudience.ts +332 -0
  222. package/Types/StatusPage/StatusPageSubscriberUnsubscribe.ts +405 -0
  223. package/Types/StatusPage/SubscriberNotificationInterruption.ts +32 -0
  224. package/Types/StatusPage/SubscriberNotificationPreview.ts +322 -0
  225. package/Types/StatusPage/SubscriberNotificationResend.ts +132 -0
  226. package/Types/StatusPage/SubscriberNotificationTemplateCompiler.ts +180 -0
  227. package/Types/StatusPage/SubscriberNotificationTemplatePreview.ts +301 -0
  228. package/Types/StatusPage/SubscriberNotificationTemplateVariables.ts +217 -4
  229. package/Types/StatusPage/SubscriberUpdateNotification.ts +32 -11
  230. package/UI/Components/CustomFields/CustomFieldFormFields.ts +317 -0
  231. package/UI/Components/CustomFields/CustomFieldModelFormFields.tsx +395 -0
  232. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +95 -55
  233. package/UI/Components/Forms/ModelForm.tsx +70 -8
  234. package/UI/Components/Forms/Validation.ts +1 -1
  235. package/UI/Components/ModelFormModal/ModelFormModal.tsx +2 -4
  236. package/UI/Components/ModelTable/AnalyticsModelTable.tsx +2 -6
  237. package/UI/Components/ModelTable/BaseModelTable.tsx +9 -8
  238. package/UI/Components/ModelTable/CustomFieldColumns.tsx +14 -1
  239. package/UI/Components/ModelTable/ModelTable.tsx +6 -7
  240. package/UI/Utils/SensitiveUrlToken.ts +10 -5
  241. package/Utils/API.ts +247 -2
  242. package/Utils/Incident/IncidentNoteTemplateVariables.ts +304 -0
  243. package/Utils/Markdown/MarkdownEscape.ts +65 -0
  244. package/Utils/ProbeNetworkFailureUtil.ts +673 -0
  245. package/build/dist/Models/DatabaseModels/Incident.js +267 -1
  246. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  247. package/build/dist/Models/DatabaseModels/IncidentCustomField.js +207 -1
  248. package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
  249. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +31 -0
  250. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  251. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +124 -1
  252. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  253. package/build/dist/Models/DatabaseModels/StatusPage.js +56 -0
  254. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  255. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +131 -0
  256. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  257. package/build/dist/Server/API/IncidentAPI.js +100 -1
  258. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  259. package/build/dist/Server/API/StatusPageAPI.js +417 -176
  260. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  261. package/build/dist/Server/API/StatusPageSubscriberAPI.js +52 -13
  262. package/build/dist/Server/API/StatusPageSubscriberAPI.js.map +1 -1
  263. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js +41 -0
  264. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js.map +1 -0
  265. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js +57 -0
  266. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js.map +1 -0
  267. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js +23 -0
  268. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js.map +1 -0
  269. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js +40 -0
  270. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js.map +1 -0
  271. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js +26 -0
  272. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js.map +1 -0
  273. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js +89 -0
  274. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js.map +1 -0
  275. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js +29 -0
  276. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js.map +1 -0
  277. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -0
  278. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  279. package/build/dist/Server/Infrastructure/Semaphore.js +3 -0
  280. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  281. package/build/dist/Server/Middleware/IdentityRateLimit.js +89 -3
  282. package/build/dist/Server/Middleware/IdentityRateLimit.js.map +1 -1
  283. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js +149 -0
  284. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js.map +1 -0
  285. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js +190 -0
  286. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js.map +1 -0
  287. package/build/dist/Server/Services/DatabaseService.js +56 -7
  288. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  289. package/build/dist/Server/Services/IncidentCustomFieldService.js +204 -8
  290. package/build/dist/Server/Services/IncidentCustomFieldService.js.map +1 -1
  291. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js +24 -0
  292. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js.map +1 -1
  293. package/build/dist/Server/Services/IncidentEpisodeService.js +14 -0
  294. package/build/dist/Server/Services/IncidentEpisodeService.js.map +1 -1
  295. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +26 -0
  296. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  297. package/build/dist/Server/Services/IncidentPublicNoteService.js +26 -1
  298. package/build/dist/Server/Services/IncidentPublicNoteService.js.map +1 -1
  299. package/build/dist/Server/Services/IncidentService.js +1052 -0
  300. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  301. package/build/dist/Server/Services/IncidentStateTimelineService.js +12 -0
  302. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  303. package/build/dist/Server/Services/IncidentTemplateService.js +83 -0
  304. package/build/dist/Server/Services/IncidentTemplateService.js.map +1 -1
  305. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js +26 -1
  306. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js.map +1 -1
  307. package/build/dist/Server/Services/ScheduledMaintenanceService.js +47 -17
  308. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  309. package/build/dist/Server/Services/SmsService.js +2 -22
  310. package/build/dist/Server/Services/SmsService.js.map +1 -1
  311. package/build/dist/Server/Services/StatusPageService.js +47 -9
  312. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  313. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +237 -32
  314. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  315. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js +109 -0
  316. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js.map +1 -1
  317. package/build/dist/Server/Services/StatusPageSubscriberService.js +925 -99
  318. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  319. package/build/dist/Server/Types/Markdown.js +110 -6
  320. package/build/dist/Server/Types/Markdown.js.map +1 -1
  321. package/build/dist/Server/Types/Workflow/Components/Email.js +55 -6
  322. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  323. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js +211 -0
  324. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js.map +1 -0
  325. package/build/dist/Server/Utils/DataSource/EgressGuard.js +41 -12
  326. package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -1
  327. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js +101 -0
  328. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js.map +1 -0
  329. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -0
  330. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  331. package/build/dist/Server/Utils/SSRFProtection.js +48 -6
  332. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  333. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js +631 -0
  334. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js.map +1 -0
  335. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js +403 -0
  336. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js.map +1 -0
  337. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js +451 -0
  338. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js.map +1 -0
  339. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js +13 -0
  340. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js.map +1 -0
  341. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js +91 -0
  342. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js.map +1 -0
  343. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js +101 -0
  344. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js.map +1 -0
  345. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js +47 -0
  346. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js.map +1 -0
  347. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js +273 -0
  348. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js.map +1 -0
  349. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js +73 -0
  350. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js.map +1 -0
  351. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js +722 -0
  352. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js.map +1 -0
  353. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js +102 -0
  354. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js.map +1 -0
  355. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js +527 -0
  356. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js.map +1 -0
  357. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js +278 -0
  358. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js.map +1 -0
  359. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js +137 -0
  360. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js.map +1 -0
  361. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js +103 -0
  362. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js.map +1 -0
  363. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js +114 -0
  364. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js.map +1 -0
  365. package/build/dist/Server/Utils/StatusPageResource.js +39 -7
  366. package/build/dist/Server/Utils/StatusPageResource.js.map +1 -1
  367. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  368. package/build/dist/Server/Utils/VM/VMRunner.js +1 -0
  369. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  370. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js +3 -0
  371. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js.map +1 -1
  372. package/build/dist/Types/CustomField/CustomFieldOrder.js +36 -0
  373. package/build/dist/Types/CustomField/CustomFieldOrder.js.map +1 -0
  374. package/build/dist/Types/CustomField/CustomFieldSavedViews.js +135 -0
  375. package/build/dist/Types/CustomField/CustomFieldSavedViews.js.map +1 -0
  376. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js +27 -0
  377. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js.map +1 -0
  378. package/build/dist/Types/CustomField/CustomFieldType.js +12 -0
  379. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  380. package/build/dist/Types/CustomField/CustomFieldValueFormat.js +126 -0
  381. package/build/dist/Types/CustomField/CustomFieldValueFormat.js.map +1 -0
  382. package/build/dist/Types/CustomField/CustomFieldValueValidator.js +280 -0
  383. package/build/dist/Types/CustomField/CustomFieldValueValidator.js.map +1 -0
  384. package/build/dist/Types/CustomField/CustomFieldVariableKey.js +150 -0
  385. package/build/dist/Types/CustomField/CustomFieldVariableKey.js.map +1 -0
  386. package/build/dist/Types/SafeHtml.js +84 -0
  387. package/build/dist/Types/SafeHtml.js.map +1 -0
  388. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js +117 -0
  389. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js.map +1 -0
  390. package/build/dist/Types/StatusPage/IncidentCreatedResend.js +86 -0
  391. package/build/dist/Types/StatusPage/IncidentCreatedResend.js.map +1 -0
  392. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js +246 -0
  393. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js.map +1 -0
  394. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js +183 -0
  395. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js.map +1 -0
  396. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js +277 -0
  397. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js.map +1 -0
  398. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js +27 -0
  399. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js.map +1 -0
  400. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js +190 -0
  401. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js.map +1 -0
  402. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js +102 -0
  403. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js.map +1 -0
  404. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js +97 -0
  405. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js.map +1 -0
  406. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js +175 -0
  407. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js.map +1 -0
  408. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js +144 -4
  409. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js.map +1 -1
  410. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js +29 -9
  411. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js.map +1 -1
  412. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js +169 -0
  413. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js.map +1 -0
  414. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js +212 -0
  415. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js.map +1 -0
  416. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +64 -49
  417. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  418. package/build/dist/UI/Components/Forms/ModelForm.js +39 -5
  419. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  420. package/build/dist/UI/Components/Forms/Validation.js +1 -1
  421. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  422. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  423. package/build/dist/UI/Components/ModelTable/AnalyticsModelTable.js.map +1 -1
  424. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +2 -2
  425. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  426. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +13 -1
  427. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  428. package/build/dist/UI/Components/ModelTable/ModelTable.js +1 -1
  429. package/build/dist/UI/Components/ModelTable/ModelTable.js.map +1 -1
  430. package/build/dist/UI/Utils/SensitiveUrlToken.js +10 -5
  431. package/build/dist/UI/Utils/SensitiveUrlToken.js.map +1 -1
  432. package/build/dist/Utils/API.js +177 -1
  433. package/build/dist/Utils/API.js.map +1 -1
  434. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js +155 -0
  435. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js.map +1 -0
  436. package/build/dist/Utils/Markdown/MarkdownEscape.js +48 -0
  437. package/build/dist/Utils/Markdown/MarkdownEscape.js.map +1 -1
  438. package/build/dist/Utils/ProbeNetworkFailureUtil.js +460 -0
  439. package/build/dist/Utils/ProbeNetworkFailureUtil.js.map +1 -0
  440. package/package.json +1 -1
@@ -0,0 +1,1699 @@
1
+ import Incident from "../../../../Models/DatabaseModels/Incident";
2
+ import IncidentEpisodeMember from "../../../../Models/DatabaseModels/IncidentEpisodeMember";
3
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
4
+ import StatusPage from "../../../../Models/DatabaseModels/StatusPage";
5
+ import StatusPageResource from "../../../../Models/DatabaseModels/StatusPageResource";
6
+ import IncidentEpisodeMemberService from "../../../../Server/Services/IncidentEpisodeMemberService";
7
+ import IncidentService from "../../../../Server/Services/IncidentService";
8
+ import StatusPageResourceService from "../../../../Server/Services/StatusPageResourceService";
9
+ import StatusPageService from "../../../../Server/Services/StatusPageService";
10
+ import StatusPageSubscriberService from "../../../../Server/Services/StatusPageSubscriberService";
11
+ import IncidentStatusPageScope, {
12
+ INCIDENT_SCOPE_COLUMNS,
13
+ INCIDENT_SCOPE_SELECT,
14
+ ResolvedIncidentStatusPages,
15
+ StatusPageExclusionReason,
16
+ SUBSCRIBER_NOTIFICATION_RESOURCE_SELECT,
17
+ } from "../../../../Server/Utils/StatusPage/IncidentStatusPageScope";
18
+ import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
19
+ import Dictionary from "../../../../Types/Dictionary";
20
+ import BadDataException from "../../../../Types/Exception/BadDataException";
21
+ import { JSONObject } from "../../../../Types/JSON";
22
+ import ObjectID from "../../../../Types/ObjectID";
23
+ import PositiveNumber from "../../../../Types/PositiveNumber";
24
+ import getJestMockFunction, { MockFunction } from "../../../MockType";
25
+ import {
26
+ afterEach,
27
+ beforeEach,
28
+ describe,
29
+ expect,
30
+ jest,
31
+ test,
32
+ } from "@jest/globals";
33
+
34
+ /*
35
+ * IncidentStatusPageScope decides which status pages an incident reaches:
36
+ * which pages show it and whose subscribers hear about it. These tests stub
37
+ * the database behind it - status page resources by monitor, the status
38
+ * pages, the incidents' stored scope, and the display queries - and pin down:
39
+ *
40
+ * - scope intersects with the pages the monitors reach, and never widens;
41
+ * - pages that only show scoped incidents never reach an unscoped one;
42
+ * - an episode reaches the union of its incidents, each through its own
43
+ * scope;
44
+ * - the display queries split on isScopedToStatusPages in SQL, then merge,
45
+ * sort and truncate the two halves as the database would have;
46
+ * - the counts are disjoint and include projectId and
47
+ * isVisibleOnStatusPage;
48
+ * - everything fails closed when a scope column was not loaded.
49
+ */
50
+
51
+ const PROJECT_ID: ObjectID = new ObjectID(
52
+ "10000000-0000-4000-8000-000000000001",
53
+ );
54
+
55
+ const INCIDENT_A: string = "a0000000-0000-4000-8000-00000000000a";
56
+ const INCIDENT_B: string = "a0000000-0000-4000-8000-00000000000b";
57
+
58
+ const SHARED_MONITOR: string = "c0000000-0000-4000-8000-000000000001";
59
+ const OTHER_MONITOR: string = "c0000000-0000-4000-8000-000000000002";
60
+
61
+ // Ten site pages that all list the shared monitor.
62
+ const SITE_PAGE_IDS: Array<string> = Array.from(
63
+ { length: 10 },
64
+ (_value: unknown, index: number): string => {
65
+ return `b0000000-0000-4000-8000-0000000000${(index + 1).toString().padStart(2, "0")}`;
66
+ },
67
+ );
68
+
69
+ function sitePageId(site: number): string {
70
+ return SITE_PAGE_IDS[site - 1]!;
71
+ }
72
+
73
+ function siteName(site: number): string {
74
+ return `Site ${site.toString().padStart(2, "0")}`;
75
+ }
76
+
77
+ interface PageFixture {
78
+ id: string;
79
+ name: string;
80
+ onlyShowScopedIncidents?: boolean | undefined;
81
+ }
82
+
83
+ interface ResourceFixture {
84
+ id: string;
85
+ statusPageId: string;
86
+ monitorId: string;
87
+ displayName: string;
88
+ }
89
+
90
+ interface IncidentScopeFixture {
91
+ id: string;
92
+ isScopedToStatusPages?: boolean | undefined;
93
+ statusPageIds?: Array<string> | undefined;
94
+ }
95
+
96
+ let pages: Array<PageFixture> = [];
97
+ let resources: Array<ResourceFixture> = [];
98
+ let storedScopes: Array<IncidentScopeFixture> = [];
99
+
100
+ let findByMonitors: MockFunction;
101
+ let getStatusPagesToSendNotification: MockFunction;
102
+ let incidentFindBy: MockFunction;
103
+ let incidentCountBy: MockFunction;
104
+ let episodeMemberFindBy: MockFunction;
105
+
106
+ // The ids QueryHelper.any was given (it builds a Raw IN operator).
107
+ function idsIn(operator: unknown): Array<string> {
108
+ const raw: { objectLiteralParameters?: Dictionary<unknown> } = operator as {
109
+ objectLiteralParameters?: Dictionary<unknown>;
110
+ };
111
+
112
+ return (
113
+ Object.values(raw.objectLiteralParameters || {}) as Array<
114
+ Array<string | ObjectID>
115
+ >
116
+ )
117
+ .flat()
118
+ .map((id: string | ObjectID): string => {
119
+ return id.toString().toLowerCase();
120
+ });
121
+ }
122
+
123
+ function makePage(fixture: PageFixture): StatusPage {
124
+ const page: StatusPage = new StatusPage();
125
+ page._id = fixture.id;
126
+ page.name = fixture.name;
127
+ page.projectId = PROJECT_ID;
128
+
129
+ if (fixture.onlyShowScopedIncidents !== undefined) {
130
+ page.onlyShowScopedIncidents = fixture.onlyShowScopedIncidents;
131
+ }
132
+
133
+ return page;
134
+ }
135
+
136
+ function makeResource(fixture: ResourceFixture): StatusPageResource {
137
+ const resource: StatusPageResource = new StatusPageResource();
138
+ resource._id = fixture.id;
139
+ resource.statusPageId = new ObjectID(fixture.statusPageId);
140
+ resource.displayName = fixture.displayName;
141
+ return resource;
142
+ }
143
+
144
+ function incidentOn(id: string, monitorIds: Array<string>): Incident {
145
+ const incident: Incident = new Incident();
146
+ incident._id = id;
147
+ incident.monitors = monitorIds.map((monitorId: string): Monitor => {
148
+ const monitor: Monitor = new Monitor();
149
+ monitor._id = monitorId;
150
+ return monitor;
151
+ });
152
+ return incident;
153
+ }
154
+
155
+ // Ten site pages listing the shared monitor, one resource each.
156
+ function useTenSitePages(onlyShowScopedIncidents: boolean = false): void {
157
+ pages = SITE_PAGE_IDS.map((id: string, index: number): PageFixture => {
158
+ return {
159
+ id: id,
160
+ name: siteName(index + 1),
161
+ onlyShowScopedIncidents: onlyShowScopedIncidents,
162
+ };
163
+ });
164
+
165
+ resources = SITE_PAGE_IDS.map(
166
+ (id: string, index: number): ResourceFixture => {
167
+ return {
168
+ id: `d0000000-0000-4000-8000-0000000000${(index + 1).toString().padStart(2, "0")}`,
169
+ statusPageId: id,
170
+ monitorId: SHARED_MONITOR,
171
+ displayName: `Checkout (${siteName(index + 1)})`,
172
+ };
173
+ },
174
+ );
175
+ }
176
+
177
+ function reachedPageNames(
178
+ resolved: ResolvedIncidentStatusPages,
179
+ ): Array<string> {
180
+ return resolved.statusPages.map((page: StatusPage): string => {
181
+ return page.name!;
182
+ });
183
+ }
184
+
185
+ function excludedPageNames(
186
+ resolved: ResolvedIncidentStatusPages,
187
+ reason: StatusPageExclusionReason,
188
+ ): Array<string> {
189
+ return resolved.excludedStatusPages
190
+ .filter((excluded: { reason: StatusPageExclusionReason }): boolean => {
191
+ return excluded.reason === reason;
192
+ })
193
+ .map((excluded: { statusPage: StatusPage }): string => {
194
+ return excluded.statusPage.name!;
195
+ });
196
+ }
197
+
198
+ beforeEach(() => {
199
+ pages = [];
200
+ resources = [];
201
+ storedScopes = [];
202
+
203
+ findByMonitors = getJestMockFunction();
204
+ findByMonitors.mockImplementation(
205
+ (data: {
206
+ monitorIds: Array<ObjectID>;
207
+ }): Promise<Array<StatusPageResource>> => {
208
+ const monitorIds: Array<string> = data.monitorIds.map(
209
+ (id: ObjectID): string => {
210
+ return id.toString().toLowerCase();
211
+ },
212
+ );
213
+
214
+ return Promise.resolve(
215
+ resources
216
+ .filter((resource: ResourceFixture): boolean => {
217
+ return monitorIds.includes(resource.monitorId);
218
+ })
219
+ .map(makeResource),
220
+ );
221
+ },
222
+ );
223
+ jest
224
+ .spyOn(StatusPageResourceService, "findByMonitors")
225
+ .mockImplementation(findByMonitors as never);
226
+
227
+ getStatusPagesToSendNotification = getJestMockFunction();
228
+ getStatusPagesToSendNotification.mockImplementation(
229
+ (ids: Array<ObjectID>): Promise<Array<StatusPage>> => {
230
+ const wanted: Array<string> = ids.map((id: ObjectID): string => {
231
+ return id.toString().toLowerCase();
232
+ });
233
+
234
+ return Promise.resolve(
235
+ pages
236
+ .filter((page: PageFixture): boolean => {
237
+ return wanted.includes(page.id);
238
+ })
239
+ .map(makePage),
240
+ );
241
+ },
242
+ );
243
+ jest
244
+ .spyOn(StatusPageSubscriberService, "getStatusPagesToSendNotification")
245
+ .mockImplementation(getStatusPagesToSendNotification as never);
246
+
247
+ incidentFindBy = getJestMockFunction();
248
+ incidentFindBy.mockImplementation(
249
+ (findBy: { query: JSONObject }): Promise<Array<Incident>> => {
250
+ const wanted: Array<string> = idsIn(findBy.query["_id"]);
251
+
252
+ return Promise.resolve(
253
+ storedScopes
254
+ .filter((scope: IncidentScopeFixture): boolean => {
255
+ return wanted.includes(scope.id);
256
+ })
257
+ .map((scope: IncidentScopeFixture): Incident => {
258
+ const incident: Incident = new Incident();
259
+ incident._id = scope.id;
260
+ // Left unset when the fixture has no flag, as an unselected column reads.
261
+ if (scope.isScopedToStatusPages !== undefined) {
262
+ incident.isScopedToStatusPages = scope.isScopedToStatusPages;
263
+ }
264
+ incident.statusPages = (scope.statusPageIds || []).map(
265
+ (id: string): StatusPage => {
266
+ const page: StatusPage = new StatusPage();
267
+ page._id = id;
268
+ return page;
269
+ },
270
+ );
271
+ return incident;
272
+ }),
273
+ );
274
+ },
275
+ );
276
+ jest
277
+ .spyOn(IncidentService, "findBy")
278
+ .mockImplementation(incidentFindBy as never);
279
+
280
+ incidentCountBy = getJestMockFunction();
281
+ incidentCountBy.mockImplementation(
282
+ (countBy: { query: JSONObject }): Promise<PositiveNumber> => {
283
+ return Promise.resolve(
284
+ new PositiveNumber(countBy.query["isScopedToStatusPages"] ? 2 : 5),
285
+ );
286
+ },
287
+ );
288
+ jest
289
+ .spyOn(IncidentService, "countBy")
290
+ .mockImplementation(incidentCountBy as never);
291
+
292
+ episodeMemberFindBy = getJestMockFunction();
293
+ episodeMemberFindBy.mockResolvedValue([] as never);
294
+ jest
295
+ .spyOn(IncidentEpisodeMemberService, "findBy")
296
+ .mockImplementation(episodeMemberFindBy as never);
297
+ });
298
+
299
+ afterEach(() => {
300
+ jest.restoreAllMocks();
301
+ });
302
+
303
+ describe("IncidentStatusPageScope.resolvePagesForIncidents", () => {
304
+ test("an unscoped incident on a monitor shared by ten pages reaches all ten, in name order", async () => {
305
+ useTenSitePages();
306
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
307
+ // Handed back out of order: the result is sorted by name.
308
+ pages.reverse();
309
+
310
+ const resolved: ResolvedIncidentStatusPages =
311
+ await IncidentStatusPageScope.resolvePagesForIncidents({
312
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
313
+ });
314
+
315
+ expect(reachedPageNames(resolved)).toEqual(
316
+ Array.from({ length: 10 }, (_v: unknown, i: number): string => {
317
+ return siteName(i + 1);
318
+ }),
319
+ );
320
+ expect(resolved.isScoped).toBe(false);
321
+ expect(resolved.excludedStatusPages).toEqual([]);
322
+ });
323
+
324
+ test("a monitor shared by ten pages with the incident scoped to two reaches only those two", async () => {
325
+ useTenSitePages();
326
+ storedScopes = [
327
+ {
328
+ id: INCIDENT_A,
329
+ isScopedToStatusPages: true,
330
+ statusPageIds: [sitePageId(7), sitePageId(3)],
331
+ },
332
+ ];
333
+
334
+ const resolved: ResolvedIncidentStatusPages =
335
+ await IncidentStatusPageScope.resolvePagesForIncidents({
336
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
337
+ });
338
+
339
+ expect(reachedPageNames(resolved)).toEqual(["Site 03", "Site 07"]);
340
+ expect(resolved.isScoped).toBe(true);
341
+ expect(
342
+ excludedPageNames(
343
+ resolved,
344
+ StatusPageExclusionReason.OutsideIncidentScope,
345
+ ),
346
+ ).toHaveLength(8);
347
+ });
348
+
349
+ test("each reached page keeps only its own resources", async () => {
350
+ useTenSitePages();
351
+ storedScopes = [
352
+ {
353
+ id: INCIDENT_A,
354
+ isScopedToStatusPages: true,
355
+ statusPageIds: [sitePageId(3), sitePageId(7)],
356
+ },
357
+ ];
358
+
359
+ const resolved: ResolvedIncidentStatusPages =
360
+ await IncidentStatusPageScope.resolvePagesForIncidents({
361
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
362
+ });
363
+
364
+ expect(Object.keys(resolved.statusPageToResources).sort()).toEqual(
365
+ [sitePageId(3), sitePageId(7)].sort(),
366
+ );
367
+ expect(
368
+ resolved.statusPageToResources[sitePageId(3)]!.map(
369
+ (resource: StatusPageResource): string => {
370
+ return resource.displayName!;
371
+ },
372
+ ),
373
+ ).toEqual(["Checkout (Site 03)"]);
374
+ });
375
+
376
+ test("an unscoped incident reaches none of ten pages that only show scoped incidents", async () => {
377
+ useTenSitePages(true);
378
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
379
+
380
+ const resolved: ResolvedIncidentStatusPages =
381
+ await IncidentStatusPageScope.resolvePagesForIncidents({
382
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
383
+ });
384
+
385
+ expect(resolved.statusPages).toEqual([]);
386
+ expect(resolved.statusPageToResources).toEqual({});
387
+ expect(
388
+ excludedPageNames(
389
+ resolved,
390
+ StatusPageExclusionReason.OnlyShowsScopedIncidents,
391
+ ),
392
+ ).toHaveLength(10);
393
+ });
394
+
395
+ test("an unscoped incident still reaches the pages that do not only show scoped incidents", async () => {
396
+ useTenSitePages();
397
+ pages[0]!.onlyShowScopedIncidents = true;
398
+ pages[1]!.onlyShowScopedIncidents = true;
399
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
400
+
401
+ const resolved: ResolvedIncidentStatusPages =
402
+ await IncidentStatusPageScope.resolvePagesForIncidents({
403
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
404
+ });
405
+
406
+ expect(reachedPageNames(resolved)).toHaveLength(8);
407
+ expect(reachedPageNames(resolved)).not.toContain("Site 01");
408
+ expect(reachedPageNames(resolved)).not.toContain("Site 02");
409
+ expect(
410
+ excludedPageNames(
411
+ resolved,
412
+ StatusPageExclusionReason.OnlyShowsScopedIncidents,
413
+ ),
414
+ ).toEqual(["Site 01", "Site 02"]);
415
+ });
416
+
417
+ test("a scoped incident reaches a page that only shows scoped incidents when it is scoped to it", async () => {
418
+ useTenSitePages(true);
419
+ storedScopes = [
420
+ {
421
+ id: INCIDENT_A,
422
+ isScopedToStatusPages: true,
423
+ statusPageIds: [sitePageId(5)],
424
+ },
425
+ ];
426
+
427
+ const resolved: ResolvedIncidentStatusPages =
428
+ await IncidentStatusPageScope.resolvePagesForIncidents({
429
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
430
+ });
431
+
432
+ expect(reachedPageNames(resolved)).toEqual(["Site 05"]);
433
+ });
434
+
435
+ /*
436
+ * The customer's setup: every site page only shows scoped incidents, and
437
+ * the incident is scoped to two of them. The other eight are left out
438
+ * because the incident is limited to other pages - not because they only
439
+ * show scoped incidents, which this one is.
440
+ */
441
+ test("pages that only show scoped incidents, left out of a scoped incident, are outside its scope", async () => {
442
+ useTenSitePages(true);
443
+ storedScopes = [
444
+ {
445
+ id: INCIDENT_A,
446
+ isScopedToStatusPages: true,
447
+ statusPageIds: [sitePageId(3), sitePageId(7)],
448
+ },
449
+ ];
450
+
451
+ const resolved: ResolvedIncidentStatusPages =
452
+ await IncidentStatusPageScope.resolvePagesForIncidents({
453
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
454
+ });
455
+
456
+ expect(reachedPageNames(resolved)).toEqual(["Site 03", "Site 07"]);
457
+ expect(
458
+ excludedPageNames(
459
+ resolved,
460
+ StatusPageExclusionReason.OutsideIncidentScope,
461
+ ),
462
+ ).toEqual([
463
+ "Site 01",
464
+ "Site 02",
465
+ "Site 04",
466
+ "Site 05",
467
+ "Site 06",
468
+ "Site 08",
469
+ "Site 09",
470
+ "Site 10",
471
+ ]);
472
+ expect(
473
+ excludedPageNames(
474
+ resolved,
475
+ StatusPageExclusionReason.OnlyShowsScopedIncidents,
476
+ ),
477
+ ).toEqual([]);
478
+ });
479
+
480
+ test("a scope never adds a page that does not list the incident's monitors", async () => {
481
+ useTenSitePages();
482
+ const unrelatedPage: string = "b0000000-0000-4000-8000-0000000000ff";
483
+ pages.push({ id: unrelatedPage, name: "Unrelated" });
484
+ storedScopes = [
485
+ {
486
+ id: INCIDENT_A,
487
+ isScopedToStatusPages: true,
488
+ statusPageIds: [unrelatedPage, sitePageId(2)],
489
+ },
490
+ ];
491
+
492
+ const resolved: ResolvedIncidentStatusPages =
493
+ await IncidentStatusPageScope.resolvePagesForIncidents({
494
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
495
+ });
496
+
497
+ expect(reachedPageNames(resolved)).toEqual(["Site 02"]);
498
+ // It is not even looked up: only pages the monitors reach are candidates.
499
+ const requested: Array<string> = (
500
+ getStatusPagesToSendNotification.mock.calls[0]![0] as Array<ObjectID>
501
+ ).map((id: ObjectID): string => {
502
+ return id.toString();
503
+ });
504
+ expect(requested).not.toContain(unrelatedPage);
505
+ });
506
+
507
+ test("an incident scoped to pages that were all deleted reaches nothing and looks nothing up", async () => {
508
+ useTenSitePages();
509
+ storedScopes = [
510
+ { id: INCIDENT_A, isScopedToStatusPages: true, statusPageIds: [] },
511
+ ];
512
+
513
+ const resolved: ResolvedIncidentStatusPages =
514
+ await IncidentStatusPageScope.resolvePagesForIncidents({
515
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
516
+ });
517
+
518
+ expect(resolved.statusPages).toEqual([]);
519
+ expect(resolved.isScoped).toBe(true);
520
+ expect(findByMonitors).not.toHaveBeenCalled();
521
+ expect(getStatusPagesToSendNotification).not.toHaveBeenCalled();
522
+ });
523
+
524
+ test("an incident no longer found reaches nothing, rather than every page", async () => {
525
+ useTenSitePages();
526
+ storedScopes = [];
527
+
528
+ const resolved: ResolvedIncidentStatusPages =
529
+ await IncidentStatusPageScope.resolvePagesForIncidents({
530
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
531
+ });
532
+
533
+ expect(resolved.statusPages).toEqual([]);
534
+ expect(findByMonitors).not.toHaveBeenCalled();
535
+ });
536
+
537
+ test("the scope is read from the database, never from the incident passed in", async () => {
538
+ useTenSitePages();
539
+ storedScopes = [
540
+ {
541
+ id: INCIDENT_A,
542
+ isScopedToStatusPages: true,
543
+ statusPageIds: [sitePageId(4)],
544
+ },
545
+ ];
546
+
547
+ // A stale or hand-built object claiming to be unscoped.
548
+ const passed: Incident = incidentOn(INCIDENT_A, [SHARED_MONITOR]);
549
+ passed.isScopedToStatusPages = false;
550
+ passed.statusPages = [];
551
+
552
+ const resolved: ResolvedIncidentStatusPages =
553
+ await IncidentStatusPageScope.resolvePagesForIncidents({
554
+ incidents: [passed],
555
+ });
556
+
557
+ expect(reachedPageNames(resolved)).toEqual(["Site 04"]);
558
+ expect(incidentFindBy).toHaveBeenCalledTimes(1);
559
+ expect(incidentFindBy.mock.calls[0]![0]).toEqual(
560
+ expect.objectContaining({
561
+ select: {
562
+ _id: true,
563
+ isScopedToStatusPages: true,
564
+ statusPages: { _id: true },
565
+ },
566
+ props: { isRoot: true },
567
+ }),
568
+ );
569
+ });
570
+
571
+ test("a stored scope with the flag missing is treated as scoped (fails closed)", async () => {
572
+ useTenSitePages();
573
+ storedScopes = [{ id: INCIDENT_A, statusPageIds: [sitePageId(1)] }];
574
+
575
+ const resolved: ResolvedIncidentStatusPages =
576
+ await IncidentStatusPageScope.resolvePagesForIncidents({
577
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
578
+ });
579
+
580
+ expect(reachedPageNames(resolved)).toEqual(["Site 01"]);
581
+ });
582
+
583
+ test("a page whose onlyShowScopedIncidents was not loaded never reaches an unscoped incident", async () => {
584
+ useTenSitePages();
585
+ pages[2]!.onlyShowScopedIncidents = undefined;
586
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
587
+
588
+ const resolved: ResolvedIncidentStatusPages =
589
+ await IncidentStatusPageScope.resolvePagesForIncidents({
590
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
591
+ });
592
+
593
+ expect(reachedPageNames(resolved)).not.toContain("Site 03");
594
+ expect(reachedPageNames(resolved)).toHaveLength(9);
595
+ });
596
+
597
+ test("scope ids match whatever case they were stored in", async () => {
598
+ useTenSitePages();
599
+ storedScopes = [
600
+ {
601
+ id: INCIDENT_A,
602
+ isScopedToStatusPages: true,
603
+ statusPageIds: [sitePageId(6).toUpperCase()],
604
+ },
605
+ ];
606
+
607
+ const resolved: ResolvedIncidentStatusPages =
608
+ await IncidentStatusPageScope.resolvePagesForIncidents({
609
+ incidents: [incidentOn(INCIDENT_A.toUpperCase(), [SHARED_MONITOR])],
610
+ });
611
+
612
+ expect(reachedPageNames(resolved)).toEqual(["Site 06"]);
613
+ });
614
+
615
+ test("a status page deleted since its resource was read is dropped", async () => {
616
+ useTenSitePages();
617
+ pages = pages.filter((page: PageFixture): boolean => {
618
+ return page.id !== sitePageId(9);
619
+ });
620
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
621
+
622
+ const resolved: ResolvedIncidentStatusPages =
623
+ await IncidentStatusPageScope.resolvePagesForIncidents({
624
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
625
+ });
626
+
627
+ expect(reachedPageNames(resolved)).not.toContain("Site 09");
628
+ expect(reachedPageNames(resolved)).toHaveLength(9);
629
+ });
630
+
631
+ test("an incident on no monitor reaches nothing", async () => {
632
+ useTenSitePages();
633
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
634
+
635
+ const resolved: ResolvedIncidentStatusPages =
636
+ await IncidentStatusPageScope.resolvePagesForIncidents({
637
+ incidents: [incidentOn(INCIDENT_A, [])],
638
+ });
639
+
640
+ expect(resolved.statusPages).toEqual([]);
641
+ expect(findByMonitors).not.toHaveBeenCalled();
642
+ });
643
+
644
+ test("an incident without an id is refused rather than guessed at", async () => {
645
+ const incident: Incident = new Incident();
646
+ incident.monitors = [];
647
+
648
+ await expect(
649
+ IncidentStatusPageScope.resolvePagesForIncidents({
650
+ incidents: [incident],
651
+ }),
652
+ ).rejects.toBeInstanceOf(BadDataException);
653
+ });
654
+
655
+ test("resources are read with the subscriber jobs' select, plus what grouping needs", async () => {
656
+ useTenSitePages();
657
+ storedScopes = [{ id: INCIDENT_A, isScopedToStatusPages: false }];
658
+
659
+ await IncidentStatusPageScope.resolvePagesForIncidents({
660
+ incidents: [incidentOn(INCIDENT_A, [SHARED_MONITOR])],
661
+ });
662
+
663
+ expect(findByMonitors.mock.calls[0]![0]).toEqual(
664
+ expect.objectContaining({
665
+ select: {
666
+ ...SUBSCRIBER_NOTIFICATION_RESOURCE_SELECT,
667
+ _id: true,
668
+ statusPageId: true,
669
+ },
670
+ }),
671
+ );
672
+ });
673
+
674
+ test("pages sort by name, case-insensitively and with numbers in order", () => {
675
+ const names: Array<string> = ["site 10", "Site 2", "Alpha", "site 1"];
676
+ const sorted: Array<string> = names
677
+ .map((name: string, index: number): StatusPage => {
678
+ return makePage({ id: `id-${index}`, name: name });
679
+ })
680
+ .sort(IncidentStatusPageScope.compareStatusPagesByName)
681
+ .map((page: StatusPage): string => {
682
+ return page.name!;
683
+ });
684
+
685
+ expect(sorted).toEqual(["Alpha", "site 1", "Site 2", "site 10"]);
686
+ });
687
+ });
688
+
689
+ describe("IncidentStatusPageScope.resolvePagesForIncidents, for an episode's incidents", () => {
690
+ const PAGE_EAST: string = "b0000000-0000-4000-8000-0000000000e1";
691
+ const PAGE_WEST: string = "b0000000-0000-4000-8000-0000000000e2";
692
+ const PAGE_NORTH: string = "b0000000-0000-4000-8000-0000000000e3";
693
+
694
+ beforeEach(() => {
695
+ pages = [
696
+ { id: PAGE_EAST, name: "East", onlyShowScopedIncidents: true },
697
+ { id: PAGE_WEST, name: "West", onlyShowScopedIncidents: false },
698
+ { id: PAGE_NORTH, name: "North", onlyShowScopedIncidents: false },
699
+ ];
700
+ resources = [
701
+ {
702
+ id: "d0000000-0000-4000-8000-0000000000e1",
703
+ statusPageId: PAGE_EAST,
704
+ monitorId: SHARED_MONITOR,
705
+ displayName: "Checkout",
706
+ },
707
+ {
708
+ id: "d0000000-0000-4000-8000-0000000000e2",
709
+ statusPageId: PAGE_WEST,
710
+ monitorId: SHARED_MONITOR,
711
+ displayName: "Checkout",
712
+ },
713
+ {
714
+ id: "d0000000-0000-4000-8000-0000000000e3",
715
+ statusPageId: PAGE_WEST,
716
+ monitorId: OTHER_MONITOR,
717
+ displayName: "Payments",
718
+ },
719
+ {
720
+ id: "d0000000-0000-4000-8000-0000000000e4",
721
+ statusPageId: PAGE_NORTH,
722
+ monitorId: OTHER_MONITOR,
723
+ displayName: "Payments",
724
+ },
725
+ ];
726
+ });
727
+
728
+ test("reaches the union of what each incident reaches through its own scope", async () => {
729
+ storedScopes = [
730
+ // Scoped to East, which only shows scoped incidents.
731
+ {
732
+ id: INCIDENT_A,
733
+ isScopedToStatusPages: true,
734
+ statusPageIds: [PAGE_EAST],
735
+ },
736
+ // Unscoped: every page its monitor is on, except East.
737
+ { id: INCIDENT_B, isScopedToStatusPages: false },
738
+ ];
739
+
740
+ const resolved: ResolvedIncidentStatusPages =
741
+ await IncidentStatusPageScope.resolvePagesForIncidents({
742
+ incidents: [
743
+ incidentOn(INCIDENT_A, [SHARED_MONITOR]),
744
+ incidentOn(INCIDENT_B, [OTHER_MONITOR]),
745
+ ],
746
+ });
747
+
748
+ expect(reachedPageNames(resolved)).toEqual(["East", "North", "West"]);
749
+ // Dedupe applies when any incident of the episode is scoped.
750
+ expect(resolved.isScoped).toBe(true);
751
+ });
752
+
753
+ test("a scoped-only page reached by the unscoped incident alone is left out because it only shows scoped incidents; one a scoped incident also skipped is outside its scope", async () => {
754
+ storedScopes = [
755
+ // Scoped to West, which does not only show scoped incidents.
756
+ {
757
+ id: INCIDENT_A,
758
+ isScopedToStatusPages: true,
759
+ statusPageIds: [PAGE_WEST],
760
+ },
761
+ // Unscoped, on the monitor East lists too: every page but East.
762
+ { id: INCIDENT_B, isScopedToStatusPages: false },
763
+ ];
764
+
765
+ const onlyUnscoped: ResolvedIncidentStatusPages =
766
+ await IncidentStatusPageScope.resolvePagesForIncidents({
767
+ incidents: [incidentOn(INCIDENT_B, [SHARED_MONITOR])],
768
+ });
769
+
770
+ expect(
771
+ excludedPageNames(
772
+ onlyUnscoped,
773
+ StatusPageExclusionReason.OnlyShowsScopedIncidents,
774
+ ),
775
+ ).toEqual(["East"]);
776
+
777
+ const both: ResolvedIncidentStatusPages =
778
+ await IncidentStatusPageScope.resolvePagesForIncidents({
779
+ incidents: [
780
+ incidentOn(INCIDENT_A, [SHARED_MONITOR]),
781
+ incidentOn(INCIDENT_B, [SHARED_MONITOR]),
782
+ ],
783
+ });
784
+
785
+ expect(
786
+ excludedPageNames(both, StatusPageExclusionReason.OutsideIncidentScope),
787
+ ).toEqual(["East"]);
788
+ expect(
789
+ excludedPageNames(
790
+ both,
791
+ StatusPageExclusionReason.OnlyShowsScopedIncidents,
792
+ ),
793
+ ).toEqual([]);
794
+ });
795
+
796
+ test("a page lists only the resources of the incidents that reach it", async () => {
797
+ storedScopes = [
798
+ {
799
+ id: INCIDENT_A,
800
+ isScopedToStatusPages: true,
801
+ statusPageIds: [PAGE_EAST],
802
+ },
803
+ { id: INCIDENT_B, isScopedToStatusPages: false },
804
+ ];
805
+
806
+ const resolved: ResolvedIncidentStatusPages =
807
+ await IncidentStatusPageScope.resolvePagesForIncidents({
808
+ incidents: [
809
+ incidentOn(INCIDENT_A, [SHARED_MONITOR]),
810
+ incidentOn(INCIDENT_B, [OTHER_MONITOR]),
811
+ ],
812
+ });
813
+
814
+ // West lists the shared monitor too, but only B (other monitor) reaches it.
815
+ expect(
816
+ resolved.statusPageToResources[PAGE_WEST]!.map(
817
+ (resource: StatusPageResource): string => {
818
+ return resource.displayName!;
819
+ },
820
+ ),
821
+ ).toEqual(["Payments"]);
822
+ });
823
+
824
+ test("unscoped incidents are looked up together, scoped ones on their own", async () => {
825
+ storedScopes = [
826
+ {
827
+ id: INCIDENT_A,
828
+ isScopedToStatusPages: true,
829
+ statusPageIds: [PAGE_EAST],
830
+ },
831
+ { id: INCIDENT_B, isScopedToStatusPages: false },
832
+ {
833
+ id: "a0000000-0000-4000-8000-0000000000cc",
834
+ isScopedToStatusPages: false,
835
+ },
836
+ ];
837
+
838
+ await IncidentStatusPageScope.resolvePagesForIncidents({
839
+ incidents: [
840
+ incidentOn(INCIDENT_A, [SHARED_MONITOR]),
841
+ incidentOn(INCIDENT_B, [OTHER_MONITOR]),
842
+ incidentOn("a0000000-0000-4000-8000-0000000000cc", [
843
+ OTHER_MONITOR,
844
+ SHARED_MONITOR,
845
+ ]),
846
+ ],
847
+ });
848
+
849
+ // One lookup for both unscoped incidents, one for the scoped one.
850
+ expect(findByMonitors).toHaveBeenCalledTimes(2);
851
+ const unscopedLookup: Array<string> = (
852
+ findByMonitors.mock.calls[0]![0] as { monitorIds: Array<ObjectID> }
853
+ ).monitorIds.map((id: ObjectID): string => {
854
+ return id.toString();
855
+ });
856
+ expect(unscopedLookup.sort()).toEqual(
857
+ [OTHER_MONITOR, SHARED_MONITOR].sort(),
858
+ );
859
+ // Their scope is read in one query.
860
+ expect(incidentFindBy).toHaveBeenCalledTimes(1);
861
+ });
862
+
863
+ test("a resource two incidents share is listed once", async () => {
864
+ storedScopes = [
865
+ {
866
+ id: INCIDENT_A,
867
+ isScopedToStatusPages: true,
868
+ statusPageIds: [PAGE_WEST],
869
+ },
870
+ { id: INCIDENT_B, isScopedToStatusPages: false },
871
+ ];
872
+
873
+ const resolved: ResolvedIncidentStatusPages =
874
+ await IncidentStatusPageScope.resolvePagesForIncidents({
875
+ incidents: [
876
+ incidentOn(INCIDENT_A, [OTHER_MONITOR]),
877
+ incidentOn(INCIDENT_B, [OTHER_MONITOR]),
878
+ ],
879
+ });
880
+
881
+ expect(resolved.statusPageToResources[PAGE_WEST]).toHaveLength(1);
882
+ });
883
+
884
+ test("an episode of unscoped incidents is not deduplicated", async () => {
885
+ storedScopes = [
886
+ { id: INCIDENT_A, isScopedToStatusPages: false },
887
+ { id: INCIDENT_B, isScopedToStatusPages: false },
888
+ ];
889
+
890
+ const resolved: ResolvedIncidentStatusPages =
891
+ await IncidentStatusPageScope.resolvePagesForIncidents({
892
+ incidents: [
893
+ incidentOn(INCIDENT_A, [SHARED_MONITOR]),
894
+ incidentOn(INCIDENT_B, [OTHER_MONITOR]),
895
+ ],
896
+ });
897
+
898
+ expect(resolved.isScoped).toBe(false);
899
+ expect(reachedPageNames(resolved)).toEqual(["North", "West"]);
900
+ });
901
+ });
902
+
903
+ describe("IncidentStatusPageScope.getEpisodeMemberIncidents", () => {
904
+ function member(data: {
905
+ incidentId?: string;
906
+ relationId?: string;
907
+ monitorIds?: Array<string>;
908
+ withIncident?: boolean;
909
+ }): IncidentEpisodeMember {
910
+ const row: IncidentEpisodeMember = new IncidentEpisodeMember();
911
+
912
+ if (data.incidentId) {
913
+ row.incidentId = new ObjectID(data.incidentId);
914
+ }
915
+
916
+ if (data.withIncident !== false) {
917
+ const incident: Incident = incidentOn(
918
+ data.relationId || "",
919
+ data.monitorIds || [],
920
+ );
921
+ if (!data.relationId) {
922
+ delete (incident as unknown as Dictionary<unknown>)["_id"];
923
+ }
924
+ row.incident = incident;
925
+ }
926
+
927
+ return row;
928
+ }
929
+
930
+ test("returns each member incident once, with its id and monitors", async () => {
931
+ episodeMemberFindBy.mockResolvedValue([
932
+ member({ relationId: INCIDENT_A, monitorIds: [SHARED_MONITOR] }),
933
+ // Its relation came back without _id: the foreign key fills it in.
934
+ member({ incidentId: INCIDENT_B, monitorIds: [OTHER_MONITOR] }),
935
+ member({ relationId: INCIDENT_A, monitorIds: [SHARED_MONITOR] }),
936
+ member({ incidentId: INCIDENT_B, withIncident: false }),
937
+ ] as never);
938
+
939
+ const incidents: Array<Incident> =
940
+ await IncidentStatusPageScope.getEpisodeMemberIncidents(
941
+ new ObjectID("e0000000-0000-4000-8000-000000000001"),
942
+ );
943
+
944
+ expect(
945
+ incidents.map((incident: Incident): string => {
946
+ return incident._id!;
947
+ }),
948
+ ).toEqual([INCIDENT_A, INCIDENT_B]);
949
+ expect(incidents[1]!.monitors![0]!._id).toBe(OTHER_MONITOR);
950
+ });
951
+
952
+ test("asks for the incident id and monitors, as root", async () => {
953
+ const episodeId: ObjectID = new ObjectID(
954
+ "e0000000-0000-4000-8000-000000000001",
955
+ );
956
+
957
+ await IncidentStatusPageScope.getEpisodeMemberIncidents(episodeId);
958
+
959
+ expect(episodeMemberFindBy.mock.calls[0]![0]).toEqual(
960
+ expect.objectContaining({
961
+ query: { incidentEpisodeId: episodeId },
962
+ select: {
963
+ incidentId: true,
964
+ incident: { _id: true, monitors: { _id: true } },
965
+ },
966
+ props: { isRoot: true },
967
+ }),
968
+ );
969
+ });
970
+ });
971
+
972
+ describe("IncidentStatusPageScope.resolvePagesForDraftIncident", () => {
973
+ test("with no pages picked, an unsaved incident reaches every page except scoped-only ones", async () => {
974
+ useTenSitePages();
975
+ pages[0]!.onlyShowScopedIncidents = true;
976
+
977
+ const resolved: ResolvedIncidentStatusPages =
978
+ await IncidentStatusPageScope.resolvePagesForDraftIncident({
979
+ monitorIds: [new ObjectID(SHARED_MONITOR)],
980
+ });
981
+
982
+ expect(reachedPageNames(resolved)).toHaveLength(9);
983
+ expect(resolved.isScoped).toBe(false);
984
+ // Nothing to read from the database: it is not saved.
985
+ expect(incidentFindBy).not.toHaveBeenCalled();
986
+ });
987
+
988
+ test("with pages picked, it reaches only those among the monitors' pages", async () => {
989
+ useTenSitePages(true);
990
+
991
+ const resolved: ResolvedIncidentStatusPages =
992
+ await IncidentStatusPageScope.resolvePagesForDraftIncident({
993
+ monitorIds: [new ObjectID(SHARED_MONITOR)],
994
+ statusPageIds: [new ObjectID(sitePageId(2)), sitePageId(9)],
995
+ });
996
+
997
+ expect(reachedPageNames(resolved)).toEqual(["Site 02", "Site 09"]);
998
+ expect(resolved.isScoped).toBe(true);
999
+ });
1000
+ });
1001
+
1002
+ describe("IncidentStatusPageScope.findIncidentsForStatusPage", () => {
1003
+ const STATUS_PAGE_ID: string = "b0000000-0000-4000-8000-0000000000aa";
1004
+
1005
+ function page(onlyShowScopedIncidents: boolean | undefined): StatusPage {
1006
+ return makePage({
1007
+ id: STATUS_PAGE_ID,
1008
+ name: "Site",
1009
+ onlyShowScopedIncidents: onlyShowScopedIncidents,
1010
+ });
1011
+ }
1012
+
1013
+ function incidentAt(
1014
+ id: string,
1015
+ declaredAt: Date | null,
1016
+ createdAt: Date = new Date("2026-01-01T00:00:00Z"),
1017
+ ): Incident {
1018
+ const incident: Incident = new Incident();
1019
+ incident._id = id;
1020
+ incident.declaredAt = declaredAt as Date;
1021
+ incident.createdAt = createdAt;
1022
+ incident.title = `Incident ${id}`;
1023
+ return incident;
1024
+ }
1025
+
1026
+ let unscopedRows: Array<Incident> = [];
1027
+ let scopedRows: Array<Incident> = [];
1028
+
1029
+ beforeEach(() => {
1030
+ unscopedRows = [];
1031
+ scopedRows = [];
1032
+
1033
+ incidentFindBy.mockImplementation(
1034
+ (findBy: {
1035
+ query: JSONObject;
1036
+ limit: number;
1037
+ }): Promise<Array<Incident>> => {
1038
+ const rows: Array<Incident> = findBy.query["isScopedToStatusPages"]
1039
+ ? scopedRows
1040
+ : unscopedRows;
1041
+
1042
+ // Fresh objects, as the database would return, cut at the limit.
1043
+ return Promise.resolve(
1044
+ rows.slice(0, findBy.limit).map((row: Incident): Incident => {
1045
+ const copy: Incident = new Incident();
1046
+ Object.assign(copy, row);
1047
+ return copy;
1048
+ }),
1049
+ );
1050
+ },
1051
+ );
1052
+ });
1053
+
1054
+ function queries(): Array<JSONObject> {
1055
+ return incidentFindBy.mock.calls.map((call: Array<unknown>): JSONObject => {
1056
+ return (call[0] as { query: JSONObject }).query;
1057
+ });
1058
+ }
1059
+
1060
+ test("runs the caller's query twice, split on isScopedToStatusPages", async () => {
1061
+ const monitors: Array<ObjectID> = [new ObjectID(SHARED_MONITOR)];
1062
+
1063
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1064
+ statusPage: page(false),
1065
+ query: {
1066
+ monitors: monitors as never,
1067
+ projectId: PROJECT_ID,
1068
+ isVisibleOnStatusPage: true,
1069
+ },
1070
+ select: { _id: true, title: true },
1071
+ limit: 10,
1072
+ props: { isRoot: true },
1073
+ });
1074
+
1075
+ expect(queries()).toEqual([
1076
+ {
1077
+ monitors: monitors,
1078
+ projectId: PROJECT_ID,
1079
+ isVisibleOnStatusPage: true,
1080
+ isScopedToStatusPages: false,
1081
+ },
1082
+ {
1083
+ monitors: monitors,
1084
+ projectId: PROJECT_ID,
1085
+ isVisibleOnStatusPage: true,
1086
+ isScopedToStatusPages: true,
1087
+ statusPages: [STATUS_PAGE_ID],
1088
+ },
1089
+ ]);
1090
+ });
1091
+
1092
+ test("a page that only shows scoped incidents runs only the scoped half", async () => {
1093
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1094
+ statusPage: page(true),
1095
+ query: { projectId: PROJECT_ID },
1096
+ select: { _id: true },
1097
+ limit: 10,
1098
+ props: { isRoot: true },
1099
+ });
1100
+
1101
+ expect(queries()).toEqual([
1102
+ {
1103
+ projectId: PROJECT_ID,
1104
+ isScopedToStatusPages: true,
1105
+ statusPages: [STATUS_PAGE_ID],
1106
+ },
1107
+ ]);
1108
+ });
1109
+
1110
+ test("a page whose onlyShowScopedIncidents was not loaded runs only the scoped half", async () => {
1111
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1112
+ statusPage: page(undefined),
1113
+ query: { projectId: PROJECT_ID },
1114
+ select: { _id: true },
1115
+ limit: 10,
1116
+ props: { isRoot: true },
1117
+ });
1118
+
1119
+ expect(queries()).toHaveLength(1);
1120
+ expect(queries()[0]!["isScopedToStatusPages"]).toBe(true);
1121
+ });
1122
+
1123
+ test("a caller cannot widen the scoped half through its own query", async () => {
1124
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1125
+ statusPage: page(false),
1126
+ query: {
1127
+ projectId: PROJECT_ID,
1128
+ isScopedToStatusPages: true,
1129
+ statusPages: ["b0000000-0000-4000-8000-0000000000bb"] as never,
1130
+ },
1131
+ select: { _id: true },
1132
+ limit: 10,
1133
+ props: { isRoot: true },
1134
+ });
1135
+
1136
+ expect(queries()[0]!["isScopedToStatusPages"]).toBe(false);
1137
+ expect(queries()[1]!["statusPages"]).toEqual([STATUS_PAGE_ID]);
1138
+ });
1139
+
1140
+ test("merges both halves, sorted as the database would, and cut to the limit", async () => {
1141
+ // Each half is already sorted newest first and capped by the database.
1142
+ unscopedRows = [
1143
+ incidentAt("u1", new Date("2026-05-10T00:00:00Z")),
1144
+ incidentAt("u2", new Date("2026-05-07T00:00:00Z")),
1145
+ incidentAt("u3", new Date("2026-05-01T00:00:00Z")),
1146
+ ];
1147
+ scopedRows = [
1148
+ incidentAt("s1", new Date("2026-05-09T00:00:00Z")),
1149
+ incidentAt("s2", new Date("2026-05-08T00:00:00Z")),
1150
+ incidentAt("s3", new Date("2026-04-01T00:00:00Z")),
1151
+ ];
1152
+
1153
+ const incidents: Array<Incident> =
1154
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1155
+ statusPage: page(false),
1156
+ query: { projectId: PROJECT_ID },
1157
+ select: { _id: true, title: true, declaredAt: true },
1158
+ sort: {
1159
+ declaredAt: SortOrder.Descending,
1160
+ createdAt: SortOrder.Descending,
1161
+ },
1162
+ limit: 4,
1163
+ props: { isRoot: true },
1164
+ });
1165
+
1166
+ expect(
1167
+ incidents.map((incident: Incident): string => {
1168
+ return incident._id!;
1169
+ }),
1170
+ ).toEqual(["u1", "s1", "s2", "u2"]);
1171
+
1172
+ // Each half was read up to the limit, so the cut sees every candidate.
1173
+ for (const call of incidentFindBy.mock.calls) {
1174
+ expect((call[0] as { limit: number; skip: number }).limit).toBe(4);
1175
+ expect((call[0] as { limit: number; skip: number }).skip).toBe(0);
1176
+ }
1177
+ });
1178
+
1179
+ test("an in-scope incident is never lost to the unscoped half filling the limit", async () => {
1180
+ // The unscoped half alone fills the limit with older incidents.
1181
+ unscopedRows = Array.from({ length: 5 }, (_v: unknown, i: number) => {
1182
+ return incidentAt(`u${i}`, new Date(`2026-03-0${i + 1}T00:00:00Z`));
1183
+ }).reverse();
1184
+ scopedRows = [incidentAt("scoped", new Date("2026-06-01T00:00:00Z"))];
1185
+
1186
+ const incidents: Array<Incident> =
1187
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1188
+ statusPage: page(false),
1189
+ query: { projectId: PROJECT_ID },
1190
+ select: { _id: true, declaredAt: true },
1191
+ sort: { declaredAt: SortOrder.Descending },
1192
+ limit: 5,
1193
+ props: { isRoot: true },
1194
+ });
1195
+
1196
+ expect(incidents).toHaveLength(5);
1197
+ expect(incidents[0]!._id).toBe("scoped");
1198
+ });
1199
+
1200
+ test("skip reads skip + limit from each half and pages through the merge", async () => {
1201
+ unscopedRows = [
1202
+ incidentAt("u1", new Date("2026-05-10T00:00:00Z")),
1203
+ incidentAt("u2", new Date("2026-05-07T00:00:00Z")),
1204
+ ];
1205
+ scopedRows = [
1206
+ incidentAt("s1", new Date("2026-05-09T00:00:00Z")),
1207
+ incidentAt("s2", new Date("2026-05-08T00:00:00Z")),
1208
+ ];
1209
+
1210
+ const incidents: Array<Incident> =
1211
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1212
+ statusPage: page(false),
1213
+ query: { projectId: PROJECT_ID },
1214
+ select: { _id: true, declaredAt: true },
1215
+ sort: { declaredAt: SortOrder.Descending },
1216
+ limit: 2,
1217
+ skip: 2,
1218
+ props: { isRoot: true },
1219
+ });
1220
+
1221
+ expect(
1222
+ incidents.map((incident: Incident): string => {
1223
+ return incident._id!;
1224
+ }),
1225
+ ).toEqual(["s2", "u2"]);
1226
+ expect((incidentFindBy.mock.calls[0]![0] as { limit: number }).limit).toBe(
1227
+ 4,
1228
+ );
1229
+ });
1230
+
1231
+ test("sorts missing values as Postgres does: first descending, last ascending", async () => {
1232
+ unscopedRows = [incidentAt("dated", new Date("2026-05-10T00:00:00Z"))];
1233
+ scopedRows = [incidentAt("undated", null)];
1234
+
1235
+ const descending: Array<Incident> =
1236
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1237
+ statusPage: page(false),
1238
+ query: {},
1239
+ select: { _id: true, declaredAt: true },
1240
+ sort: { declaredAt: SortOrder.Descending },
1241
+ limit: 5,
1242
+ props: { isRoot: true },
1243
+ });
1244
+
1245
+ const ascending: Array<Incident> =
1246
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1247
+ statusPage: page(false),
1248
+ query: {},
1249
+ select: { _id: true, declaredAt: true },
1250
+ sort: { declaredAt: SortOrder.Ascending },
1251
+ limit: 5,
1252
+ props: { isRoot: true },
1253
+ });
1254
+
1255
+ expect(
1256
+ descending.map((incident: Incident): string => {
1257
+ return incident._id!;
1258
+ }),
1259
+ ).toEqual(["undated", "dated"]);
1260
+ expect(
1261
+ ascending.map((incident: Incident): string => {
1262
+ return incident._id!;
1263
+ }),
1264
+ ).toEqual(["dated", "undated"]);
1265
+ });
1266
+
1267
+ test("breaks ties on the next sort key", async () => {
1268
+ const sameDay: Date = new Date("2026-05-10T00:00:00Z");
1269
+ unscopedRows = [
1270
+ incidentAt("older", sameDay, new Date("2026-05-10T01:00:00Z")),
1271
+ ];
1272
+ scopedRows = [
1273
+ incidentAt("newer", sameDay, new Date("2026-05-10T02:00:00Z")),
1274
+ ];
1275
+
1276
+ const incidents: Array<Incident> =
1277
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1278
+ statusPage: page(false),
1279
+ query: {},
1280
+ select: { _id: true, declaredAt: true, createdAt: true },
1281
+ sort: {
1282
+ declaredAt: SortOrder.Descending,
1283
+ createdAt: SortOrder.Descending,
1284
+ },
1285
+ limit: 5,
1286
+ props: { isRoot: true },
1287
+ });
1288
+
1289
+ expect(
1290
+ incidents.map((incident: Incident): string => {
1291
+ return incident._id!;
1292
+ }),
1293
+ ).toEqual(["newer", "older"]);
1294
+ });
1295
+
1296
+ test("an incident returned by both halves is listed once", async () => {
1297
+ const shared: Incident = incidentAt("same", new Date());
1298
+ unscopedRows = [shared];
1299
+ scopedRows = [shared];
1300
+
1301
+ const incidents: Array<Incident> =
1302
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1303
+ statusPage: page(false),
1304
+ query: {},
1305
+ select: { _id: true },
1306
+ limit: 5,
1307
+ props: { isRoot: true },
1308
+ });
1309
+
1310
+ expect(incidents).toHaveLength(1);
1311
+ });
1312
+
1313
+ test("reads unselected sort keys for the merge and removes them again", async () => {
1314
+ unscopedRows = [incidentAt("u1", new Date("2026-05-10T00:00:00Z"))];
1315
+
1316
+ const incidents: Array<Incident> =
1317
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1318
+ statusPage: page(false),
1319
+ query: {},
1320
+ select: { title: true },
1321
+ sort: { declaredAt: SortOrder.Descending },
1322
+ limit: 5,
1323
+ props: { isRoot: true },
1324
+ });
1325
+
1326
+ expect(
1327
+ (incidentFindBy.mock.calls[0]![0] as { select: JSONObject }).select,
1328
+ ).toEqual({ title: true, _id: true, declaredAt: true });
1329
+ expect(incidents[0]!.declaredAt).toBeUndefined();
1330
+ expect(incidents[0]!._id).toBe("u1");
1331
+ expect(incidents[0]!.title).toBe("Incident u1");
1332
+ });
1333
+
1334
+ test("never selects the scope columns into what it returns", async () => {
1335
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1336
+ statusPage: page(false),
1337
+ query: {},
1338
+ select: { _id: true, title: true },
1339
+ sort: { declaredAt: SortOrder.Descending },
1340
+ limit: 5,
1341
+ props: { isRoot: true },
1342
+ });
1343
+
1344
+ for (const call of incidentFindBy.mock.calls) {
1345
+ const select: JSONObject = (call[0] as { select: JSONObject }).select;
1346
+
1347
+ for (const column of INCIDENT_SCOPE_COLUMNS) {
1348
+ expect(select[column]).toBeUndefined();
1349
+ }
1350
+ }
1351
+ });
1352
+
1353
+ test("passes the caller's props through and does not change the caller's select", async () => {
1354
+ const select: JSONObject = { title: true };
1355
+
1356
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1357
+ statusPage: page(false),
1358
+ query: {},
1359
+ select: select,
1360
+ sort: { declaredAt: SortOrder.Descending },
1361
+ limit: 5,
1362
+ props: { isRoot: true, ignoreHooks: true },
1363
+ });
1364
+
1365
+ expect(select).toEqual({ title: true });
1366
+ expect(
1367
+ (incidentFindBy.mock.calls[0]![0] as { props: JSONObject }).props,
1368
+ ).toEqual({ isRoot: true, ignoreHooks: true });
1369
+ });
1370
+
1371
+ test("a limit of 0 reads nothing", async () => {
1372
+ const incidents: Array<Incident> =
1373
+ await IncidentStatusPageScope.findIncidentsForStatusPage({
1374
+ statusPage: page(false),
1375
+ query: {},
1376
+ select: { _id: true },
1377
+ limit: 0,
1378
+ props: { isRoot: true },
1379
+ });
1380
+
1381
+ expect(incidents).toEqual([]);
1382
+ expect(incidentFindBy).not.toHaveBeenCalled();
1383
+ });
1384
+
1385
+ test("a status page without an id is refused", async () => {
1386
+ await expect(
1387
+ IncidentStatusPageScope.findIncidentsForStatusPage({
1388
+ statusPage: { onlyShowScopedIncidents: false },
1389
+ query: {},
1390
+ select: { _id: true },
1391
+ limit: 5,
1392
+ props: { isRoot: true },
1393
+ }),
1394
+ ).rejects.toBeInstanceOf(BadDataException);
1395
+ });
1396
+
1397
+ test("findOneIncidentForStatusPage returns the first match or null", async () => {
1398
+ scopedRows = [incidentAt("only", new Date())];
1399
+
1400
+ const found: Incident | null =
1401
+ await IncidentStatusPageScope.findOneIncidentForStatusPage({
1402
+ statusPage: page(true),
1403
+ query: { _id: "only" },
1404
+ select: { _id: true },
1405
+ props: { isRoot: true },
1406
+ });
1407
+
1408
+ expect(found?._id).toBe("only");
1409
+ expect((incidentFindBy.mock.calls[0]![0] as { limit: number }).limit).toBe(
1410
+ 1,
1411
+ );
1412
+
1413
+ scopedRows = [];
1414
+
1415
+ expect(
1416
+ await IncidentStatusPageScope.findOneIncidentForStatusPage({
1417
+ statusPage: page(true),
1418
+ query: { _id: "only" },
1419
+ select: { _id: true },
1420
+ props: { isRoot: true },
1421
+ }),
1422
+ ).toBeNull();
1423
+ });
1424
+ });
1425
+
1426
+ describe("IncidentStatusPageScope.countIncidentsForStatusPage", () => {
1427
+ const STATUS_PAGE_ID: string = "b0000000-0000-4000-8000-0000000000aa";
1428
+
1429
+ function countQueries(): Array<JSONObject> {
1430
+ return incidentCountBy.mock.calls.map(
1431
+ (call: Array<unknown>): JSONObject => {
1432
+ return (call[0] as { query: JSONObject }).query;
1433
+ },
1434
+ );
1435
+ }
1436
+
1437
+ test("adds two disjoint counts, each on the project's visible incidents", async () => {
1438
+ const monitors: Array<ObjectID> = [new ObjectID(SHARED_MONITOR)];
1439
+
1440
+ const count: number =
1441
+ await IncidentStatusPageScope.countIncidentsForStatusPage({
1442
+ statusPage: makePage({
1443
+ id: STATUS_PAGE_ID,
1444
+ name: "Site",
1445
+ onlyShowScopedIncidents: false,
1446
+ }),
1447
+ projectId: PROJECT_ID,
1448
+ query: { monitors: monitors as never },
1449
+ });
1450
+
1451
+ // 5 unscoped + 2 scoped (see the countBy stub).
1452
+ expect(count).toBe(7);
1453
+ expect(countQueries()).toEqual([
1454
+ {
1455
+ monitors: monitors,
1456
+ projectId: PROJECT_ID,
1457
+ isVisibleOnStatusPage: true,
1458
+ isScopedToStatusPages: false,
1459
+ },
1460
+ {
1461
+ monitors: monitors,
1462
+ projectId: PROJECT_ID,
1463
+ isVisibleOnStatusPage: true,
1464
+ isScopedToStatusPages: true,
1465
+ statusPages: [STATUS_PAGE_ID],
1466
+ },
1467
+ ]);
1468
+ });
1469
+
1470
+ test("a page that only shows scoped incidents counts only those", async () => {
1471
+ const count: number =
1472
+ await IncidentStatusPageScope.countIncidentsForStatusPage({
1473
+ statusPage: makePage({
1474
+ id: STATUS_PAGE_ID,
1475
+ name: "Site",
1476
+ onlyShowScopedIncidents: true,
1477
+ }),
1478
+ projectId: PROJECT_ID,
1479
+ query: {},
1480
+ });
1481
+
1482
+ expect(count).toBe(2);
1483
+ expect(countQueries()).toHaveLength(1);
1484
+ });
1485
+
1486
+ test("projectId and isVisibleOnStatusPage cannot be loosened by the caller's query", async () => {
1487
+ const otherProject: ObjectID = ObjectID.generate();
1488
+
1489
+ await IncidentStatusPageScope.countIncidentsForStatusPage({
1490
+ statusPage: makePage({
1491
+ id: STATUS_PAGE_ID,
1492
+ name: "Site",
1493
+ onlyShowScopedIncidents: false,
1494
+ }),
1495
+ projectId: PROJECT_ID,
1496
+ query: { projectId: otherProject, isVisibleOnStatusPage: false },
1497
+ });
1498
+
1499
+ for (const query of countQueries()) {
1500
+ expect(query["projectId"]).toBe(PROJECT_ID);
1501
+ expect(query["isVisibleOnStatusPage"]).toBe(true);
1502
+ }
1503
+ });
1504
+
1505
+ test("counts as root unless told otherwise", async () => {
1506
+ await IncidentStatusPageScope.countIncidentsForStatusPage({
1507
+ statusPage: makePage({
1508
+ id: STATUS_PAGE_ID,
1509
+ name: "Site",
1510
+ onlyShowScopedIncidents: true,
1511
+ }),
1512
+ projectId: PROJECT_ID,
1513
+ query: {},
1514
+ });
1515
+
1516
+ expect(
1517
+ (incidentCountBy.mock.calls[0]![0] as { props: JSONObject }).props,
1518
+ ).toEqual({ isRoot: true });
1519
+ });
1520
+ });
1521
+
1522
+ describe("IncidentStatusPageScope.isIncidentInScope", () => {
1523
+ const PAGE: string = "b0000000-0000-4000-8000-0000000000aa";
1524
+ const OTHER_PAGE: string = "b0000000-0000-4000-8000-0000000000bb";
1525
+
1526
+ function scoped(ids: Array<unknown>): Incident {
1527
+ const incident: Incident = new Incident();
1528
+ incident.isScopedToStatusPages = true;
1529
+ incident.statusPages = ids as Array<StatusPage>;
1530
+ return incident;
1531
+ }
1532
+
1533
+ function unscoped(): Incident {
1534
+ const incident: Incident = new Incident();
1535
+ incident.isScopedToStatusPages = false;
1536
+ incident.statusPages = [];
1537
+ return incident;
1538
+ }
1539
+
1540
+ function target(onlyShowScopedIncidents: boolean | undefined): StatusPage {
1541
+ return makePage({
1542
+ id: PAGE,
1543
+ name: "Site",
1544
+ onlyShowScopedIncidents: onlyShowScopedIncidents,
1545
+ });
1546
+ }
1547
+
1548
+ test("a scoped incident is in scope only on its own pages", () => {
1549
+ expect(
1550
+ IncidentStatusPageScope.isIncidentInScope(
1551
+ scoped([{ _id: PAGE }]),
1552
+ target(true),
1553
+ ),
1554
+ ).toBe(true);
1555
+ expect(
1556
+ IncidentStatusPageScope.isIncidentInScope(
1557
+ scoped([{ _id: OTHER_PAGE }]),
1558
+ target(false),
1559
+ ),
1560
+ ).toBe(false);
1561
+ });
1562
+
1563
+ test("scope ids match in any shape and case", () => {
1564
+ expect(
1565
+ IncidentStatusPageScope.isIncidentInScope(
1566
+ scoped([new ObjectID(PAGE.toUpperCase())]),
1567
+ target(true),
1568
+ ),
1569
+ ).toBe(true);
1570
+ expect(
1571
+ IncidentStatusPageScope.isIncidentInScope(scoped([PAGE]), {
1572
+ id: new ObjectID(PAGE),
1573
+ onlyShowScopedIncidents: true,
1574
+ }),
1575
+ ).toBe(true);
1576
+ });
1577
+
1578
+ test("a scoped incident whose pages were all deleted is in no page's scope", () => {
1579
+ expect(
1580
+ IncidentStatusPageScope.isIncidentInScope(scoped([]), target(false)),
1581
+ ).toBe(false);
1582
+ });
1583
+
1584
+ test("an unscoped incident is in scope unless the page only shows scoped incidents", () => {
1585
+ expect(
1586
+ IncidentStatusPageScope.isIncidentInScope(unscoped(), target(false)),
1587
+ ).toBe(true);
1588
+ expect(
1589
+ IncidentStatusPageScope.isIncidentInScope(unscoped(), target(true)),
1590
+ ).toBe(false);
1591
+ });
1592
+
1593
+ test("fails closed when a scope column was not loaded", () => {
1594
+ // The page's switch not loaded.
1595
+ expect(
1596
+ IncidentStatusPageScope.isIncidentInScope(unscoped(), target(undefined)),
1597
+ ).toBe(false);
1598
+
1599
+ // The incident's flag not loaded.
1600
+ const notLoaded: Incident = new Incident();
1601
+ expect(
1602
+ IncidentStatusPageScope.isIncidentInScope(notLoaded, target(false)),
1603
+ ).toBe(false);
1604
+ });
1605
+ });
1606
+
1607
+ describe("IncidentStatusPageScope.removeScopeColumns", () => {
1608
+ test("removes every scope column and leaves the rest", () => {
1609
+ const incident: Incident = new Incident();
1610
+ incident._id = INCIDENT_A;
1611
+ incident.title = "Checkout down";
1612
+ incident.isScopedToStatusPages = true;
1613
+ incident.statusPages = [makePage({ id: "x", name: "Secret site" })];
1614
+ incident.statusPagesNotifiedOnCreation = ["x"];
1615
+
1616
+ const cleaned: Incident =
1617
+ IncidentStatusPageScope.removeScopeColumns(incident);
1618
+
1619
+ expect(cleaned.title).toBe("Checkout down");
1620
+ expect(cleaned._id).toBe(INCIDENT_A);
1621
+ for (const column of INCIDENT_SCOPE_COLUMNS) {
1622
+ expect(Object.prototype.hasOwnProperty.call(cleaned, column)).toBe(false);
1623
+ }
1624
+ expect(JSON.stringify(cleaned)).not.toContain("Secret site");
1625
+ });
1626
+
1627
+ test("removes them from an incident's JSON too", () => {
1628
+ const json: JSONObject = {
1629
+ _id: INCIDENT_A,
1630
+ title: "Checkout down",
1631
+ isScopedToStatusPages: true,
1632
+ statusPages: [{ _id: "x", name: "Secret site" }],
1633
+ statusPagesNotifiedOnCreation: ["x"],
1634
+ };
1635
+
1636
+ expect(IncidentStatusPageScope.removeScopeColumns(json)).toEqual({
1637
+ _id: INCIDENT_A,
1638
+ title: "Checkout down",
1639
+ });
1640
+ });
1641
+ });
1642
+
1643
+ describe("INCIDENT_SCOPE_SELECT", () => {
1644
+ test("selects what isIncidentInScope reads, all of it scope columns to remove again", () => {
1645
+ expect(Object.keys(INCIDENT_SCOPE_SELECT).sort()).toEqual([
1646
+ "isScopedToStatusPages",
1647
+ "statusPages",
1648
+ ]);
1649
+
1650
+ for (const column of Object.keys(INCIDENT_SCOPE_SELECT)) {
1651
+ expect(INCIDENT_SCOPE_COLUMNS).toContain(column);
1652
+ }
1653
+ });
1654
+
1655
+ test("an incident read with it can be placed in or out of a page's scope", () => {
1656
+ const incident: Incident = new Incident();
1657
+ incident.isScopedToStatusPages = true;
1658
+ incident.statusPages = [makePage({ id: sitePageId(1), name: siteName(1) })];
1659
+
1660
+ expect(
1661
+ IncidentStatusPageScope.isIncidentInScope(incident, {
1662
+ _id: sitePageId(1),
1663
+ onlyShowScopedIncidents: true,
1664
+ }),
1665
+ ).toBe(true);
1666
+ expect(
1667
+ IncidentStatusPageScope.isIncidentInScope(incident, {
1668
+ _id: sitePageId(2),
1669
+ onlyShowScopedIncidents: false,
1670
+ }),
1671
+ ).toBe(false);
1672
+ });
1673
+ });
1674
+
1675
+ describe("StatusPageSubscriberService.getStatusPagesToSendNotification", () => {
1676
+ test("loads onlyShowScopedIncidents, which the scope helper decides from", async () => {
1677
+ jest.restoreAllMocks();
1678
+
1679
+ const statusPageFindBy: MockFunction = getJestMockFunction();
1680
+ statusPageFindBy.mockResolvedValue([] as never);
1681
+ jest
1682
+ .spyOn(StatusPageService, "findBy")
1683
+ .mockImplementation(statusPageFindBy as never);
1684
+
1685
+ await StatusPageSubscriberService.getStatusPagesToSendNotification([
1686
+ new ObjectID(SITE_PAGE_IDS[0]!),
1687
+ ]);
1688
+
1689
+ const select: JSONObject = (
1690
+ statusPageFindBy.mock.calls[0]![0] as { select: JSONObject }
1691
+ ).select;
1692
+
1693
+ expect(select["onlyShowScopedIncidents"]).toBe(true);
1694
+ // And what the jobs need to name and order the pages.
1695
+ expect(select["name"]).toBe(true);
1696
+ expect(select["showIncidentsOnStatusPage"]).toBe(true);
1697
+ expect(select["showEpisodesOnStatusPage"]).toBe(true);
1698
+ });
1699
+ });