@oneuptime/common 14.0.6 → 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 (556) 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/InventoryItem.ts +23 -12
  6. package/Models/DatabaseModels/StatusPage.ts +55 -0
  7. package/Models/DatabaseModels/StatusPageSubscriber.ts +128 -0
  8. package/Server/API/IncidentAPI.ts +162 -0
  9. package/Server/API/StatusPageAPI.ts +487 -171
  10. package/Server/API/StatusPageSubscriberAPI.ts +63 -15
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.ts +49 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.ts +108 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.ts +33 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.ts +51 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.ts +33 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.ts +168 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.ts +44 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +16 -0
  20. package/Server/Infrastructure/Semaphore.ts +10 -0
  21. package/Server/Middleware/IdentityRateLimit.ts +108 -3
  22. package/Server/Middleware/SubscriberNotificationPreviewRateLimit.ts +237 -0
  23. package/Server/Middleware/SubscriberNotificationTestSendRateLimit.ts +304 -0
  24. package/Server/Services/AlertService.ts +32 -36
  25. package/Server/Services/DatabaseService.ts +93 -10
  26. package/Server/Services/IncidentAlertService.ts +609 -2
  27. package/Server/Services/IncidentCustomFieldService.ts +276 -1
  28. package/Server/Services/IncidentEpisodePublicNoteService.ts +28 -0
  29. package/Server/Services/IncidentEpisodeService.ts +15 -0
  30. package/Server/Services/IncidentEpisodeStateTimelineService.ts +27 -1
  31. package/Server/Services/IncidentPublicNoteService.ts +30 -1
  32. package/Server/Services/IncidentService.ts +1663 -103
  33. package/Server/Services/IncidentStateTimelineService.ts +13 -0
  34. package/Server/Services/IncidentTemplateService.ts +109 -0
  35. package/Server/Services/InventoryItemService.ts +38 -0
  36. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  37. package/Server/Services/ScheduledMaintenancePublicNoteService.ts +30 -1
  38. package/Server/Services/ScheduledMaintenanceService.ts +118 -64
  39. package/Server/Services/SmsService.ts +10 -0
  40. package/Server/Services/StatusPageService.ts +65 -8
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +299 -37
  42. package/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.ts +129 -0
  43. package/Server/Services/StatusPageSubscriberService.ts +1161 -64
  44. package/Server/Services/UserNotificationRuleService.ts +55 -53
  45. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  46. package/Server/Types/Markdown.ts +153 -6
  47. package/Server/Types/Workflow/Components/Email.ts +61 -2
  48. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  49. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  50. package/Server/Utils/CustomField/CustomFieldRename.ts +378 -0
  51. package/Server/Utils/DataSource/EgressGuard.ts +66 -21
  52. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  53. package/Server/Utils/Mail/PinnedSmtpSocket.ts +130 -0
  54. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +10 -0
  55. package/Server/Utils/Response.ts +31 -0
  56. package/Server/Utils/SSRFProtection.ts +76 -16
  57. package/Server/Utils/StatusPage/IncidentStatusPageScope.ts +948 -0
  58. package/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.ts +656 -0
  59. package/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.ts +805 -0
  60. package/Server/Utils/StatusPage/StatusPageExclusion.ts +18 -0
  61. package/Server/Utils/StatusPage/StatusPageReadAccess.ts +125 -0
  62. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.ts +151 -0
  63. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.ts +70 -0
  64. package/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.ts +421 -0
  65. package/Server/Utils/StatusPage/SubscriberNotificationClaim.ts +107 -0
  66. package/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.ts +1049 -0
  67. package/Server/Utils/StatusPage/SubscriberNotificationFanOut.ts +180 -0
  68. package/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.ts +839 -0
  69. package/Server/Utils/StatusPage/SubscriberNotificationResendAccess.ts +393 -0
  70. package/Server/Utils/StatusPage/SubscriberNotificationRunLimit.ts +246 -0
  71. package/Server/Utils/StatusPage/SubscriberNotificationTiming.ts +133 -0
  72. package/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.ts +215 -0
  73. package/Server/Utils/StatusPageResource.ts +51 -20
  74. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  75. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  76. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  77. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  78. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  79. package/Server/Utils/VM/VMAPI.ts +1 -0
  80. package/Server/Utils/VM/VMRunner.ts +10 -0
  81. package/Server/Utils/Workflow/OAuth2TokenClient.ts +10 -0
  82. package/Server/Views/Partials/SensitiveUrlToken.ejs +8 -5
  83. package/Tests/App/Accounts/RegisterVerifyEmail.test.tsx +748 -0
  84. package/Tests/App/Accounts/ResendVerificationEmail.test.tsx +1222 -0
  85. package/Tests/App/Accounts/VerificationEmailResendUtils.test.ts +797 -0
  86. package/Tests/App/Accounts/VerifyEmailPending.test.tsx +538 -0
  87. package/Tests/App/Accounts/VerifyEmailResend.test.tsx +779 -0
  88. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  89. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  90. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  91. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  92. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  93. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  94. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  95. package/Tests/App/Dashboard/CustomFieldsSettingsIncidentOptions.test.tsx +357 -0
  96. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  97. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  98. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  99. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  100. package/Tests/App/Dashboard/EventNotes.test.tsx +776 -0
  101. package/Tests/App/Dashboard/EventNotesPages.test.tsx +119 -0
  102. package/Tests/App/Dashboard/EventNotesUtil.test.ts +116 -17
  103. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +534 -6
  104. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  105. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  106. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  107. package/Tests/App/Dashboard/IncidentChangeStateNotifyDefault.test.tsx +91 -1
  108. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  109. package/Tests/App/Dashboard/IncidentCreateDetailsStep.test.tsx +643 -0
  110. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +8 -1
  111. package/Tests/App/Dashboard/IncidentCreateStatusPageScope.test.tsx +601 -0
  112. package/Tests/App/Dashboard/IncidentCustomFieldTemplateVariables.test.tsx +386 -0
  113. package/Tests/App/Dashboard/IncidentNoteTemplatePlaceholders.test.tsx +124 -0
  114. package/Tests/App/Dashboard/IncidentNoteTemplateVariablesLoader.test.ts +305 -0
  115. package/Tests/App/Dashboard/IncidentOverviewNotifyDefault.test.tsx +112 -0
  116. package/Tests/App/Dashboard/IncidentSettingsRenotifyOnPublish.test.tsx +674 -0
  117. package/Tests/App/Dashboard/IncidentSettingsStatusPageScope.test.tsx +1051 -0
  118. package/Tests/App/Dashboard/IncidentTemplateCustomFields.test.tsx +384 -0
  119. package/Tests/App/Dashboard/IncidentTemplateStatusPageScopeCard.test.tsx +215 -0
  120. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  121. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  122. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  123. package/Tests/App/Dashboard/PublicNotePagesNotifyDefault.test.tsx +113 -1
  124. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  125. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  126. package/Tests/App/Dashboard/SubscriberAudienceSummary.test.tsx +512 -0
  127. package/Tests/App/Dashboard/SubscriberNotificationPreviewModal.test.tsx +837 -0
  128. package/Tests/App/Dashboard/SubscriberNotificationStatusLabel.test.tsx +88 -0
  129. package/Tests/App/Dashboard/SubscriberNotificationStatusResend.test.tsx +473 -0
  130. package/Tests/App/Dashboard/SubscriberTemplateLivePreview.test.tsx +196 -0
  131. package/Tests/App/Dashboard/TeamAddedSubscribersUnsubscribedNotice.test.tsx +248 -0
  132. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  133. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  134. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  135. package/Tests/App/SensitiveUrlTokenBootstrap.test.ts +64 -3
  136. package/Tests/App/StatusPage/StatusPageUnsubscribePage.test.tsx +412 -0
  137. package/Tests/Models/IncidentCustomFieldApiSchemaContract.test.ts +217 -0
  138. package/Tests/Models/IncidentCustomFieldCreateSettingsColumns.test.ts +252 -0
  139. package/Tests/Models/StatusPageSubscriberUnsubscribeColumns.test.ts +218 -0
  140. package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +7 -0
  141. package/Tests/Server/API/IncidentSubscriberAudienceAPI.test.ts +1408 -0
  142. package/Tests/Server/API/StatusPageIncidentScope.test.ts +1868 -0
  143. package/Tests/Server/API/StatusPageIncidentScopePostgres.test.ts +790 -0
  144. package/Tests/Server/API/StatusPageManageSubscriptionRecipient.test.ts +10 -1
  145. package/Tests/Server/API/StatusPageManageSubscriptionTemplateVariables.test.ts +206 -18
  146. package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +14 -2
  147. package/Tests/Server/API/StatusPageSubscriberCreateResponse.test.ts +217 -0
  148. package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +103 -2
  149. package/Tests/Server/API/StatusPageUnsubscribeAPI.test.ts +396 -0
  150. package/Tests/Server/Infrastructure/Postgres/AddIncidentStatusPageScopeMigration.test.ts +395 -0
  151. package/Tests/Server/Infrastructure/Postgres/AddIncidentTemplateStatusPageScopeFlagMigration.test.ts +150 -0
  152. package/Tests/Server/Infrastructure/Postgres/AddSubscriberNotificationClaimedAtMigration.test.ts +156 -0
  153. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  154. package/Tests/Server/Infrastructure/Postgres/SkipHiddenIncidentCreatedNotificationsMigration.test.ts +410 -0
  155. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +551 -1
  156. package/Tests/Server/Middleware/SubscriberNotificationPreviewRateLimit.test.ts +238 -0
  157. package/Tests/Server/Middleware/SubscriberNotificationTestSendRateLimit.test.ts +245 -0
  158. package/Tests/Server/Services/AddStatusPageSubscriberUnsubscribeTokenMigration.test.ts +152 -0
  159. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +221 -36
  160. package/Tests/Server/Services/DatabaseServiceCompareAndSetColumns.test.ts +191 -0
  161. package/Tests/Server/Services/DomainService.test.ts +525 -0
  162. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  163. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  164. package/Tests/Server/Services/IncidentCreatedRenotifyOnPublish.test.ts +681 -0
  165. package/Tests/Server/Services/IncidentCreatedResend.test.ts +775 -0
  166. package/Tests/Server/Services/IncidentCustomFieldRenamePostgres.test.ts +713 -0
  167. package/Tests/Server/Services/IncidentCustomFieldService.test.ts +751 -0
  168. package/Tests/Server/Services/IncidentCustomFieldValueValidation.test.ts +556 -0
  169. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  170. package/Tests/Server/Services/IncidentStatusPageScope.test.ts +2971 -0
  171. package/Tests/Server/Services/IncidentStatusPageScopeFeed.test.ts +131 -0
  172. package/Tests/Server/Services/IncidentStatusPageScopePostgres.test.ts +625 -0
  173. package/Tests/Server/Services/IncidentTemplateCustomFieldsCopy.test.ts +311 -0
  174. package/Tests/Server/Services/IncidentTemplateStatusPageScope.test.ts +363 -0
  175. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  176. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  177. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  178. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  179. package/Tests/Server/Services/PublicNoteEditFeedEventType.test.ts +213 -0
  180. package/Tests/Server/Services/PublicNoteResendAccess.test.ts +994 -0
  181. package/Tests/Server/Services/ScheduledMaintenanceSubscriberTemplateFormats.test.ts +289 -9
  182. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  183. package/Tests/Server/Services/SmsServiceFailIfNotSent.test.ts +67 -0
  184. package/Tests/Server/Services/StatusPageReportIncidentCounts.test.ts +610 -0
  185. package/Tests/Server/Services/StatusPageSubscriberAudienceCounts.test.ts +201 -0
  186. package/Tests/Server/Services/StatusPageSubscriberAudienceCountsPostgres.test.ts +271 -0
  187. package/Tests/Server/Services/StatusPageSubscriberConfirmationTemplateEscaping.test.ts +200 -0
  188. package/Tests/Server/Services/StatusPageSubscriberNotificationLogRedaction.test.ts +54 -0
  189. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateCompile.test.ts +37 -0
  190. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateIncidentRecordAccess.test.ts +772 -0
  191. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookup.test.ts +506 -0
  192. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookupPostgres.test.ts +261 -0
  193. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +21 -5
  194. package/Tests/Server/Services/StatusPageSubscriberUnsubscribe.test.ts +1618 -0
  195. package/Tests/Server/Services/StatusPageSubscriberUnsubscribeLinkSenders.test.ts +373 -0
  196. package/Tests/Server/Services/StatusPageSubscriberUnsubscribePostgres.test.ts +388 -0
  197. package/Tests/Server/Services/SubscriberNotificationInFlightRequeue.test.ts +500 -0
  198. package/Tests/Server/Types/MarkdownEmailLinks.test.ts +320 -0
  199. package/Tests/Server/Types/Workflow/Components/Email.test.ts +491 -1
  200. package/Tests/Server/Types/Workflow/Components/EmailSmtpPinning.test.ts +398 -0
  201. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  202. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  203. package/Tests/Server/Utils/CustomField/CustomFieldRename.test.ts +586 -0
  204. package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +246 -10
  205. package/Tests/Server/Utils/DataSource/EgressGuardDeploymentConfig.test.ts +226 -0
  206. package/Tests/Server/Utils/DataSource/EgressGuardPinning.test.ts +131 -0
  207. package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +4 -0
  208. package/Tests/Server/Utils/EgressPolicyEnvironment.ts +45 -0
  209. package/Tests/Server/Utils/Mail/PinnedSmtpSocket.test.ts +409 -0
  210. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +23 -0
  211. package/Tests/Server/Utils/SSRFProtection.test.ts +4 -0
  212. package/Tests/Server/Utils/SSRFProtectionCloudServiceAddresses.test.ts +4 -0
  213. package/Tests/Server/Utils/SSRFProtectionPrivateNetwork.test.ts +4 -0
  214. package/Tests/Server/Utils/SSRFProtectionResolutionDetail.test.ts +314 -0
  215. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScope.test.ts +1699 -0
  216. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScopeQueriesPostgres.test.ts +508 -0
  217. package/Tests/Server/Utils/StatusPage/IncidentSubscriberAudienceCallerPermission.test.ts +246 -0
  218. package/Tests/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.test.ts +1348 -0
  219. package/Tests/Server/Utils/StatusPage/StatusPageReadAccess.test.ts +221 -0
  220. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.test.ts +132 -0
  221. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.test.ts +155 -0
  222. package/Tests/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.test.ts +708 -0
  223. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaim.test.ts +255 -0
  224. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaimPostgres.test.ts +528 -0
  225. package/Tests/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.test.ts +1238 -0
  226. package/Tests/Server/Utils/StatusPage/SubscriberNotificationFanOut.test.ts +472 -0
  227. package/Tests/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.test.ts +1168 -0
  228. package/Tests/Server/Utils/StatusPage/SubscriberNotificationRunLimit.test.ts +275 -0
  229. package/Tests/Server/Utils/StatusPage/SubscriberNotificationTiming.test.ts +131 -0
  230. package/Tests/Server/Utils/StatusPageResource.test.ts +98 -0
  231. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  232. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  233. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  234. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  235. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  236. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  237. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  238. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  239. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  240. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  241. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  242. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  243. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  244. package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +17 -0
  245. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +75 -0
  246. package/Tests/Types/CustomField/CustomFieldSavedViews.test.ts +247 -0
  247. package/Tests/Types/CustomField/CustomFieldTemplateValues.test.ts +186 -0
  248. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +48 -0
  249. package/Tests/Types/CustomField/CustomFieldValueFormat.test.ts +215 -0
  250. package/Tests/Types/CustomField/CustomFieldValueValidator.test.ts +343 -0
  251. package/Tests/Types/CustomField/CustomFieldVariableKey.test.ts +364 -0
  252. package/Tests/Types/SafeHtml.test.ts +102 -0
  253. package/Tests/Types/StatusPage/IncidentCreatedRenotify.test.ts +419 -0
  254. package/Tests/Types/StatusPage/IncidentCreatedResend.test.ts +118 -0
  255. package/Tests/Types/StatusPage/IncidentScopeAddedPagesNotification.test.ts +558 -0
  256. package/Tests/Types/StatusPage/IncidentSubscriberAudience.test.ts +284 -0
  257. package/Tests/Types/StatusPage/StatusPageSubscriberUnsubscribe.test.ts +341 -0
  258. package/Tests/Types/StatusPage/SubscriberNotificationPreview.test.ts +196 -0
  259. package/Tests/Types/StatusPage/SubscriberNotificationResend.test.ts +201 -0
  260. package/Tests/Types/StatusPage/SubscriberNotificationTemplateCompiler.test.ts +277 -0
  261. package/Tests/Types/StatusPage/SubscriberNotificationTemplatePreview.test.ts +179 -0
  262. package/Tests/Types/StatusPage/SubscriberNotificationTemplateVariables.test.ts +324 -0
  263. package/Tests/Types/StatusPage/SubscriberUpdateNotification.test.ts +60 -13
  264. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  265. package/Tests/UI/Components/CustomFields/CustomFieldFormFields.test.ts +432 -0
  266. package/Tests/UI/Components/CustomFields/CustomFieldModelFormFields.test.tsx +685 -0
  267. package/Tests/UI/Components/CustomFields/CustomFieldsDetailOrder.test.tsx +469 -0
  268. package/Tests/UI/Components/Forms/ModelFormCustomFieldInputs.test.tsx +325 -0
  269. package/Tests/UI/Components/Forms/ModelFormDropdownOptionsRefused.test.tsx +358 -0
  270. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +22 -0
  271. package/Tests/UI/Monitor/NetworkPathView.test.tsx +289 -0
  272. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  273. package/Tests/Utils/Incident/IncidentNoteTemplateVariables.test.ts +623 -0
  274. package/Tests/Utils/Markdown/MarkdownEscape.test.ts +89 -0
  275. package/Tests/Utils/ProbeNetworkFailureUtil.test.ts +992 -0
  276. package/Tests/Utils/RequestFailedDetails.test.ts +575 -0
  277. package/Types/CustomField/CustomFieldOrder.ts +55 -0
  278. package/Types/CustomField/CustomFieldSavedViews.ts +225 -0
  279. package/Types/CustomField/CustomFieldTemplateMerge.ts +71 -0
  280. package/Types/CustomField/CustomFieldType.ts +14 -0
  281. package/Types/CustomField/CustomFieldValueFormat.ts +161 -0
  282. package/Types/CustomField/CustomFieldValueValidator.ts +488 -0
  283. package/Types/CustomField/CustomFieldVariableKey.ts +211 -0
  284. package/Types/Incident/IncidentAlertLink.ts +19 -0
  285. package/Types/SafeHtml.ts +93 -0
  286. package/Types/StatusPage/IncidentCreatedRenotify.ts +224 -0
  287. package/Types/StatusPage/IncidentCreatedResend.ts +108 -0
  288. package/Types/StatusPage/IncidentScopeAddedPagesNotification.ts +343 -0
  289. package/Types/StatusPage/IncidentSubscriberAudience.ts +332 -0
  290. package/Types/StatusPage/StatusPageSubscriberUnsubscribe.ts +405 -0
  291. package/Types/StatusPage/SubscriberNotificationInterruption.ts +32 -0
  292. package/Types/StatusPage/SubscriberNotificationPreview.ts +322 -0
  293. package/Types/StatusPage/SubscriberNotificationResend.ts +132 -0
  294. package/Types/StatusPage/SubscriberNotificationTemplateCompiler.ts +180 -0
  295. package/Types/StatusPage/SubscriberNotificationTemplatePreview.ts +301 -0
  296. package/Types/StatusPage/SubscriberNotificationTemplateVariables.ts +217 -4
  297. package/Types/StatusPage/SubscriberUpdateNotification.ts +32 -11
  298. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  299. package/Types/Topology/TopologyApi.ts +428 -0
  300. package/Types/Topology/TopologyTypeRules.ts +191 -0
  301. package/UI/Components/CustomFields/CustomFieldFormFields.ts +317 -0
  302. package/UI/Components/CustomFields/CustomFieldModelFormFields.tsx +395 -0
  303. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +95 -55
  304. package/UI/Components/Forms/ModelForm.tsx +70 -8
  305. package/UI/Components/Forms/Validation.ts +1 -1
  306. package/UI/Components/ModelFormModal/ModelFormModal.tsx +2 -4
  307. package/UI/Components/ModelTable/AnalyticsModelTable.tsx +2 -6
  308. package/UI/Components/ModelTable/BaseModelTable.tsx +9 -8
  309. package/UI/Components/ModelTable/CustomFieldColumns.tsx +14 -1
  310. package/UI/Components/ModelTable/ModelTable.tsx +6 -7
  311. package/UI/Utils/SensitiveUrlToken.ts +10 -5
  312. package/Utils/API.ts +247 -2
  313. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  314. package/Utils/Incident/IncidentNoteTemplateVariables.ts +304 -0
  315. package/Utils/Markdown/MarkdownEscape.ts +65 -0
  316. package/Utils/ProbeNetworkFailureUtil.ts +673 -0
  317. package/build/dist/Models/DatabaseModels/Incident.js +267 -1
  318. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  319. package/build/dist/Models/DatabaseModels/IncidentCustomField.js +207 -1
  320. package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
  321. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +31 -0
  322. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  323. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +124 -1
  324. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  325. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  326. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  327. package/build/dist/Models/DatabaseModels/StatusPage.js +56 -0
  328. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  329. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +131 -0
  330. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  331. package/build/dist/Server/API/IncidentAPI.js +100 -1
  332. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  333. package/build/dist/Server/API/StatusPageAPI.js +417 -176
  334. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  335. package/build/dist/Server/API/StatusPageSubscriberAPI.js +52 -13
  336. package/build/dist/Server/API/StatusPageSubscriberAPI.js.map +1 -1
  337. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  338. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  339. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js +41 -0
  340. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js.map +1 -0
  341. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js +57 -0
  342. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js.map +1 -0
  343. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js +23 -0
  344. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js.map +1 -0
  345. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js +40 -0
  346. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js.map +1 -0
  347. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js +26 -0
  348. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js.map +1 -0
  349. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js +89 -0
  350. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js.map +1 -0
  351. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js +29 -0
  352. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js.map +1 -0
  353. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +16 -0
  354. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  355. package/build/dist/Server/Infrastructure/Semaphore.js +3 -0
  356. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  357. package/build/dist/Server/Middleware/IdentityRateLimit.js +89 -3
  358. package/build/dist/Server/Middleware/IdentityRateLimit.js.map +1 -1
  359. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js +149 -0
  360. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js.map +1 -0
  361. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js +190 -0
  362. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js.map +1 -0
  363. package/build/dist/Server/Services/AlertService.js +21 -33
  364. package/build/dist/Server/Services/AlertService.js.map +1 -1
  365. package/build/dist/Server/Services/DatabaseService.js +56 -7
  366. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  367. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  368. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  369. package/build/dist/Server/Services/IncidentCustomFieldService.js +204 -8
  370. package/build/dist/Server/Services/IncidentCustomFieldService.js.map +1 -1
  371. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js +24 -0
  372. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js.map +1 -1
  373. package/build/dist/Server/Services/IncidentEpisodeService.js +14 -0
  374. package/build/dist/Server/Services/IncidentEpisodeService.js.map +1 -1
  375. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +26 -0
  376. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  377. package/build/dist/Server/Services/IncidentPublicNoteService.js +26 -1
  378. package/build/dist/Server/Services/IncidentPublicNoteService.js.map +1 -1
  379. package/build/dist/Server/Services/IncidentService.js +1148 -42
  380. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  381. package/build/dist/Server/Services/IncidentStateTimelineService.js +12 -0
  382. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  383. package/build/dist/Server/Services/IncidentTemplateService.js +83 -0
  384. package/build/dist/Server/Services/IncidentTemplateService.js.map +1 -1
  385. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  386. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  387. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  388. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  389. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js +26 -1
  390. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js.map +1 -1
  391. package/build/dist/Server/Services/ScheduledMaintenanceService.js +84 -57
  392. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  393. package/build/dist/Server/Services/SmsService.js +2 -22
  394. package/build/dist/Server/Services/SmsService.js.map +1 -1
  395. package/build/dist/Server/Services/StatusPageService.js +47 -9
  396. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  397. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +237 -32
  398. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  399. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js +109 -0
  400. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js.map +1 -1
  401. package/build/dist/Server/Services/StatusPageSubscriberService.js +925 -99
  402. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  403. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  404. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  405. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  406. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  407. package/build/dist/Server/Types/Markdown.js +110 -6
  408. package/build/dist/Server/Types/Markdown.js.map +1 -1
  409. package/build/dist/Server/Types/Workflow/Components/Email.js +55 -6
  410. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  411. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  412. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  413. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  414. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  415. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js +211 -0
  416. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js.map +1 -0
  417. package/build/dist/Server/Utils/DataSource/EgressGuard.js +41 -12
  418. package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -1
  419. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  420. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  421. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js +101 -0
  422. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js.map +1 -0
  423. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -0
  424. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  425. package/build/dist/Server/Utils/Response.js +30 -0
  426. package/build/dist/Server/Utils/Response.js.map +1 -1
  427. package/build/dist/Server/Utils/SSRFProtection.js +48 -6
  428. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  429. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js +631 -0
  430. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js.map +1 -0
  431. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js +403 -0
  432. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js.map +1 -0
  433. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js +451 -0
  434. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js.map +1 -0
  435. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js +13 -0
  436. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js.map +1 -0
  437. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js +91 -0
  438. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js.map +1 -0
  439. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js +101 -0
  440. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js.map +1 -0
  441. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js +47 -0
  442. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js.map +1 -0
  443. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js +273 -0
  444. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js.map +1 -0
  445. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js +73 -0
  446. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js.map +1 -0
  447. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js +722 -0
  448. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js.map +1 -0
  449. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js +102 -0
  450. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js.map +1 -0
  451. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js +527 -0
  452. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js.map +1 -0
  453. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js +278 -0
  454. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js.map +1 -0
  455. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js +137 -0
  456. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js.map +1 -0
  457. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js +103 -0
  458. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js.map +1 -0
  459. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js +114 -0
  460. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js.map +1 -0
  461. package/build/dist/Server/Utils/StatusPageResource.js +39 -7
  462. package/build/dist/Server/Utils/StatusPageResource.js.map +1 -1
  463. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  464. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  465. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  466. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  467. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  468. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  469. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  470. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  471. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  472. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  473. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  474. package/build/dist/Server/Utils/VM/VMRunner.js +1 -0
  475. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  476. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js +3 -0
  477. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js.map +1 -1
  478. package/build/dist/Types/CustomField/CustomFieldOrder.js +36 -0
  479. package/build/dist/Types/CustomField/CustomFieldOrder.js.map +1 -0
  480. package/build/dist/Types/CustomField/CustomFieldSavedViews.js +135 -0
  481. package/build/dist/Types/CustomField/CustomFieldSavedViews.js.map +1 -0
  482. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js +27 -0
  483. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js.map +1 -0
  484. package/build/dist/Types/CustomField/CustomFieldType.js +12 -0
  485. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  486. package/build/dist/Types/CustomField/CustomFieldValueFormat.js +126 -0
  487. package/build/dist/Types/CustomField/CustomFieldValueFormat.js.map +1 -0
  488. package/build/dist/Types/CustomField/CustomFieldValueValidator.js +280 -0
  489. package/build/dist/Types/CustomField/CustomFieldValueValidator.js.map +1 -0
  490. package/build/dist/Types/CustomField/CustomFieldVariableKey.js +150 -0
  491. package/build/dist/Types/CustomField/CustomFieldVariableKey.js.map +1 -0
  492. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  493. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  494. package/build/dist/Types/SafeHtml.js +84 -0
  495. package/build/dist/Types/SafeHtml.js.map +1 -0
  496. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js +117 -0
  497. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js.map +1 -0
  498. package/build/dist/Types/StatusPage/IncidentCreatedResend.js +86 -0
  499. package/build/dist/Types/StatusPage/IncidentCreatedResend.js.map +1 -0
  500. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js +246 -0
  501. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js.map +1 -0
  502. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js +183 -0
  503. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js.map +1 -0
  504. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js +277 -0
  505. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js.map +1 -0
  506. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js +27 -0
  507. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js.map +1 -0
  508. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js +190 -0
  509. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js.map +1 -0
  510. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js +102 -0
  511. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js.map +1 -0
  512. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js +97 -0
  513. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js.map +1 -0
  514. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js +175 -0
  515. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js.map +1 -0
  516. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js +144 -4
  517. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js.map +1 -1
  518. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js +29 -9
  519. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js.map +1 -1
  520. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  521. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  522. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  523. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  524. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  525. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  526. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js +169 -0
  527. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js.map +1 -0
  528. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js +212 -0
  529. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js.map +1 -0
  530. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +64 -49
  531. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  532. package/build/dist/UI/Components/Forms/ModelForm.js +39 -5
  533. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  534. package/build/dist/UI/Components/Forms/Validation.js +1 -1
  535. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  536. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  537. package/build/dist/UI/Components/ModelTable/AnalyticsModelTable.js.map +1 -1
  538. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +2 -2
  539. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  540. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +13 -1
  541. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  542. package/build/dist/UI/Components/ModelTable/ModelTable.js +1 -1
  543. package/build/dist/UI/Components/ModelTable/ModelTable.js.map +1 -1
  544. package/build/dist/UI/Utils/SensitiveUrlToken.js +10 -5
  545. package/build/dist/UI/Utils/SensitiveUrlToken.js.map +1 -1
  546. package/build/dist/Utils/API.js +177 -1
  547. package/build/dist/Utils/API.js.map +1 -1
  548. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  549. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  550. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js +155 -0
  551. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js.map +1 -0
  552. package/build/dist/Utils/Markdown/MarkdownEscape.js +48 -0
  553. package/build/dist/Utils/Markdown/MarkdownEscape.js.map +1 -1
  554. package/build/dist/Utils/ProbeNetworkFailureUtil.js +460 -0
  555. package/build/dist/Utils/ProbeNetworkFailureUtil.js.map +1 -0
  556. package/package.json +1 -1
