@oneuptime/common 12.0.13 → 12.0.14
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/OnCallDutyPolicySchedule.ts +14 -0
- package/Models/DatabaseModels/UserCall.ts +94 -3
- package/Models/DatabaseModels/UserEmail.ts +94 -3
- package/Models/DatabaseModels/UserIncomingCallNumber.ts +94 -3
- package/Models/DatabaseModels/UserPush.ts +37 -0
- package/Models/DatabaseModels/UserSMS.ts +94 -3
- package/Models/DatabaseModels/UserWhatsApp.ts +93 -2
- package/Server/API/BaseAPI.ts +79 -14
- package/Server/API/UserCallAPI.ts +53 -45
- package/Server/API/UserEmailAPI.ts +53 -44
- package/Server/API/UserIncomingCallNumberAPI.ts +48 -38
- package/Server/API/UserNotificationMethodAdminAPI.ts +481 -0
- package/Server/API/UserPushAPI.ts +126 -2
- package/Server/API/UserSmsAPI.ts +53 -44
- package/Server/API/UserWhatsAppAPI.ts +53 -50
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.ts +154 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.ts +17 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.ts +45 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Middleware/VerificationCodeRateLimit.ts +586 -0
- package/Server/Services/AnalyticsDatabaseService.ts +169 -0
- package/Server/Services/DatabaseService.ts +101 -0
- package/Server/Services/LogAggregationService.ts +5 -0
- package/Server/Services/PushNotificationService.ts +127 -12
- package/Server/Services/TeamMemberService.ts +53 -13
- package/Server/Services/UserCallService.ts +55 -24
- package/Server/Services/UserEmailService.ts +83 -17
- package/Server/Services/UserIncomingCallNumberService.ts +49 -85
- package/Server/Services/UserNotificationMethodAdminService.ts +1527 -0
- package/Server/Services/UserNotificationRuleService.ts +26 -0
- package/Server/Services/UserPushService.ts +89 -0
- package/Server/Services/UserSmsService.ts +50 -17
- package/Server/Services/UserWhatsAppService.ts +48 -15
- package/Server/Types/Database/QueryHelper.ts +30 -0
- package/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.ts +59 -0
- package/Server/Utils/ChannelVerification.ts +510 -0
- package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +13 -16
- package/Server/Utils/Monitor/MonitorAlert.ts +24 -21
- package/Server/Utils/Monitor/MonitorIncident.ts +21 -18
- package/Server/Utils/Monitor/MonitorResourceContext.ts +107 -0
- package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +442 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +3 -3
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +96 -28
- package/Server/Utils/TotpAuth.ts +137 -9
- package/Server/Utils/UserRegistrationToken.ts +182 -0
- package/Server/Utils/VerificationCode.ts +134 -0
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +173 -94
- package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +1151 -0
- package/Tests/App/Dashboard/AdminUserOnCallPages.test.tsx +905 -0
- package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +338 -0
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +1 -1
- package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +28 -0
- package/Tests/Models/UserPushCriticalAlertColumn.test.ts +172 -0
- package/Tests/Server/API/BaseAPIGetListParallel.test.ts +519 -0
- package/Tests/Server/API/NotificationChannelVerificationAPI.test.ts +543 -0
- package/Tests/Server/API/UserIncomingCallNumberApi.test.ts +108 -18
- package/Tests/Server/API/UserNotificationMethodAdminAPI.test.ts +963 -0
- package/Tests/Server/API/UserPushCriticalAlertsApi.test.ts +114 -0
- package/Tests/Server/API/UserSmsApi.test.ts +107 -14
- package/Tests/Server/API/UserTotpAuthAPI.test.ts +427 -0
- package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +315 -0
- package/Tests/Server/Middleware/VerificationCodeRateLimit.test.ts +775 -0
- package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +10 -62
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +33 -13
- package/Tests/Server/Services/AnalyticsDatabaseSortKeyBoundary.test.ts +658 -0
- package/Tests/Server/Services/CriticalOnCallAlertDelivery.test.ts +458 -0
- package/Tests/Server/Services/CriticalPushAlertPayload.test.ts +301 -0
- package/Tests/Server/Services/DatabaseServiceAtomicIncrement.test.ts +143 -0
- package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1 -0
- package/Tests/Server/Services/LogAggregationScanMemory.test.ts +421 -0
- package/Tests/Server/Services/LogAggregationService.test.ts +2 -2
- package/Tests/Server/Services/NotificationChannelCodeIssuance.test.ts +401 -0
- package/Tests/Server/Services/PushNotificationDeliveryOptions.test.ts +204 -0
- package/Tests/Server/Services/TeamMemberAutoAcceptInvitation.test.ts +21 -1
- package/Tests/Server/Services/TeamMemberInviteRegistrationToken.test.ts +290 -0
- package/Tests/Server/Services/UserNotificationMethodAdminService.test.ts +948 -0
- package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1 -0
- package/Tests/Server/Services/UserPushCriticalAlertToggle.test.ts +255 -0
- package/Tests/Server/Services/UserTotpAuthEnrolment.test.ts +218 -0
- package/Tests/Server/TestingUtils/AuthenticatorApp.ts +248 -0
- package/Tests/Server/Types/Database/Permissions/NotificationChannelVerificationColumns.test.ts +135 -0
- package/Tests/Server/Types/Database/Permissions/OnCallScheduleRelationSelect.test.ts +504 -0
- package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAnyOf.test.ts +91 -0
- package/Tests/Server/Types/Workflow/Components/JsonToText.test.ts +384 -0
- package/Tests/Server/Types/Workflow/Components/MergeJson.test.ts +403 -0
- package/Tests/Server/Utils/Browser.test.ts +162 -0
- package/Tests/Server/Utils/ChannelVerification.test.ts +689 -0
- package/Tests/Server/Utils/Express.test.ts +371 -0
- package/Tests/Server/Utils/Monitor/Criteria/CustomCodeMonitorCriteria.test.ts +707 -0
- package/Tests/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.test.ts +698 -0
- package/Tests/Server/Utils/Monitor/Criteria/IncomingEmailBodyCriteria.test.ts +243 -0
- package/Tests/Server/Utils/Monitor/Criteria/IncomingRequestBodyCriteria.test.ts +278 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +160 -20
- package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +13 -8
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +162 -18
- package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +349 -0
- package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +690 -0
- package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +7 -2
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +92 -18
- package/Tests/Server/Utils/PushNotificationUtilCreators.test.ts +267 -0
- package/Tests/Server/Utils/Telemetry/OneuptimeLabel.test.ts +439 -0
- package/Tests/Server/Utils/TotpAuth.test.ts +719 -0
- package/Tests/Server/Utils/UserRegistrationToken.test.ts +322 -0
- package/Tests/Server/Utils/VerificationCode.test.ts +296 -0
- package/Tests/Types/AutoRemediation/AutoRemediationSuggestionStatus.test.ts +192 -0
- package/Tests/Types/Call/CallRequest.test.ts +189 -0
- package/Tests/Types/Dashboard/DashboardSize.test.ts +226 -0
- package/Tests/Types/Incident/IncidentSlaStatus.test.ts +191 -0
- package/Tests/Types/Metrics/MetricDashboardMetricType.test.ts +278 -0
- package/Tests/Types/Metrics/MetricPipelineRuleType.test.ts +517 -0
- package/Tests/Types/Monitor/IncomingMonitorDefaultCriteria.test.ts +470 -0
- package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +6 -4
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +262 -5
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +1 -1
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +1 -1
- package/Tests/Types/Monitor/ServiceAlertTemplates.test.ts +905 -0
- package/Tests/Types/Service/ServiceLanguage.test.ts +255 -0
- package/Tests/Types/TextRandomGeneration.test.ts +211 -0
- package/Tests/UI/Components/Button.test.tsx +175 -1
- package/Tests/UI/Components/CodeBlockLanguages.test.tsx +236 -0
- package/Tests/UI/Components/CommandPalette/CommandPalette.test.tsx +568 -0
- package/Tests/UI/Components/CommandPalette/CommandPaletteKeyboard.test.tsx +239 -0
- package/Tests/UI/Components/CommandPalette/CommandPaletteProviders.test.tsx +400 -0
- package/Tests/UI/Components/CommandPalette/PaletteFilter.test.ts +234 -0
- package/Tests/UI/Components/CommandPalette/RecentCommands.test.ts +97 -0
- package/Tests/UI/Components/CountModelSideMenuItemGuard.test.tsx +198 -0
- package/Tests/UI/Components/FeedItemSafeMode.test.tsx +1 -1
- package/Tests/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.test.ts +267 -0
- package/Tests/UI/Components/List.test.tsx +9 -2
- package/Tests/UI/Components/ListLoadingStates.test.tsx +173 -0
- package/Tests/UI/Components/MarkdownLazy.test.tsx +113 -0
- package/Tests/UI/Components/MarkdownMermaidRetry.test.tsx +110 -0
- package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +8 -0
- package/Tests/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.test.ts +692 -0
- package/Tests/UI/Components/MoreMenuMotion.test.tsx +118 -0
- package/Tests/UI/Components/NavBarCommandKGating.test.tsx +178 -0
- package/Tests/UI/Components/OrderedStatesList.test.tsx +19 -2
- package/Tests/UI/Components/SideOverMotion.test.tsx +109 -0
- package/Tests/UI/Components/Skeleton.test.tsx +71 -0
- package/Tests/UI/Components/TableLoadingStates.test.tsx +279 -0
- package/Tests/UI/Components/Tabs.test.tsx +24 -0
- package/Tests/UI/Components/Toast.test.tsx +130 -7
- package/Tests/UI/Components/ToastStacking.test.tsx +198 -0
- package/Tests/UI/Utils/Dropdown.test.ts +423 -0
- package/Tests/UI/Utils/ModelAPITenantHeader.test.ts +122 -0
- package/Tests/UI/Utils/ModelListCache.test.ts +338 -0
- package/Tests/UI/Utils/ProjectListCacheInvalidation.test.ts +225 -0
- package/Tests/Utils/API.test.ts +16 -2
- package/Tests/Utils/Dashboard/Components/DashboardKubernetesResourceListShared.test.ts +347 -0
- package/Tests/Utils/Dashboard/Components/DashboardListSharedArgs.test.ts +259 -0
- package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +466 -0
- package/Tests/Utils/Rum/UrlScrubberSegments.test.ts +362 -0
- package/Types/Email/EmailTemplateType.ts +1 -0
- package/Types/Monitor/MonitorCriteriaInstance.ts +41 -20
- package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +131 -8
- package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +48 -8
- package/Types/Monitor/ServiceAlertTemplates.ts +1352 -0
- package/Types/Permission.ts +35 -0
- package/Types/PushNotification/AndroidNotificationChannel.ts +26 -0
- package/Types/PushNotification/PushNotificationMessage.ts +15 -0
- package/Types/PushNotification/PushNotificationRequest.ts +8 -16
- package/Types/Service/ServiceLanguage.ts +144 -0
- package/Types/Text.ts +111 -19
- package/UI/Components/Button/Button.tsx +27 -14
- package/UI/Components/CodeBlock/CodeBlock.tsx +54 -7
- package/UI/Components/CodeBlock/LanguageRegistry.ts +103 -0
- package/UI/Components/CommandPalette/CommandPalette.tsx +790 -0
- package/UI/Components/CommandPalette/PaletteFilter.ts +196 -0
- package/UI/Components/CommandPalette/PaletteRow.tsx +173 -0
- package/UI/Components/CommandPalette/RecentCommands.ts +57 -0
- package/UI/Components/CommandPalette/Types.ts +49 -0
- package/UI/Components/CommandPalette/UseProviderSearch.ts +129 -0
- package/UI/Components/Dropdown/Dropdown.tsx +6 -1
- package/UI/Components/Feed/FeedItem.tsx +1 -1
- package/UI/Components/List/List.tsx +37 -5
- package/UI/Components/List/ListSkeleton.tsx +54 -0
- package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +6 -1
- package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +71 -26
- package/UI/Components/Modal/Modal.tsx +2 -2
- package/UI/Components/ModelTable/BaseModelTable.tsx +1 -1
- package/UI/Components/ModelTable/useCustomFieldColumns.ts +11 -5
- package/UI/Components/MoreMenu/MoreMenu.tsx +39 -1
- package/UI/Components/Navbar/NavBar.tsx +14 -1
- package/UI/Components/OrderedStatesList/OrderedStatesList.tsx +50 -5
- package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +40 -1
- package/UI/Components/SideOver/SideOver.tsx +37 -2
- package/UI/Components/Skeleton/Skeleton.tsx +53 -0
- package/UI/Components/Table/Table.tsx +74 -44
- package/UI/Components/Table/TableSkeletonRows.tsx +141 -0
- package/UI/Components/Tabs/Tab.tsx +1 -1
- package/UI/Components/Toast/Toast.tsx +118 -53
- package/UI/Components/Toast/ToastInit.tsx +82 -32
- package/UI/Styles/Theme.css +40 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +20 -10
- package/UI/Utils/ModelListCache.ts +188 -0
- package/UI/Utils/Project.ts +29 -0
- package/Utils/API.ts +6 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +14 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserCall.js +101 -3
- package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserEmail.js +101 -3
- package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js +101 -3
- package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserPush.js +38 -0
- package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSMS.js +101 -3
- package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js +100 -2
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +69 -13
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/UserCallAPI.js +42 -38
- package/build/dist/Server/API/UserCallAPI.js.map +1 -1
- package/build/dist/Server/API/UserEmailAPI.js +42 -38
- package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
- package/build/dist/Server/API/UserIncomingCallNumberAPI.js +37 -32
- package/build/dist/Server/API/UserIncomingCallNumberAPI.js.map +1 -1
- package/build/dist/Server/API/UserNotificationMethodAdminAPI.js +341 -0
- package/build/dist/Server/API/UserNotificationMethodAdminAPI.js.map +1 -0
- package/build/dist/Server/API/UserPushAPI.js +95 -2
- package/build/dist/Server/API/UserPushAPI.js.map +1 -1
- package/build/dist/Server/API/UserSmsAPI.js +42 -38
- package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
- package/build/dist/Server/API/UserWhatsAppAPI.js +42 -40
- package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js +89 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js +40 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/VerificationCodeRateLimit.js +393 -0
- package/build/dist/Server/Middleware/VerificationCodeRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +136 -0
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +74 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/LogAggregationService.js +5 -0
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/PushNotificationService.js +69 -30
- package/build/dist/Server/Services/PushNotificationService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +43 -7
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/UserCallService.js +54 -22
- package/build/dist/Server/Services/UserCallService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +78 -17
- package/build/dist/Server/Services/UserEmailService.js.map +1 -1
- package/build/dist/Server/Services/UserIncomingCallNumberService.js +45 -74
- package/build/dist/Server/Services/UserIncomingCallNumberService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationMethodAdminService.js +1117 -0
- package/build/dist/Server/Services/UserNotificationMethodAdminService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js +20 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserPushService.js +77 -0
- package/build/dist/Server/Services/UserPushService.js.map +1 -1
- package/build/dist/Server/Services/UserSmsService.js +50 -17
- package/build/dist/Server/Services/UserSmsService.js.map +1 -1
- package/build/dist/Server/Services/UserWhatsAppService.js +48 -15
- package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +27 -0
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js +46 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js.map +1 -1
- package/build/dist/Server/Utils/ChannelVerification.js +348 -0
- package/build/dist/Server/Utils/ChannelVerification.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +13 -12
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +20 -17
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +17 -14
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +95 -0
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +313 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +3 -3
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +67 -26
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
- package/build/dist/Server/Utils/TotpAuth.js +127 -8
- package/build/dist/Server/Utils/TotpAuth.js.map +1 -1
- package/build/dist/Server/Utils/UserRegistrationToken.js +170 -0
- package/build/dist/Server/Utils/UserRegistrationToken.js.map +1 -0
- package/build/dist/Server/Utils/VerificationCode.js +120 -0
- package/build/dist/Server/Utils/VerificationCode.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +42 -21
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +77 -5
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +18 -6
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
- package/build/dist/Types/Monitor/ServiceAlertTemplates.js +1028 -0
- package/build/dist/Types/Monitor/ServiceAlertTemplates.js.map +1 -0
- package/build/dist/Types/Permission.js +33 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/PushNotification/AndroidNotificationChannel.js +27 -0
- package/build/dist/Types/PushNotification/AndroidNotificationChannel.js.map +1 -0
- package/build/dist/Types/Service/ServiceLanguage.js +96 -0
- package/build/dist/Types/Service/ServiceLanguage.js.map +1 -0
- package/build/dist/Types/Text.js +82 -17
- package/build/dist/Types/Text.js.map +1 -1
- package/build/dist/UI/Components/Button/Button.js +22 -14
- package/build/dist/UI/Components/Button/Button.js.map +1 -1
- package/build/dist/UI/Components/CodeBlock/CodeBlock.js +35 -3
- package/build/dist/UI/Components/CodeBlock/CodeBlock.js.map +1 -1
- package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js +57 -0
- package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js +439 -0
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/PaletteFilter.js +135 -0
- package/build/dist/UI/Components/CommandPalette/PaletteFilter.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/PaletteRow.js +83 -0
- package/build/dist/UI/Components/CommandPalette/PaletteRow.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/RecentCommands.js +47 -0
- package/build/dist/UI/Components/CommandPalette/RecentCommands.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/Types.js +2 -0
- package/build/dist/UI/Components/CommandPalette/Types.js.map +1 -0
- package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js +88 -0
- package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js.map +1 -0
- package/build/dist/UI/Components/Dropdown/Dropdown.js +6 -1
- package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedItem.js +1 -1
- package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
- package/build/dist/UI/Components/List/List.js +19 -5
- package/build/dist/UI/Components/List/List.js.map +1 -1
- package/build/dist/UI/Components/List/ListSkeleton.js +21 -0
- package/build/dist/UI/Components/List/ListSkeleton.js.map +1 -0
- package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +52 -25
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/Modal/Modal.js +2 -2
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +9 -2
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js +32 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +5 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js +29 -5
- package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js.map +1 -1
- package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +23 -1
- package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
- package/build/dist/UI/Components/SideOver/SideOver.js +23 -2
- package/build/dist/UI/Components/SideOver/SideOver.js.map +1 -1
- package/build/dist/UI/Components/Skeleton/Skeleton.js +23 -0
- package/build/dist/UI/Components/Skeleton/Skeleton.js.map +1 -0
- package/build/dist/UI/Components/Table/Table.js +35 -24
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableSkeletonRows.js +52 -0
- package/build/dist/UI/Components/Table/TableSkeletonRows.js.map +1 -0
- package/build/dist/UI/Components/Tabs/Tab.js +1 -1
- package/build/dist/UI/Components/Tabs/Tab.js.map +1 -1
- package/build/dist/UI/Components/Toast/Toast.js +77 -23
- package/build/dist/UI/Components/Toast/Toast.js.map +1 -1
- package/build/dist/UI/Components/Toast/ToastInit.js +37 -15
- package/build/dist/UI/Components/Toast/ToastInit.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +18 -9
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/ModelListCache.js +126 -0
- package/build/dist/UI/Utils/ModelListCache.js.map +1 -0
- package/build/dist/UI/Utils/Project.js +24 -1
- package/build/dist/UI/Utils/Project.js.map +1 -1
- package/build/dist/Utils/API.js +6 -1
- package/build/dist/Utils/API.js.map +1 -1
- package/jest.config.json +1 -1
- package/package.json +2 -3
- package/Server/Utils/Monitor/MonitorClusterContext.ts +0 -182
- package/Tests/__mocks__/otpauth.js +0 -30
- package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +0 -141
- package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js.map +0 -1
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import {
|
|
11
|
+
cleanup,
|
|
12
|
+
fireEvent,
|
|
13
|
+
render,
|
|
14
|
+
screen,
|
|
15
|
+
waitFor,
|
|
16
|
+
within,
|
|
17
|
+
} from "@testing-library/react";
|
|
18
|
+
import React from "react";
|
|
19
|
+
import {
|
|
20
|
+
MemoryRouter,
|
|
21
|
+
Outlet,
|
|
22
|
+
Route as RouterRoute,
|
|
23
|
+
Routes as RouterRoutes,
|
|
24
|
+
} from "react-router-dom";
|
|
25
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Users > View > Notification Methods — an administrator managing the devices
|
|
29
|
+
* and addresses somebody else's pages are delivered to.
|
|
30
|
+
*
|
|
31
|
+
* This page reverses a decision the previous phase made deliberately, so the
|
|
32
|
+
* assertions worth writing down are the ones that keep the REASON for that
|
|
33
|
+
* decision satisfied by the new answer.
|
|
34
|
+
*
|
|
35
|
+
* The old surface showed a masked, read-only list and a prefilled "please add
|
|
36
|
+
* one yourself" email, because the seven notification method models are scoped
|
|
37
|
+
* to the person who owns the device: the columns behind them are the raw
|
|
38
|
+
* address, the phone number, the webhook bearer url, the push device token and
|
|
39
|
+
* the verification code, and an attempt to widen that scope so an admin could
|
|
40
|
+
* read them leaked every one of those in turn. What it cost was the commonest
|
|
41
|
+
* broken responder of all — a new joiner with no method at all — being fixable
|
|
42
|
+
* by nobody but themselves.
|
|
43
|
+
*
|
|
44
|
+
* So the models are STILL owner-scoped, and this page talks to a narrow
|
|
45
|
+
* server-side capability instead. What that buys, and what it must not:
|
|
46
|
+
*
|
|
47
|
+
* - NO METHOD MODEL IS READ. Not by ModelAPI over UserEmail and its six
|
|
48
|
+
* siblings, and not through a nested relation select on a rule, which
|
|
49
|
+
* reaches the same columns through a table an administrator IS allowed to
|
|
50
|
+
* read. The assertion is about the REQUEST, because a component that asks
|
|
51
|
+
* is already wrong even when the server refuses it.
|
|
52
|
+
*
|
|
53
|
+
* - NO RAW IDENTIFIER REACHES THE DOM. Every fixture plants the raw value
|
|
54
|
+
* beside the masked one under three plausible field names, so a parse that
|
|
55
|
+
* starts copying unknown keys through fails here.
|
|
56
|
+
*
|
|
57
|
+
* - AN ADMIN CANNOT MAKE A METHOD LIVE. This is the property the whole design
|
|
58
|
+
* rests on: the row is written unverified, the code goes to the device, and
|
|
59
|
+
* the verify endpoints refuse anybody but the owner. The page must never
|
|
60
|
+
* offer a "verify" control, must say why, and must show an admin-added
|
|
61
|
+
* method as waiting for its owner.
|
|
62
|
+
*
|
|
63
|
+
* - REMOVAL SAYS WHAT IT COSTS. Every method foreign key on
|
|
64
|
+
* UserNotificationRule is onDelete: "CASCADE", so removing one number takes
|
|
65
|
+
* every rule that pointed at it. An administrator has even less reason to
|
|
66
|
+
* expect that than the owner does.
|
|
67
|
+
*
|
|
68
|
+
* - A FAILED READ IS NEVER AN EMPTY LIST. "This person has no notification
|
|
69
|
+
* methods" is the most alarming thing this page can say, and saying it
|
|
70
|
+
* because a request failed sends somebody off to reconfigure an account
|
|
71
|
+
* that is perfectly well set up.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
const PROJECT_ID_STRING: string = "10000000-0000-4000-8000-000000000001";
|
|
75
|
+
const SIGNED_IN_USER_ID_STRING: string = "20000000-0000-4000-8000-000000000002";
|
|
76
|
+
const TARGET_USER_ID_STRING: string = "30000000-0000-4000-8000-000000000003";
|
|
77
|
+
|
|
78
|
+
const EMAIL_METHOD_ID: string = "60000000-0000-4000-8000-000000000001";
|
|
79
|
+
const SMS_METHOD_ID: string = "60000000-0000-4000-8000-000000000002";
|
|
80
|
+
const WEBHOOK_METHOD_ID: string = "60000000-0000-4000-8000-000000000003";
|
|
81
|
+
|
|
82
|
+
const TARGET_USER_NAME: string = "Jane Ops";
|
|
83
|
+
const TARGET_USER_FIRST_NAME: string = "Jane";
|
|
84
|
+
const TARGET_LOGIN_EMAIL: string = "jane.ops@example.com";
|
|
85
|
+
|
|
86
|
+
// Exactly the shapes OnCallReadinessService.maskIdentifier emits.
|
|
87
|
+
const MASKED_EMAIL: string = "j•••@example.com";
|
|
88
|
+
const MASKED_PHONE: string = "+1 ••• ••• 4821";
|
|
89
|
+
const MASKED_WEBHOOK: string = "pa•••";
|
|
90
|
+
|
|
91
|
+
/* The values that must never survive the trip to the DOM. */
|
|
92
|
+
const RAW_EMAIL: string = "jane.ops.personal@example.com";
|
|
93
|
+
const RAW_PHONE: string = "+15551234821";
|
|
94
|
+
const RAW_WEBHOOK_URL: string = "https://hooks.example.com/T0P-53CR3T-T0K3N";
|
|
95
|
+
|
|
96
|
+
const ALL_RAW_IDENTIFIERS: Array<string> = [
|
|
97
|
+
RAW_EMAIL,
|
|
98
|
+
RAW_PHONE,
|
|
99
|
+
RAW_WEBHOOK_URL,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const UNMASKED_EMAIL_PATTERN: RegExp =
|
|
103
|
+
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
|
|
104
|
+
const UNMASKED_PHONE_PATTERN: RegExp = /\+?\d[\d\s().-]{6,}\d/;
|
|
105
|
+
|
|
106
|
+
const getListMock: MockFunction = getJestMockFunction();
|
|
107
|
+
const getItemMock: MockFunction = getJestMockFunction();
|
|
108
|
+
const getCommonHeadersMock: MockFunction = getJestMockFunction();
|
|
109
|
+
const apiGetMock: MockFunction = getJestMockFunction();
|
|
110
|
+
const apiPostMock: MockFunction = getJestMockFunction();
|
|
111
|
+
const apiDeleteMock: MockFunction = getJestMockFunction();
|
|
112
|
+
|
|
113
|
+
let pendingRequestCount: number = 0;
|
|
114
|
+
|
|
115
|
+
type TrackRequestFunction = (result: unknown) => unknown;
|
|
116
|
+
|
|
117
|
+
const trackRequest: TrackRequestFunction = (result: unknown): unknown => {
|
|
118
|
+
if (!(result instanceof Promise)) {
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
pendingRequestCount++;
|
|
123
|
+
|
|
124
|
+
return result.finally((): void => {
|
|
125
|
+
pendingRequestCount--;
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
130
|
+
return {
|
|
131
|
+
__esModule: true,
|
|
132
|
+
default: {
|
|
133
|
+
getList: (...args: Array<any>) => {
|
|
134
|
+
return trackRequest(getListMock(...args));
|
|
135
|
+
},
|
|
136
|
+
getItem: (...args: Array<any>) => {
|
|
137
|
+
return trackRequest(getItemMock(...args));
|
|
138
|
+
},
|
|
139
|
+
getCommonHeaders: (...args: Array<any>) => {
|
|
140
|
+
return getCommonHeadersMock(...args);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
jest.mock("../../../UI/Utils/API/API", () => {
|
|
147
|
+
return {
|
|
148
|
+
__esModule: true,
|
|
149
|
+
default: {
|
|
150
|
+
get: (...args: Array<any>) => {
|
|
151
|
+
return trackRequest(apiGetMock(...args));
|
|
152
|
+
},
|
|
153
|
+
post: (...args: Array<any>) => {
|
|
154
|
+
return trackRequest(apiPostMock(...args));
|
|
155
|
+
},
|
|
156
|
+
delete: (...args: Array<any>) => {
|
|
157
|
+
return trackRequest(apiDeleteMock(...args));
|
|
158
|
+
},
|
|
159
|
+
getFriendlyMessage: (error: unknown) => {
|
|
160
|
+
const message: unknown = (error as { message?: unknown } | null)
|
|
161
|
+
?.message;
|
|
162
|
+
|
|
163
|
+
return typeof message === "string" && message
|
|
164
|
+
? message
|
|
165
|
+
: "Could not load";
|
|
166
|
+
},
|
|
167
|
+
getFriendlyErrorMessage: (error: unknown) => {
|
|
168
|
+
const message: unknown = (error as { message?: unknown } | null)
|
|
169
|
+
?.message;
|
|
170
|
+
|
|
171
|
+
return typeof message === "string" && message
|
|
172
|
+
? message
|
|
173
|
+
: "Could not load";
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
jest.mock("react-i18next", () => {
|
|
180
|
+
return {
|
|
181
|
+
useTranslation: () => {
|
|
182
|
+
return {
|
|
183
|
+
t: (key: string, options?: { defaultValue?: string }): string => {
|
|
184
|
+
return options?.defaultValue ?? key;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/*
|
|
192
|
+
* A stand-in ModelTable that records the model it was mounted over.
|
|
193
|
+
*
|
|
194
|
+
* It renders nothing on purpose. What this file needs from it is the single
|
|
195
|
+
* fact that a table over UserEmail (or any of its six siblings) would be
|
|
196
|
+
* visible here at all — the self-serve method components mount exactly those,
|
|
197
|
+
* so "did the admin branch accidentally render the self-serve one?" is answered
|
|
198
|
+
* by this list.
|
|
199
|
+
*/
|
|
200
|
+
let mountedTableModels: Array<unknown> = [];
|
|
201
|
+
|
|
202
|
+
jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
|
|
203
|
+
return {
|
|
204
|
+
__esModule: true,
|
|
205
|
+
default: (props: { modelType: unknown }) => {
|
|
206
|
+
mountedTableModels.push(props.modelType);
|
|
207
|
+
return null;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
import UserViewNotificationMethods from "../../../../App/FeatureSet/Dashboard/src/Pages/Users/View/OnCall/NotificationMethods";
|
|
213
|
+
import UserViewOnCallLayout from "../../../../App/FeatureSet/Dashboard/src/Pages/Users/View/OnCall/Layout";
|
|
214
|
+
import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
|
|
215
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
216
|
+
import TeamMember from "../../../Models/DatabaseModels/TeamMember";
|
|
217
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
218
|
+
import UserCall from "../../../Models/DatabaseModels/UserCall";
|
|
219
|
+
import UserEmail from "../../../Models/DatabaseModels/UserEmail";
|
|
220
|
+
import UserPush from "../../../Models/DatabaseModels/UserPush";
|
|
221
|
+
import UserSMS from "../../../Models/DatabaseModels/UserSMS";
|
|
222
|
+
import UserTelegram from "../../../Models/DatabaseModels/UserTelegram";
|
|
223
|
+
import UserWebhook from "../../../Models/DatabaseModels/UserWebhook";
|
|
224
|
+
import UserWhatsApp from "../../../Models/DatabaseModels/UserWhatsApp";
|
|
225
|
+
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
|
|
226
|
+
import HTTPResponse from "../../../Types/API/HTTPResponse";
|
|
227
|
+
import Route from "../../../Types/API/Route";
|
|
228
|
+
import Email from "../../../Types/Email";
|
|
229
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
230
|
+
import Name from "../../../Types/Name";
|
|
231
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
232
|
+
import Permission from "../../../Types/Permission";
|
|
233
|
+
import PermissionUtil from "../../../UI/Utils/Permission";
|
|
234
|
+
import ProjectUtil from "../../../UI/Utils/Project";
|
|
235
|
+
import UserUtil from "../../../UI/Utils/User";
|
|
236
|
+
|
|
237
|
+
const PROJECT_ID: ObjectID = new ObjectID(PROJECT_ID_STRING);
|
|
238
|
+
const SIGNED_IN_USER_ID: ObjectID = new ObjectID(SIGNED_IN_USER_ID_STRING);
|
|
239
|
+
|
|
240
|
+
/*
|
|
241
|
+
* The seven models only their owner may read. Asserted as a SET because which
|
|
242
|
+
* one an admin surface reaches for hardly matters — every one carries a raw
|
|
243
|
+
* identifier and most carry a credential.
|
|
244
|
+
*/
|
|
245
|
+
const NOTIFICATION_METHOD_MODELS: Array<unknown> = [
|
|
246
|
+
UserEmail,
|
|
247
|
+
UserSMS,
|
|
248
|
+
UserCall,
|
|
249
|
+
UserPush,
|
|
250
|
+
UserWhatsApp,
|
|
251
|
+
UserTelegram,
|
|
252
|
+
UserWebhook,
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
const pageProps: PageComponentProps = {
|
|
256
|
+
pageRoute: new Route("/users/notification-methods"),
|
|
257
|
+
currentProject: null,
|
|
258
|
+
hasPaymentMethod: false,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
interface MethodSpec {
|
|
262
|
+
methodId: string;
|
|
263
|
+
methodType: string;
|
|
264
|
+
maskedIdentifier: string;
|
|
265
|
+
isVerified: boolean;
|
|
266
|
+
isAdminAddable: boolean;
|
|
267
|
+
// The value a leaky server — or a leaky future refactor — would hand over.
|
|
268
|
+
leakedRawValue: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type MethodJsonFunction = (spec: MethodSpec) => JSONObject;
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
* Three plausible spellings of the same mistake, planted at once. None is part
|
|
275
|
+
* of the contract the page parses, so all three are dropped and nothing
|
|
276
|
+
* downstream has an unmasked value available to render by accident.
|
|
277
|
+
*/
|
|
278
|
+
const methodJson: MethodJsonFunction = (spec: MethodSpec): JSONObject => {
|
|
279
|
+
return {
|
|
280
|
+
methodId: spec.methodId,
|
|
281
|
+
methodType: spec.methodType,
|
|
282
|
+
maskedIdentifier: spec.maskedIdentifier,
|
|
283
|
+
isVerified: spec.isVerified,
|
|
284
|
+
isAdminAddable: spec.isAdminAddable,
|
|
285
|
+
identifier: spec.leakedRawValue,
|
|
286
|
+
rawIdentifier: spec.leakedRawValue,
|
|
287
|
+
webhookUrl: RAW_WEBHOOK_URL,
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const VERIFIED_EMAIL: JSONObject = methodJson({
|
|
292
|
+
methodId: EMAIL_METHOD_ID,
|
|
293
|
+
methodType: "Email",
|
|
294
|
+
maskedIdentifier: MASKED_EMAIL,
|
|
295
|
+
isVerified: true,
|
|
296
|
+
isAdminAddable: true,
|
|
297
|
+
leakedRawValue: RAW_EMAIL,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const UNVERIFIED_SMS: JSONObject = methodJson({
|
|
301
|
+
methodId: SMS_METHOD_ID,
|
|
302
|
+
methodType: "SMS",
|
|
303
|
+
maskedIdentifier: MASKED_PHONE,
|
|
304
|
+
isVerified: false,
|
|
305
|
+
isAdminAddable: true,
|
|
306
|
+
leakedRawValue: RAW_PHONE,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/*
|
|
310
|
+
* A webhook: listed and removable, never addable by an administrator, and with
|
|
311
|
+
* no verification concept at all. It is the channel whose presence proves the
|
|
312
|
+
* "add" list and the "list" list are not the same list.
|
|
313
|
+
*/
|
|
314
|
+
const WEBHOOK: JSONObject = methodJson({
|
|
315
|
+
methodId: WEBHOOK_METHOD_ID,
|
|
316
|
+
methodType: "Webhook",
|
|
317
|
+
maskedIdentifier: MASKED_WEBHOOK,
|
|
318
|
+
isVerified: true,
|
|
319
|
+
isAdminAddable: false,
|
|
320
|
+
leakedRawValue: RAW_WEBHOOK_URL,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const READINESS_PAYLOAD: JSONObject = {
|
|
324
|
+
userId: TARGET_USER_ID_STRING,
|
|
325
|
+
userName: TARGET_USER_NAME,
|
|
326
|
+
userEmail: TARGET_LOGIN_EMAIL,
|
|
327
|
+
status: "PartiallyReady",
|
|
328
|
+
methods: [],
|
|
329
|
+
coverage: [],
|
|
330
|
+
reasons: [],
|
|
331
|
+
reachedVia: ["Team"],
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
type RespondWithMethodsFunction = (methods: Array<JSONObject>) => void;
|
|
335
|
+
|
|
336
|
+
/*
|
|
337
|
+
* The two GETs this page's section makes are told apart by their url: readiness
|
|
338
|
+
* for the section layout, the admin method list for the page. Keying on the
|
|
339
|
+
* url rather than on call order is what keeps these fixtures stable when the
|
|
340
|
+
* layout's own reads change.
|
|
341
|
+
*/
|
|
342
|
+
const respondWithMethods: RespondWithMethodsFunction = (
|
|
343
|
+
methods: Array<JSONObject>,
|
|
344
|
+
): void => {
|
|
345
|
+
apiGetMock.mockImplementation((data: any) => {
|
|
346
|
+
const url: string = String(data.url);
|
|
347
|
+
|
|
348
|
+
if (url.includes("/user-notification-method-admin/")) {
|
|
349
|
+
return Promise.resolve(
|
|
350
|
+
new HTTPResponse<JSONObject>(200, { methods: methods }, {}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return Promise.resolve(
|
|
355
|
+
new HTTPResponse<JSONObject>(200, READINESS_PAYLOAD, {}),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
type BuildTeamMemberFunction = () => TeamMember;
|
|
361
|
+
|
|
362
|
+
const buildTeamMember: BuildTeamMemberFunction = (): TeamMember => {
|
|
363
|
+
const user: User = new User();
|
|
364
|
+
user._id = TARGET_USER_ID_STRING;
|
|
365
|
+
user.name = new Name(TARGET_USER_NAME);
|
|
366
|
+
user.email = new Email(TARGET_LOGIN_EMAIL);
|
|
367
|
+
|
|
368
|
+
const member: TeamMember = new TeamMember();
|
|
369
|
+
member.user = user;
|
|
370
|
+
|
|
371
|
+
return member;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
type RenderPageFunction = () => Promise<HTMLElement>;
|
|
375
|
+
|
|
376
|
+
const renderPage: RenderPageFunction = async (): Promise<HTMLElement> => {
|
|
377
|
+
const { container } = render(
|
|
378
|
+
<MemoryRouter
|
|
379
|
+
initialEntries={[
|
|
380
|
+
`/dashboard/${PROJECT_ID_STRING}/users/${TARGET_USER_ID_STRING}/notification-methods`,
|
|
381
|
+
]}
|
|
382
|
+
>
|
|
383
|
+
<RouterRoutes>
|
|
384
|
+
<RouterRoute
|
|
385
|
+
path="/dashboard/:projectId/users/:id"
|
|
386
|
+
element={<Outlet />}
|
|
387
|
+
>
|
|
388
|
+
<RouterRoute element={<UserViewOnCallLayout />}>
|
|
389
|
+
<RouterRoute
|
|
390
|
+
path="notification-methods"
|
|
391
|
+
element={<UserViewNotificationMethods {...pageProps} />}
|
|
392
|
+
/>
|
|
393
|
+
</RouterRoute>
|
|
394
|
+
</RouterRoute>
|
|
395
|
+
</RouterRoutes>
|
|
396
|
+
</MemoryRouter>,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
/*
|
|
400
|
+
* Two conditions, and neither is sufficient alone.
|
|
401
|
+
*
|
|
402
|
+
* `pendingRequestCount` dips to zero between the SECTION layout's reads
|
|
403
|
+
* finishing and the page's own read being issued — the page does not exist
|
|
404
|
+
* until the layout has an identity to render it with, so waiting on the
|
|
405
|
+
* counter alone hands the test a loading skeleton. The skeleton's absence
|
|
406
|
+
* alone is no better: it is also absent before the first render.
|
|
407
|
+
*/
|
|
408
|
+
await waitFor(
|
|
409
|
+
(): void => {
|
|
410
|
+
expect(pendingRequestCount).toBe(0);
|
|
411
|
+
expect(screen.queryByTestId("methods-loading")).not.toBeInTheDocument();
|
|
412
|
+
},
|
|
413
|
+
{ timeout: 4000 },
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
return container;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
type MethodRowsFunction = () => Array<HTMLElement>;
|
|
420
|
+
|
|
421
|
+
const methodRows: MethodRowsFunction = (): Array<HTMLElement> => {
|
|
422
|
+
return Array.from(
|
|
423
|
+
screen.getByTestId("admin-notification-method-list").querySelectorAll("li"),
|
|
424
|
+
);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
type RowForFunction = (maskedIdentifier: string) => HTMLElement;
|
|
428
|
+
|
|
429
|
+
const rowFor: RowForFunction = (maskedIdentifier: string): HTMLElement => {
|
|
430
|
+
const row: HTMLElement | undefined = methodRows().find(
|
|
431
|
+
(candidate: HTMLElement): boolean => {
|
|
432
|
+
return (candidate.textContent || "").includes(maskedIdentifier);
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
if (!row) {
|
|
437
|
+
throw new Error(`no method row for ${maskedIdentifier}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return row;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
type ClickButtonFunction = (root: HTMLElement, label: string) => void;
|
|
444
|
+
|
|
445
|
+
const clickButton: ClickButtonFunction = (
|
|
446
|
+
root: HTMLElement,
|
|
447
|
+
label: string,
|
|
448
|
+
): void => {
|
|
449
|
+
fireEvent.click(within(root).getByText(label));
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
type ModalFunction = () => HTMLElement;
|
|
453
|
+
|
|
454
|
+
/*
|
|
455
|
+
* The open modal, scoped.
|
|
456
|
+
*
|
|
457
|
+
* Its confirm button carries the same word as the row control that opened it —
|
|
458
|
+
* "Remove" opens a confirmation whose submit button also says "Remove", which
|
|
459
|
+
* is right for the reader and ambiguous for a global query. Scoping to the
|
|
460
|
+
* dialog is also what makes "nothing was deleted by merely opening this" a
|
|
461
|
+
* meaningful assertion: an unscoped click could be hitting either one.
|
|
462
|
+
*/
|
|
463
|
+
const modal: ModalFunction = (): HTMLElement => {
|
|
464
|
+
return screen.getByTestId("modal");
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
beforeEach((): void => {
|
|
468
|
+
mountedTableModels = [];
|
|
469
|
+
pendingRequestCount = 0;
|
|
470
|
+
|
|
471
|
+
getListMock.mockReset();
|
|
472
|
+
getItemMock.mockReset();
|
|
473
|
+
getCommonHeadersMock.mockReset();
|
|
474
|
+
apiGetMock.mockReset();
|
|
475
|
+
apiPostMock.mockReset();
|
|
476
|
+
apiDeleteMock.mockReset();
|
|
477
|
+
|
|
478
|
+
getListMock.mockImplementation((data: any) => {
|
|
479
|
+
if (data.modelType === TeamMember) {
|
|
480
|
+
return Promise.resolve({
|
|
481
|
+
data: [buildTeamMember()],
|
|
482
|
+
count: 1,
|
|
483
|
+
skip: 0,
|
|
484
|
+
limit: 1,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/*
|
|
489
|
+
* Anything else, INCLUDING the seven method models this page must never ask
|
|
490
|
+
* for. A non-empty answer would make a leak look like a feature working, so
|
|
491
|
+
* the fallback stays empty and the assertion that matters is about the
|
|
492
|
+
* request rather than the response.
|
|
493
|
+
*/
|
|
494
|
+
return Promise.resolve({ data: [], count: 0, skip: 0, limit: 0 });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const project: Project = new Project();
|
|
498
|
+
project.disableOnCallNotificationFallback = false;
|
|
499
|
+
getItemMock.mockResolvedValue(project as never);
|
|
500
|
+
|
|
501
|
+
getCommonHeadersMock.mockReturnValue({} as never);
|
|
502
|
+
|
|
503
|
+
respondWithMethods([VERIFIED_EMAIL, UNVERIFIED_SMS, WEBHOOK]);
|
|
504
|
+
|
|
505
|
+
apiPostMock.mockResolvedValue(
|
|
506
|
+
new HTTPResponse<JSONObject>(200, {}, {}) as never,
|
|
507
|
+
);
|
|
508
|
+
apiDeleteMock.mockResolvedValue(
|
|
509
|
+
new HTTPResponse<JSONObject>(200, {}, {}) as never,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
513
|
+
jest.spyOn(UserUtil, "getUserId").mockReturnValue(SIGNED_IN_USER_ID);
|
|
514
|
+
jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(false);
|
|
515
|
+
jest
|
|
516
|
+
.spyOn(PermissionUtil, "getAllPermissions")
|
|
517
|
+
.mockReturnValue([Permission.ProjectAdmin]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
afterEach(async (): Promise<void> => {
|
|
521
|
+
cleanup();
|
|
522
|
+
|
|
523
|
+
for (
|
|
524
|
+
let attempt: number = 0;
|
|
525
|
+
pendingRequestCount > 0 && attempt < 100;
|
|
526
|
+
attempt++
|
|
527
|
+
) {
|
|
528
|
+
await new Promise<void>((resolve: () => void): void => {
|
|
529
|
+
setTimeout(resolve, 0);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
jest.restoreAllMocks();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe("the list", () => {
|
|
537
|
+
test("shows every channel, masked, with its verification state", async () => {
|
|
538
|
+
await renderPage();
|
|
539
|
+
|
|
540
|
+
expect(methodRows()).toHaveLength(3);
|
|
541
|
+
|
|
542
|
+
const emailRow: HTMLElement = rowFor(MASKED_EMAIL);
|
|
543
|
+
expect(emailRow.textContent).toContain("Email");
|
|
544
|
+
expect(emailRow.textContent).toContain("Verified");
|
|
545
|
+
|
|
546
|
+
/*
|
|
547
|
+
* Not "Unverified". An admin who has just typed a number in needs to know
|
|
548
|
+
* whose move it is next, and the answer is never theirs.
|
|
549
|
+
*/
|
|
550
|
+
const smsRow: HTMLElement = rowFor(MASKED_PHONE);
|
|
551
|
+
expect(smsRow.textContent).toContain(
|
|
552
|
+
`Waiting for ${TARGET_USER_FIRST_NAME} to verify`,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
/*
|
|
556
|
+
* The webhook is LISTED even though an admin cannot create one. A page that
|
|
557
|
+
* hid the channels it cannot add would show a responder with a working
|
|
558
|
+
* webhook as having nothing, and send somebody off to add a duplicate.
|
|
559
|
+
*/
|
|
560
|
+
expect(rowFor(MASKED_WEBHOOK).textContent).toContain("Webhook");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("never reads a notification method model", async () => {
|
|
564
|
+
await renderPage();
|
|
565
|
+
|
|
566
|
+
const requestedModels: Array<unknown> = getListMock.mock.calls.map(
|
|
567
|
+
(call: Array<any>) => {
|
|
568
|
+
return call[0].modelType;
|
|
569
|
+
},
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
/*
|
|
573
|
+
* The assertion is about the REQUEST, not the render. The seven models are
|
|
574
|
+
* scoped to their owner, so a read here would be refused — but a component
|
|
575
|
+
* that asks is already wrong, and the refusal is not something this page
|
|
576
|
+
* may rely on.
|
|
577
|
+
*/
|
|
578
|
+
for (const model of NOTIFICATION_METHOD_MODELS) {
|
|
579
|
+
expect(requestedModels).not.toContain(model);
|
|
580
|
+
expect(mountedTableModels).not.toContain(model);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("asks the admin endpoint for the user in the URL", async () => {
|
|
585
|
+
await renderPage();
|
|
586
|
+
|
|
587
|
+
const methodCall: any = apiGetMock.mock.calls.find(
|
|
588
|
+
(call: Array<any>): boolean => {
|
|
589
|
+
return String(call[0].url).includes(
|
|
590
|
+
"/user-notification-method-admin/user/",
|
|
591
|
+
);
|
|
592
|
+
},
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
expect(methodCall).toBeDefined();
|
|
596
|
+
expect(String(methodCall[0].url)).toContain(TARGET_USER_ID_STRING);
|
|
597
|
+
expect(String(methodCall[0].url)).not.toContain(SIGNED_IN_USER_ID_STRING);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("a failed read says so rather than reporting an empty account", async () => {
|
|
601
|
+
apiGetMock.mockImplementation((data: any) => {
|
|
602
|
+
if (String(data.url).includes("/user-notification-method-admin/")) {
|
|
603
|
+
return Promise.resolve(
|
|
604
|
+
new HTTPErrorResponse(500, { message: "methods are down" }, {}),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return Promise.resolve(
|
|
609
|
+
new HTTPResponse<JSONObject>(200, READINESS_PAYLOAD, {}),
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const container: HTMLElement = await renderPage();
|
|
614
|
+
|
|
615
|
+
expect(container.textContent).toContain("methods are down");
|
|
616
|
+
|
|
617
|
+
/*
|
|
618
|
+
* "This person has no notification methods" is the most alarming thing this
|
|
619
|
+
* page can print about somebody, and printing it because a request failed
|
|
620
|
+
* sends an admin chasing an account that is perfectly well configured.
|
|
621
|
+
*/
|
|
622
|
+
expect(container.textContent).not.toContain(
|
|
623
|
+
"has no notification methods at all",
|
|
624
|
+
);
|
|
625
|
+
expect(
|
|
626
|
+
screen.queryByTestId("no-methods-empty-state"),
|
|
627
|
+
).not.toBeInTheDocument();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("the empty state says what it costs and what to do", async () => {
|
|
631
|
+
respondWithMethods([]);
|
|
632
|
+
|
|
633
|
+
const container: HTMLElement = await renderPage();
|
|
634
|
+
|
|
635
|
+
expect(screen.getByTestId("no-methods-empty-state")).toBeInTheDocument();
|
|
636
|
+
expect(container.textContent).toContain(
|
|
637
|
+
"has no notification methods at all",
|
|
638
|
+
);
|
|
639
|
+
expect(container.textContent).toContain("dropped");
|
|
640
|
+
|
|
641
|
+
/*
|
|
642
|
+
* And it points at the control rather than at the person: this is the case
|
|
643
|
+
* the whole capability exists for, so the empty state that used to say
|
|
644
|
+
* "only they can add one" now says an admin can.
|
|
645
|
+
*/
|
|
646
|
+
expect(container.textContent).toContain(
|
|
647
|
+
`Add one for ${TARGET_USER_FIRST_NAME}`,
|
|
648
|
+
);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
describe("adding a method on somebody's behalf", () => {
|
|
653
|
+
test("offers only the four channels a code can be sent to", async () => {
|
|
654
|
+
await renderPage();
|
|
655
|
+
|
|
656
|
+
clickButton(document.body, "Add notification method");
|
|
657
|
+
|
|
658
|
+
/*
|
|
659
|
+
* Push is a device token minted at registration — there is nothing to type.
|
|
660
|
+
* Telegram needs the account holder to message the bot first. Webhook has
|
|
661
|
+
* no verification at all, so an admin-created one would be live the instant
|
|
662
|
+
* it was written, which is exactly the silent redirect this design rules
|
|
663
|
+
* out.
|
|
664
|
+
*/
|
|
665
|
+
const modalText: string = document.body.textContent || "";
|
|
666
|
+
|
|
667
|
+
expect(modalText).toContain("Email");
|
|
668
|
+
expect(modalText).toContain("SMS");
|
|
669
|
+
expect(modalText).toContain("WhatsApp");
|
|
670
|
+
|
|
671
|
+
const optionLabels: Array<string> = Array.from(
|
|
672
|
+
document.querySelectorAll("option"),
|
|
673
|
+
).map((option: Element): string => {
|
|
674
|
+
return option.textContent || "";
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
for (const forbidden of ["Push", "Telegram", "Webhook"]) {
|
|
678
|
+
expect(optionLabels).not.toContain(forbidden);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("says out loud that the admin cannot finish the job", async () => {
|
|
683
|
+
await renderPage();
|
|
684
|
+
|
|
685
|
+
clickButton(document.body, "Add notification method");
|
|
686
|
+
|
|
687
|
+
const modalText: string = document.body.textContent || "";
|
|
688
|
+
|
|
689
|
+
/*
|
|
690
|
+
* The property the whole design rests on, stated at the moment somebody is
|
|
691
|
+
* about to rely on it. A method added here is inert until its owner enters
|
|
692
|
+
* a code that was sent to the device — the verify endpoints compare the
|
|
693
|
+
* row's owner against the signed-in caller and refuse anybody else.
|
|
694
|
+
*/
|
|
695
|
+
expect(modalText).toContain("verification code is sent");
|
|
696
|
+
expect(modalText).toContain(
|
|
697
|
+
`${TARGET_USER_FIRST_NAME} has to enter that code`,
|
|
698
|
+
);
|
|
699
|
+
expect(modalText).toContain("you cannot verify it for them");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("offers no way to verify a method for somebody else", async () => {
|
|
703
|
+
await renderPage();
|
|
704
|
+
|
|
705
|
+
/*
|
|
706
|
+
* Structural rather than copy-deep, because the next person to be tempted
|
|
707
|
+
* will add a button, not a sentence. A "verify" control here would be the
|
|
708
|
+
* one thing that turns an admin typing in their own number from an inert
|
|
709
|
+
* row into a redirect of somebody else's pages.
|
|
710
|
+
*/
|
|
711
|
+
const buttonLabels: Array<string> = Array.from(
|
|
712
|
+
document.querySelectorAll("button"),
|
|
713
|
+
).map((button: Element): string => {
|
|
714
|
+
return (button.textContent || "").toLowerCase();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
for (const label of buttonLabels) {
|
|
718
|
+
expect(label).not.toContain("verify");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Not vacuous: the controls this page DOES offer are present.
|
|
722
|
+
expect(buttonLabels.join(" ")).toContain("remove");
|
|
723
|
+
expect(buttonLabels.join(" ")).toContain("resend code");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("posts the channel and the value to the admin endpoint", async () => {
|
|
727
|
+
await renderPage();
|
|
728
|
+
|
|
729
|
+
clickButton(document.body, "Add notification method");
|
|
730
|
+
|
|
731
|
+
const valueInput: HTMLElement = screen.getByPlaceholderText(
|
|
732
|
+
"you@company.com or +15551234567",
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
fireEvent.change(valueInput, { target: { value: RAW_PHONE } });
|
|
736
|
+
|
|
737
|
+
const methodSelect: HTMLElement | null = document.querySelector("select");
|
|
738
|
+
|
|
739
|
+
if (methodSelect) {
|
|
740
|
+
fireEvent.change(methodSelect, { target: { value: "SMS" } });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fireEvent.click(within(modal()).getByText("Add"));
|
|
744
|
+
|
|
745
|
+
await waitFor((): void => {
|
|
746
|
+
expect(apiPostMock).toHaveBeenCalled();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const call: any = apiPostMock.mock.calls[0]![0];
|
|
750
|
+
|
|
751
|
+
/*
|
|
752
|
+
* The user id travels in the URL, not in the body. The server re-derives
|
|
753
|
+
* everything else — the actor, the project — from the session, so a body
|
|
754
|
+
* that carried an actor would be a body a caller could sign with somebody
|
|
755
|
+
* else's name.
|
|
756
|
+
*/
|
|
757
|
+
expect(String(call.url)).toContain(
|
|
758
|
+
`/user-notification-method-admin/user/${TARGET_USER_ID_STRING}`,
|
|
759
|
+
);
|
|
760
|
+
expect(call.data.value).toBe(RAW_PHONE);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("re-reads the list and the readiness summary after a successful add", async () => {
|
|
764
|
+
await renderPage();
|
|
765
|
+
|
|
766
|
+
apiGetMock.mockClear();
|
|
767
|
+
|
|
768
|
+
clickButton(document.body, "Add notification method");
|
|
769
|
+
|
|
770
|
+
fireEvent.change(
|
|
771
|
+
screen.getByPlaceholderText("you@company.com or +15551234567"),
|
|
772
|
+
{ target: { value: RAW_PHONE } },
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
fireEvent.click(within(modal()).getByText("Add"));
|
|
776
|
+
|
|
777
|
+
await waitFor((): void => {
|
|
778
|
+
expect(apiPostMock).toHaveBeenCalled();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
await waitFor((): void => {
|
|
782
|
+
const urls: Array<string> = apiGetMock.mock.calls.map(
|
|
783
|
+
(call: Array<any>): string => {
|
|
784
|
+
return String(call[0].url);
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
expect(
|
|
789
|
+
urls.some((url: string): boolean => {
|
|
790
|
+
return url.includes("/user-notification-method-admin/");
|
|
791
|
+
}),
|
|
792
|
+
).toBe(true);
|
|
793
|
+
|
|
794
|
+
/*
|
|
795
|
+
* And readiness with `refresh`, because the service answers from a 60s
|
|
796
|
+
* cache and the admin has just changed the answer. A cached summary
|
|
797
|
+
* redrawn after the change reads as "the add did not work".
|
|
798
|
+
*/
|
|
799
|
+
expect(
|
|
800
|
+
urls.some((url: string): boolean => {
|
|
801
|
+
return url.includes("/on-call-readiness/") && url.includes("refresh");
|
|
802
|
+
}),
|
|
803
|
+
).toBe(true);
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("surfaces the server's refusal instead of claiming success", async () => {
|
|
808
|
+
await renderPage();
|
|
809
|
+
|
|
810
|
+
apiPostMock.mockResolvedValue(
|
|
811
|
+
new HTTPErrorResponse(
|
|
812
|
+
400,
|
|
813
|
+
{ message: "This user already has a SMS notification method" },
|
|
814
|
+
{},
|
|
815
|
+
) as never,
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
clickButton(document.body, "Add notification method");
|
|
819
|
+
|
|
820
|
+
fireEvent.change(
|
|
821
|
+
screen.getByPlaceholderText("you@company.com or +15551234567"),
|
|
822
|
+
{ target: { value: RAW_PHONE } },
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
fireEvent.click(within(modal()).getByText("Add"));
|
|
826
|
+
|
|
827
|
+
/*
|
|
828
|
+
* The duplicate check, the channel check and the "not a member of this
|
|
829
|
+
* project" check all live on the server, and all of them come back as a
|
|
830
|
+
* message. A page that swallowed them would close the modal on a write that
|
|
831
|
+
* never happened.
|
|
832
|
+
*/
|
|
833
|
+
/*
|
|
834
|
+
* findAllBy, not findBy: BasicFormModal renders `error` itself AND spreads
|
|
835
|
+
* its whole props object into Modal, which renders it again. That is a
|
|
836
|
+
* quirk of the shared component rather than of this page, and asserting
|
|
837
|
+
* "exactly one" here would pin this test to it.
|
|
838
|
+
*/
|
|
839
|
+
const messages: Array<HTMLElement> = await screen.findAllByText(
|
|
840
|
+
/already has a SMS notification method/,
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
844
|
+
|
|
845
|
+
// The modal stays open on a refusal, so the admin can correct and retry.
|
|
846
|
+
expect(
|
|
847
|
+
within(modal()).getAllByText(/already has a SMS/).length,
|
|
848
|
+
).toBeGreaterThan(0);
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe("removing a method", () => {
|
|
853
|
+
test("asks for the impact before it asks for confirmation", async () => {
|
|
854
|
+
await renderPage();
|
|
855
|
+
|
|
856
|
+
apiGetMock.mockImplementation((data: any) => {
|
|
857
|
+
const url: string = String(data.url);
|
|
858
|
+
|
|
859
|
+
if (url.includes("/deletion-impact")) {
|
|
860
|
+
return Promise.resolve(
|
|
861
|
+
new HTTPResponse<JSONObject>(
|
|
862
|
+
200,
|
|
863
|
+
{
|
|
864
|
+
rulesDeletedCount: 3,
|
|
865
|
+
coverageLostCount: 2,
|
|
866
|
+
verifiedMethodCountAfterDeletion: 0,
|
|
867
|
+
reachability: "NotReachable",
|
|
868
|
+
isFallbackEnabled: true,
|
|
869
|
+
isTruncated: false,
|
|
870
|
+
},
|
|
871
|
+
{},
|
|
872
|
+
),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return Promise.resolve(
|
|
877
|
+
new HTTPResponse<JSONObject>(200, { methods: [] }, {}),
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
clickButton(rowFor(MASKED_EMAIL), "Remove");
|
|
882
|
+
|
|
883
|
+
const preview: HTMLElement = await screen.findByTestId("deletion-preview");
|
|
884
|
+
|
|
885
|
+
/*
|
|
886
|
+
* Every method foreign key on UserNotificationRule is onDelete: "CASCADE",
|
|
887
|
+
* so removing one address takes every rule that pointed at it. Somebody
|
|
888
|
+
* tidying up a dead device has no reason to expect that, and an
|
|
889
|
+
* administrator has even less than the owner does.
|
|
890
|
+
*/
|
|
891
|
+
expect(preview.textContent).toContain(
|
|
892
|
+
"3 notification rules will be deleted",
|
|
893
|
+
);
|
|
894
|
+
expect(preview.textContent).toContain("2 severities");
|
|
895
|
+
|
|
896
|
+
/*
|
|
897
|
+
* The one sentence worth interrupting for: every other number describes a
|
|
898
|
+
* degradation, this one says nothing will be able to page them afterwards.
|
|
899
|
+
*/
|
|
900
|
+
expect(preview.textContent).toContain(
|
|
901
|
+
`${TARGET_USER_FIRST_NAME} will have no verified notification method left`,
|
|
902
|
+
);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("removes only after the confirmation, and names the method", async () => {
|
|
906
|
+
await renderPage();
|
|
907
|
+
|
|
908
|
+
clickButton(rowFor(MASKED_PHONE), "Remove");
|
|
909
|
+
|
|
910
|
+
// Nothing is deleted by opening the confirmation.
|
|
911
|
+
expect(apiDeleteMock).not.toHaveBeenCalled();
|
|
912
|
+
|
|
913
|
+
const dialogText: string = document.body.textContent || "";
|
|
914
|
+
|
|
915
|
+
expect(dialogText).toContain(MASKED_PHONE);
|
|
916
|
+
// Said before the click, because the mail goes out whatever happens next.
|
|
917
|
+
expect(dialogText).toContain("is emailed about this removal");
|
|
918
|
+
|
|
919
|
+
fireEvent.click(within(modal()).getByText("Remove"));
|
|
920
|
+
|
|
921
|
+
await waitFor((): void => {
|
|
922
|
+
expect(apiDeleteMock).toHaveBeenCalled();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
const url: string = String(apiDeleteMock.mock.calls[0]![0].url);
|
|
926
|
+
|
|
927
|
+
/*
|
|
928
|
+
* The channel and the row's own id, both in the path. The server re-checks
|
|
929
|
+
* that the row belongs to this user in this project, and answers "not
|
|
930
|
+
* found" identically for a row that does not exist and one that belongs to
|
|
931
|
+
* somebody else — so a caller cannot probe method ids through it.
|
|
932
|
+
*/
|
|
933
|
+
expect(url).toContain(TARGET_USER_ID_STRING);
|
|
934
|
+
expect(url).toContain("SMS");
|
|
935
|
+
expect(url).toContain(SMS_METHOD_ID);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test("a failed impact read does not block the removal", async () => {
|
|
939
|
+
await renderPage();
|
|
940
|
+
|
|
941
|
+
apiGetMock.mockImplementation((data: any) => {
|
|
942
|
+
if (String(data.url).includes("/deletion-impact")) {
|
|
943
|
+
return Promise.resolve(
|
|
944
|
+
new HTTPErrorResponse(500, { message: "impact is down" }, {}),
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return Promise.resolve(
|
|
949
|
+
new HTTPResponse<JSONObject>(200, { methods: [] }, {}),
|
|
950
|
+
);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
clickButton(rowFor(MASKED_EMAIL), "Remove");
|
|
954
|
+
|
|
955
|
+
await waitFor((): void => {
|
|
956
|
+
expect(pendingRequestCount).toBe(0);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
/*
|
|
960
|
+
* A preview is an improvement on the confirmation, not a precondition for
|
|
961
|
+
* it. Refusing to let an admin clear a dead device because a count failed
|
|
962
|
+
* to load would be the worse failure — and the general warning above the
|
|
963
|
+
* numbers is true with or without them.
|
|
964
|
+
*/
|
|
965
|
+
expect(screen.queryByTestId("deletion-preview")).not.toBeInTheDocument();
|
|
966
|
+
expect(document.body.textContent).toContain(
|
|
967
|
+
"every notification rule that points at it goes with it",
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
fireEvent.click(within(modal()).getByText("Remove"));
|
|
971
|
+
|
|
972
|
+
await waitFor((): void => {
|
|
973
|
+
expect(apiDeleteMock).toHaveBeenCalled();
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test("an admin may remove a channel they could never add", async () => {
|
|
978
|
+
await renderPage();
|
|
979
|
+
|
|
980
|
+
/*
|
|
981
|
+
* The asymmetry, asserted on the row it is about. A webhook cannot be added
|
|
982
|
+
* by an administrator — it has no verification, so it would be live
|
|
983
|
+
* immediately — but a webhook pointing at a decommissioned endpoint on
|
|
984
|
+
* somebody's leaving day is exactly the thing an admin has to be able to
|
|
985
|
+
* clear up.
|
|
986
|
+
*/
|
|
987
|
+
const webhookRow: HTMLElement = rowFor(MASKED_WEBHOOK);
|
|
988
|
+
|
|
989
|
+
expect(within(webhookRow).getByText("Remove")).toBeInTheDocument();
|
|
990
|
+
expect(within(webhookRow).queryByText("Resend code")).toBeNull();
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
describe("resending a verification code", () => {
|
|
995
|
+
test("is offered on an unverified method and not on a verified one", async () => {
|
|
996
|
+
await renderPage();
|
|
997
|
+
|
|
998
|
+
expect(
|
|
999
|
+
within(rowFor(MASKED_PHONE)).getByText("Resend code"),
|
|
1000
|
+
).toBeInTheDocument();
|
|
1001
|
+
|
|
1002
|
+
expect(within(rowFor(MASKED_EMAIL)).queryByText("Resend code")).toBeNull();
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("sends to the device and says who has to read it", async () => {
|
|
1006
|
+
await renderPage();
|
|
1007
|
+
|
|
1008
|
+
clickButton(rowFor(MASKED_PHONE), "Resend code");
|
|
1009
|
+
|
|
1010
|
+
expect(document.body.textContent).toContain(MASKED_PHONE);
|
|
1011
|
+
expect(document.body.textContent).toContain(
|
|
1012
|
+
`Only ${TARGET_USER_FIRST_NAME} can read it`,
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
fireEvent.click(within(modal()).getByText("Resend code"));
|
|
1016
|
+
|
|
1017
|
+
await waitFor((): void => {
|
|
1018
|
+
expect(apiPostMock).toHaveBeenCalled();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
expect(String(apiPostMock.mock.calls[0]![0].url)).toContain(
|
|
1022
|
+
"resend-verification-code",
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe("permissions", () => {
|
|
1028
|
+
test("a reader without the manage permission gets the list and no controls", async () => {
|
|
1029
|
+
jest
|
|
1030
|
+
.spyOn(PermissionUtil, "getAllPermissions")
|
|
1031
|
+
.mockReturnValue([Permission.ReadProjectUserNotificationRule]);
|
|
1032
|
+
|
|
1033
|
+
await renderPage();
|
|
1034
|
+
|
|
1035
|
+
// Still the diagnosis: they can see whether this person can be reached.
|
|
1036
|
+
expect(methodRows()).toHaveLength(3);
|
|
1037
|
+
|
|
1038
|
+
/*
|
|
1039
|
+
* A convenience over the server's own check, never a substitute — the API
|
|
1040
|
+
* refuses these writes for the same caller anyway. Drawing buttons that
|
|
1041
|
+
* exist only to be rejected is its own kind of broken.
|
|
1042
|
+
*/
|
|
1043
|
+
const buttonLabels: Array<string> = Array.from(
|
|
1044
|
+
document.querySelectorAll("button"),
|
|
1045
|
+
).map((button: Element): string => {
|
|
1046
|
+
return (button.textContent || "").toLowerCase();
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
expect(buttonLabels.join(" ")).not.toContain("remove");
|
|
1050
|
+
expect(buttonLabels.join(" ")).not.toContain("resend code");
|
|
1051
|
+
expect(buttonLabels.join(" ")).not.toContain("add notification method");
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("a project owner keeps the controls without the granular permission", async () => {
|
|
1055
|
+
jest
|
|
1056
|
+
.spyOn(PermissionUtil, "getAllPermissions")
|
|
1057
|
+
.mockReturnValue([Permission.ProjectOwner]);
|
|
1058
|
+
|
|
1059
|
+
await renderPage();
|
|
1060
|
+
|
|
1061
|
+
/*
|
|
1062
|
+
* Existing teams are seeded with ROLES, never with individual granular
|
|
1063
|
+
* permissions, so a permission introduced in this release is held by nobody
|
|
1064
|
+
* until an administrator grants it. A page checking only the granular one
|
|
1065
|
+
* would be dead on arrival for every project that already exists.
|
|
1066
|
+
*/
|
|
1067
|
+
expect(screen.getByText("Add notification method")).toBeInTheDocument();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("a member reading their own row gets the self-serve page instead", async () => {
|
|
1071
|
+
jest
|
|
1072
|
+
.spyOn(UserUtil, "getUserId")
|
|
1073
|
+
.mockReturnValue(new ObjectID(TARGET_USER_ID_STRING));
|
|
1074
|
+
|
|
1075
|
+
await renderPage();
|
|
1076
|
+
|
|
1077
|
+
/*
|
|
1078
|
+
* Their own rows, unmasked, with the verification flows — the masked admin
|
|
1079
|
+
* view would be a strictly worse version of the settings page they already
|
|
1080
|
+
* have. The self-serve components mount tables over the method models,
|
|
1081
|
+
* which is exactly the read that IS allowed for their owner.
|
|
1082
|
+
*/
|
|
1083
|
+
expect(
|
|
1084
|
+
screen.queryByTestId("admin-notification-method-list"),
|
|
1085
|
+
).not.toBeInTheDocument();
|
|
1086
|
+
|
|
1087
|
+
expect(mountedTableModels).toContain(UserEmail);
|
|
1088
|
+
expect(mountedTableModels).toContain(UserSMS);
|
|
1089
|
+
|
|
1090
|
+
// And it does not ask the admin endpoint about a page it is not rendering.
|
|
1091
|
+
const adminReads: Array<unknown> = apiGetMock.mock.calls.filter(
|
|
1092
|
+
(call: Array<any>): boolean => {
|
|
1093
|
+
return String(call[0].url).includes("/user-notification-method-admin/");
|
|
1094
|
+
},
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
expect(adminReads).toHaveLength(0);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
describe("no unmasked identifier reaches the DOM", () => {
|
|
1102
|
+
type AssertNoRawIdentifiersFunction = (container: HTMLElement) => void;
|
|
1103
|
+
|
|
1104
|
+
const assertNoRawIdentifiers: AssertNoRawIdentifiersFunction = (
|
|
1105
|
+
container: HTMLElement,
|
|
1106
|
+
): void => {
|
|
1107
|
+
/*
|
|
1108
|
+
* innerHTML rather than textContent, because a leak is just as real in an
|
|
1109
|
+
* href, a title or a data attribute as it is in visible copy.
|
|
1110
|
+
*/
|
|
1111
|
+
for (const raw of ALL_RAW_IDENTIFIERS) {
|
|
1112
|
+
expect(container.innerHTML).not.toContain(raw);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/*
|
|
1116
|
+
* The scan above catches the planted values by name; these two catch the
|
|
1117
|
+
* SHAPE, so an identifier this file never thought of is caught too.
|
|
1118
|
+
*/
|
|
1119
|
+
expect(container.textContent || "").not.toMatch(UNMASKED_EMAIL_PATTERN);
|
|
1120
|
+
expect(container.textContent || "").not.toMatch(UNMASKED_PHONE_PATTERN);
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
test("the list renders masked identifiers only", async () => {
|
|
1124
|
+
const container: HTMLElement = await renderPage();
|
|
1125
|
+
|
|
1126
|
+
// Not vacuous: the masked forms are all present.
|
|
1127
|
+
expect(container.textContent).toContain(MASKED_EMAIL);
|
|
1128
|
+
expect(container.textContent).toContain(MASKED_PHONE);
|
|
1129
|
+
expect(container.textContent).toContain(MASKED_WEBHOOK);
|
|
1130
|
+
|
|
1131
|
+
assertNoRawIdentifiers(container);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test("the removal confirmation names the mask, not the value", async () => {
|
|
1135
|
+
const container: HTMLElement = await renderPage();
|
|
1136
|
+
|
|
1137
|
+
clickButton(rowFor(MASKED_EMAIL), "Remove");
|
|
1138
|
+
|
|
1139
|
+
await waitFor((): void => {
|
|
1140
|
+
expect(pendingRequestCount).toBe(0);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
expect(document.body.textContent).toContain(MASKED_EMAIL);
|
|
1144
|
+
|
|
1145
|
+
assertNoRawIdentifiers(container);
|
|
1146
|
+
|
|
1147
|
+
for (const raw of ALL_RAW_IDENTIFIERS) {
|
|
1148
|
+
expect(document.body.innerHTML).not.toContain(raw);
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
});
|