@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.
Files changed (376) hide show
  1. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +14 -0
  2. package/Models/DatabaseModels/UserCall.ts +94 -3
  3. package/Models/DatabaseModels/UserEmail.ts +94 -3
  4. package/Models/DatabaseModels/UserIncomingCallNumber.ts +94 -3
  5. package/Models/DatabaseModels/UserPush.ts +37 -0
  6. package/Models/DatabaseModels/UserSMS.ts +94 -3
  7. package/Models/DatabaseModels/UserWhatsApp.ts +93 -2
  8. package/Server/API/BaseAPI.ts +79 -14
  9. package/Server/API/UserCallAPI.ts +53 -45
  10. package/Server/API/UserEmailAPI.ts +53 -44
  11. package/Server/API/UserIncomingCallNumberAPI.ts +48 -38
  12. package/Server/API/UserNotificationMethodAdminAPI.ts +481 -0
  13. package/Server/API/UserPushAPI.ts +126 -2
  14. package/Server/API/UserSmsAPI.ts +53 -44
  15. package/Server/API/UserWhatsAppAPI.ts +53 -50
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.ts +154 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.ts +17 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.ts +45 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  20. package/Server/Middleware/VerificationCodeRateLimit.ts +586 -0
  21. package/Server/Services/AnalyticsDatabaseService.ts +169 -0
  22. package/Server/Services/DatabaseService.ts +101 -0
  23. package/Server/Services/LogAggregationService.ts +5 -0
  24. package/Server/Services/PushNotificationService.ts +127 -12
  25. package/Server/Services/TeamMemberService.ts +53 -13
  26. package/Server/Services/UserCallService.ts +55 -24
  27. package/Server/Services/UserEmailService.ts +83 -17
  28. package/Server/Services/UserIncomingCallNumberService.ts +49 -85
  29. package/Server/Services/UserNotificationMethodAdminService.ts +1527 -0
  30. package/Server/Services/UserNotificationRuleService.ts +26 -0
  31. package/Server/Services/UserPushService.ts +89 -0
  32. package/Server/Services/UserSmsService.ts +50 -17
  33. package/Server/Services/UserWhatsAppService.ts +48 -15
  34. package/Server/Types/Database/QueryHelper.ts +30 -0
  35. package/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.ts +59 -0
  36. package/Server/Utils/ChannelVerification.ts +510 -0
  37. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +13 -16
  38. package/Server/Utils/Monitor/MonitorAlert.ts +24 -21
  39. package/Server/Utils/Monitor/MonitorIncident.ts +21 -18
  40. package/Server/Utils/Monitor/MonitorResourceContext.ts +107 -0
  41. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +442 -0
  42. package/Server/Utils/Monitor/SeriesResourceLabels.ts +3 -3
  43. package/Server/Utils/Monitor/SeriesResourceLinker.ts +96 -28
  44. package/Server/Utils/TotpAuth.ts +137 -9
  45. package/Server/Utils/UserRegistrationToken.ts +182 -0
  46. package/Server/Utils/VerificationCode.ts +134 -0
  47. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +173 -94
  48. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +1151 -0
  49. package/Tests/App/Dashboard/AdminUserOnCallPages.test.tsx +905 -0
  50. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +338 -0
  51. package/Tests/App/Dashboard/InvestigationPanel.test.tsx +1 -1
  52. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +28 -0
  53. package/Tests/Models/UserPushCriticalAlertColumn.test.ts +172 -0
  54. package/Tests/Server/API/BaseAPIGetListParallel.test.ts +519 -0
  55. package/Tests/Server/API/NotificationChannelVerificationAPI.test.ts +543 -0
  56. package/Tests/Server/API/UserIncomingCallNumberApi.test.ts +108 -18
  57. package/Tests/Server/API/UserNotificationMethodAdminAPI.test.ts +963 -0
  58. package/Tests/Server/API/UserPushCriticalAlertsApi.test.ts +114 -0
  59. package/Tests/Server/API/UserSmsApi.test.ts +107 -14
  60. package/Tests/Server/API/UserTotpAuthAPI.test.ts +427 -0
  61. package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +315 -0
  62. package/Tests/Server/Middleware/VerificationCodeRateLimit.test.ts +775 -0
  63. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +10 -62
  64. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +33 -13
  65. package/Tests/Server/Services/AnalyticsDatabaseSortKeyBoundary.test.ts +658 -0
  66. package/Tests/Server/Services/CriticalOnCallAlertDelivery.test.ts +458 -0
  67. package/Tests/Server/Services/CriticalPushAlertPayload.test.ts +301 -0
  68. package/Tests/Server/Services/DatabaseServiceAtomicIncrement.test.ts +143 -0
  69. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1 -0
  70. package/Tests/Server/Services/LogAggregationScanMemory.test.ts +421 -0
  71. package/Tests/Server/Services/LogAggregationService.test.ts +2 -2
  72. package/Tests/Server/Services/NotificationChannelCodeIssuance.test.ts +401 -0
  73. package/Tests/Server/Services/PushNotificationDeliveryOptions.test.ts +204 -0
  74. package/Tests/Server/Services/TeamMemberAutoAcceptInvitation.test.ts +21 -1
  75. package/Tests/Server/Services/TeamMemberInviteRegistrationToken.test.ts +290 -0
  76. package/Tests/Server/Services/UserNotificationMethodAdminService.test.ts +948 -0
  77. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1 -0
  78. package/Tests/Server/Services/UserPushCriticalAlertToggle.test.ts +255 -0
  79. package/Tests/Server/Services/UserTotpAuthEnrolment.test.ts +218 -0
  80. package/Tests/Server/TestingUtils/AuthenticatorApp.ts +248 -0
  81. package/Tests/Server/Types/Database/Permissions/NotificationChannelVerificationColumns.test.ts +135 -0
  82. package/Tests/Server/Types/Database/Permissions/OnCallScheduleRelationSelect.test.ts +504 -0
  83. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAnyOf.test.ts +91 -0
  84. package/Tests/Server/Types/Workflow/Components/JsonToText.test.ts +384 -0
  85. package/Tests/Server/Types/Workflow/Components/MergeJson.test.ts +403 -0
  86. package/Tests/Server/Utils/Browser.test.ts +162 -0
  87. package/Tests/Server/Utils/ChannelVerification.test.ts +689 -0
  88. package/Tests/Server/Utils/Express.test.ts +371 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/CustomCodeMonitorCriteria.test.ts +707 -0
  90. package/Tests/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.test.ts +698 -0
  91. package/Tests/Server/Utils/Monitor/Criteria/IncomingEmailBodyCriteria.test.ts +243 -0
  92. package/Tests/Server/Utils/Monitor/Criteria/IncomingRequestBodyCriteria.test.ts +278 -0
  93. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +160 -20
  94. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +13 -8
  95. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +162 -18
  96. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +349 -0
  97. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +690 -0
  98. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +7 -2
  99. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +92 -18
  100. package/Tests/Server/Utils/PushNotificationUtilCreators.test.ts +267 -0
  101. package/Tests/Server/Utils/Telemetry/OneuptimeLabel.test.ts +439 -0
  102. package/Tests/Server/Utils/TotpAuth.test.ts +719 -0
  103. package/Tests/Server/Utils/UserRegistrationToken.test.ts +322 -0
  104. package/Tests/Server/Utils/VerificationCode.test.ts +296 -0
  105. package/Tests/Types/AutoRemediation/AutoRemediationSuggestionStatus.test.ts +192 -0
  106. package/Tests/Types/Call/CallRequest.test.ts +189 -0
  107. package/Tests/Types/Dashboard/DashboardSize.test.ts +226 -0
  108. package/Tests/Types/Incident/IncidentSlaStatus.test.ts +191 -0
  109. package/Tests/Types/Metrics/MetricDashboardMetricType.test.ts +278 -0
  110. package/Tests/Types/Metrics/MetricPipelineRuleType.test.ts +517 -0
  111. package/Tests/Types/Monitor/IncomingMonitorDefaultCriteria.test.ts +470 -0
  112. package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +6 -4
  113. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +262 -5
  114. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +1 -1
  115. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +1 -1
  116. package/Tests/Types/Monitor/ServiceAlertTemplates.test.ts +905 -0
  117. package/Tests/Types/Service/ServiceLanguage.test.ts +255 -0
  118. package/Tests/Types/TextRandomGeneration.test.ts +211 -0
  119. package/Tests/UI/Components/Button.test.tsx +175 -1
  120. package/Tests/UI/Components/CodeBlockLanguages.test.tsx +236 -0
  121. package/Tests/UI/Components/CommandPalette/CommandPalette.test.tsx +568 -0
  122. package/Tests/UI/Components/CommandPalette/CommandPaletteKeyboard.test.tsx +239 -0
  123. package/Tests/UI/Components/CommandPalette/CommandPaletteProviders.test.tsx +400 -0
  124. package/Tests/UI/Components/CommandPalette/PaletteFilter.test.ts +234 -0
  125. package/Tests/UI/Components/CommandPalette/RecentCommands.test.ts +97 -0
  126. package/Tests/UI/Components/CountModelSideMenuItemGuard.test.tsx +198 -0
  127. package/Tests/UI/Components/FeedItemSafeMode.test.tsx +1 -1
  128. package/Tests/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.test.ts +267 -0
  129. package/Tests/UI/Components/List.test.tsx +9 -2
  130. package/Tests/UI/Components/ListLoadingStates.test.tsx +173 -0
  131. package/Tests/UI/Components/MarkdownLazy.test.tsx +113 -0
  132. package/Tests/UI/Components/MarkdownMermaidRetry.test.tsx +110 -0
  133. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +8 -0
  134. package/Tests/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.test.ts +692 -0
  135. package/Tests/UI/Components/MoreMenuMotion.test.tsx +118 -0
  136. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +178 -0
  137. package/Tests/UI/Components/OrderedStatesList.test.tsx +19 -2
  138. package/Tests/UI/Components/SideOverMotion.test.tsx +109 -0
  139. package/Tests/UI/Components/Skeleton.test.tsx +71 -0
  140. package/Tests/UI/Components/TableLoadingStates.test.tsx +279 -0
  141. package/Tests/UI/Components/Tabs.test.tsx +24 -0
  142. package/Tests/UI/Components/Toast.test.tsx +130 -7
  143. package/Tests/UI/Components/ToastStacking.test.tsx +198 -0
  144. package/Tests/UI/Utils/Dropdown.test.ts +423 -0
  145. package/Tests/UI/Utils/ModelAPITenantHeader.test.ts +122 -0
  146. package/Tests/UI/Utils/ModelListCache.test.ts +338 -0
  147. package/Tests/UI/Utils/ProjectListCacheInvalidation.test.ts +225 -0
  148. package/Tests/Utils/API.test.ts +16 -2
  149. package/Tests/Utils/Dashboard/Components/DashboardKubernetesResourceListShared.test.ts +347 -0
  150. package/Tests/Utils/Dashboard/Components/DashboardListSharedArgs.test.ts +259 -0
  151. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +466 -0
  152. package/Tests/Utils/Rum/UrlScrubberSegments.test.ts +362 -0
  153. package/Types/Email/EmailTemplateType.ts +1 -0
  154. package/Types/Monitor/MonitorCriteriaInstance.ts +41 -20
  155. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +131 -8
  156. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +48 -8
  157. package/Types/Monitor/ServiceAlertTemplates.ts +1352 -0
  158. package/Types/Permission.ts +35 -0
  159. package/Types/PushNotification/AndroidNotificationChannel.ts +26 -0
  160. package/Types/PushNotification/PushNotificationMessage.ts +15 -0
  161. package/Types/PushNotification/PushNotificationRequest.ts +8 -16
  162. package/Types/Service/ServiceLanguage.ts +144 -0
  163. package/Types/Text.ts +111 -19
  164. package/UI/Components/Button/Button.tsx +27 -14
  165. package/UI/Components/CodeBlock/CodeBlock.tsx +54 -7
  166. package/UI/Components/CodeBlock/LanguageRegistry.ts +103 -0
  167. package/UI/Components/CommandPalette/CommandPalette.tsx +790 -0
  168. package/UI/Components/CommandPalette/PaletteFilter.ts +196 -0
  169. package/UI/Components/CommandPalette/PaletteRow.tsx +173 -0
  170. package/UI/Components/CommandPalette/RecentCommands.ts +57 -0
  171. package/UI/Components/CommandPalette/Types.ts +49 -0
  172. package/UI/Components/CommandPalette/UseProviderSearch.ts +129 -0
  173. package/UI/Components/Dropdown/Dropdown.tsx +6 -1
  174. package/UI/Components/Feed/FeedItem.tsx +1 -1
  175. package/UI/Components/List/List.tsx +37 -5
  176. package/UI/Components/List/ListSkeleton.tsx +54 -0
  177. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +6 -1
  178. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +71 -26
  179. package/UI/Components/Modal/Modal.tsx +2 -2
  180. package/UI/Components/ModelTable/BaseModelTable.tsx +1 -1
  181. package/UI/Components/ModelTable/useCustomFieldColumns.ts +11 -5
  182. package/UI/Components/MoreMenu/MoreMenu.tsx +39 -1
  183. package/UI/Components/Navbar/NavBar.tsx +14 -1
  184. package/UI/Components/OrderedStatesList/OrderedStatesList.tsx +50 -5
  185. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +40 -1
  186. package/UI/Components/SideOver/SideOver.tsx +37 -2
  187. package/UI/Components/Skeleton/Skeleton.tsx +53 -0
  188. package/UI/Components/Table/Table.tsx +74 -44
  189. package/UI/Components/Table/TableSkeletonRows.tsx +141 -0
  190. package/UI/Components/Tabs/Tab.tsx +1 -1
  191. package/UI/Components/Toast/Toast.tsx +118 -53
  192. package/UI/Components/Toast/ToastInit.tsx +82 -32
  193. package/UI/Styles/Theme.css +40 -0
  194. package/UI/Utils/ModelAPI/ModelAPI.ts +20 -10
  195. package/UI/Utils/ModelListCache.ts +188 -0
  196. package/UI/Utils/Project.ts +29 -0
  197. package/Utils/API.ts +6 -1
  198. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +14 -0
  199. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  200. package/build/dist/Models/DatabaseModels/UserCall.js +101 -3
  201. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  202. package/build/dist/Models/DatabaseModels/UserEmail.js +101 -3
  203. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  204. package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js +101 -3
  205. package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js.map +1 -1
  206. package/build/dist/Models/DatabaseModels/UserPush.js +38 -0
  207. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  208. package/build/dist/Models/DatabaseModels/UserSMS.js +101 -3
  209. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  210. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +100 -2
  211. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  212. package/build/dist/Server/API/BaseAPI.js +69 -13
  213. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  214. package/build/dist/Server/API/UserCallAPI.js +42 -38
  215. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  216. package/build/dist/Server/API/UserEmailAPI.js +42 -38
  217. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  218. package/build/dist/Server/API/UserIncomingCallNumberAPI.js +37 -32
  219. package/build/dist/Server/API/UserIncomingCallNumberAPI.js.map +1 -1
  220. package/build/dist/Server/API/UserNotificationMethodAdminAPI.js +341 -0
  221. package/build/dist/Server/API/UserNotificationMethodAdminAPI.js.map +1 -0
  222. package/build/dist/Server/API/UserPushAPI.js +95 -2
  223. package/build/dist/Server/API/UserPushAPI.js.map +1 -1
  224. package/build/dist/Server/API/UserSmsAPI.js +42 -38
  225. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  226. package/build/dist/Server/API/UserWhatsAppAPI.js +42 -40
  227. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js +89 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js.map +1 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js +12 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js.map +1 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js +40 -0
  233. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js.map +1 -0
  234. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  235. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  236. package/build/dist/Server/Middleware/VerificationCodeRateLimit.js +393 -0
  237. package/build/dist/Server/Middleware/VerificationCodeRateLimit.js.map +1 -0
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js +136 -0
  239. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  240. package/build/dist/Server/Services/DatabaseService.js +74 -0
  241. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  242. package/build/dist/Server/Services/LogAggregationService.js +5 -0
  243. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  244. package/build/dist/Server/Services/PushNotificationService.js +69 -30
  245. package/build/dist/Server/Services/PushNotificationService.js.map +1 -1
  246. package/build/dist/Server/Services/TeamMemberService.js +43 -7
  247. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  248. package/build/dist/Server/Services/UserCallService.js +54 -22
  249. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  250. package/build/dist/Server/Services/UserEmailService.js +78 -17
  251. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  252. package/build/dist/Server/Services/UserIncomingCallNumberService.js +45 -74
  253. package/build/dist/Server/Services/UserIncomingCallNumberService.js.map +1 -1
  254. package/build/dist/Server/Services/UserNotificationMethodAdminService.js +1117 -0
  255. package/build/dist/Server/Services/UserNotificationMethodAdminService.js.map +1 -0
  256. package/build/dist/Server/Services/UserNotificationRuleService.js +20 -0
  257. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  258. package/build/dist/Server/Services/UserPushService.js +77 -0
  259. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  260. package/build/dist/Server/Services/UserSmsService.js +50 -17
  261. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  262. package/build/dist/Server/Services/UserWhatsAppService.js +48 -15
  263. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  264. package/build/dist/Server/Types/Database/QueryHelper.js +27 -0
  265. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  266. package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js +46 -0
  267. package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js.map +1 -1
  268. package/build/dist/Server/Utils/ChannelVerification.js +348 -0
  269. package/build/dist/Server/Utils/ChannelVerification.js.map +1 -0
  270. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +13 -12
  271. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  272. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +20 -17
  273. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  274. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +17 -14
  275. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  276. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +95 -0
  277. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -0
  278. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +313 -0
  279. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -0
  280. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +3 -3
  281. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +67 -26
  282. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  283. package/build/dist/Server/Utils/TotpAuth.js +127 -8
  284. package/build/dist/Server/Utils/TotpAuth.js.map +1 -1
  285. package/build/dist/Server/Utils/UserRegistrationToken.js +170 -0
  286. package/build/dist/Server/Utils/UserRegistrationToken.js.map +1 -0
  287. package/build/dist/Server/Utils/VerificationCode.js +120 -0
  288. package/build/dist/Server/Utils/VerificationCode.js.map +1 -0
  289. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  290. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  291. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +42 -21
  292. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  293. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +77 -5
  294. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  295. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +18 -6
  296. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  297. package/build/dist/Types/Monitor/ServiceAlertTemplates.js +1028 -0
  298. package/build/dist/Types/Monitor/ServiceAlertTemplates.js.map +1 -0
  299. package/build/dist/Types/Permission.js +33 -0
  300. package/build/dist/Types/Permission.js.map +1 -1
  301. package/build/dist/Types/PushNotification/AndroidNotificationChannel.js +27 -0
  302. package/build/dist/Types/PushNotification/AndroidNotificationChannel.js.map +1 -0
  303. package/build/dist/Types/Service/ServiceLanguage.js +96 -0
  304. package/build/dist/Types/Service/ServiceLanguage.js.map +1 -0
  305. package/build/dist/Types/Text.js +82 -17
  306. package/build/dist/Types/Text.js.map +1 -1
  307. package/build/dist/UI/Components/Button/Button.js +22 -14
  308. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  309. package/build/dist/UI/Components/CodeBlock/CodeBlock.js +35 -3
  310. package/build/dist/UI/Components/CodeBlock/CodeBlock.js.map +1 -1
  311. package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js +57 -0
  312. package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js.map +1 -0
  313. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +439 -0
  314. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -0
  315. package/build/dist/UI/Components/CommandPalette/PaletteFilter.js +135 -0
  316. package/build/dist/UI/Components/CommandPalette/PaletteFilter.js.map +1 -0
  317. package/build/dist/UI/Components/CommandPalette/PaletteRow.js +83 -0
  318. package/build/dist/UI/Components/CommandPalette/PaletteRow.js.map +1 -0
  319. package/build/dist/UI/Components/CommandPalette/RecentCommands.js +47 -0
  320. package/build/dist/UI/Components/CommandPalette/RecentCommands.js.map +1 -0
  321. package/build/dist/UI/Components/CommandPalette/Types.js +2 -0
  322. package/build/dist/UI/Components/CommandPalette/Types.js.map +1 -0
  323. package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js +88 -0
  324. package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js.map +1 -0
  325. package/build/dist/UI/Components/Dropdown/Dropdown.js +6 -1
  326. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  327. package/build/dist/UI/Components/Feed/FeedItem.js +1 -1
  328. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  329. package/build/dist/UI/Components/List/List.js +19 -5
  330. package/build/dist/UI/Components/List/List.js.map +1 -1
  331. package/build/dist/UI/Components/List/ListSkeleton.js +21 -0
  332. package/build/dist/UI/Components/List/ListSkeleton.js.map +1 -0
  333. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  334. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +52 -25
  335. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  336. package/build/dist/UI/Components/Modal/Modal.js +2 -2
  337. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +1 -1
  338. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  339. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +9 -2
  340. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  341. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +32 -1
  342. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  343. package/build/dist/UI/Components/Navbar/NavBar.js +5 -1
  344. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  345. package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js +29 -5
  346. package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js.map +1 -1
  347. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +23 -1
  348. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  349. package/build/dist/UI/Components/SideOver/SideOver.js +23 -2
  350. package/build/dist/UI/Components/SideOver/SideOver.js.map +1 -1
  351. package/build/dist/UI/Components/Skeleton/Skeleton.js +23 -0
  352. package/build/dist/UI/Components/Skeleton/Skeleton.js.map +1 -0
  353. package/build/dist/UI/Components/Table/Table.js +35 -24
  354. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  355. package/build/dist/UI/Components/Table/TableSkeletonRows.js +52 -0
  356. package/build/dist/UI/Components/Table/TableSkeletonRows.js.map +1 -0
  357. package/build/dist/UI/Components/Tabs/Tab.js +1 -1
  358. package/build/dist/UI/Components/Tabs/Tab.js.map +1 -1
  359. package/build/dist/UI/Components/Toast/Toast.js +77 -23
  360. package/build/dist/UI/Components/Toast/Toast.js.map +1 -1
  361. package/build/dist/UI/Components/Toast/ToastInit.js +37 -15
  362. package/build/dist/UI/Components/Toast/ToastInit.js.map +1 -1
  363. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +18 -9
  364. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  365. package/build/dist/UI/Utils/ModelListCache.js +126 -0
  366. package/build/dist/UI/Utils/ModelListCache.js.map +1 -0
  367. package/build/dist/UI/Utils/Project.js +24 -1
  368. package/build/dist/UI/Utils/Project.js.map +1 -1
  369. package/build/dist/Utils/API.js +6 -1
  370. package/build/dist/Utils/API.js.map +1 -1
  371. package/jest.config.json +1 -1
  372. package/package.json +2 -3
  373. package/Server/Utils/Monitor/MonitorClusterContext.ts +0 -182
  374. package/Tests/__mocks__/otpauth.js +0 -30
  375. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +0 -141
  376. 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
+ });