@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.
- package/Models/DatabaseModels/Incident.ts +267 -1
- package/Models/DatabaseModels/IncidentCustomField.ts +202 -0
- package/Models/DatabaseModels/IncidentEpisode.ts +30 -0
- package/Models/DatabaseModels/IncidentTemplate.ts +126 -1
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Models/DatabaseModels/StatusPage.ts +55 -0
- package/Models/DatabaseModels/StatusPageSubscriber.ts +128 -0
- package/Server/API/IncidentAPI.ts +162 -0
- package/Server/API/StatusPageAPI.ts +487 -171
- package/Server/API/StatusPageSubscriberAPI.ts +63 -15
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.ts +49 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.ts +108 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.ts +51 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.ts +168 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.ts +44 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +16 -0
- package/Server/Infrastructure/Semaphore.ts +10 -0
- package/Server/Middleware/IdentityRateLimit.ts +108 -3
- package/Server/Middleware/SubscriberNotificationPreviewRateLimit.ts +237 -0
- package/Server/Middleware/SubscriberNotificationTestSendRateLimit.ts +304 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/DatabaseService.ts +93 -10
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentCustomFieldService.ts +276 -1
- package/Server/Services/IncidentEpisodePublicNoteService.ts +28 -0
- package/Server/Services/IncidentEpisodeService.ts +15 -0
- package/Server/Services/IncidentEpisodeStateTimelineService.ts +27 -1
- package/Server/Services/IncidentPublicNoteService.ts +30 -1
- package/Server/Services/IncidentService.ts +1663 -103
- package/Server/Services/IncidentStateTimelineService.ts +13 -0
- package/Server/Services/IncidentTemplateService.ts +109 -0
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/ScheduledMaintenancePublicNoteService.ts +30 -1
- package/Server/Services/ScheduledMaintenanceService.ts +118 -64
- package/Server/Services/SmsService.ts +10 -0
- package/Server/Services/StatusPageService.ts +65 -8
- package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +299 -37
- package/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.ts +129 -0
- package/Server/Services/StatusPageSubscriberService.ts +1161 -64
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Types/Markdown.ts +153 -6
- package/Server/Types/Workflow/Components/Email.ts +61 -2
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/CustomField/CustomFieldRename.ts +378 -0
- package/Server/Utils/DataSource/EgressGuard.ts +66 -21
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Mail/PinnedSmtpSocket.ts +130 -0
- package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +10 -0
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/SSRFProtection.ts +76 -16
- package/Server/Utils/StatusPage/IncidentStatusPageScope.ts +948 -0
- package/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.ts +656 -0
- package/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.ts +805 -0
- package/Server/Utils/StatusPage/StatusPageExclusion.ts +18 -0
- package/Server/Utils/StatusPage/StatusPageReadAccess.ts +125 -0
- package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.ts +151 -0
- package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.ts +70 -0
- package/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.ts +421 -0
- package/Server/Utils/StatusPage/SubscriberNotificationClaim.ts +107 -0
- package/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.ts +1049 -0
- package/Server/Utils/StatusPage/SubscriberNotificationFanOut.ts +180 -0
- package/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.ts +839 -0
- package/Server/Utils/StatusPage/SubscriberNotificationResendAccess.ts +393 -0
- package/Server/Utils/StatusPage/SubscriberNotificationRunLimit.ts +246 -0
- package/Server/Utils/StatusPage/SubscriberNotificationTiming.ts +133 -0
- package/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.ts +215 -0
- package/Server/Utils/StatusPageResource.ts +51 -20
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Server/Utils/VM/VMAPI.ts +1 -0
- package/Server/Utils/VM/VMRunner.ts +10 -0
- package/Server/Utils/Workflow/OAuth2TokenClient.ts +10 -0
- package/Server/Views/Partials/SensitiveUrlToken.ejs +8 -5
- package/Tests/App/Accounts/RegisterVerifyEmail.test.tsx +748 -0
- package/Tests/App/Accounts/ResendVerificationEmail.test.tsx +1222 -0
- package/Tests/App/Accounts/VerificationEmailResendUtils.test.ts +797 -0
- package/Tests/App/Accounts/VerifyEmailPending.test.tsx +538 -0
- package/Tests/App/Accounts/VerifyEmailResend.test.tsx +779 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/CustomFieldsSettingsIncidentOptions.test.tsx +357 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventNotes.test.tsx +776 -0
- package/Tests/App/Dashboard/EventNotesPages.test.tsx +119 -0
- package/Tests/App/Dashboard/EventNotesUtil.test.ts +116 -17
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +534 -6
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentChangeStateNotifyDefault.test.tsx +91 -1
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/IncidentCreateDetailsStep.test.tsx +643 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +8 -1
- package/Tests/App/Dashboard/IncidentCreateStatusPageScope.test.tsx +601 -0
- package/Tests/App/Dashboard/IncidentCustomFieldTemplateVariables.test.tsx +386 -0
- package/Tests/App/Dashboard/IncidentNoteTemplatePlaceholders.test.tsx +124 -0
- package/Tests/App/Dashboard/IncidentNoteTemplateVariablesLoader.test.ts +305 -0
- package/Tests/App/Dashboard/IncidentOverviewNotifyDefault.test.tsx +112 -0
- package/Tests/App/Dashboard/IncidentSettingsRenotifyOnPublish.test.tsx +674 -0
- package/Tests/App/Dashboard/IncidentSettingsStatusPageScope.test.tsx +1051 -0
- package/Tests/App/Dashboard/IncidentTemplateCustomFields.test.tsx +384 -0
- package/Tests/App/Dashboard/IncidentTemplateStatusPageScopeCard.test.tsx +215 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/PublicNotePagesNotifyDefault.test.tsx +113 -1
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/SubscriberAudienceSummary.test.tsx +512 -0
- package/Tests/App/Dashboard/SubscriberNotificationPreviewModal.test.tsx +837 -0
- package/Tests/App/Dashboard/SubscriberNotificationStatusLabel.test.tsx +88 -0
- package/Tests/App/Dashboard/SubscriberNotificationStatusResend.test.tsx +473 -0
- package/Tests/App/Dashboard/SubscriberTemplateLivePreview.test.tsx +196 -0
- package/Tests/App/Dashboard/TeamAddedSubscribersUnsubscribedNotice.test.tsx +248 -0
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/App/SensitiveUrlTokenBootstrap.test.ts +64 -3
- package/Tests/App/StatusPage/StatusPageUnsubscribePage.test.tsx +412 -0
- package/Tests/Models/IncidentCustomFieldApiSchemaContract.test.ts +217 -0
- package/Tests/Models/IncidentCustomFieldCreateSettingsColumns.test.ts +252 -0
- package/Tests/Models/StatusPageSubscriberUnsubscribeColumns.test.ts +218 -0
- package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +7 -0
- package/Tests/Server/API/IncidentSubscriberAudienceAPI.test.ts +1408 -0
- package/Tests/Server/API/StatusPageIncidentScope.test.ts +1868 -0
- package/Tests/Server/API/StatusPageIncidentScopePostgres.test.ts +790 -0
- package/Tests/Server/API/StatusPageManageSubscriptionRecipient.test.ts +10 -1
- package/Tests/Server/API/StatusPageManageSubscriptionTemplateVariables.test.ts +206 -18
- package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +14 -2
- package/Tests/Server/API/StatusPageSubscriberCreateResponse.test.ts +217 -0
- package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +103 -2
- package/Tests/Server/API/StatusPageUnsubscribeAPI.test.ts +396 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentStatusPageScopeMigration.test.ts +395 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentTemplateStatusPageScopeFlagMigration.test.ts +150 -0
- package/Tests/Server/Infrastructure/Postgres/AddSubscriberNotificationClaimedAtMigration.test.ts +156 -0
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Infrastructure/Postgres/SkipHiddenIncidentCreatedNotificationsMigration.test.ts +410 -0
- package/Tests/Server/Middleware/IdentityRateLimit.test.ts +551 -1
- package/Tests/Server/Middleware/SubscriberNotificationPreviewRateLimit.test.ts +238 -0
- package/Tests/Server/Middleware/SubscriberNotificationTestSendRateLimit.test.ts +245 -0
- package/Tests/Server/Services/AddStatusPageSubscriberUnsubscribeTokenMigration.test.ts +152 -0
- package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +221 -36
- package/Tests/Server/Services/DatabaseServiceCompareAndSetColumns.test.ts +191 -0
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentCreatedRenotifyOnPublish.test.ts +681 -0
- package/Tests/Server/Services/IncidentCreatedResend.test.ts +775 -0
- package/Tests/Server/Services/IncidentCustomFieldRenamePostgres.test.ts +713 -0
- package/Tests/Server/Services/IncidentCustomFieldService.test.ts +751 -0
- package/Tests/Server/Services/IncidentCustomFieldValueValidation.test.ts +556 -0
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/IncidentStatusPageScope.test.ts +2971 -0
- package/Tests/Server/Services/IncidentStatusPageScopeFeed.test.ts +131 -0
- package/Tests/Server/Services/IncidentStatusPageScopePostgres.test.ts +625 -0
- package/Tests/Server/Services/IncidentTemplateCustomFieldsCopy.test.ts +311 -0
- package/Tests/Server/Services/IncidentTemplateStatusPageScope.test.ts +363 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/PublicNoteEditFeedEventType.test.ts +213 -0
- package/Tests/Server/Services/PublicNoteResendAccess.test.ts +994 -0
- package/Tests/Server/Services/ScheduledMaintenanceSubscriberTemplateFormats.test.ts +289 -9
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Services/SmsServiceFailIfNotSent.test.ts +67 -0
- package/Tests/Server/Services/StatusPageReportIncidentCounts.test.ts +610 -0
- package/Tests/Server/Services/StatusPageSubscriberAudienceCounts.test.ts +201 -0
- package/Tests/Server/Services/StatusPageSubscriberAudienceCountsPostgres.test.ts +271 -0
- package/Tests/Server/Services/StatusPageSubscriberConfirmationTemplateEscaping.test.ts +200 -0
- package/Tests/Server/Services/StatusPageSubscriberNotificationLogRedaction.test.ts +54 -0
- package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateCompile.test.ts +37 -0
- package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateIncidentRecordAccess.test.ts +772 -0
- package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookup.test.ts +506 -0
- package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookupPostgres.test.ts +261 -0
- package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +21 -5
- package/Tests/Server/Services/StatusPageSubscriberUnsubscribe.test.ts +1618 -0
- package/Tests/Server/Services/StatusPageSubscriberUnsubscribeLinkSenders.test.ts +373 -0
- package/Tests/Server/Services/StatusPageSubscriberUnsubscribePostgres.test.ts +388 -0
- package/Tests/Server/Services/SubscriberNotificationInFlightRequeue.test.ts +500 -0
- package/Tests/Server/Types/MarkdownEmailLinks.test.ts +320 -0
- package/Tests/Server/Types/Workflow/Components/Email.test.ts +491 -1
- package/Tests/Server/Types/Workflow/Components/EmailSmtpPinning.test.ts +398 -0
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/CustomField/CustomFieldRename.test.ts +586 -0
- package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +246 -10
- package/Tests/Server/Utils/DataSource/EgressGuardDeploymentConfig.test.ts +226 -0
- package/Tests/Server/Utils/DataSource/EgressGuardPinning.test.ts +131 -0
- package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +4 -0
- package/Tests/Server/Utils/EgressPolicyEnvironment.ts +45 -0
- package/Tests/Server/Utils/Mail/PinnedSmtpSocket.test.ts +409 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +23 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +4 -0
- package/Tests/Server/Utils/SSRFProtectionCloudServiceAddresses.test.ts +4 -0
- package/Tests/Server/Utils/SSRFProtectionPrivateNetwork.test.ts +4 -0
- package/Tests/Server/Utils/SSRFProtectionResolutionDetail.test.ts +314 -0
- package/Tests/Server/Utils/StatusPage/IncidentStatusPageScope.test.ts +1699 -0
- package/Tests/Server/Utils/StatusPage/IncidentStatusPageScopeQueriesPostgres.test.ts +508 -0
- package/Tests/Server/Utils/StatusPage/IncidentSubscriberAudienceCallerPermission.test.ts +246 -0
- package/Tests/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.test.ts +1348 -0
- package/Tests/Server/Utils/StatusPage/StatusPageReadAccess.test.ts +221 -0
- package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.test.ts +132 -0
- package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.test.ts +155 -0
- package/Tests/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.test.ts +708 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaim.test.ts +255 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaimPostgres.test.ts +528 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.test.ts +1238 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationFanOut.test.ts +472 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.test.ts +1168 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationRunLimit.test.ts +275 -0
- package/Tests/Server/Utils/StatusPage/SubscriberNotificationTiming.test.ts +131 -0
- package/Tests/Server/Utils/StatusPageResource.test.ts +98 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +17 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +75 -0
- package/Tests/Types/CustomField/CustomFieldSavedViews.test.ts +247 -0
- package/Tests/Types/CustomField/CustomFieldTemplateValues.test.ts +186 -0
- package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +48 -0
- package/Tests/Types/CustomField/CustomFieldValueFormat.test.ts +215 -0
- package/Tests/Types/CustomField/CustomFieldValueValidator.test.ts +343 -0
- package/Tests/Types/CustomField/CustomFieldVariableKey.test.ts +364 -0
- package/Tests/Types/SafeHtml.test.ts +102 -0
- package/Tests/Types/StatusPage/IncidentCreatedRenotify.test.ts +419 -0
- package/Tests/Types/StatusPage/IncidentCreatedResend.test.ts +118 -0
- package/Tests/Types/StatusPage/IncidentScopeAddedPagesNotification.test.ts +558 -0
- package/Tests/Types/StatusPage/IncidentSubscriberAudience.test.ts +284 -0
- package/Tests/Types/StatusPage/StatusPageSubscriberUnsubscribe.test.ts +341 -0
- package/Tests/Types/StatusPage/SubscriberNotificationPreview.test.ts +196 -0
- package/Tests/Types/StatusPage/SubscriberNotificationResend.test.ts +201 -0
- package/Tests/Types/StatusPage/SubscriberNotificationTemplateCompiler.test.ts +277 -0
- package/Tests/Types/StatusPage/SubscriberNotificationTemplatePreview.test.ts +179 -0
- package/Tests/Types/StatusPage/SubscriberNotificationTemplateVariables.test.ts +324 -0
- package/Tests/Types/StatusPage/SubscriberUpdateNotification.test.ts +60 -13
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/UI/Components/CustomFields/CustomFieldFormFields.test.ts +432 -0
- package/Tests/UI/Components/CustomFields/CustomFieldModelFormFields.test.tsx +685 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetailOrder.test.tsx +469 -0
- package/Tests/UI/Components/Forms/ModelFormCustomFieldInputs.test.tsx +325 -0
- package/Tests/UI/Components/Forms/ModelFormDropdownOptionsRefused.test.tsx +358 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +22 -0
- package/Tests/UI/Monitor/NetworkPathView.test.tsx +289 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Tests/Utils/Incident/IncidentNoteTemplateVariables.test.ts +623 -0
- package/Tests/Utils/Markdown/MarkdownEscape.test.ts +89 -0
- package/Tests/Utils/ProbeNetworkFailureUtil.test.ts +992 -0
- package/Tests/Utils/RequestFailedDetails.test.ts +575 -0
- package/Types/CustomField/CustomFieldOrder.ts +55 -0
- package/Types/CustomField/CustomFieldSavedViews.ts +225 -0
- package/Types/CustomField/CustomFieldTemplateMerge.ts +71 -0
- package/Types/CustomField/CustomFieldType.ts +14 -0
- package/Types/CustomField/CustomFieldValueFormat.ts +161 -0
- package/Types/CustomField/CustomFieldValueValidator.ts +488 -0
- package/Types/CustomField/CustomFieldVariableKey.ts +211 -0
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/SafeHtml.ts +93 -0
- package/Types/StatusPage/IncidentCreatedRenotify.ts +224 -0
- package/Types/StatusPage/IncidentCreatedResend.ts +108 -0
- package/Types/StatusPage/IncidentScopeAddedPagesNotification.ts +343 -0
- package/Types/StatusPage/IncidentSubscriberAudience.ts +332 -0
- package/Types/StatusPage/StatusPageSubscriberUnsubscribe.ts +405 -0
- package/Types/StatusPage/SubscriberNotificationInterruption.ts +32 -0
- package/Types/StatusPage/SubscriberNotificationPreview.ts +322 -0
- package/Types/StatusPage/SubscriberNotificationResend.ts +132 -0
- package/Types/StatusPage/SubscriberNotificationTemplateCompiler.ts +180 -0
- package/Types/StatusPage/SubscriberNotificationTemplatePreview.ts +301 -0
- package/Types/StatusPage/SubscriberNotificationTemplateVariables.ts +217 -4
- package/Types/StatusPage/SubscriberUpdateNotification.ts +32 -11
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/UI/Components/CustomFields/CustomFieldFormFields.ts +317 -0
- package/UI/Components/CustomFields/CustomFieldModelFormFields.tsx +395 -0
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +95 -55
- package/UI/Components/Forms/ModelForm.tsx +70 -8
- package/UI/Components/Forms/Validation.ts +1 -1
- package/UI/Components/ModelFormModal/ModelFormModal.tsx +2 -4
- package/UI/Components/ModelTable/AnalyticsModelTable.tsx +2 -6
- package/UI/Components/ModelTable/BaseModelTable.tsx +9 -8
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +14 -1
- package/UI/Components/ModelTable/ModelTable.tsx +6 -7
- package/UI/Utils/SensitiveUrlToken.ts +10 -5
- package/Utils/API.ts +247 -2
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/Utils/Incident/IncidentNoteTemplateVariables.ts +304 -0
- package/Utils/Markdown/MarkdownEscape.ts +65 -0
- package/Utils/ProbeNetworkFailureUtil.ts +673 -0
- package/build/dist/Models/DatabaseModels/Incident.js +267 -1
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentCustomField.js +207 -1
- package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js +31 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentTemplate.js +124 -1
- package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +56 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +131 -0
- package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
- package/build/dist/Server/API/IncidentAPI.js +100 -1
- package/build/dist/Server/API/IncidentAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +417 -176
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageSubscriberAPI.js +52 -13
- package/build/dist/Server/API/StatusPageSubscriberAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js +41 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js +57 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js +23 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js +40 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js +89 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js +29 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +16 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Semaphore.js +3 -0
- package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
- package/build/dist/Server/Middleware/IdentityRateLimit.js +89 -3
- package/build/dist/Server/Middleware/IdentityRateLimit.js.map +1 -1
- package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js +149 -0
- package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js.map +1 -0
- package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js +190 -0
- package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +56 -7
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentCustomFieldService.js +204 -8
- package/build/dist/Server/Services/IncidentCustomFieldService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js +24 -0
- package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeService.js +14 -0
- package/build/dist/Server/Services/IncidentEpisodeService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +26 -0
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentPublicNoteService.js +26 -1
- package/build/dist/Server/Services/IncidentPublicNoteService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +1148 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +12 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentTemplateService.js +83 -0
- package/build/dist/Server/Services/IncidentTemplateService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js +26 -1
- package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +84 -57
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/SmsService.js +2 -22
- package/build/dist/Server/Services/SmsService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +47 -9
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +237 -32
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js +109 -0
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberService.js +925 -99
- package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Types/Markdown.js +110 -6
- package/build/dist/Server/Types/Markdown.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Email.js +55 -6
- package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldRename.js +211 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldRename.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/EgressGuard.js +41 -12
- package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -1
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js +101 -0
- package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +48 -6
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js +631 -0
- package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js +403 -0
- package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js +451 -0
- package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js +13 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js +91 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js +101 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js +47 -0
- package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js +273 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js +73 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js +722 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js +102 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js +527 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js +278 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js +137 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js +103 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js +114 -0
- package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js.map +1 -0
- package/build/dist/Server/Utils/StatusPageResource.js +39 -7
- package/build/dist/Server/Utils/StatusPageResource.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMRunner.js +1 -0
- package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
- package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js +3 -0
- package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js.map +1 -1
- package/build/dist/Types/CustomField/CustomFieldOrder.js +36 -0
- package/build/dist/Types/CustomField/CustomFieldOrder.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldSavedViews.js +135 -0
- package/build/dist/Types/CustomField/CustomFieldSavedViews.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js +27 -0
- package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldType.js +12 -0
- package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
- package/build/dist/Types/CustomField/CustomFieldValueFormat.js +126 -0
- package/build/dist/Types/CustomField/CustomFieldValueFormat.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldValueValidator.js +280 -0
- package/build/dist/Types/CustomField/CustomFieldValueValidator.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldVariableKey.js +150 -0
- package/build/dist/Types/CustomField/CustomFieldVariableKey.js.map +1 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/SafeHtml.js +84 -0
- package/build/dist/Types/SafeHtml.js.map +1 -0
- package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js +117 -0
- package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js.map +1 -0
- package/build/dist/Types/StatusPage/IncidentCreatedResend.js +86 -0
- package/build/dist/Types/StatusPage/IncidentCreatedResend.js.map +1 -0
- package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js +246 -0
- package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js.map +1 -0
- package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js +183 -0
- package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js.map +1 -0
- package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js +277 -0
- package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js +27 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js +190 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationResend.js +102 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationResend.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js +97 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js +175 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js.map +1 -0
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js +144 -4
- package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js.map +1 -1
- package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js +29 -9
- package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js +169 -0
- package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js.map +1 -0
- package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js +212 -0
- package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js.map +1 -0
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +64 -49
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/Forms/ModelForm.js +39 -5
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/Forms/Validation.js +1 -1
- package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/AnalyticsModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +2 -2
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +13 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ModelTable.js +1 -1
- package/build/dist/UI/Components/ModelTable/ModelTable.js.map +1 -1
- package/build/dist/UI/Utils/SensitiveUrlToken.js +10 -5
- package/build/dist/UI/Utils/SensitiveUrlToken.js.map +1 -1
- package/build/dist/Utils/API.js +177 -1
- package/build/dist/Utils/API.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js +155 -0
- package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js.map +1 -0
- package/build/dist/Utils/Markdown/MarkdownEscape.js +48 -0
- package/build/dist/Utils/Markdown/MarkdownEscape.js.map +1 -1
- package/build/dist/Utils/ProbeNetworkFailureUtil.js +460 -0
- package/build/dist/Utils/ProbeNetworkFailureUtil.js.map +1 -0
- 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
|
+
});
|