@@ -0,0 +1,1618 @@
1
+ import StatusPage from "../../../Models/DatabaseModels/StatusPage";
2
+ import StatusPageSubscriber from "../../../Models/DatabaseModels/StatusPageSubscriber";
3
+ import User from "../../../Models/DatabaseModels/User";
4
+ import DatabaseRequestType from "../../../Server/Types/BaseDatabase/DatabaseRequestType";
5
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
6
+ import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
7
+ import ColumnPermissions from "../../../Server/Types/Database/Permissions/ColumnPermission";
8
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
9
+ import DatabaseService from "../../../Server/Services/DatabaseService";
10
+ import MailService from "../../../Server/Services/MailService";
11
+ import ProjectService from "../../../Server/Services/ProjectService";
12
+ import StatusPageService from "../../../Server/Services/StatusPageService";
13
+ import StatusPageSubscriberService from "../../../Server/Services/StatusPageSubscriberService";
14
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
15
+ import UserService from "../../../Server/Services/UserService";
16
+ import logger from "../../../Server/Utils/Logger";
17
+ import StatusPageSubscriberUnsubscribeNotice, {
18
+ StatusPageSubscriberUnsubscribeSource,
19
+ } from "../../../Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice";
20
+ import URL from "../../../Types/API/URL";
21
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
22
+ import Email from "../../../Types/Email";
23
+ import EmailTemplateType from "../../../Types/Email/EmailTemplateType";
24
+ import { JSONObject } from "../../../Types/JSON";
25
+ import Name from "../../../Types/Name";
26
+ import ObjectID from "../../../Types/ObjectID";
27
+ import Permission, {
28
+ UserTenantAccessPermission,
29
+ } from "../../../Types/Permission";
30
+ import Phone from "../../../Types/Phone";
31
+ import UserType from "../../../Types/UserType";
32
+ import StatusPageSubscriberUnsubscribe, {
33
+ StatusPageSubscriberUnsubscribeChannel,
34
+ StatusPageSubscriberUnsubscribeDetails,
35
+ StatusPageSubscriberUnsubscribeState,
36
+ } from "../../../Types/StatusPage/StatusPageSubscriberUnsubscribe";
37
+ import getJestMockFunction, { MockFunction } from "../../MockType";
38
+ import crypto from "crypto";
39
+ import LIMIT_MAX from "../../../Types/Database/LimitMax";
40
+ import { FindOperator } from "typeorm";
41
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
42
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
43
+
44
+ /*
45
+ * Unsubscribing from a status page without signing in: the token every
46
+ * subscriber gets, the link built from it, what the unsubscribe page's GET
47
+ * and POST do with it, Unsubscribed At, and the email a status page's team
48
+ * gets when a subscriber it added leaves.
49
+ *
50
+ * The database is stubbed: the service's own lookups are faked per test and
51
+ * its hand-written SQL is recorded (StatusPageSubscriberUnsubscribePostgres
52
+ * runs that SQL against a real Postgres).
53
+ */
54
+
55
+ const PROJECT_ID: ObjectID = new ObjectID(
56
+ "10000000-0000-4000-8000-000000000001",
57
+ );
58
+ const STATUS_PAGE_ID: ObjectID = new ObjectID(
59
+ "20000000-0000-4000-8000-000000000002",
60
+ );
61
+ const OTHER_STATUS_PAGE_ID: ObjectID = new ObjectID(
62
+ "20000000-0000-4000-8000-000000000003",
63
+ );
64
+ const SUBSCRIBER_ID: ObjectID = new ObjectID(
65
+ "30000000-0000-4000-8000-000000000004",
66
+ );
67
+ const CREATOR_ID: ObjectID = new ObjectID(
68
+ "40000000-0000-4000-8000-000000000005",
69
+ );
70
+ const OWNER_ID: ObjectID = new ObjectID("40000000-0000-4000-8000-000000000006");
71
+ const TEAM_OWNER_ID: ObjectID = new ObjectID(
72
+ "40000000-0000-4000-8000-000000000007",
73
+ );
74
+
75
+ const TOKEN: string =
76
+ "3f9a1c2b4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8";
77
+ const WRONG_TOKEN: string = `${TOKEN.slice(0, 63)}0`;
78
+
79
+ const STATUS_PAGE_URL: string = "https://site03.status.acme.com";
80
+ const DASHBOARD_URL: string = `https://oneuptime.acme.com/dashboard/${PROJECT_ID.toString()}/status-pages/${STATUS_PAGE_ID.toString()}`;
81
+
82
+ let query: MockFunction;
83
+ let storedSubscriber: StatusPageSubscriber | null;
84
+
85
+ function subscriberRow(overrides?: {
86
+ isUnsubscribed?: boolean;
87
+ createdByUserId?: ObjectID | null;
88
+ isAddedByTeam?: boolean;
89
+ unsubscribeToken?: string | null;
90
+ statusPageId?: ObjectID;
91
+ }): StatusPageSubscriber {
92
+ const row: StatusPageSubscriber = new StatusPageSubscriber();
93
+ row._id = SUBSCRIBER_ID.toString();
94
+ row.projectId = PROJECT_ID;
95
+ row.statusPageId = overrides?.statusPageId || STATUS_PAGE_ID;
96
+ row.subscriberEmail = new Email("site03-all@acme.com");
97
+ row.isUnsubscribed = overrides?.isUnsubscribed ?? false;
98
+ row.createdAt = new Date("2026-01-05T08:00:00.000Z");
99
+
100
+ if (overrides?.unsubscribeToken !== null) {
101
+ row.unsubscribeToken = overrides?.unsubscribeToken || TOKEN;
102
+ }
103
+
104
+ if (overrides?.createdByUserId !== null) {
105
+ row.createdByUserId = overrides?.createdByUserId || CREATOR_ID;
106
+ }
107
+
108
+ // A teammate's create, unless a test says otherwise.
109
+ row.isAddedByTeam =
110
+ overrides?.isAddedByTeam ?? overrides?.createdByUserId !== null;
111
+
112
+ return row;
113
+ }
114
+
115
+ function user(id: ObjectID, name: string, email: string): User {
116
+ const row: User = new User();
117
+ row._id = id.toString();
118
+ row.name = new Name(name);
119
+ row.email = new Email(email);
120
+ return row;
121
+ }
122
+
123
+ /*
124
+ * Stands in for the database: the subscriber comes back only when the query
125
+ * names it and its own status page - exactly the scoping the service must
126
+ * apply.
127
+ */
128
+ function findOneByFake(findBy: {
129
+ query: JSONObject;
130
+ }): Promise<StatusPageSubscriber | null> {
131
+ const lookup: JSONObject = findBy.query;
132
+
133
+ if (
134
+ !storedSubscriber ||
135
+ lookup["_id"]?.toString() !== storedSubscriber._id ||
136
+ (lookup["statusPageId"] &&
137
+ lookup["statusPageId"].toString() !==
138
+ storedSubscriber.statusPageId?.toString())
139
+ ) {
140
+ return Promise.resolve(null);
141
+ }
142
+
143
+ return Promise.resolve(storedSubscriber);
144
+ }
145
+
146
+ function sqlCalls(): Array<{ sql: string; params: Array<unknown> }> {
147
+ return query.mock.calls.map((call: Array<unknown>) => {
148
+ return {
149
+ sql: (call[0] as string).replace(/\s+/g, " ").trim(),
150
+ params: call[1] as Array<unknown>,
151
+ };
152
+ });
153
+ }
154
+
155
+ function sentMail(): Array<{ mail: JSONObject; options: JSONObject }> {
156
+ return (MailService.sendMail as unknown as jest.Mock).mock.calls.map(
157
+ (call: Array<unknown>) => {
158
+ return {
159
+ mail: call[0] as JSONObject,
160
+ options: call[1] as JSONObject,
161
+ };
162
+ },
163
+ );
164
+ }
165
+
166
+ function linkData(token: string = TOKEN): {
167
+ statusPageId: string;
168
+ subscriberId: string;
169
+ token: string;
170
+ } {
171
+ return {
172
+ statusPageId: STATUS_PAGE_ID.toString(),
173
+ subscriberId: SUBSCRIBER_ID.toString(),
174
+ token: token,
175
+ };
176
+ }
177
+
178
+ beforeEach(() => {
179
+ storedSubscriber = subscriberRow();
180
+
181
+ query = getJestMockFunction();
182
+ // An UPDATE ... RETURNING through the postgres driver: [rows, rowCount].
183
+ query.mockResolvedValue([
184
+ [{ _id: SUBSCRIBER_ID.toString(), projectId: PROJECT_ID.toString() }],
185
+ 1,
186
+ ] as never);
187
+
188
+ jest.spyOn(StatusPageSubscriberService, "getRepository").mockReturnValue({
189
+ manager: { query: query },
190
+ } as never);
191
+
192
+ jest
193
+ .spyOn(StatusPageSubscriberService, "findOneBy")
194
+ .mockImplementation(findOneByFake as never);
195
+ jest
196
+ .spyOn(StatusPageSubscriberService, "findOneById")
197
+ .mockImplementation((() => {
198
+ return Promise.resolve(storedSubscriber);
199
+ }) as never);
200
+ jest
201
+ .spyOn(StatusPageSubscriberService, "updateOneBy")
202
+ .mockResolvedValue(1 as never);
203
+ jest
204
+ .spyOn(StatusPageSubscriberService, "updateOneById")
205
+ .mockResolvedValue(undefined as never);
206
+ jest
207
+ .spyOn(StatusPageSubscriberService, "onTriggerWorkflow")
208
+ .mockResolvedValue(undefined as never);
209
+ jest
210
+ .spyOn(StatusPageSubscriberService, "onTriggerRealtime")
211
+ .mockResolvedValue(undefined as never);
212
+
213
+ jest.spyOn(StatusPageService, "findOneById").mockImplementation((() => {
214
+ const page: StatusPage = new StatusPage();
215
+ page._id = STATUS_PAGE_ID.toString();
216
+ page.name = "Site 03";
217
+ page.pageTitle = "Site 03 Status";
218
+ return Promise.resolve(page);
219
+ }) as never);
220
+ jest
221
+ .spyOn(StatusPageService, "findOwners")
222
+ .mockResolvedValue([
223
+ user(OWNER_ID, "Olga Owner", "olga@acme.com"),
224
+ user(TEAM_OWNER_ID, "Tom Teamowner", "tom@acme.com"),
225
+ ] as never);
226
+ jest
227
+ .spyOn(StatusPageService, "getStatusPageLinkInDashboard")
228
+ .mockResolvedValue(URL.fromString(DASHBOARD_URL) as never);
229
+
230
+ jest
231
+ .spyOn(UserService, "findOneById")
232
+ .mockResolvedValue(
233
+ user(CREATOR_ID, "Dana Admin", "dana@acme.com") as never,
234
+ );
235
+
236
+ // Everyone asked about is a member, unless a test says otherwise.
237
+ jest
238
+ .spyOn(TeamMemberService, "filterUsersToProjectMembers")
239
+ .mockImplementation(((data: { users: Array<User> }) => {
240
+ return Promise.resolve(data.users);
241
+ }) as never);
242
+
243
+ jest.spyOn(MailService, "sendMail").mockResolvedValue(undefined as never);
244
+ });
245
+
246
+ afterEach(() => {
247
+ jest.restoreAllMocks();
248
+ });
249
+
250
+ describe("a subscriber's unsubscribe token, minted on create", () => {
251
+ const makeProps: (
252
+ permissions: Array<Permission>,
253
+ ) => DatabaseCommonInteractionProps = (
254
+ permissions: Array<Permission>,
255
+ ): DatabaseCommonInteractionProps => {
256
+ const userId: ObjectID = ObjectID.generate();
257
+ const tenantPermission: UserTenantAccessPermission = {
258
+ projectId: PROJECT_ID,
259
+ _type: "UserTenantAccessPermission",
260
+ permissions: permissions.map((permission: Permission) => {
261
+ return {
262
+ _type: "UserPermission",
263
+ permission: permission,
264
+ labelIds: [],
265
+ isBlockPermission: false,
266
+ };
267
+ }),
268
+ };
269
+
270
+ return {
271
+ userId: userId,
272
+ tenantId: PROJECT_ID,
273
+ userTenantAccessPermission: {
274
+ [PROJECT_ID.toString()]: tenantPermission,
275
+ },
276
+ };
277
+ };
278
+
279
+ async function runOnBeforeCreate(
280
+ data: StatusPageSubscriber,
281
+ props: DatabaseCommonInteractionProps,
282
+ ): Promise<StatusPageSubscriber> {
283
+ jest
284
+ .spyOn(StatusPageSubscriberService, "getStatusPagesToSendNotification")
285
+ .mockImplementation((() => {
286
+ const page: StatusPage = new StatusPage();
287
+ page._id = STATUS_PAGE_ID.toString();
288
+ page.projectId = PROJECT_ID;
289
+ page.name = "Site 03";
290
+ return Promise.resolve([page]);
291
+ }) as never);
292
+ jest
293
+ .spyOn(ProjectService, "getCurrentPlan")
294
+ .mockResolvedValue({ plan: null, isSubscriptionUnpaid: false } as never);
295
+ // Nobody is subscribed with this address yet.
296
+ jest
297
+ .spyOn(StatusPageSubscriberService, "findOneBy")
298
+ .mockResolvedValue(null as never);
299
+
300
+ const onCreate: OnCreate<StatusPageSubscriber> = await (
301
+ StatusPageSubscriberService as unknown as {
302
+ onBeforeCreate: (
303
+ createBy: CreateBy<StatusPageSubscriber>,
304
+ ) => Promise<OnCreate<StatusPageSubscriber>>;
305
+ }
306
+ ).onBeforeCreate({ data: data, props: props });
307
+
308
+ return onCreate.createBy.data;
309
+ }
310
+
311
+ function newSubscriber(): StatusPageSubscriber {
312
+ const row: StatusPageSubscriber = new StatusPageSubscriber();
313
+ row.projectId = PROJECT_ID;
314
+ row.statusPageId = STATUS_PAGE_ID;
315
+ row.subscriberEmail = new Email("site03-all@acme.com");
316
+ row.isSubscriptionConfirmed = true;
317
+ return row;
318
+ }
319
+
320
+ test("a teammate's dashboard create (Add in Bulk) gets a fresh token", async () => {
321
+ const created: StatusPageSubscriber = await runOnBeforeCreate(
322
+ newSubscriber(),
323
+ makeProps([Permission.ProjectAdmin]),
324
+ );
325
+
326
+ expect(
327
+ StatusPageSubscriberUnsubscribe.isWellFormedToken(
328
+ created.unsubscribeToken,
329
+ ),
330
+ ).toBe(true);
331
+ });
332
+
333
+ test("a sign-up on the status page (created as root) gets one too", async () => {
334
+ const created: StatusPageSubscriber = await runOnBeforeCreate(
335
+ newSubscriber(),
336
+ { isRoot: true },
337
+ );
338
+
339
+ expect(
340
+ StatusPageSubscriberUnsubscribe.isWellFormedToken(
341
+ created.unsubscribeToken,
342
+ ),
343
+ ).toBe(true);
344
+ });
345
+
346
+ test("a token the client sent is replaced, never kept", async () => {
347
+ const data: StatusPageSubscriber = newSubscriber();
348
+ data.unsubscribeToken = TOKEN;
349
+
350
+ const created: StatusPageSubscriber = await runOnBeforeCreate(
351
+ data,
352
+ makeProps([Permission.ProjectAdmin]),
353
+ );
354
+
355
+ expect(created.unsubscribeToken).not.toBe(TOKEN);
356
+ expect(
357
+ StatusPageSubscriberUnsubscribe.isWellFormedToken(
358
+ created.unsubscribeToken,
359
+ ),
360
+ ).toBe(true);
361
+ });
362
+
363
+ test("every subscriber gets its own token", async () => {
364
+ const first: StatusPageSubscriber = await runOnBeforeCreate(
365
+ newSubscriber(),
366
+ { isRoot: true },
367
+ );
368
+ const second: StatusPageSubscriber = await runOnBeforeCreate(
369
+ newSubscriber(),
370
+ { isRoot: true },
371
+ );
372
+
373
+ expect(first.unsubscribeToken).not.toBe(second.unsubscribeToken);
374
+ });
375
+
376
+ test("the stamped create still passes the column check for a teammate (the column is computed)", async () => {
377
+ const created: StatusPageSubscriber = await runOnBeforeCreate(
378
+ newSubscriber(),
379
+ makeProps([Permission.ProjectAdmin]),
380
+ );
381
+
382
+ /*
383
+ * DatabaseService.create runs the hook first and the column check on
384
+ * what it stamped. Without `computed`, the token - which grants nobody
385
+ * create - would refuse every Add in Bulk from the dashboard.
386
+ */
387
+ expect(() => {
388
+ ColumnPermissions.checkDataColumnPermissions(
389
+ StatusPageSubscriber,
390
+ created,
391
+ makeProps([Permission.ProjectAdmin]),
392
+ DatabaseRequestType.Create,
393
+ );
394
+ }).not.toThrow();
395
+ });
396
+
397
+ test("a client-sent Unsubscribed At is dropped; a subscriber created unsubscribed is dated now", async () => {
398
+ const data: StatusPageSubscriber = newSubscriber();
399
+ data.unsubscribedAt = new Date("2001-01-01T00:00:00.000Z");
400
+
401
+ const created: StatusPageSubscriber = await runOnBeforeCreate(
402
+ data,
403
+ makeProps([Permission.ProjectAdmin]),
404
+ );
405
+
406
+ expect(created.unsubscribedAt).toBeUndefined();
407
+
408
+ const cancelled: StatusPageSubscriber = newSubscriber();
409
+ cancelled.isUnsubscribed = true;
410
+ cancelled.unsubscribedAt = new Date("2001-01-01T00:00:00.000Z");
411
+
412
+ const before: number = Date.now();
413
+ const createdCancelled: StatusPageSubscriber = await runOnBeforeCreate(
414
+ cancelled,
415
+ makeProps([Permission.ProjectAdmin]),
416
+ );
417
+
418
+ expect(createdCancelled.unsubscribedAt!.getTime()).toBeGreaterThanOrEqual(
419
+ before,
420
+ );
421
+ });
422
+ });
423
+
424
+ describe("who added a subscriber (Is Added By Team), set on create", () => {
425
+ function newSubscriber(): StatusPageSubscriber {
426
+ const row: StatusPageSubscriber = new StatusPageSubscriber();
427
+ row.projectId = PROJECT_ID;
428
+ row.statusPageId = STATUS_PAGE_ID;
429
+ row.subscriberEmail = new Email("site03-all@acme.com");
430
+ row.isSubscriptionConfirmed = true;
431
+ return row;
432
+ }
433
+
434
+ /*
435
+ * DatabaseService.create with the database taken out: it runs the
436
+ * service's onBeforeCreate on the very row it was handed, as the real one
437
+ * does, and "saves" it with the secrets the hook minted.
438
+ */
439
+ let seenByHook: Array<StatusPageSubscriber>;
440
+
441
+ beforeEach(() => {
442
+ seenByHook = [];
443
+
444
+ jest
445
+ .spyOn(StatusPageSubscriberService, "getStatusPagesToSendNotification")
446
+ .mockImplementation((() => {
447
+ const page: StatusPage = new StatusPage();
448
+ page._id = STATUS_PAGE_ID.toString();
449
+ page.projectId = PROJECT_ID;
450
+ page.name = "Site 03";
451
+ return Promise.resolve([page]);
452
+ }) as never);
453
+ jest
454
+ .spyOn(ProjectService, "getCurrentPlan")
455
+ .mockResolvedValue({ plan: null, isSubscriptionUnpaid: false } as never);
456
+ jest
457
+ .spyOn(StatusPageSubscriberService, "findOneBy")
458
+ .mockResolvedValue(null as never);
459
+
460
+ jest
461
+ .spyOn(DatabaseService.prototype, "create")
462
+ .mockImplementation(async function (
463
+ this: unknown,
464
+ createBy: unknown,
465
+ ): Promise<unknown> {
466
+ const onCreate: OnCreate<StatusPageSubscriber> = await (
467
+ this as {
468
+ onBeforeCreate: (
469
+ createBy: CreateBy<StatusPageSubscriber>,
470
+ ) => Promise<OnCreate<StatusPageSubscriber>>;
471
+ }
472
+ ).onBeforeCreate(createBy as CreateBy<StatusPageSubscriber>);
473
+
474
+ const saved: StatusPageSubscriber = onCreate.createBy.data;
475
+ saved._id = SUBSCRIBER_ID.toString();
476
+ seenByHook.push(saved);
477
+ return saved;
478
+ } as never);
479
+ });
480
+
481
+ const teammate: DatabaseCommonInteractionProps = {
482
+ userId: CREATOR_ID,
483
+ userType: UserType.User,
484
+ tenantId: PROJECT_ID,
485
+ };
486
+
487
+ // A project API key: the REST API, Terraform, a script. No user.
488
+ const apiKey: DatabaseCommonInteractionProps = {
489
+ userType: UserType.API,
490
+ tenantId: PROJECT_ID,
491
+ };
492
+
493
+ // A workflow's Create Status Page Subscriber step.
494
+ const workflow: DatabaseCommonInteractionProps = {
495
+ isRoot: true,
496
+ tenantId: PROJECT_ID,
497
+ };
498
+
499
+ test.each([
500
+ ["a teammate on the dashboard", teammate],
501
+ ["a project API key, which carries no user", apiKey],
502
+ ["a workflow, which runs as root", workflow],
503
+ ])(
504
+ "%s adds a subscriber for the team",
505
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
506
+ await StatusPageSubscriberService.create({
507
+ data: newSubscriber(),
508
+ props: props,
509
+ });
510
+
511
+ expect(seenByHook[0]!.isAddedByTeam).toBe(true);
512
+ },
513
+ );
514
+
515
+ test("a sign-up on the status page is not the team's", async () => {
516
+ await StatusPageSubscriberService.createFromStatusPageSignUp(
517
+ newSubscriber(),
518
+ );
519
+
520
+ expect(seenByHook[0]!.isAddedByTeam).toBe(false);
521
+ });
522
+
523
+ test("a sign-up is created as root, like the rest of the subscribe endpoint", async () => {
524
+ await StatusPageSubscriberService.createFromStatusPageSignUp(
525
+ newSubscriber(),
526
+ );
527
+
528
+ const createBy: CreateBy<StatusPageSubscriber> = (
529
+ DatabaseService.prototype.create as unknown as jest.Mock
530
+ ).mock.calls[0]![0] as CreateBy<StatusPageSubscriber>;
531
+
532
+ expect(createBy.props).toEqual({ isRoot: true });
533
+ });
534
+
535
+ test("whatever a client sends for it is ignored", async () => {
536
+ const claimsSignUp: StatusPageSubscriber = newSubscriber();
537
+ claimsSignUp.isAddedByTeam = false;
538
+
539
+ await StatusPageSubscriberService.create({
540
+ data: claimsSignUp,
541
+ props: apiKey,
542
+ });
543
+
544
+ expect(seenByHook[0]!.isAddedByTeam).toBe(true);
545
+ });
546
+
547
+ test("a row stops counting as a sign-up once its create is over", async () => {
548
+ const row: StatusPageSubscriber = newSubscriber();
549
+
550
+ await StatusPageSubscriberService.createFromStatusPageSignUp(row);
551
+ expect(row.isAddedByTeam).toBe(false);
552
+
553
+ // The same instance, created again by the team (the hook sees the row itself).
554
+ await StatusPageSubscriberService.create({ data: row, props: teammate });
555
+ expect(row.isAddedByTeam).toBe(true);
556
+ });
557
+
558
+ test("a sign-up that fails still leaves nothing behind", async () => {
559
+ const row: StatusPageSubscriber = newSubscriber();
560
+
561
+ (
562
+ DatabaseService.prototype.create as unknown as jest.Mock
563
+ ).mockRejectedValueOnce(new Error("database is down") as never);
564
+
565
+ await expect(
566
+ StatusPageSubscriberService.createFromStatusPageSignUp(row),
567
+ ).rejects.toThrow("database is down");
568
+
569
+ await StatusPageSubscriberService.create({ data: row, props: teammate });
570
+
571
+ expect(seenByHook[0]!.isAddedByTeam).toBe(true);
572
+ });
573
+
574
+ test("the column is computed, so the stamp passes a teammate's column check", async () => {
575
+ await StatusPageSubscriberService.create({
576
+ data: newSubscriber(),
577
+ props: teammate,
578
+ });
579
+
580
+ expect(() => {
581
+ ColumnPermissions.checkDataColumnPermissions(
582
+ StatusPageSubscriber,
583
+ seenByHook[0]!,
584
+ {
585
+ ...teammate,
586
+ userTenantAccessPermission: {
587
+ [PROJECT_ID.toString()]: {
588
+ projectId: PROJECT_ID,
589
+ _type: "UserTenantAccessPermission",
590
+ permissions: [
591
+ {
592
+ _type: "UserPermission",
593
+ permission: Permission.ProjectAdmin,
594
+ labelIds: [],
595
+ isBlockPermission: false,
596
+ },
597
+ ],
598
+ },
599
+ },
600
+ },
601
+ DatabaseRequestType.Create,
602
+ );
603
+ }).not.toThrow();
604
+ });
605
+ });
606
+
607
+ describe("a create never hands back the secrets it minted", () => {
608
+ beforeEach(() => {
609
+ jest.spyOn(DatabaseService.prototype, "create").mockImplementation((() => {
610
+ // What DatabaseService.create returns: the saved row, secrets and all.
611
+ const saved: StatusPageSubscriber = subscriberRow();
612
+ saved.subscriptionConfirmationToken = "123456";
613
+ return Promise.resolve(saved);
614
+ }) as never);
615
+ });
616
+
617
+ test.each([
618
+ ["the create a teammate, an API key or a workflow makes", false],
619
+ ["a sign-up on the status page", true],
620
+ ])("%s", async (_label: string, isSignUp: boolean) => {
621
+ const data: StatusPageSubscriber = new StatusPageSubscriber();
622
+
623
+ const created: StatusPageSubscriber = isSignUp
624
+ ? await StatusPageSubscriberService.createFromStatusPageSignUp(data)
625
+ : await StatusPageSubscriberService.create({
626
+ data: data,
627
+ props: { userType: UserType.API, tenantId: PROJECT_ID },
628
+ });
629
+
630
+ expect(created.unsubscribeToken).toBeUndefined();
631
+ expect(created.subscriptionConfirmationToken).toBeUndefined();
632
+ // Nothing else is lost.
633
+ expect(created.id?.toString()).toBe(SUBSCRIBER_ID.toString());
634
+ expect(created.subscriberEmail?.toString()).toBe("site03-all@acme.com");
635
+
636
+ const json: string = JSON.stringify(
637
+ StatusPageSubscriber.toJSON(created, StatusPageSubscriber),
638
+ );
639
+ expect(json).not.toContain(TOKEN);
640
+ expect(json).not.toContain("123456");
641
+ expect(json).not.toContain("unsubscribeToken");
642
+ });
643
+ });
644
+
645
+ describe("StatusPageSubscriberService.getUnsubscribeLink", () => {
646
+ test("builds the token link from the subscriber row", () => {
647
+ expect(
648
+ StatusPageSubscriberService.getUnsubscribeLink(
649
+ URL.fromString(STATUS_PAGE_URL),
650
+ subscriberRow(),
651
+ ).toString(),
652
+ ).toBe(
653
+ `${STATUS_PAGE_URL}/unsubscribe/${SUBSCRIBER_ID.toString()}-${TOKEN}`,
654
+ );
655
+ });
656
+
657
+ test("a subscriber without a token still gets a link - to the out-of-date page - and it is logged", () => {
658
+ const error: jest.SpyInstance = jest
659
+ .spyOn(logger, "error")
660
+ .mockImplementation(() => {});
661
+
662
+ const link: string = StatusPageSubscriberService.getUnsubscribeLink(
663
+ URL.fromString(STATUS_PAGE_URL),
664
+ subscriberRow({ unsubscribeToken: null }),
665
+ ).toString();
666
+
667
+ expect(link).toBe(
668
+ `${STATUS_PAGE_URL}/unsubscribe/${SUBSCRIBER_ID.toString()}`,
669
+ );
670
+ expect(error).toHaveBeenCalled();
671
+ });
672
+
673
+ test("the manage link is still the Update Subscription page", () => {
674
+ expect(
675
+ StatusPageSubscriberService.getManageSubscriptionLink(
676
+ URL.fromString(STATUS_PAGE_URL),
677
+ SUBSCRIBER_ID,
678
+ ).toString(),
679
+ ).toBe(
680
+ `${STATUS_PAGE_URL}/update-subscription/${SUBSCRIBER_ID.toString()}`,
681
+ );
682
+ });
683
+ });
684
+
685
+ describe("StatusPageSubscriberService.getSubscribersByStatusPage", () => {
686
+ /*
687
+ * A page is read in batches: one read stopped at LIMIT_MAX without a word,
688
+ * so the 10,001st subscriber of a page was never told. The subscriber
689
+ * jobs read on after the last _id of each batch
690
+ * (SubscriberNotificationFanOut), which needs a stable _id order.
691
+ */
692
+ test("reads in _id order, at most LIMIT_MAX by default, from the start", async () => {
693
+ const findBy: jest.SpyInstance = jest
694
+ .spyOn(StatusPageSubscriberService, "findBy")
695
+ .mockResolvedValue([subscriberRow()] as never);
696
+
697
+ await StatusPageSubscriberService.getSubscribersByStatusPage(
698
+ STATUS_PAGE_ID,
699
+ { isRoot: true, ignoreHooks: true },
700
+ );
701
+
702
+ const read: JSONObject = findBy.mock.calls[0]![0] as unknown as JSONObject;
703
+
704
+ expect(read["sort"]).toEqual({ _id: "ASC" });
705
+ expect(read["limit"]).toBe(LIMIT_MAX);
706
+ expect(read["skip"]).toBe(0);
707
+ expect((read["query"] as JSONObject)["_id"]).toBeUndefined();
708
+ expect((read["query"] as JSONObject)["isUnsubscribed"]).toBe(false);
709
+ expect((read["query"] as JSONObject)["isSubscriptionConfirmed"]).toBe(true);
710
+ });
711
+
712
+ test("reads the next batch after the _id given, as many as asked", async () => {
713
+ const findBy: jest.SpyInstance = jest
714
+ .spyOn(StatusPageSubscriberService, "findBy")
715
+ .mockResolvedValue([] as never);
716
+ const afterId: ObjectID = new ObjectID(
717
+ "00000000-0000-4000-8000-000000010000",
718
+ );
719
+
720
+ await StatusPageSubscriberService.getSubscribersByStatusPage(
721
+ STATUS_PAGE_ID,
722
+ { isRoot: true, ignoreHooks: true },
723
+ { afterId: afterId, limit: 500 },
724
+ );
725
+
726
+ const read: JSONObject = findBy.mock.calls[0]![0] as unknown as JSONObject;
727
+ const cursor: FindOperator<unknown> = (read["query"] as JSONObject)[
728
+ "_id"
729
+ ] as unknown as FindOperator<unknown>;
730
+
731
+ expect(read["limit"]).toBe(500);
732
+ expect(read["sort"]).toEqual({ _id: "ASC" });
733
+ expect(Object.values(cursor.objectLiteralParameters || {})).toEqual([
734
+ afterId.toString(),
735
+ ]);
736
+ // Strictly after it: the cursor row itself was in the last batch.
737
+ expect(cursor.type).toBe("raw");
738
+ expect(cursor.getSql!("alias")).toMatch(/^\(alias > :\w+\)$/);
739
+ });
740
+
741
+ test("reads each subscriber's token and creator, for the links and the team notice", async () => {
742
+ const findBy: jest.SpyInstance = jest
743
+ .spyOn(StatusPageSubscriberService, "findBy")
744
+ .mockResolvedValue([subscriberRow()] as never);
745
+
746
+ await StatusPageSubscriberService.getSubscribersByStatusPage(
747
+ STATUS_PAGE_ID,
748
+ { isRoot: true, ignoreHooks: true },
749
+ );
750
+
751
+ const select: JSONObject = (
752
+ findBy.mock.calls[0]![0] as unknown as { select: JSONObject }
753
+ ).select;
754
+
755
+ expect(select["unsubscribeToken"]).toBe(true);
756
+ expect(select["isAddedByTeam"]).toBe(true);
757
+ expect(select["createdByUserId"]).toBe(true);
758
+ // Rows with a token are left alone.
759
+ expect(query).not.toHaveBeenCalled();
760
+ });
761
+
762
+ test("a subscriber without a token is given one before any link is built", async () => {
763
+ const withoutToken: StatusPageSubscriber = subscriberRow({
764
+ unsubscribeToken: null,
765
+ });
766
+
767
+ // The page's list, then the read-back of what the database now holds.
768
+ const findBy: jest.SpyInstance = jest
769
+ .spyOn(StatusPageSubscriberService, "findBy")
770
+ .mockResolvedValueOnce([withoutToken] as never)
771
+ .mockResolvedValueOnce([
772
+ subscriberRow({ unsubscribeToken: TOKEN }),
773
+ ] as never);
774
+
775
+ const subscribers: Array<StatusPageSubscriber> =
776
+ await StatusPageSubscriberService.getSubscribersByStatusPage(
777
+ STATUS_PAGE_ID,
778
+ { isRoot: true, ignoreHooks: true },
779
+ );
780
+
781
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
782
+
783
+ expect(writes).toHaveLength(1);
784
+ // Only an empty column is filled, so two racing senders agree on one token.
785
+ expect(writes[0]!.sql).toBe(
786
+ 'UPDATE "StatusPageSubscriber" AS "subscriber" SET "unsubscribeToken" = "minted"."token" FROM unnest($1::uuid[], $2::text[]) AS "minted"("id", "token") WHERE "subscriber"."_id" = "minted"."id" AND "subscriber"."unsubscribeToken" IS NULL',
787
+ );
788
+ expect(writes[0]!.params[0]).toEqual([SUBSCRIBER_ID.toString()]);
789
+ const minted: Array<unknown> = writes[0]!.params[1] as Array<unknown>;
790
+ expect(minted).toHaveLength(1);
791
+ expect(StatusPageSubscriberUnsubscribe.isWellFormedToken(minted[0])).toBe(
792
+ true,
793
+ );
794
+
795
+ // What the database holds - the winner's token - is what the link uses.
796
+ expect(
797
+ (findBy.mock.calls[1]![0] as unknown as { select: JSONObject }).select,
798
+ ).toEqual({ _id: true, unsubscribeToken: true });
799
+ expect(subscribers[0]!.unsubscribeToken).toBe(TOKEN);
800
+ });
801
+ });
802
+
803
+ describe("StatusPageSubscriberService.ensureUnsubscribeTokens", () => {
804
+ const SECOND_ID: string = "30000000-0000-4000-8000-000000000008";
805
+ const SECOND_TOKEN: string = "cd".repeat(32);
806
+
807
+ test("tops up a whole list in one statement and one read, not one per subscriber", async () => {
808
+ const first: StatusPageSubscriber = subscriberRow({
809
+ unsubscribeToken: null,
810
+ });
811
+ // The same subscriber twice, as a list can hold it.
812
+ const firstAgain: StatusPageSubscriber = subscriberRow({
813
+ unsubscribeToken: null,
814
+ });
815
+ const second: StatusPageSubscriber = subscriberRow({
816
+ unsubscribeToken: null,
817
+ });
818
+ second._id = SECOND_ID;
819
+ const alreadyHasOne: StatusPageSubscriber = subscriberRow();
820
+ alreadyHasOne._id = "30000000-0000-4000-8000-000000000009";
821
+
822
+ const storedFirst: StatusPageSubscriber = subscriberRow();
823
+ const storedSecond: StatusPageSubscriber = subscriberRow({
824
+ unsubscribeToken: SECOND_TOKEN,
825
+ });
826
+ storedSecond._id = SECOND_ID;
827
+
828
+ const findBy: jest.SpyInstance = jest
829
+ .spyOn(StatusPageSubscriberService, "findBy")
830
+ .mockResolvedValue([storedFirst, storedSecond] as never);
831
+
832
+ await StatusPageSubscriberService.ensureUnsubscribeTokens([
833
+ first,
834
+ firstAgain,
835
+ second,
836
+ alreadyHasOne,
837
+ ]);
838
+
839
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
840
+ expect(writes).toHaveLength(1);
841
+ expect(writes[0]!.params[0]).toEqual([SUBSCRIBER_ID.toString(), SECOND_ID]);
842
+
843
+ // Each gets a token of its own.
844
+ const minted: Array<string> = writes[0]!.params[1] as Array<string>;
845
+ expect(minted).toHaveLength(2);
846
+ expect(new Set(minted).size).toBe(2);
847
+
848
+ expect(findBy).toHaveBeenCalledTimes(1);
849
+ expect(first.unsubscribeToken).toBe(TOKEN);
850
+ expect(firstAgain.unsubscribeToken).toBe(TOKEN);
851
+ expect(second.unsubscribeToken).toBe(SECOND_TOKEN);
852
+ expect(alreadyHasOne.unsubscribeToken).toBe(TOKEN);
853
+ });
854
+
855
+ test("a list that already has every token costs nothing", async () => {
856
+ const findBy: jest.SpyInstance = jest.spyOn(
857
+ StatusPageSubscriberService,
858
+ "findBy",
859
+ );
860
+
861
+ await StatusPageSubscriberService.ensureUnsubscribeTokens([
862
+ subscriberRow(),
863
+ ]);
864
+
865
+ expect(query).not.toHaveBeenCalled();
866
+ expect(findBy).not.toHaveBeenCalled();
867
+ });
868
+ });
869
+
870
+ describe("StatusPageSubscriberService.backfillUnsubscribeColumns", () => {
871
+ const ID_A: string = "30000000-0000-4000-8000-00000000000a";
872
+ const ID_B: string = "30000000-0000-4000-8000-00000000000b";
873
+ const ID_C: string = "30000000-0000-4000-8000-00000000000c";
874
+
875
+ function page(
876
+ rows: Array<[string, boolean, boolean]>,
877
+ ): Array<{ _id: string; needsToken: boolean; needsAddedByTeam: boolean }> {
878
+ return rows.map(
879
+ (
880
+ row: [string, boolean, boolean],
881
+ ): { _id: string; needsToken: boolean; needsAddedByTeam: boolean } => {
882
+ return { _id: row[0], needsToken: row[1], needsAddedByTeam: row[2] };
883
+ },
884
+ );
885
+ }
886
+
887
+ test("walks the table by primary key, a batch at a time, writing only what is empty", async () => {
888
+ query.mockReset();
889
+ query
890
+ // First page: from the start.
891
+ .mockResolvedValueOnce(
892
+ page([
893
+ [ID_A, true, false],
894
+ [ID_B, false, true],
895
+ ]) as never,
896
+ )
897
+ .mockResolvedValueOnce([[], 1] as never)
898
+ .mockResolvedValueOnce([[], 1] as never)
899
+ // Second page: after the last id of the first.
900
+ .mockResolvedValueOnce(page([[ID_C, true, true]]) as never)
901
+ .mockResolvedValueOnce([[], 1] as never)
902
+ .mockResolvedValueOnce([[], 1] as never);
903
+
904
+ const result: { tokensGiven: number; markedAddedByTeam: number } =
905
+ await StatusPageSubscriberService.backfillUnsubscribeColumns({
906
+ batchSize: 2,
907
+ });
908
+
909
+ const statements: Array<{ sql: string; params: Array<unknown> }> =
910
+ sqlCalls();
911
+
912
+ expect(statements).toHaveLength(6);
913
+
914
+ // Keyset pages, never OFFSET: each page starts after the last id seen.
915
+ expect(statements[0]!.sql).toContain('ORDER BY "_id" ASC LIMIT $1');
916
+ expect(statements[0]!.sql).toContain(
917
+ '($2::uuid IS NULL OR "_id" > $2::uuid)',
918
+ );
919
+ expect(statements[0]!.sql).not.toContain("OFFSET");
920
+ expect(statements[0]!.params).toEqual([2, null]);
921
+ expect(statements[3]!.params).toEqual([2, ID_B]);
922
+
923
+ // Tokens only for the rows without one, and never over one.
924
+ expect(statements[1]!.sql).toContain(
925
+ '"subscriber"."unsubscribeToken" IS NULL',
926
+ );
927
+ expect(statements[1]!.params[0]).toEqual([ID_A]);
928
+ // Added By Team only for rows with a creator that are not marked yet.
929
+ expect(statements[2]!.sql).toBe(
930
+ 'UPDATE "StatusPageSubscriber" SET "isAddedByTeam" = true WHERE "_id" = ANY($1::uuid[]) AND "createdByUserId" IS NOT NULL AND "isAddedByTeam" = false',
931
+ );
932
+ expect(statements[2]!.params[0]).toEqual([ID_B]);
933
+
934
+ expect(statements[4]!.params[0]).toEqual([ID_C]);
935
+ expect(statements[5]!.params[0]).toEqual([ID_C]);
936
+
937
+ // A short page is the last one.
938
+ expect(result).toEqual({ tokensGiven: 2, markedAddedByTeam: 2 });
939
+ });
940
+
941
+ test("a page with nothing to fill writes nothing, and an empty table ends at once", async () => {
942
+ query.mockReset();
943
+ query
944
+ .mockResolvedValueOnce(page([[ID_A, false, false]]) as never)
945
+ .mockResolvedValueOnce([] as never);
946
+
947
+ const result: { tokensGiven: number; markedAddedByTeam: number } =
948
+ await StatusPageSubscriberService.backfillUnsubscribeColumns({
949
+ batchSize: 1,
950
+ });
951
+
952
+ const statements: Array<{ sql: string; params: Array<unknown> }> =
953
+ sqlCalls();
954
+
955
+ expect(statements).toHaveLength(2);
956
+ expect(statements[0]!.sql.startsWith("SELECT")).toBe(true);
957
+ expect(statements[1]!.sql.startsWith("SELECT")).toBe(true);
958
+ expect(statements[1]!.params).toEqual([1, ID_A]);
959
+ expect(result).toEqual({ tokensGiven: 0, markedAddedByTeam: 0 });
960
+ });
961
+
962
+ test("reads the whole table, soft-deleted subscribers included", async () => {
963
+ query.mockReset();
964
+ query.mockResolvedValueOnce([] as never);
965
+
966
+ await StatusPageSubscriberService.backfillUnsubscribeColumns();
967
+
968
+ const select: { sql: string; params: Array<unknown> } = sqlCalls()[0]!;
969
+ expect(select.sql).not.toContain("deletedAt");
970
+ // The default batch keeps every statement small.
971
+ expect(select.params[0]).toBe(1000);
972
+ });
973
+ });
974
+
975
+ describe("opening an unsubscribe link (GET) changes nothing", () => {
976
+ test("a good link describes the subscription and writes nothing", async () => {
977
+ const details: StatusPageSubscriberUnsubscribeDetails =
978
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
979
+
980
+ expect(details).toEqual({
981
+ state: StatusPageSubscriberUnsubscribeState.Subscribed,
982
+ channel: StatusPageSubscriberUnsubscribeChannel.Email,
983
+ contact: "site03-all@acme.com",
984
+ wasAddedByTeam: true,
985
+ });
986
+
987
+ expect(query).not.toHaveBeenCalled();
988
+ expect(StatusPageSubscriberService.updateOneBy).not.toHaveBeenCalled();
989
+ expect(StatusPageSubscriberService.updateOneById).not.toHaveBeenCalled();
990
+ expect(MailService.sendMail).not.toHaveBeenCalled();
991
+ });
992
+
993
+ test("never hands out the token", async () => {
994
+ const details: StatusPageSubscriberUnsubscribeDetails =
995
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
996
+
997
+ expect(JSON.stringify(details)).not.toContain(TOKEN);
998
+ });
999
+
1000
+ test("looks the subscriber up by id and its own status page, never by the token", async () => {
1001
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1002
+
1003
+ const lookup: JSONObject = (
1004
+ (StatusPageSubscriberService.findOneBy as unknown as jest.Mock).mock
1005
+ .calls[0]![0] as { query: JSONObject }
1006
+ ).query;
1007
+
1008
+ expect(Object.keys(lookup).sort()).toEqual(["_id", "statusPageId"]);
1009
+ expect(lookup["_id"]).toBe(SUBSCRIBER_ID.toString());
1010
+ expect(lookup["statusPageId"]?.toString()).toBe(STATUS_PAGE_ID.toString());
1011
+ });
1012
+
1013
+ test("a subscriber an API key or a workflow added is described as added by the team", async () => {
1014
+ storedSubscriber = subscriberRow({
1015
+ createdByUserId: null,
1016
+ isAddedByTeam: true,
1017
+ });
1018
+
1019
+ const details: StatusPageSubscriberUnsubscribeDetails =
1020
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1021
+
1022
+ expect(details.wasAddedByTeam).toBe(true);
1023
+ });
1024
+
1025
+ test("so is one a teammate added before Is Added By Team was backfilled", async () => {
1026
+ storedSubscriber = subscriberRow({ isAddedByTeam: false });
1027
+
1028
+ const details: StatusPageSubscriberUnsubscribeDetails =
1029
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1030
+
1031
+ expect(details.wasAddedByTeam).toBe(true);
1032
+ });
1033
+
1034
+ test("a subscriber that signed up itself is not described as added by the team", async () => {
1035
+ storedSubscriber = subscriberRow({ createdByUserId: null });
1036
+
1037
+ const details: StatusPageSubscriberUnsubscribeDetails =
1038
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1039
+
1040
+ expect(details.wasAddedByTeam).toBe(false);
1041
+ });
1042
+
1043
+ test("a cancelled subscription reads as Unsubscribed", async () => {
1044
+ storedSubscriber = subscriberRow({ isUnsubscribed: true });
1045
+
1046
+ const details: StatusPageSubscriberUnsubscribeDetails =
1047
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1048
+
1049
+ expect(details.state).toBe(
1050
+ StatusPageSubscriberUnsubscribeState.Unsubscribed,
1051
+ );
1052
+ });
1053
+
1054
+ test("a wrong token, a deleted subscriber and another page's link all get the same answer", async () => {
1055
+ const wrongToken: StatusPageSubscriberUnsubscribeDetails =
1056
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(
1057
+ linkData(WRONG_TOKEN),
1058
+ );
1059
+
1060
+ storedSubscriber = subscriberRow({ statusPageId: OTHER_STATUS_PAGE_ID });
1061
+ const otherPage: StatusPageSubscriberUnsubscribeDetails =
1062
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1063
+
1064
+ storedSubscriber = null;
1065
+ const deleted: StatusPageSubscriberUnsubscribeDetails =
1066
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1067
+
1068
+ const invalid: StatusPageSubscriberUnsubscribeDetails = {
1069
+ state: StatusPageSubscriberUnsubscribeState.Invalid,
1070
+ };
1071
+
1072
+ expect(wrongToken).toEqual(invalid);
1073
+ expect(otherPage).toEqual(invalid);
1074
+ expect(deleted).toEqual(invalid);
1075
+ });
1076
+
1077
+ test("a malformed link is refused without a lookup", async () => {
1078
+ const details: StatusPageSubscriberUnsubscribeDetails =
1079
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails({
1080
+ statusPageId: "not-a-uuid",
1081
+ subscriberId: "' OR 1=1 --",
1082
+ token: TOKEN,
1083
+ });
1084
+
1085
+ expect(details).toEqual({
1086
+ state: StatusPageSubscriberUnsubscribeState.Invalid,
1087
+ });
1088
+ expect(StatusPageSubscriberService.findOneBy).not.toHaveBeenCalled();
1089
+ });
1090
+
1091
+ test("the token is compared in constant time, even when there is no subscriber", async () => {
1092
+ const timingSafeEqual: jest.SpyInstance = jest.spyOn(
1093
+ crypto,
1094
+ "timingSafeEqual",
1095
+ );
1096
+
1097
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(
1098
+ linkData(WRONG_TOKEN),
1099
+ );
1100
+ storedSubscriber = null;
1101
+ await StatusPageSubscriberService.getUnsubscribeLinkDetails(linkData());
1102
+
1103
+ expect(timingSafeEqual).toHaveBeenCalledTimes(2);
1104
+ });
1105
+ });
1106
+
1107
+ describe("confirming an unsubscribe link (POST)", () => {
1108
+ test("cancels the subscription the link belongs to", async () => {
1109
+ const state: StatusPageSubscriberUnsubscribeState =
1110
+ await StatusPageSubscriberService.unsubscribeWithLink(linkData());
1111
+
1112
+ expect(state).toBe(StatusPageSubscriberUnsubscribeState.Unsubscribed);
1113
+
1114
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
1115
+ expect(writes).toHaveLength(1);
1116
+ expect(writes[0]!.sql).toContain('SET "isUnsubscribed" = true');
1117
+ expect(writes[0]!.params[1]).toBe(SUBSCRIBER_ID.toString());
1118
+ });
1119
+
1120
+ test("a wrong token, a deleted subscriber and another page's link are refused alike, and write nothing", async () => {
1121
+ const wrongToken: StatusPageSubscriberUnsubscribeState =
1122
+ await StatusPageSubscriberService.unsubscribeWithLink(
1123
+ linkData(WRONG_TOKEN),
1124
+ );
1125
+
1126
+ storedSubscriber = subscriberRow({ statusPageId: OTHER_STATUS_PAGE_ID });
1127
+ const otherPage: StatusPageSubscriberUnsubscribeState =
1128
+ await StatusPageSubscriberService.unsubscribeWithLink(linkData());
1129
+
1130
+ storedSubscriber = null;
1131
+ const deleted: StatusPageSubscriberUnsubscribeState =
1132
+ await StatusPageSubscriberService.unsubscribeWithLink(linkData());
1133
+
1134
+ expect(wrongToken).toBe(StatusPageSubscriberUnsubscribeState.Invalid);
1135
+ expect(otherPage).toBe(StatusPageSubscriberUnsubscribeState.Invalid);
1136
+ expect(deleted).toBe(StatusPageSubscriberUnsubscribeState.Invalid);
1137
+ expect(query).not.toHaveBeenCalled();
1138
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1139
+ });
1140
+
1141
+ test("is idempotent: a subscription already cancelled answers Unsubscribed and changes nothing", async () => {
1142
+ storedSubscriber = subscriberRow({ isUnsubscribed: true });
1143
+
1144
+ const state: StatusPageSubscriberUnsubscribeState =
1145
+ await StatusPageSubscriberService.unsubscribeWithLink(linkData());
1146
+
1147
+ expect(state).toBe(StatusPageSubscriberUnsubscribeState.Unsubscribed);
1148
+ expect(query).not.toHaveBeenCalled();
1149
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1150
+ });
1151
+
1152
+ test("works whatever the status page's visibility - it never asks for read access", async () => {
1153
+ const hasReadAccess: jest.SpyInstance = jest.spyOn(
1154
+ StatusPageService,
1155
+ "hasReadAccess",
1156
+ );
1157
+
1158
+ await StatusPageSubscriberService.unsubscribeWithLink(linkData());
1159
+
1160
+ expect(hasReadAccess).not.toHaveBeenCalled();
1161
+ });
1162
+ });
1163
+
1164
+ describe("StatusPageSubscriberService.unsubscribe - the one write that cancels", () => {
1165
+ test("one guarded statement: only a live, undeleted subscription is cancelled, and it says whether it was", async () => {
1166
+ const changed: boolean = await StatusPageSubscriberService.unsubscribe({
1167
+ subscriberId: SUBSCRIBER_ID,
1168
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1169
+ });
1170
+
1171
+ expect(changed).toBe(true);
1172
+
1173
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
1174
+ expect(writes).toHaveLength(1);
1175
+ expect(writes[0]!.sql).toBe(
1176
+ 'UPDATE "StatusPageSubscriber" SET "isUnsubscribed" = true, "unsubscribedAt" = $1, "updatedAt" = CURRENT_TIMESTAMP WHERE "_id" = $2 AND "isUnsubscribed" = false AND "deletedAt" IS NULL RETURNING "_id", "projectId"',
1177
+ );
1178
+ expect(writes[0]!.params[0]).toBeInstanceOf(Date);
1179
+ expect(writes[0]!.params[1]).toBe(SUBSCRIBER_ID.toString());
1180
+ });
1181
+
1182
+ test("fires the update workflow trigger and the realtime event it bypassed", async () => {
1183
+ await StatusPageSubscriberService.unsubscribe({
1184
+ subscriberId: SUBSCRIBER_ID,
1185
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1186
+ });
1187
+
1188
+ expect(StatusPageSubscriberService.onTriggerWorkflow).toHaveBeenCalledTimes(
1189
+ 1,
1190
+ );
1191
+ const workflowCall: Array<unknown> = (
1192
+ StatusPageSubscriberService.onTriggerWorkflow as unknown as jest.Mock
1193
+ ).mock.calls[0]!;
1194
+ expect((workflowCall[0] as ObjectID).toString()).toBe(
1195
+ SUBSCRIBER_ID.toString(),
1196
+ );
1197
+ expect((workflowCall[1] as ObjectID).toString()).toBe(
1198
+ PROJECT_ID.toString(),
1199
+ );
1200
+ expect(workflowCall[2]).toBe("on-update");
1201
+ expect(
1202
+ ((workflowCall[3] as JSONObject)["updatedFields"] as JSONObject)[
1203
+ "isUnsubscribed"
1204
+ ],
1205
+ ).toBe(true);
1206
+ expect(StatusPageSubscriberService.onTriggerRealtime).toHaveBeenCalledTimes(
1207
+ 1,
1208
+ );
1209
+ });
1210
+
1211
+ test("a subscription that was already cancelled (or is gone) is left alone, and nobody is told", async () => {
1212
+ query.mockResolvedValue([[], 0] as never);
1213
+
1214
+ const changed: boolean = await StatusPageSubscriberService.unsubscribe({
1215
+ subscriberId: SUBSCRIBER_ID,
1216
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1217
+ });
1218
+
1219
+ expect(changed).toBe(false);
1220
+ expect(
1221
+ StatusPageSubscriberService.onTriggerWorkflow,
1222
+ ).not.toHaveBeenCalled();
1223
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1224
+ });
1225
+
1226
+ test("an unrecognisable driver answer reads as nothing cancelled", async () => {
1227
+ query.mockResolvedValue(undefined as never);
1228
+
1229
+ expect(
1230
+ await StatusPageSubscriberService.unsubscribe({
1231
+ subscriberId: SUBSCRIBER_ID,
1232
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1233
+ }),
1234
+ ).toBe(false);
1235
+ });
1236
+
1237
+ test("a failure to tell the team does not undo or fail the unsubscribe", async () => {
1238
+ jest.spyOn(logger, "error").mockImplementation(() => {});
1239
+ jest
1240
+ .spyOn(StatusPageService, "findOwners")
1241
+ .mockRejectedValue(new Error("owners unavailable") as never);
1242
+
1243
+ await expect(
1244
+ StatusPageSubscriberService.unsubscribe({
1245
+ subscriberId: SUBSCRIBER_ID,
1246
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1247
+ }),
1248
+ ).resolves.toBe(true);
1249
+ });
1250
+ });
1251
+
1252
+ describe("telling the team when a subscriber it added unsubscribes", () => {
1253
+ async function unsubscribeNow(): Promise<void> {
1254
+ await StatusPageSubscriberService.unsubscribe({
1255
+ subscriberId: SUBSCRIBER_ID,
1256
+ source: StatusPageSubscriberUnsubscribeSource.UnsubscribeLink,
1257
+ });
1258
+ }
1259
+
1260
+ function recipients(): Array<string> {
1261
+ return sentMail()
1262
+ .map((sent: { mail: JSONObject }) => {
1263
+ return sent.mail["toEmail"]!.toString();
1264
+ })
1265
+ .sort();
1266
+ }
1267
+
1268
+ test("emails the page's owners and the teammate who added it, each once", async () => {
1269
+ // The creator also owns the page: still one email.
1270
+ jest
1271
+ .spyOn(StatusPageService, "findOwners")
1272
+ .mockResolvedValue([
1273
+ user(OWNER_ID, "Olga Owner", "olga@acme.com"),
1274
+ user(CREATOR_ID, "Dana Admin", "dana@acme.com"),
1275
+ user(TEAM_OWNER_ID, "Tom Teamowner", "tom@acme.com"),
1276
+ ] as never);
1277
+
1278
+ await unsubscribeNow();
1279
+
1280
+ expect(recipients()).toEqual([
1281
+ "dana@acme.com",
1282
+ "olga@acme.com",
1283
+ "tom@acme.com",
1284
+ ]);
1285
+ });
1286
+
1287
+ test("sends one plain email that names the subscriber, the page and who added it", async () => {
1288
+ await unsubscribeNow();
1289
+
1290
+ const { mail, options } = sentMail()[0]!;
1291
+
1292
+ expect(mail["templateType"]).toBe(EmailTemplateType.SimpleMessage);
1293
+ expect(mail["subject"]).toBe(
1294
+ "A subscriber your team added unsubscribed from Site 03",
1295
+ );
1296
+ expect(mail["isSubjectLiteral"]).toBe(true);
1297
+
1298
+ const vars: JSONObject = mail["vars"] as JSONObject;
1299
+ expect(vars["subject"]).toBe(mail["subject"]);
1300
+ expect(vars["message"]).toContain("site03-all@acme.com");
1301
+ expect(vars["message"]).toContain("Dana Admin added this subscriber");
1302
+ expect(vars["message"]).toContain(`${DASHBOARD_URL}/email-subscribers`);
1303
+
1304
+ expect(options["projectId"]?.toString()).toBe(PROJECT_ID.toString());
1305
+ });
1306
+
1307
+ test("never for a subscriber that signed up itself", async () => {
1308
+ storedSubscriber = subscriberRow({ createdByUserId: null });
1309
+
1310
+ await unsubscribeNow();
1311
+
1312
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1313
+ expect(StatusPageService.findOwners).not.toHaveBeenCalled();
1314
+ });
1315
+
1316
+ test("a subscriber an API key or a workflow added (no creator) still tells the page's owners", async () => {
1317
+ storedSubscriber = subscriberRow({
1318
+ createdByUserId: null,
1319
+ isAddedByTeam: true,
1320
+ });
1321
+
1322
+ await unsubscribeNow();
1323
+
1324
+ expect(recipients()).toEqual(["olga@acme.com", "tom@acme.com"]);
1325
+ // There is no teammate to look up, or to name.
1326
+ expect(UserService.findOneById).not.toHaveBeenCalled();
1327
+ expect(
1328
+ (sentMail()[0]!.mail["vars"] as JSONObject)["message"] as string,
1329
+ ).toContain("Someone on your team added this subscriber");
1330
+ });
1331
+
1332
+ test("one an API key added, on a page with no owners, tells nobody", async () => {
1333
+ storedSubscriber = subscriberRow({
1334
+ createdByUserId: null,
1335
+ isAddedByTeam: true,
1336
+ });
1337
+ jest.spyOn(StatusPageService, "findOwners").mockResolvedValue([] as never);
1338
+
1339
+ await unsubscribeNow();
1340
+
1341
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1342
+ });
1343
+
1344
+ test("a teammate's subscriber from before the backfill (creator, not marked yet) still tells the team", async () => {
1345
+ storedSubscriber = subscriberRow({ isAddedByTeam: false });
1346
+
1347
+ await unsubscribeNow();
1348
+
1349
+ expect(recipients()).toEqual([
1350
+ "dana@acme.com",
1351
+ "olga@acme.com",
1352
+ "tom@acme.com",
1353
+ ]);
1354
+ });
1355
+
1356
+ test("a page with no owners tells only the teammate who added the subscriber", async () => {
1357
+ jest.spyOn(StatusPageService, "findOwners").mockResolvedValue([] as never);
1358
+
1359
+ await unsubscribeNow();
1360
+
1361
+ expect(recipients()).toEqual(["dana@acme.com"]);
1362
+ });
1363
+
1364
+ test("only people who are still members of the project are told", async () => {
1365
+ jest
1366
+ .spyOn(TeamMemberService, "filterUsersToProjectMembers")
1367
+ .mockImplementation(((data: { users: Array<User> }) => {
1368
+ return Promise.resolve(
1369
+ data.users.filter((candidate: User): boolean => {
1370
+ return candidate.id!.toString() !== CREATOR_ID.toString();
1371
+ }),
1372
+ );
1373
+ }) as never);
1374
+
1375
+ await unsubscribeNow();
1376
+
1377
+ expect(recipients()).toEqual(["olga@acme.com", "tom@acme.com"]);
1378
+ expect(
1379
+ (TeamMemberService.filterUsersToProjectMembers as unknown as jest.Mock)
1380
+ .mock.calls[0]![0],
1381
+ ).toEqual(expect.objectContaining({ projectId: PROJECT_ID }));
1382
+ });
1383
+
1384
+ test("names an SMS subscriber by its full number for the team", async () => {
1385
+ const smsSubscriber: StatusPageSubscriber = subscriberRow();
1386
+ delete smsSubscriber.subscriberEmail;
1387
+ smsSubscriber.subscriberPhone = new Phone("+15555550123");
1388
+ storedSubscriber = smsSubscriber;
1389
+
1390
+ await unsubscribeNow();
1391
+
1392
+ const message: string = (sentMail()[0]!.mail["vars"] as JSONObject)[
1393
+ "message"
1394
+ ] as string;
1395
+ expect(message).toContain("+15555550123");
1396
+ expect(message).toContain(`${DASHBOARD_URL}/sms-subscribers`);
1397
+ });
1398
+
1399
+ /*
1400
+ * The public manage page re-subscribes a subscriber and cancels it again,
1401
+ * for anyone who can see the page and knows the subscriber's id. A loop of
1402
+ * the two must not email every owner on every turn: one notice per
1403
+ * subscriber per window.
1404
+ */
1405
+ test("tells the team about a subscriber at most once per window", async () => {
1406
+ const claimed: Set<string> = new Set();
1407
+ const fence: jest.SpyInstance = jest
1408
+ .spyOn(GlobalCache, "setStringIfNotExists")
1409
+ .mockImplementation((async (
1410
+ namespace: string,
1411
+ key: string,
1412
+ _value: string,
1413
+ options?: { expiresInSeconds?: number },
1414
+ ): Promise<boolean> => {
1415
+ expect(namespace).toBe(
1416
+ StatusPageSubscriberUnsubscribeNotice.noticeCacheNamespace,
1417
+ );
1418
+ expect(options?.expiresInSeconds).toBe(
1419
+ StatusPageSubscriberUnsubscribeNotice.noticeWindowInHours * 60 * 60,
1420
+ );
1421
+
1422
+ if (claimed.has(key)) {
1423
+ return false;
1424
+ }
1425
+
1426
+ claimed.add(key);
1427
+ return true;
1428
+ }) as never);
1429
+
1430
+ await unsubscribeNow();
1431
+ const firstRound: number = sentMail().length;
1432
+
1433
+ // Re-subscribed on the manage page, and cancelled again, twice over.
1434
+ await unsubscribeNow();
1435
+ await StatusPageSubscriberService.unsubscribe({
1436
+ subscriberId: SUBSCRIBER_ID,
1437
+ source: StatusPageSubscriberUnsubscribeSource.ManageSubscriptionPage,
1438
+ });
1439
+
1440
+ expect(firstRound).toBe(3);
1441
+ expect(sentMail()).toHaveLength(firstRound);
1442
+ expect(fence).toHaveBeenCalledTimes(3);
1443
+ expect(fence.mock.calls[0]![1]).toBe(
1444
+ SUBSCRIBER_ID.toString().toLowerCase(),
1445
+ );
1446
+ });
1447
+
1448
+ test("tells the team when the cache that keeps the window cannot be reached", async () => {
1449
+ jest
1450
+ .spyOn(GlobalCache, "setStringIfNotExists")
1451
+ .mockRejectedValue(new Error("Cache is not connected") as never);
1452
+
1453
+ await unsubscribeNow();
1454
+
1455
+ expect(recipients()).toEqual([
1456
+ "dana@acme.com",
1457
+ "olga@acme.com",
1458
+ "tom@acme.com",
1459
+ ]);
1460
+ });
1461
+
1462
+ test("a subscriber nobody is to be told about does not use up the window", async () => {
1463
+ storedSubscriber = subscriberRow({ createdByUserId: null });
1464
+ const fence: jest.SpyInstance = jest.spyOn(
1465
+ GlobalCache,
1466
+ "setStringIfNotExists",
1467
+ );
1468
+
1469
+ await unsubscribeNow();
1470
+
1471
+ expect(fence).not.toHaveBeenCalled();
1472
+ });
1473
+
1474
+ test("the manage page's cancellation is reported as such", async () => {
1475
+ await StatusPageSubscriberService.unsubscribe({
1476
+ subscriberId: SUBSCRIBER_ID,
1477
+ source: StatusPageSubscriberUnsubscribeSource.ManageSubscriptionPage,
1478
+ });
1479
+
1480
+ expect(
1481
+ (sentMail()[0]!.mail["vars"] as JSONObject)["message"] as string,
1482
+ ).toContain("Update Subscription page");
1483
+ });
1484
+ });
1485
+
1486
+ describe("Unsubscribed At follows Is Unsubscribed on every other write", () => {
1487
+ const hooks: {
1488
+ onBeforeUpdate: (
1489
+ updateBy: UpdateBy<StatusPageSubscriber>,
1490
+ ) => Promise<OnUpdate<StatusPageSubscriber>>;
1491
+ onUpdateSuccess: (
1492
+ onUpdate: OnUpdate<StatusPageSubscriber>,
1493
+ ids: Array<ObjectID>,
1494
+ ) => Promise<OnUpdate<StatusPageSubscriber>>;
1495
+ } = StatusPageSubscriberService as unknown as {
1496
+ onBeforeUpdate: (
1497
+ updateBy: UpdateBy<StatusPageSubscriber>,
1498
+ ) => Promise<OnUpdate<StatusPageSubscriber>>;
1499
+ onUpdateSuccess: (
1500
+ onUpdate: OnUpdate<StatusPageSubscriber>,
1501
+ ids: Array<ObjectID>,
1502
+ ) => Promise<OnUpdate<StatusPageSubscriber>>;
1503
+ };
1504
+
1505
+ const ALREADY_CANCELLED_ID: ObjectID = new ObjectID(
1506
+ "30000000-0000-4000-8000-000000000009",
1507
+ );
1508
+
1509
+ function matchedRows(): Array<StatusPageSubscriber> {
1510
+ const live: StatusPageSubscriber = subscriberRow();
1511
+ const cancelled: StatusPageSubscriber = subscriberRow({
1512
+ isUnsubscribed: true,
1513
+ });
1514
+ cancelled._id = ALREADY_CANCELLED_ID.toString();
1515
+ return [live, cancelled];
1516
+ }
1517
+
1518
+ function updateBy(data: JSONObject): UpdateBy<StatusPageSubscriber> {
1519
+ return {
1520
+ query: { statusPageId: STATUS_PAGE_ID },
1521
+ data: data as never,
1522
+ limit: 100,
1523
+ skip: 0,
1524
+ props: { isRoot: true },
1525
+ };
1526
+ }
1527
+
1528
+ test("a teammate cancelling subscriptions dates only the ones that were live", async () => {
1529
+ const findBy: jest.SpyInstance = jest
1530
+ .spyOn(StatusPageSubscriberService, "findBy")
1531
+ .mockResolvedValue(matchedRows() as never);
1532
+
1533
+ const onUpdate: OnUpdate<StatusPageSubscriber> = await hooks.onBeforeUpdate(
1534
+ updateBy({ isUnsubscribed: true }),
1535
+ );
1536
+
1537
+ // The rows are read with the update's own query, before it runs.
1538
+ expect(
1539
+ (findBy.mock.calls[0]![0] as unknown as { query: JSONObject }).query,
1540
+ ).toEqual({ statusPageId: STATUS_PAGE_ID });
1541
+
1542
+ await hooks.onUpdateSuccess(onUpdate, [
1543
+ SUBSCRIBER_ID,
1544
+ ALREADY_CANCELLED_ID,
1545
+ ]);
1546
+
1547
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
1548
+ expect(writes).toHaveLength(1);
1549
+ expect(writes[0]!.sql).toBe(
1550
+ 'UPDATE "StatusPageSubscriber" SET "unsubscribedAt" = $1 WHERE "_id" = ANY($2::uuid[]) AND "isUnsubscribed" = true AND "unsubscribedAt" IS NULL',
1551
+ );
1552
+ expect(writes[0]!.params[1]).toEqual([SUBSCRIBER_ID.toString()]);
1553
+ });
1554
+
1555
+ test("a row the update did not reach is not dated", async () => {
1556
+ jest
1557
+ .spyOn(StatusPageSubscriberService, "findBy")
1558
+ .mockResolvedValue(matchedRows() as never);
1559
+
1560
+ const onUpdate: OnUpdate<StatusPageSubscriber> = await hooks.onBeforeUpdate(
1561
+ updateBy({ isUnsubscribed: true }),
1562
+ );
1563
+
1564
+ await hooks.onUpdateSuccess(onUpdate, []);
1565
+
1566
+ expect(query).not.toHaveBeenCalled();
1567
+ });
1568
+
1569
+ test("subscribing again clears the date", async () => {
1570
+ const findBy: jest.SpyInstance = jest.spyOn(
1571
+ StatusPageSubscriberService,
1572
+ "findBy",
1573
+ );
1574
+
1575
+ const onUpdate: OnUpdate<StatusPageSubscriber> = await hooks.onBeforeUpdate(
1576
+ updateBy({ isUnsubscribed: false }),
1577
+ );
1578
+
1579
+ expect(findBy).not.toHaveBeenCalled();
1580
+
1581
+ await hooks.onUpdateSuccess(onUpdate, [SUBSCRIBER_ID]);
1582
+
1583
+ const writes: Array<{ sql: string; params: Array<unknown> }> = sqlCalls();
1584
+ expect(writes).toHaveLength(1);
1585
+ expect(writes[0]!.sql).toBe(
1586
+ 'UPDATE "StatusPageSubscriber" SET "unsubscribedAt" = NULL WHERE "_id" = ANY($1::uuid[]) AND "isUnsubscribed" = false AND "unsubscribedAt" IS NOT NULL',
1587
+ );
1588
+ expect(writes[0]!.params[0]).toEqual([SUBSCRIBER_ID.toString()]);
1589
+ });
1590
+
1591
+ test("a write that does not touch Is Unsubscribed leaves the date alone", async () => {
1592
+ const findBy: jest.SpyInstance = jest.spyOn(
1593
+ StatusPageSubscriberService,
1594
+ "findBy",
1595
+ );
1596
+
1597
+ const onUpdate: OnUpdate<StatusPageSubscriber> = await hooks.onBeforeUpdate(
1598
+ updateBy({ internalNote: "Site 03's list" }),
1599
+ );
1600
+ await hooks.onUpdateSuccess(onUpdate, [SUBSCRIBER_ID]);
1601
+
1602
+ expect(findBy).not.toHaveBeenCalled();
1603
+ expect(query).not.toHaveBeenCalled();
1604
+ });
1605
+
1606
+ test("a teammate's toggle on the dashboard does not email the team", async () => {
1607
+ jest
1608
+ .spyOn(StatusPageSubscriberService, "findBy")
1609
+ .mockResolvedValue(matchedRows() as never);
1610
+
1611
+ const onUpdate: OnUpdate<StatusPageSubscriber> = await hooks.onBeforeUpdate(
1612
+ updateBy({ isUnsubscribed: true }),
1613
+ );
1614
+ await hooks.onUpdateSuccess(onUpdate, [SUBSCRIBER_ID]);
1615
+
1616
+ expect(MailService.sendMail).not.toHaveBeenCalled();
1617
+ });
1618
+ });