@oneuptime/common 13.0.7 → 13.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +1 -1
  2. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +1 -1
  3. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +1 -1
  4. package/Server/API/AIBillingAPI.ts +1 -0
  5. package/Server/API/AIInsightAPI.ts +1 -4
  6. package/Server/API/AIInvestigationAPI.ts +1 -4
  7. package/Server/API/AIReadinessAPI.ts +4 -5
  8. package/Server/API/AlertAPI.ts +1 -0
  9. package/Server/API/AutoRemediationAPI.ts +1 -4
  10. package/Server/API/BaseAPI.ts +13 -0
  11. package/Server/API/BaseAnalyticsAPI.ts +80 -9
  12. package/Server/API/BillingAPI.ts +2 -0
  13. package/Server/API/BillingInvoiceAPI.ts +1 -0
  14. package/Server/API/BillingPaymentMethodAPI.ts +2 -0
  15. package/Server/API/CodeFixRunAPI.ts +1 -3
  16. package/Server/API/CommonAPI.ts +68 -4
  17. package/Server/API/DashboardAPI.ts +12 -12
  18. package/Server/API/GlobalConfigAPI.ts +17 -0
  19. package/Server/API/IncidentAPI.ts +2 -0
  20. package/Server/API/IncidentEpisodeAPI.ts +1 -0
  21. package/Server/API/NotificationAPI.ts +1 -0
  22. package/Server/API/OnCallCalendarAPI.ts +4 -5
  23. package/Server/API/OnCallDutyPolicyAPI.ts +7 -0
  24. package/Server/API/OnCallReadinessAPI.ts +4 -1
  25. package/Server/API/OnCallScheduleTimelineAPI.ts +731 -0
  26. package/Server/API/ProjectAPI.ts +1 -0
  27. package/Server/API/ScheduledMaintenanceAPI.ts +1 -0
  28. package/Server/API/StatusPageAPI.ts +161 -32
  29. package/Server/API/TeamMemberAPI.ts +3 -0
  30. package/Server/API/UserCallAPI.ts +2 -0
  31. package/Server/API/UserEmailAPI.ts +2 -0
  32. package/Server/API/UserIncomingCallNumberAPI.ts +2 -0
  33. package/Server/API/UserMicrosoftTeamsAPI.ts +1 -0
  34. package/Server/API/UserSlackAPI.ts +1 -0
  35. package/Server/API/UserSmsAPI.ts +2 -0
  36. package/Server/API/UserTotpAuthAPI.ts +1 -0
  37. package/Server/API/UserWebAuthnAPI.ts +2 -0
  38. package/Server/API/UserWebhookAPI.ts +1 -0
  39. package/Server/API/UserWhatsAppAPI.ts +2 -0
  40. package/Server/Middleware/MasterAdminAuthorization.ts +13 -2
  41. package/Server/Middleware/UserAuthorization.ts +79 -5
  42. package/Server/Services/ApiKeyPermissionService.ts +101 -4
  43. package/Server/Services/DatabaseService.ts +245 -0
  44. package/Server/Services/DetectionRuleService.ts +5 -4
  45. package/Server/Services/OnCallDutyPolicyChildService.ts +261 -0
  46. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +2 -2
  47. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +2 -2
  48. package/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.ts +2 -2
  49. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +2 -2
  50. package/Server/Services/PayAsYouGoBillingService.ts +23 -28
  51. package/Server/Services/ProjectService.ts +18 -0
  52. package/Server/Services/RumSessionPinService.ts +274 -22
  53. package/Server/Services/TeamPermissionService.ts +120 -26
  54. package/Server/Services/ThreatIntelFeedService.ts +6 -3
  55. package/Server/Types/Database/Permissions/BasePermission.ts +9 -7
  56. package/Server/Types/Database/Permissions/Index.ts +75 -11
  57. package/Server/Types/Database/Permissions/TablePermission.ts +24 -28
  58. package/Server/Utils/AI/AIChatPrivacyFilter.ts +8 -0
  59. package/Server/Utils/AI/AIRunPrivacyFilter.ts +8 -0
  60. package/Server/Utils/MasterAdminPageAccess.ts +61 -0
  61. package/Server/Utils/Monitor/MonitorAlert.ts +3 -1
  62. package/Server/Utils/Monitor/MonitorIncident.ts +3 -1
  63. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1 -1
  64. package/Server/Utils/Realtime.ts +163 -45
  65. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +11 -28
  66. package/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.ts +170 -0
  67. package/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.ts +1 -1
  68. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.ts +6 -2
  69. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.ts +10 -9
  70. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +1059 -0
  71. package/Tests/App/Dashboard/AffectedResourcesPickerEditForm.test.tsx +376 -0
  72. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +757 -0
  73. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +307 -0
  74. package/Tests/App/Dashboard/ArgumentsFormGroupByUnlistedAttributes.test.tsx +131 -0
  75. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +1 -1
  76. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +9 -1
  77. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +24 -0
  78. package/Tests/App/Dashboard/DashboardVariableSelector.test.tsx +162 -8
  79. package/Tests/App/Dashboard/EntityFilterDropdownArchivedSlos.test.tsx +105 -6
  80. package/Tests/App/Dashboard/EntityFilterDropdownUnlistedSelection.test.tsx +434 -0
  81. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +276 -1
  82. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +9 -1
  83. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +58 -42
  84. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +42 -5
  85. package/Tests/App/Dashboard/ResourceAuditLogsSideMenus.test.tsx +34 -23
  86. package/Tests/App/Dashboard/ScheduleTimeline.test.tsx +1580 -0
  87. package/Tests/App/Dashboard/ScheduleTimelineAPI.test.ts +280 -0
  88. package/Tests/App/Dashboard/ScheduleTimelineModel.test.ts +673 -0
  89. package/Tests/App/Dashboard/SecurityEventConnectionDiagnostics.test.tsx +95 -68
  90. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +86 -3
  91. package/Tests/App/Dashboard/SecurityEventsEmptyState.test.tsx +28 -3
  92. package/Tests/App/Dashboard/SecurityEventsExplorer.test.tsx +761 -0
  93. package/Tests/App/Dashboard/SecurityEventsGuideContent.test.ts +1313 -0
  94. package/Tests/App/Dashboard/SecurityEventsHowItWorksCard.test.tsx +536 -0
  95. package/Tests/App/Dashboard/SecurityEventsNoResults.test.tsx +222 -0
  96. package/Tests/App/Dashboard/SecurityEventsThreatIntelPage.test.tsx +199 -0
  97. package/Tests/App/Dashboard/SecurityEventsVolumeChart.test.tsx +321 -0
  98. package/Tests/App/Dashboard/SloOverviewHero.test.tsx +171 -36
  99. package/Tests/App/Dashboard/SloViewSideMenu.test.tsx +215 -0
  100. package/Tests/App/Dashboard/SloViewSideMenuCounts.test.tsx +40 -3
  101. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +12 -4
  102. package/Tests/App/PublicDashboard/PublicDashboardWidgetListAuth.test.tsx +623 -0
  103. package/Tests/Models/OnCallCalendarFeedModels.test.ts +23 -1
  104. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +138 -5
  105. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +187 -0
  106. package/Tests/Server/API/AIInvestigationAvailabilityAuthorization.test.ts +51 -3
  107. package/Tests/Server/API/AIInvestigationEvidenceAPI.test.ts +55 -2
  108. package/Tests/Server/API/BaseAnalyticsAPIAggregateCache.test.ts +729 -0
  109. package/Tests/Server/API/BillingPayAsYouGoStatus.test.ts +93 -7
  110. package/Tests/Server/API/BillingPaymentMethodAPI.test.ts +36 -3
  111. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +1003 -24
  112. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +6 -3
  113. package/Tests/Server/API/ExpiredSessionAIRoutes.test.ts +708 -0
  114. package/Tests/Server/API/ExpiredSessionCustomRoutes.test.ts +928 -0
  115. package/Tests/Server/API/ExpiredSessionMonitorTemplate.test.ts +319 -0
  116. package/Tests/Server/API/ExpiredSessionOnCallDutyPolicy.test.ts +336 -0
  117. package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +820 -0
  118. package/Tests/Server/API/GlobalConfigLicense.test.ts +140 -0
  119. package/Tests/Server/API/NotificationChannelVerificationAPI.test.ts +115 -12
  120. package/Tests/Server/API/OnCallCalendarAPI.test.ts +375 -8
  121. package/Tests/Server/API/OnCallReadinessAPI.test.ts +175 -17
  122. package/Tests/Server/API/OnCallScheduleTimelineAPI.test.ts +1513 -0
  123. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +119 -7
  124. package/Tests/Server/API/ProjectScopedCustomRouteAuth.test.ts +111 -4
  125. package/Tests/Server/API/PublicRouteInvalidAccessToken.test.ts +615 -0
  126. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +89 -2
  127. package/Tests/Server/API/SessionReplayAPI.test.ts +121 -0
  128. package/Tests/Server/API/StatusPageMasterPasswordRateLimit.test.ts +4 -2
  129. package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +441 -0
  130. package/Tests/Server/API/StatusPageTestEmailReportAuth.test.ts +664 -0
  131. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +7 -0
  132. package/Tests/Server/API/UserIncomingCallNumberApi.test.ts +6 -0
  133. package/Tests/Server/API/UserNotificationSettingAPI.test.ts +65 -4
  134. package/Tests/Server/API/UserProjectsAPI.test.ts +63 -3
  135. package/Tests/Server/API/UserTotpAuthAPI.test.ts +48 -2
  136. package/Tests/Server/API/UserWebAuthnAuthenticationOptionsRoute.test.ts +11 -2
  137. package/Tests/Server/API/UserWebAuthnBackupCodeMinting.test.ts +46 -3
  138. package/Tests/Server/API/UserWebAuthnPasskeyAPI.test.ts +18 -2
  139. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +24 -7
  140. package/Tests/Server/Middleware/ExpiredSessionUserMiddleware.test.ts +563 -0
  141. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +299 -19
  142. package/Tests/Server/Middleware/PublicRouteUserMiddleware.test.ts +453 -0
  143. package/Tests/Server/Services/AIChatPrivacyPin.test.ts +24 -0
  144. package/Tests/Server/Services/ApiKeyPermissionSecurity.test.ts +2 -0
  145. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +3 -3
  146. package/Tests/Server/Services/DatabaseServiceCreateSuppliedId.test.ts +147 -0
  147. package/Tests/Server/Services/DatabaseServiceLoginBeforeHooks.test.ts +1078 -0
  148. package/Tests/Server/Services/DatabaseServiceTenantRelationGuard.test.ts +259 -0
  149. package/Tests/Server/Services/DatabaseServiceWriteOperatorValues.test.ts +147 -0
  150. package/Tests/Server/Services/MonitorPayAsYouGoBilling.test.ts +115 -1
  151. package/Tests/Server/Services/OnCallChildCreateAuthorizationPostgres.test.ts +711 -0
  152. package/Tests/Server/Services/OnCallDutyPolicyChildService.test.ts +733 -0
  153. package/Tests/Server/Services/PayAsYouGoBillingService.test.ts +190 -140
  154. package/Tests/Server/Services/PayAsYouGoStripeRateLimitBudget.test.ts +4 -9
  155. package/Tests/Server/Services/PermissionBlockDeletion.test.ts +780 -0
  156. package/Tests/Server/Services/PermissionBlockDeletionPostgres.test.ts +628 -0
  157. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +77 -1
  158. package/Tests/Server/Services/RumSessionPinCreatePermission.test.ts +715 -0
  159. package/Tests/Server/Services/TeamMemberMultiTenantReadPostgres.test.ts +394 -0
  160. package/Tests/Server/Services/TeamPrivilegeEscalation.test.ts +6 -0
  161. package/Tests/Server/Services/TelemetryPayAsYouGoBilling.test.ts +131 -0
  162. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +13 -2
  163. package/Tests/Server/Types/Database/Permissions/ModelPermissionAnonymousRefusal.test.ts +518 -0
  164. package/Tests/Server/Types/Database/Permissions/MultiTenantPermissions.test.ts +350 -0
  165. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +282 -25
  166. package/Tests/Server/Types/Database/Permissions/TableLevelBlockPermission.test.ts +459 -0
  167. package/Tests/Server/Utils/AI/AIRunPrivacyFilter.test.ts +101 -9
  168. package/Tests/Server/Utils/AI/ChatAgentHelpers.test.ts +88 -4
  169. package/Tests/Server/Utils/AI/Toolbox/ExceptionTools.test.ts +459 -0
  170. package/Tests/Server/Utils/AI/Toolbox/LogTools.test.ts +794 -0
  171. package/Tests/Server/Utils/AI/Toolbox/RecentChangesTools.test.ts +574 -0
  172. package/Tests/Server/Utils/AI/Toolbox/SecurityEventTools.test.ts +596 -0
  173. package/Tests/Server/Utils/CaptchaRequestAndLogging.test.ts +395 -0
  174. package/Tests/Server/Utils/MasterAdminPageAccess.test.ts +251 -0
  175. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +616 -0
  176. package/Tests/Server/Utils/Realtime.test.ts +502 -0
  177. package/Tests/Server/Utils/SecurityEvent/Connectors/GoogleSecOps/GoogleSecOpsConnectorSeverity.test.ts +216 -0
  178. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +142 -0
  179. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.test.ts +140 -0
  180. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepairClickhouseIntegration.test.ts +542 -0
  181. package/Tests/Server/Utils/Telemetry/ChangeEventRow.test.ts +743 -0
  182. package/Tests/Server/Utils/Telemetry/RuntimeMetrics.test.ts +465 -0
  183. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/Alert.test.ts +202 -0
  184. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/Incident.test.ts +194 -0
  185. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/ScheduledMaintenance.test.ts +231 -0
  186. package/Tests/Server/Utils/Workspace/Slack/Messages/Alert.test.ts +194 -0
  187. package/Tests/Server/Utils/Workspace/Slack/Messages/AlertEpisode.test.ts +194 -0
  188. package/Tests/Server/Utils/Workspace/Slack/Messages/Incident.test.ts +194 -0
  189. package/Tests/Server/Utils/Workspace/Slack/Messages/IncidentEpisode.test.ts +194 -0
  190. package/Tests/Server/Utils/Workspace/Slack/Messages/ScheduledMaintenance.test.ts +247 -0
  191. package/Tests/Types/AI/AIInsightEvidence.test.ts +206 -0
  192. package/Tests/Types/BaseDatabase/DatabaseCommonInteractionPropsUtilAnonymous.test.ts +144 -0
  193. package/Tests/Types/Call/CallProvider.test.ts +528 -0
  194. package/Tests/Types/Kubernetes/KubernetesCostIngest.test.ts +236 -0
  195. package/Tests/Types/OnCallDutyPolicy/ScheduleTimeline.test.ts +736 -0
  196. package/Tests/Types/OnCallDutyPolicy/ScheduleTimelineLayout.test.ts +996 -0
  197. package/Tests/Types/SecurityEvent/SigmaRule.test.ts +142 -0
  198. package/Tests/Types/Workflow/Components/JSON.test.ts +182 -0
  199. package/Tests/UI/Components/BasicFormDropdownNormalization.test.tsx +116 -1
  200. package/Tests/UI/Components/Dropdown/DropdownAsyncOptions.test.tsx +81 -0
  201. package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +89 -1
  202. package/Tests/UI/Components/EventAttachmentList.test.tsx +174 -0
  203. package/Tests/UI/Components/Forms/BasicFormBurnRateTemplateDefaults.test.tsx +395 -0
  204. package/Tests/UI/Components/Forms/BasicFormCollapsibleSections.test.tsx +286 -0
  205. package/Tests/UI/Components/Forms/FormSummary.test.tsx +128 -0
  206. package/Tests/UI/Components/ModelTable/BaseModelTableQueryChange.test.tsx +365 -0
  207. package/Tests/UI/Components/OpenAuthenticatedUrl.test.ts +358 -0
  208. package/Tests/UI/Components/SessionAwareImage.test.tsx +496 -0
  209. package/Tests/UI/Components/TelemetryHistogramZeroBuckets.test.tsx +190 -0
  210. package/Tests/UI/Components/TooltipInteractive.test.tsx +97 -0
  211. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardInstallGuidance.test.tsx +1 -1
  212. package/Tests/UI/Monitor/EvaluationLogListTimestamps.test.tsx +367 -0
  213. package/Tests/UI/Utils/API/BaseAPIRefreshSession.test.ts +1087 -0
  214. package/Tests/UI/Utils/API/DashboardHost.ts +12 -0
  215. package/Tests/UI/Utils/API/FakeAxiosServer.ts +183 -0
  216. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  217. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  218. package/Tests/UI/Utils/Cookie.test.ts +440 -0
  219. package/Tests/UI/Utils/ModelAPIRequestClient.test.ts +278 -0
  220. package/Tests/UI/Utils/Navigation.test.ts +33 -0
  221. package/Tests/UI/Utils/PricingPlan.test.ts +295 -0
  222. package/Tests/UI/Utils/Realtime.test.ts +759 -0
  223. package/Tests/UI/Utils/Telemetry/Telemetry.test.ts +500 -0
  224. package/Tests/Utils/APIInstanceDispatch.test.ts +311 -0
  225. package/Tests/Utils/SecurityEvent/GoogleSecOpsAlertNormalizerSeverity.test.ts +565 -0
  226. package/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.ts +40 -0
  227. package/Types/Monitor/MonitorEvaluationSummary.ts +3 -0
  228. package/Types/OnCallDutyPolicy/ScheduleTimeline.ts +507 -0
  229. package/Types/OnCallDutyPolicy/ScheduleTimelineLayout.ts +589 -0
  230. package/Types/Realtime/EventName.ts +11 -0
  231. package/Types/SecurityEvent/DetectionFindingConstants.ts +14 -0
  232. package/Types/SecurityEvent/OcsfSeverity.ts +31 -26
  233. package/Types/SecurityEvent/SigmaRule.ts +51 -0
  234. package/Types/SecurityEvent/ThreatIntelConstants.ts +18 -0
  235. package/UI/Components/AttachmentList/EventAttachmentList.tsx +30 -2
  236. package/UI/Components/Dashboard/DashboardVariableControl.tsx +12 -2
  237. package/UI/Components/Dropdown/Dropdown.tsx +6 -1
  238. package/UI/Components/EditionLabel/EditionLabel.tsx +12 -0
  239. package/UI/Components/EventItem/EventItem.tsx +12 -1
  240. package/UI/Components/Forms/BasicForm.tsx +151 -126
  241. package/UI/Components/Forms/CollapsibleFormSection.tsx +63 -0
  242. package/UI/Components/Forms/Fields/FormField.tsx +10 -4
  243. package/UI/Components/Forms/FormSummary.tsx +7 -0
  244. package/UI/Components/Forms/Types/Field.ts +10 -0
  245. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +2 -1
  246. package/UI/Components/Markdown.tsx/SessionAwareImage.tsx +122 -0
  247. package/UI/Components/ModelTable/BaseModelTable.tsx +34 -5
  248. package/UI/Components/ModelTable/TableView.tsx +1 -1
  249. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +8 -1
  250. package/UI/Components/Tooltip/Tooltip.tsx +8 -1
  251. package/UI/Utils/API/API.ts +173 -12
  252. package/UI/Utils/API/RequestOptions.ts +11 -0
  253. package/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts +31 -7
  254. package/UI/Utils/ModelAPI/ModelAPI.ts +30 -9
  255. package/UI/Utils/Navigation.ts +9 -0
  256. package/UI/Utils/OpenAuthenticatedUrl.ts +154 -0
  257. package/UI/Utils/Realtime.ts +176 -30
  258. package/Utils/API.ts +14 -5
  259. package/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.ts +157 -7
  260. package/Utils/SecurityEvent/Sigma/SigmaRuleParser.ts +9 -17
  261. package/Utils/Slo/SloBurnRateTemplate.ts +2 -2
  262. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +1 -1
  263. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -1
  264. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +1 -1
  265. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -1
  266. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +1 -1
  267. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -1
  268. package/build/dist/Server/API/AIBillingAPI.js +1 -1
  269. package/build/dist/Server/API/AIBillingAPI.js.map +1 -1
  270. package/build/dist/Server/API/AIInsightAPI.js +1 -4
  271. package/build/dist/Server/API/AIInsightAPI.js.map +1 -1
  272. package/build/dist/Server/API/AIInvestigationAPI.js +1 -4
  273. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  274. package/build/dist/Server/API/AIReadinessAPI.js +1 -3
  275. package/build/dist/Server/API/AIReadinessAPI.js.map +1 -1
  276. package/build/dist/Server/API/AlertAPI.js +1 -1
  277. package/build/dist/Server/API/AlertAPI.js.map +1 -1
  278. package/build/dist/Server/API/AutoRemediationAPI.js +1 -4
  279. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  280. package/build/dist/Server/API/BaseAPI.js +12 -0
  281. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  282. package/build/dist/Server/API/BaseAnalyticsAPI.js +55 -10
  283. package/build/dist/Server/API/BaseAnalyticsAPI.js.map +1 -1
  284. package/build/dist/Server/API/BillingAPI.js +2 -2
  285. package/build/dist/Server/API/BillingAPI.js.map +1 -1
  286. package/build/dist/Server/API/BillingInvoiceAPI.js +1 -1
  287. package/build/dist/Server/API/BillingInvoiceAPI.js.map +1 -1
  288. package/build/dist/Server/API/BillingPaymentMethodAPI.js +2 -2
  289. package/build/dist/Server/API/BillingPaymentMethodAPI.js.map +1 -1
  290. package/build/dist/Server/API/CodeFixRunAPI.js +1 -3
  291. package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
  292. package/build/dist/Server/API/CommonAPI.js +50 -5
  293. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  294. package/build/dist/Server/API/DashboardAPI.js +12 -12
  295. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  296. package/build/dist/Server/API/GlobalConfigAPI.js +12 -0
  297. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  298. package/build/dist/Server/API/IncidentAPI.js +2 -2
  299. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  300. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  301. package/build/dist/Server/API/IncidentEpisodeAPI.js.map +1 -1
  302. package/build/dist/Server/API/NotificationAPI.js +1 -1
  303. package/build/dist/Server/API/NotificationAPI.js.map +1 -1
  304. package/build/dist/Server/API/OnCallCalendarAPI.js +1 -4
  305. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -1
  306. package/build/dist/Server/API/OnCallDutyPolicyAPI.js +6 -0
  307. package/build/dist/Server/API/OnCallDutyPolicyAPI.js.map +1 -1
  308. package/build/dist/Server/API/OnCallReadinessAPI.js +4 -1
  309. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -1
  310. package/build/dist/Server/API/OnCallScheduleTimelineAPI.js +494 -0
  311. package/build/dist/Server/API/OnCallScheduleTimelineAPI.js.map +1 -0
  312. package/build/dist/Server/API/ProjectAPI.js +1 -1
  313. package/build/dist/Server/API/ProjectAPI.js.map +1 -1
  314. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  315. package/build/dist/Server/API/ScheduledMaintenanceAPI.js.map +1 -1
  316. package/build/dist/Server/API/StatusPageAPI.js +125 -33
  317. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  318. package/build/dist/Server/API/TeamMemberAPI.js +3 -3
  319. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  320. package/build/dist/Server/API/UserCallAPI.js +2 -2
  321. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  322. package/build/dist/Server/API/UserEmailAPI.js +2 -2
  323. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  324. package/build/dist/Server/API/UserIncomingCallNumberAPI.js +2 -2
  325. package/build/dist/Server/API/UserIncomingCallNumberAPI.js.map +1 -1
  326. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js +1 -1
  327. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js.map +1 -1
  328. package/build/dist/Server/API/UserSlackAPI.js +1 -1
  329. package/build/dist/Server/API/UserSlackAPI.js.map +1 -1
  330. package/build/dist/Server/API/UserSmsAPI.js +2 -2
  331. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  332. package/build/dist/Server/API/UserTotpAuthAPI.js +1 -1
  333. package/build/dist/Server/API/UserTotpAuthAPI.js.map +1 -1
  334. package/build/dist/Server/API/UserWebAuthnAPI.js +2 -2
  335. package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
  336. package/build/dist/Server/API/UserWebhookAPI.js +1 -1
  337. package/build/dist/Server/API/UserWebhookAPI.js.map +1 -1
  338. package/build/dist/Server/API/UserWhatsAppAPI.js +2 -2
  339. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  340. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -2
  341. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  342. package/build/dist/Server/Middleware/UserAuthorization.js +64 -4
  343. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  344. package/build/dist/Server/Services/ApiKeyPermissionService.js +80 -4
  345. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  346. package/build/dist/Server/Services/DatabaseService.js +171 -0
  347. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  348. package/build/dist/Server/Services/DetectionRuleService.js +4 -4
  349. package/build/dist/Server/Services/DetectionRuleService.js.map +1 -1
  350. package/build/dist/Server/Services/OnCallDutyPolicyChildService.js +170 -0
  351. package/build/dist/Server/Services/OnCallDutyPolicyChildService.js.map +1 -0
  352. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +2 -2
  353. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  354. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +2 -2
  355. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  356. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.js +2 -2
  357. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.js.map +1 -1
  358. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +2 -2
  359. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  360. package/build/dist/Server/Services/PayAsYouGoBillingService.js +20 -27
  361. package/build/dist/Server/Services/PayAsYouGoBillingService.js.map +1 -1
  362. package/build/dist/Server/Services/ProjectService.js +17 -0
  363. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  364. package/build/dist/Server/Services/RumSessionPinService.js +180 -16
  365. package/build/dist/Server/Services/RumSessionPinService.js.map +1 -1
  366. package/build/dist/Server/Services/TeamPermissionService.js +89 -27
  367. package/build/dist/Server/Services/TeamPermissionService.js.map +1 -1
  368. package/build/dist/Server/Services/ThreatIntelFeedService.js +5 -4
  369. package/build/dist/Server/Services/ThreatIntelFeedService.js.map +1 -1
  370. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +9 -6
  371. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  372. package/build/dist/Server/Types/Database/Permissions/Index.js +60 -6
  373. package/build/dist/Server/Types/Database/Permissions/Index.js.map +1 -1
  374. package/build/dist/Server/Types/Database/Permissions/TablePermission.js +14 -13
  375. package/build/dist/Server/Types/Database/Permissions/TablePermission.js.map +1 -1
  376. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js +7 -0
  377. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js.map +1 -1
  378. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js +7 -0
  379. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js.map +1 -1
  380. package/build/dist/Server/Utils/MasterAdminPageAccess.js +39 -0
  381. package/build/dist/Server/Utils/MasterAdminPageAccess.js.map +1 -0
  382. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +3 -1
  383. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  384. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +3 -1
  385. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  386. package/build/dist/Server/Utils/Realtime.js +126 -37
  387. package/build/dist/Server/Utils/Realtime.js.map +1 -1
  388. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +7 -25
  389. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  390. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.js +127 -0
  391. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.js.map +1 -0
  392. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js +1 -1
  393. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js.map +1 -1
  394. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js +3 -2
  395. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js.map +1 -1
  396. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js +4 -8
  397. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js.map +1 -1
  398. package/build/dist/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.js +31 -1
  399. package/build/dist/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.js.map +1 -1
  400. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimeline.js +321 -0
  401. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimeline.js.map +1 -0
  402. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimelineLayout.js +372 -0
  403. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimelineLayout.js.map +1 -0
  404. package/build/dist/Types/Realtime/EventName.js +10 -0
  405. package/build/dist/Types/Realtime/EventName.js.map +1 -1
  406. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +11 -0
  407. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -1
  408. package/build/dist/Types/SecurityEvent/OcsfSeverity.js +30 -25
  409. package/build/dist/Types/SecurityEvent/OcsfSeverity.js.map +1 -1
  410. package/build/dist/Types/SecurityEvent/SigmaRule.js +47 -0
  411. package/build/dist/Types/SecurityEvent/SigmaRule.js.map +1 -1
  412. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js +14 -0
  413. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js.map +1 -1
  414. package/build/dist/UI/Components/AttachmentList/EventAttachmentList.js +9 -4
  415. package/build/dist/UI/Components/AttachmentList/EventAttachmentList.js.map +1 -1
  416. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +10 -2
  417. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -1
  418. package/build/dist/UI/Components/Dropdown/Dropdown.js +5 -1
  419. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  420. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +11 -0
  421. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  422. package/build/dist/UI/Components/EventItem/EventItem.js +2 -2
  423. package/build/dist/UI/Components/EventItem/EventItem.js.map +1 -1
  424. package/build/dist/UI/Components/Forms/BasicForm.js +70 -64
  425. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  426. package/build/dist/UI/Components/Forms/CollapsibleFormSection.js +27 -0
  427. package/build/dist/UI/Components/Forms/CollapsibleFormSection.js.map +1 -0
  428. package/build/dist/UI/Components/Forms/Fields/FormField.js +10 -7
  429. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  430. package/build/dist/UI/Components/Forms/FormSummary.js +7 -0
  431. package/build/dist/UI/Components/Forms/FormSummary.js.map +1 -1
  432. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +2 -1
  433. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  434. package/build/dist/UI/Components/Markdown.tsx/SessionAwareImage.js +84 -0
  435. package/build/dist/UI/Components/Markdown.tsx/SessionAwareImage.js.map +1 -0
  436. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +14 -1
  437. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  438. package/build/dist/UI/Components/ModelTable/TableView.js +1 -1
  439. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  440. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +8 -1
  441. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  442. package/build/dist/UI/Components/Tooltip/Tooltip.js +2 -1
  443. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  444. package/build/dist/UI/Utils/API/API.js +143 -19
  445. package/build/dist/UI/Utils/API/API.js.map +1 -1
  446. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js +21 -7
  447. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js.map +1 -1
  448. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +26 -9
  449. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  450. package/build/dist/UI/Utils/Navigation.js +8 -0
  451. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  452. package/build/dist/UI/Utils/OpenAuthenticatedUrl.js +92 -0
  453. package/build/dist/UI/Utils/OpenAuthenticatedUrl.js.map +1 -0
  454. package/build/dist/UI/Utils/Realtime.js +120 -15
  455. package/build/dist/UI/Utils/Realtime.js.map +1 -1
  456. package/build/dist/Utils/API.js +13 -5
  457. package/build/dist/Utils/API.js.map +1 -1
  458. package/build/dist/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.js +123 -6
  459. package/build/dist/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.js.map +1 -1
  460. package/build/dist/Utils/SecurityEvent/Sigma/SigmaRuleParser.js +4 -18
  461. package/build/dist/Utils/SecurityEvent/Sigma/SigmaRuleParser.js.map +1 -1
  462. package/build/dist/Utils/Slo/SloBurnRateTemplate.js +2 -2
  463. package/package.json +1 -1
@@ -0,0 +1,1087 @@
1
+ // Must stay the first import: the client's URLs are built from HOST on load.
2
+ import { DASHBOARD_ORIGIN } from "./DashboardHost";
3
+ import FakeAxiosServer, {
4
+ FakeTransport,
5
+ Gate,
6
+ SentRequest,
7
+ createGate,
8
+ settle,
9
+ } from "./FakeAxiosServer";
10
+ import BaseAPI from "../../../../UI/Utils/API/API";
11
+ import { IDENTITY_URL } from "../../../../UI/Config";
12
+ import Navigation from "../../../../UI/Utils/Navigation";
13
+ import User from "../../../../UI/Utils/User";
14
+ import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
15
+ import HTTPMethod from "../../../../Types/API/HTTPMethod";
16
+ import HTTPResponse from "../../../../Types/API/HTTPResponse";
17
+ import Route from "../../../../Types/API/Route";
18
+ import URL from "../../../../Types/API/URL";
19
+ import APIException from "../../../../Types/Exception/ApiException";
20
+ import { JSONObject } from "../../../../Types/JSON";
21
+ import Sleep from "../../../../Types/Sleep";
22
+ import getJestMockFunction, { MockFunction } from "../../../MockType";
23
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
24
+ import axios from "axios";
25
+
26
+ jest.mock("axios", () => {
27
+ // Keep the real helpers (axios.isAxiosError, AxiosError) and fake only the call.
28
+ return Object.assign(jest.fn(), jest.requireActual("axios"));
29
+ });
30
+
31
+ /*
32
+ * THE SESSION THAT LAPSED WHILE THE TAB WAS OPEN.
33
+ *
34
+ * The access-token cookie lives exactly as long as the 15-minute JWT inside it,
35
+ * so a Dashboard left open for a quarter of an hour sends its next request with
36
+ * no credentials at all. The server now answers every such request 401, and the
37
+ * browser client is expected to do one thing with a 401: refresh the session
38
+ * once, replay the request once, and hand the caller the replayed answer - as if
39
+ * the lapse had never happened.
40
+ *
41
+ * Everything here runs through the real client, down to where axios would open
42
+ * a socket: the fake below answers in axios's place, with real AxiosErrors.
43
+ * What these tests pin:
44
+ *
45
+ * - a 401 costs exactly one refresh and one replay, of the same request;
46
+ * - a page load of concurrent 401s still costs ONE refresh (the server rotates
47
+ * the refresh token on every use, so a second refresh would spend a token
48
+ * that no longer exists and log the user out);
49
+ * - a refresh the server refuses is final, and ends at the login page -
50
+ * unless the reader is already there, where a forced navigation would
51
+ * reload the page into the same 401 forever;
52
+ * - a refresh that failed for a reason a retry can fix is retried first;
53
+ * - no other status - above all the 422 the customer saw - refreshes anything;
54
+ * - refreshSession(), the public entry point, shares the single flight, uses
55
+ * the cross-tab lock when there is one, and reuses a refresh another tab
56
+ * finished moments ago.
57
+ */
58
+
59
+ const mockedAxios: FakeTransport = axios as unknown as FakeTransport;
60
+
61
+ const REFRESH_URL: string = URL.fromString(IDENTITY_URL.toString())
62
+ .addRoute("/refresh-token")
63
+ .toString();
64
+
65
+ const API_ORIGIN: string = `${DASHBOARD_ORIGIN}/api`;
66
+
67
+ const SEND_SETUP_REMINDER_URL: string = `${API_ORIGIN}/on-call-readiness/send-setup-reminder`;
68
+
69
+ const PROJECT_ID: string = "dddddddd-4444-4444-8444-444444444444";
70
+
71
+ const SESSION_REFRESHED_AT_KEY: string = "session-refreshed-at:dashboard";
72
+
73
+ const NOT_AUTHENTICATED_BODY: JSONObject = {
74
+ message: "Authentication required. Please log in to access this resource.",
75
+ };
76
+
77
+ const DASHBOARD_ROUTE: Route = new Route(`/dashboard/${PROJECT_ID}/on-call`);
78
+
79
+ const LOGIN_ROUTE: string = "/accounts/login";
80
+
81
+ let server: FakeAxiosServer;
82
+
83
+ // Simulated wall clock, so the "recently refreshed" window is exact.
84
+ let nowInMs: number = 0;
85
+
86
+ let sleptForInMs: Array<number> = [];
87
+
88
+ let logoutSpy: jest.SpiedFunction<typeof User.logout>;
89
+
90
+ let navigateSpy: jest.SpiedFunction<typeof Navigation.navigate>;
91
+
92
+ let currentRouteSpy: jest.SpiedFunction<typeof Navigation.getCurrentRoute>;
93
+
94
+ type EndpointFunction = (path: string) => string;
95
+
96
+ const endpoint: EndpointFunction = (path: string): string => {
97
+ return `${API_ORIGIN}${path}`;
98
+ };
99
+
100
+ type RefreshRequestsFunction = () => Array<SentRequest>;
101
+
102
+ const refreshRequests: RefreshRequestsFunction = (): Array<SentRequest> => {
103
+ return server.requestsTo(HTTPMethod.POST, REFRESH_URL);
104
+ };
105
+
106
+ type NavigatedRoutesFunction = () => Array<string>;
107
+
108
+ const navigatedRoutes: NavigatedRoutesFunction = (): Array<string> => {
109
+ return navigateSpy.mock.calls.map(
110
+ (call: Parameters<typeof Navigation.navigate>): string => {
111
+ return call[0].toString();
112
+ },
113
+ );
114
+ };
115
+
116
+ type SetLocksFunction = (locks: LockManager | undefined) => void;
117
+
118
+ const setNavigatorLocks: SetLocksFunction = (
119
+ locks: LockManager | undefined,
120
+ ): void => {
121
+ if (locks === undefined) {
122
+ delete (window.navigator as unknown as { locks?: LockManager }).locks;
123
+ return;
124
+ }
125
+
126
+ Object.defineProperty(window.navigator, "locks", {
127
+ configurable: true,
128
+ value: locks,
129
+ });
130
+ };
131
+
132
+ beforeEach(() => {
133
+ localStorage.clear();
134
+ setNavigatorLocks(undefined);
135
+
136
+ server = new FakeAxiosServer(mockedAxios);
137
+
138
+ nowInMs = 1_700_000_000_000;
139
+ sleptForInMs = [];
140
+
141
+ jest.spyOn(Date, "now").mockImplementation((): number => {
142
+ return nowInMs;
143
+ });
144
+
145
+ jest.spyOn(Sleep, "sleep").mockImplementation(async (ms: number) => {
146
+ sleptForInMs.push(ms);
147
+ nowInMs += ms;
148
+ });
149
+
150
+ // Deterministic backoff jitter: the midpoint of each window.
151
+ jest.spyOn(Math, "random").mockReturnValue(0.5);
152
+
153
+ logoutSpy = jest.spyOn(User, "logout").mockImplementation((): void => {});
154
+
155
+ navigateSpy = jest
156
+ .spyOn(Navigation, "navigate")
157
+ .mockImplementation((): void => {});
158
+
159
+ currentRouteSpy = jest
160
+ .spyOn(Navigation, "getCurrentRoute")
161
+ .mockReturnValue(DASHBOARD_ROUTE);
162
+ });
163
+
164
+ afterEach(() => {
165
+ mockedAxios.mockReset();
166
+ jest.restoreAllMocks();
167
+ setNavigatorLocks(undefined);
168
+ localStorage.clear();
169
+ });
170
+
171
+ describe("A request that comes back 401 because the session lapsed", () => {
172
+ test("is refreshed once and replayed once, and the caller gets the replayed answer", async () => {
173
+ const url: string = endpoint("/incident/get-list");
174
+
175
+ server
176
+ .on(HTTPMethod.POST, `${url}?limit=10&skip=0`, [
177
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
178
+ { status: 200, data: { count: 3 } },
179
+ ])
180
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
181
+
182
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
183
+ await BaseAPI.post<JSONObject>({
184
+ url: URL.fromString(url),
185
+ data: { query: { title: "db" } },
186
+ headers: { tenantid: PROJECT_ID },
187
+ params: { limit: "10", skip: "0" },
188
+ });
189
+
190
+ expect(response).toBeInstanceOf(HTTPResponse);
191
+ expect(response).not.toBeInstanceOf(HTTPErrorResponse);
192
+ expect(response.statusCode).toBe(200);
193
+ expect(response.data).toEqual({ count: 3 });
194
+
195
+ // The order on the wire: the refused request, the refresh, the replay.
196
+ expect(
197
+ server.sent.map((request: SentRequest): string => {
198
+ return `${request.method.toUpperCase()} ${request.url}`;
199
+ }),
200
+ ).toEqual([
201
+ `POST ${url}?limit=10&skip=0`,
202
+ `POST ${REFRESH_URL}`,
203
+ `POST ${url}?limit=10&skip=0`,
204
+ ]);
205
+
206
+ const original: SentRequest = server.sent[0]!;
207
+ const refresh: SentRequest = server.sent[1]!;
208
+ const replay: SentRequest = server.sent[2]!;
209
+
210
+ // The replay is the same request: same body, same tenant, same query once.
211
+ expect(replay.data).toEqual(original.data);
212
+ expect(replay.data).toEqual({ query: { title: "db" } });
213
+ expect(replay.headers["tenantid"]).toBe(PROJECT_ID);
214
+ expect(replay.url.match(/limit=/g)).toHaveLength(1);
215
+ expect(replay.url.match(/skip=/g)).toHaveLength(1);
216
+
217
+ // The refresh carries nothing of the request it is refreshing for.
218
+ expect(refresh.data).toBeUndefined();
219
+ expect(refresh.headers["tenantid"]).toBeUndefined();
220
+
221
+ // Nobody is logged out and nothing navigates: the lapse is invisible.
222
+ expect(logoutSpy).not.toHaveBeenCalled();
223
+ expect(navigateSpy).not.toHaveBeenCalled();
224
+ });
225
+
226
+ test.each([
227
+ HTTPMethod.GET,
228
+ HTTPMethod.PUT,
229
+ HTTPMethod.PATCH,
230
+ HTTPMethod.DELETE,
231
+ ])("a %s is replayed with its own method", async (method: HTTPMethod) => {
232
+ const url: string = endpoint("/monitor/abc");
233
+
234
+ server
235
+ .on(method, url, [
236
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
237
+ { status: 200, data: { ok: true } },
238
+ ])
239
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
240
+
241
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
242
+ await BaseAPI.fetch<JSONObject>({
243
+ method: method,
244
+ url: URL.fromString(url),
245
+ });
246
+
247
+ expect(response.statusCode).toBe(200);
248
+ expect(server.requestsTo(method, url)).toHaveLength(2);
249
+ expect(refreshRequests()).toHaveLength(1);
250
+ });
251
+
252
+ test("a successful refresh is remembered for the other tabs", async () => {
253
+ const url: string = endpoint("/project/get-list");
254
+
255
+ server
256
+ .on(HTTPMethod.POST, url, [{ status: 401 }, { status: 200 }])
257
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
258
+
259
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
260
+
261
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBe(
262
+ String(nowInMs),
263
+ );
264
+ });
265
+
266
+ /*
267
+ * One refresh buys one replay. A replay refused again means the refresh did
268
+ * not help, and a second refresh would not either - it would only spend the
269
+ * rotated token.
270
+ */
271
+ test("a replay that is refused again is final: no second refresh, one logout", async () => {
272
+ const url: string = endpoint("/alert/get-list");
273
+
274
+ server
275
+ .on(HTTPMethod.POST, url, [
276
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
277
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
278
+ ])
279
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
280
+
281
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
282
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
283
+
284
+ expect(response).toBeInstanceOf(HTTPErrorResponse);
285
+ expect(response.statusCode).toBe(401);
286
+ expect(refreshRequests()).toHaveLength(1);
287
+ expect(server.requestsTo(HTTPMethod.POST, url)).toHaveLength(2);
288
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
289
+ expect(navigatedRoutes()).toEqual([LOGIN_ROUTE]);
290
+ });
291
+
292
+ test("a request that opted out of refreshing gets its 401 as it came", async () => {
293
+ const url: string = endpoint("/user/me");
294
+
295
+ server.on(HTTPMethod.GET, url, [
296
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
297
+ ]);
298
+
299
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
300
+ await BaseAPI.get<JSONObject>({
301
+ url: URL.fromString(url),
302
+ options: { skipAuthRefresh: true },
303
+ });
304
+
305
+ expect(response.statusCode).toBe(401);
306
+ expect(refreshRequests()).toHaveLength(0);
307
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
308
+ });
309
+ });
310
+
311
+ describe("Many requests lapsing at once", () => {
312
+ /*
313
+ * A page load: five lists requested together, all of them past the token's
314
+ * lifetime. The refresh is held open until every one of the five has come
315
+ * back 401, so this is the worst case for a single flight rather than a
316
+ * lucky ordering.
317
+ */
318
+ test("five concurrent 401s share ONE refresh, and all five are replayed", async () => {
319
+ const refreshGate: Gate = createGate();
320
+
321
+ const urls: Array<string> = [1, 2, 3, 4, 5].map((n: number): string => {
322
+ return endpoint(`/widget-${n}/get-list`);
323
+ });
324
+
325
+ urls.forEach((url: string, index: number) => {
326
+ server.on(HTTPMethod.POST, url, [
327
+ { status: 401, data: NOT_AUTHENTICATED_BODY },
328
+ { status: 200, data: { widget: index + 1 } },
329
+ ]);
330
+ });
331
+
332
+ server.on(HTTPMethod.POST, REFRESH_URL, [
333
+ { status: 200, gate: refreshGate.promise },
334
+ ]);
335
+
336
+ const pending: Promise<
337
+ Array<HTTPResponse<JSONObject> | HTTPErrorResponse>
338
+ > = Promise.all(
339
+ urls.map(
340
+ (
341
+ url: string,
342
+ ): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> => {
343
+ return BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
344
+ },
345
+ ),
346
+ );
347
+
348
+ await settle();
349
+
350
+ // All five have been refused and all five are waiting on the same refresh.
351
+ for (const url of urls) {
352
+ expect(server.requestsTo(HTTPMethod.POST, url)).toHaveLength(1);
353
+ }
354
+ expect(refreshRequests()).toHaveLength(1);
355
+
356
+ refreshGate.open();
357
+
358
+ const responses: Array<HTTPResponse<JSONObject> | HTTPErrorResponse> =
359
+ await pending;
360
+
361
+ expect(
362
+ responses.map(
363
+ (response: HTTPResponse<JSONObject> | HTTPErrorResponse): number => {
364
+ return response.statusCode;
365
+ },
366
+ ),
367
+ ).toEqual([200, 200, 200, 200, 200]);
368
+
369
+ expect(
370
+ responses.map(
371
+ (response: HTTPResponse<JSONObject> | HTTPErrorResponse): unknown => {
372
+ return (response.data as JSONObject)["widget"];
373
+ },
374
+ ),
375
+ ).toEqual([1, 2, 3, 4, 5]);
376
+
377
+ expect(refreshRequests()).toHaveLength(1);
378
+
379
+ for (const url of urls) {
380
+ expect(server.requestsTo(HTTPMethod.POST, url)).toHaveLength(2);
381
+ }
382
+
383
+ expect(logoutSpy).not.toHaveBeenCalled();
384
+ expect(navigateSpy).not.toHaveBeenCalled();
385
+ });
386
+
387
+ /*
388
+ * The straggler: a request that was already on the wire when the refresh
389
+ * finished comes back 401 a moment later. The session is fresh, so it is
390
+ * replayed on the refresh that just happened instead of spending the new
391
+ * refresh token on another one.
392
+ */
393
+ test("a 401 that lands just after a refresh finished reuses it", async () => {
394
+ const first: string = endpoint("/incident/get-list");
395
+ const straggler: string = endpoint("/alert/get-list");
396
+
397
+ server
398
+ .on(HTTPMethod.POST, first, [{ status: 401 }, { status: 200 }])
399
+ .on(HTTPMethod.POST, straggler, [{ status: 401 }, { status: 200 }])
400
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
401
+
402
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(first) });
403
+
404
+ nowInMs += 2_000;
405
+
406
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
407
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(straggler) });
408
+
409
+ expect(response.statusCode).toBe(200);
410
+ expect(refreshRequests()).toHaveLength(1);
411
+ });
412
+ });
413
+
414
+ describe("A refresh the server refuses", () => {
415
+ test("the caller gets its own 401, the user is logged out and sent to the login page", async () => {
416
+ const url: string = endpoint("/incident/get-list");
417
+
418
+ server
419
+ .on(HTTPMethod.POST, url, [{ status: 401, data: NOT_AUTHENTICATED_BODY }])
420
+ .on(HTTPMethod.POST, REFRESH_URL, [
421
+ { status: 401, data: { message: "Refresh token expired." } },
422
+ ]);
423
+
424
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
425
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
426
+
427
+ // The caller's own refusal, not the refresh's.
428
+ expect(response).toBeInstanceOf(HTTPErrorResponse);
429
+ expect(response.statusCode).toBe(401);
430
+ expect((response as HTTPErrorResponse).message).toBe(
431
+ NOT_AUTHENTICATED_BODY["message"],
432
+ );
433
+
434
+ // Never replayed: there is no session to replay it on.
435
+ expect(server.requestsTo(HTTPMethod.POST, url)).toHaveLength(1);
436
+
437
+ // A 401 is final for the refresh too - it is not retried.
438
+ expect(refreshRequests()).toHaveLength(1);
439
+ expect(sleptForInMs).toEqual([]);
440
+
441
+ /*
442
+ * The refresh request answers through the same error handler as the
443
+ * request it was refreshing for, so the logout runs for each of them;
444
+ * what matters is that it runs and that every navigation goes to login.
445
+ */
446
+ expect(logoutSpy).toHaveBeenCalled();
447
+ expect(new Set(navigatedRoutes())).toEqual(new Set([LOGIN_ROUTE]));
448
+ for (const call of navigateSpy.mock.calls) {
449
+ expect(call[1]).toEqual({ forceNavigate: true });
450
+ }
451
+
452
+ // A failed refresh is not remembered as a refresh.
453
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBeNull();
454
+ });
455
+
456
+ /*
457
+ * The reload loop. The login page makes requests of its own; if they 401 and
458
+ * the handler force-navigates to the login page it is already on, the page
459
+ * reloads, makes the same requests, and does it again.
460
+ */
461
+ test("on the login page itself, it logs out but does not navigate", async () => {
462
+ currentRouteSpy.mockReturnValue(new Route(LOGIN_ROUTE));
463
+
464
+ const url: string = endpoint("/global-config/get");
465
+
466
+ server
467
+ .on(HTTPMethod.GET, url, [{ status: 401 }])
468
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 401 }]);
469
+
470
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
471
+ await BaseAPI.get<JSONObject>({ url: URL.fromString(url) });
472
+
473
+ expect(response.statusCode).toBe(401);
474
+ expect(logoutSpy).toHaveBeenCalled();
475
+ expect(navigateSpy).not.toHaveBeenCalled();
476
+ });
477
+
478
+ test("a 405 (the project is gone) still logs out and navigates away", async () => {
479
+ const url: string = endpoint("/incident/get-list");
480
+
481
+ server.on(HTTPMethod.POST, url, [{ status: 405 }]);
482
+
483
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
484
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
485
+
486
+ expect(response.statusCode).toBe(405);
487
+ expect(refreshRequests()).toHaveLength(0);
488
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
489
+ expect(navigatedRoutes()).toEqual([LOGIN_ROUTE]);
490
+ });
491
+
492
+ test("the failed refresh does not stick: the next 401 refreshes afresh", async () => {
493
+ const url: string = endpoint("/incident/get-list");
494
+
495
+ server
496
+ .on(HTTPMethod.POST, url, [
497
+ { status: 401 },
498
+ { status: 401 },
499
+ { status: 200, data: { ok: true } },
500
+ ])
501
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 401 }, { status: 200 }]);
502
+
503
+ const refused: HTTPResponse<JSONObject> | HTTPErrorResponse =
504
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
505
+
506
+ expect(refused.statusCode).toBe(401);
507
+
508
+ const recovered: HTTPResponse<JSONObject> | HTTPErrorResponse =
509
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
510
+
511
+ expect(recovered.statusCode).toBe(200);
512
+ expect(refreshRequests()).toHaveLength(2);
513
+ });
514
+ });
515
+
516
+ describe("A refresh that fails for a reason a retry can fix", () => {
517
+ /*
518
+ * The identity service restarting during a deploy answers 503 for a few
519
+ * seconds. That is not a dead session, and logging the user out for it is
520
+ * what the retries are there to prevent.
521
+ */
522
+ test("a 503 is retried with backoff, and the request is replayed once it works", async () => {
523
+ const url: string = endpoint("/incident/get-list");
524
+
525
+ server
526
+ .on(HTTPMethod.POST, url, [
527
+ { status: 401 },
528
+ { status: 200, data: { ok: true } },
529
+ ])
530
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 503 }, { status: 200 }]);
531
+
532
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
533
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
534
+
535
+ expect(response.statusCode).toBe(200);
536
+ expect(response.data).toEqual({ ok: true });
537
+ expect(refreshRequests()).toHaveLength(2);
538
+ // Exponential backoff with equal jitter: 2s window, midpoint = 1.5s.
539
+ expect(sleptForInMs).toEqual([1500]);
540
+ expect(logoutSpy).not.toHaveBeenCalled();
541
+ expect(navigateSpy).not.toHaveBeenCalled();
542
+ });
543
+
544
+ test("a refresh still failing after its retries is a failed refresh: logout", async () => {
545
+ const url: string = endpoint("/incident/get-list");
546
+
547
+ server
548
+ .on(HTTPMethod.POST, url, [{ status: 401 }])
549
+ .on(HTTPMethod.POST, REFRESH_URL, [
550
+ { status: 503 },
551
+ { status: 503 },
552
+ { status: 503 },
553
+ ]);
554
+
555
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
556
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
557
+
558
+ expect(response.statusCode).toBe(401);
559
+ // One attempt and two retries, backing off 1.5s then 3s.
560
+ expect(refreshRequests()).toHaveLength(3);
561
+ expect(sleptForInMs).toEqual([1500, 3000]);
562
+ expect(server.requestsTo(HTTPMethod.POST, url)).toHaveLength(1);
563
+ expect(logoutSpy).toHaveBeenCalled();
564
+ expect(navigatedRoutes()).toContain(LOGIN_ROUTE);
565
+ });
566
+
567
+ test("a dropped connection is retried, and the request replayed once it works", async () => {
568
+ const url: string = endpoint("/incident/get-list");
569
+
570
+ server
571
+ .on(HTTPMethod.POST, url, [{ status: 401 }, { status: 200 }])
572
+ .on(HTTPMethod.POST, REFRESH_URL, [
573
+ { networkErrorCode: "ECONNRESET" },
574
+ { status: 200 },
575
+ ]);
576
+
577
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
578
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
579
+
580
+ expect(response.statusCode).toBe(200);
581
+ expect(refreshRequests()).toHaveLength(2);
582
+ expect(logoutSpy).not.toHaveBeenCalled();
583
+ });
584
+
585
+ /*
586
+ * No answer at all, three times over. The client does NOT read that as a
587
+ * dead session: the refresh rejects with the transport error instead of
588
+ * resolving false, so the request that needed it rejects too, and nobody is
589
+ * logged out over what may be a network blip. The in-flight slot is still
590
+ * released, so the next 401 tries again.
591
+ */
592
+ test("a refresh that never gets an answer rejects rather than logging the user out", async () => {
593
+ const url: string = endpoint("/incident/get-list");
594
+
595
+ server
596
+ .on(HTTPMethod.POST, url, [{ status: 401 }])
597
+ .on(HTTPMethod.POST, REFRESH_URL, [
598
+ { networkErrorCode: "ECONNREFUSED" },
599
+ { networkErrorCode: "ECONNREFUSED" },
600
+ { networkErrorCode: "ECONNREFUSED" },
601
+ { status: 200 },
602
+ ]);
603
+
604
+ await expect(
605
+ BaseAPI.post<JSONObject>({ url: URL.fromString(url) }),
606
+ ).rejects.toBeInstanceOf(APIException);
607
+
608
+ expect(refreshRequests()).toHaveLength(3);
609
+ expect(sleptForInMs).toEqual([1500, 3000]);
610
+ expect(logoutSpy).not.toHaveBeenCalled();
611
+ expect(navigateSpy).not.toHaveBeenCalled();
612
+
613
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
614
+ expect(refreshRequests()).toHaveLength(4);
615
+ });
616
+
617
+ /*
618
+ * Called directly - by a raw fetch, a navigation, the realtime socket - the
619
+ * same unanswered refresh resolves false instead of rejecting, so none of
620
+ * those callers has to guard against a throw. It still logs nobody out.
621
+ */
622
+ test("refreshSession() called directly resolves false when the refresh never gets an answer", async () => {
623
+ server.on(HTTPMethod.POST, REFRESH_URL, [
624
+ { networkErrorCode: "ECONNREFUSED" },
625
+ { networkErrorCode: "ECONNREFUSED" },
626
+ { networkErrorCode: "ECONNREFUSED" },
627
+ ]);
628
+
629
+ await expect(BaseAPI.refreshSession()).resolves.toBe(false);
630
+
631
+ expect(refreshRequests()).toHaveLength(3);
632
+ expect(logoutSpy).not.toHaveBeenCalled();
633
+ expect(navigateSpy).not.toHaveBeenCalled();
634
+ });
635
+ });
636
+
637
+ describe("Statuses that are not a lapsed session", () => {
638
+ /*
639
+ * The customer's screenshot: a 422 "You are not authorized to access this
640
+ * project's data." for a request that simply had no credentials. The client
641
+ * cannot tell that 422 from a real permission refusal, so it must never
642
+ * refresh on one - which is exactly why the server now says 401.
643
+ */
644
+ test.each([
645
+ [400, "Project ID is required."],
646
+ [403, "Forbidden."],
647
+ [404, "Not found."],
648
+ [422, "You are not authorized to access this project's data."],
649
+ [500, "Server error."],
650
+ ])(
651
+ "a %s goes straight back to the caller, with no refresh",
652
+ async (status: number, message: string) => {
653
+ const url: string = SEND_SETUP_REMINDER_URL;
654
+
655
+ server.on(HTTPMethod.POST, url, [
656
+ { status: status, data: { message: message } },
657
+ ]);
658
+
659
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
660
+ await BaseAPI.post<JSONObject>({
661
+ url: URL.fromString(url),
662
+ data: { userIds: ["aaaaaaaa-1111-4111-8111-111111111111"] },
663
+ });
664
+
665
+ expect(response).toBeInstanceOf(HTTPErrorResponse);
666
+ expect(response.statusCode).toBe(status);
667
+ expect((response as HTTPErrorResponse).message).toBe(message);
668
+ expect(server.sent).toHaveLength(1);
669
+ expect(refreshRequests()).toHaveLength(0);
670
+ expect(logoutSpy).not.toHaveBeenCalled();
671
+ expect(navigatedRoutes()).not.toContain(LOGIN_ROUTE);
672
+ },
673
+ );
674
+ });
675
+
676
+ describe("BaseAPI.refreshSession()", () => {
677
+ test("returns true on a 2xx and stamps the time it happened", async () => {
678
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
679
+
680
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
681
+
682
+ expect(refreshRequests()).toHaveLength(1);
683
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBe(
684
+ String(nowInMs),
685
+ );
686
+ });
687
+
688
+ test("returns false on a refusal, and stamps nothing", async () => {
689
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 401 }]);
690
+
691
+ await expect(BaseAPI.refreshSession()).resolves.toBe(false);
692
+
693
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBeNull();
694
+ });
695
+
696
+ /*
697
+ * Another tab (or an earlier request in this one) refreshed a moment ago.
698
+ * Tabs share cookies, so that refresh's access token is already this tab's,
699
+ * and refreshing again would spend the refresh token that tab just rotated.
700
+ */
701
+ test("inside the recent-refresh window it returns true without a request", async () => {
702
+ localStorage.setItem(SESSION_REFRESHED_AT_KEY, String(nowInMs));
703
+
704
+ nowInMs += BaseAPI.RECENT_SESSION_REFRESH_WINDOW_IN_MS - 1;
705
+
706
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
707
+
708
+ expect(server.sent).toHaveLength(0);
709
+ });
710
+
711
+ test("a second call right after a successful one is free", async () => {
712
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
713
+
714
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
715
+
716
+ nowInMs += 5_000;
717
+
718
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
719
+
720
+ expect(refreshRequests()).toHaveLength(1);
721
+ });
722
+
723
+ test("once the window has passed it refreshes again", async () => {
724
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }, { status: 200 }]);
725
+
726
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
727
+
728
+ nowInMs += BaseAPI.RECENT_SESSION_REFRESH_WINDOW_IN_MS;
729
+
730
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
731
+
732
+ expect(refreshRequests()).toHaveLength(2);
733
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBe(
734
+ String(nowInMs),
735
+ );
736
+ });
737
+
738
+ test("the window is fifteen seconds", () => {
739
+ expect(BaseAPI.RECENT_SESSION_REFRESH_WINDOW_IN_MS).toBe(15_000);
740
+ });
741
+
742
+ // A stamp from the future (a skewed clock in another tab) proves nothing.
743
+ test("a stamp from the future is ignored", async () => {
744
+ localStorage.setItem(SESSION_REFRESHED_AT_KEY, String(nowInMs + 60_000));
745
+
746
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
747
+
748
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
749
+
750
+ expect(refreshRequests()).toHaveLength(1);
751
+ });
752
+
753
+ test("a stamp that is not a time is ignored", async () => {
754
+ localStorage.setItem(SESSION_REFRESHED_AT_KEY, "not-a-number");
755
+
756
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
757
+
758
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
759
+
760
+ expect(refreshRequests()).toHaveLength(1);
761
+ });
762
+
763
+ /*
764
+ * The stamp is best effort: storage that refuses it (quota, privacy modes)
765
+ * must cost the cross-tab reuse and nothing else - every tab just refreshes.
766
+ */
767
+ test("a stamp that cannot be read or written never breaks the refresh", async () => {
768
+ const originalGetItem: Storage["getItem"] = Storage.prototype.getItem;
769
+ const originalSetItem: Storage["setItem"] = Storage.prototype.setItem;
770
+
771
+ type IsStampKeyFunction = (key: string) => boolean;
772
+
773
+ const isStampKey: IsStampKeyFunction = (key: string): boolean => {
774
+ return key.startsWith("session-refreshed-at:");
775
+ };
776
+
777
+ jest
778
+ .spyOn(Storage.prototype, "getItem")
779
+ .mockImplementation((key: string): string | null => {
780
+ if (isStampKey(key)) {
781
+ throw new Error("SecurityError: storage is disabled");
782
+ }
783
+
784
+ return originalGetItem.call(window.localStorage, key);
785
+ });
786
+
787
+ jest
788
+ .spyOn(Storage.prototype, "setItem")
789
+ .mockImplementation((key: string, value: string): void => {
790
+ if (isStampKey(key)) {
791
+ throw new Error("QuotaExceededError: storage is full");
792
+ }
793
+
794
+ originalSetItem.call(window.localStorage, key, value);
795
+ });
796
+
797
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
798
+
799
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
800
+
801
+ expect(refreshRequests()).toHaveLength(1);
802
+ });
803
+
804
+ test("concurrent callers share one request and one answer", async () => {
805
+ const refreshGate: Gate = createGate();
806
+
807
+ server.on(HTTPMethod.POST, REFRESH_URL, [
808
+ { status: 200, gate: refreshGate.promise },
809
+ ]);
810
+
811
+ const results: Promise<Array<boolean>> = Promise.all([
812
+ BaseAPI.refreshSession(),
813
+ BaseAPI.refreshSession(),
814
+ BaseAPI.refreshSession(),
815
+ ]);
816
+
817
+ await settle();
818
+
819
+ expect(refreshRequests()).toHaveLength(1);
820
+
821
+ refreshGate.open();
822
+
823
+ await expect(results).resolves.toEqual([true, true, true]);
824
+ expect(refreshRequests()).toHaveLength(1);
825
+ });
826
+
827
+ test("concurrent callers share a failure too", async () => {
828
+ const refreshGate: Gate = createGate();
829
+
830
+ server.on(HTTPMethod.POST, REFRESH_URL, [
831
+ { status: 401, gate: refreshGate.promise },
832
+ ]);
833
+
834
+ const results: Promise<Array<boolean>> = Promise.all([
835
+ BaseAPI.refreshSession(),
836
+ BaseAPI.refreshSession(),
837
+ ]);
838
+
839
+ await settle();
840
+
841
+ refreshGate.open();
842
+
843
+ await expect(results).resolves.toEqual([false, false]);
844
+ expect(refreshRequests()).toHaveLength(1);
845
+ });
846
+
847
+ test("the refresh is a bodiless POST to the identity service's refresh-token route", async () => {
848
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
849
+
850
+ await BaseAPI.refreshSession();
851
+
852
+ expect(REFRESH_URL).toBe(
853
+ "https://oneuptime.example.com/identity/refresh-token",
854
+ );
855
+ expect(server.sent).toEqual([
856
+ expect.objectContaining({
857
+ method: HTTPMethod.POST,
858
+ url: REFRESH_URL,
859
+ data: undefined,
860
+ }),
861
+ ]);
862
+ });
863
+ });
864
+
865
+ describe("BaseAPI.refreshSession() across tabs", () => {
866
+ test("with Web Locks, the request is made while holding the session's lock", async () => {
867
+ let isInsideLock: boolean = false;
868
+ const requestsMadeInsideLock: Array<boolean> = [];
869
+
870
+ const request: MockFunction = getJestMockFunction().mockImplementation(
871
+ async (
872
+ _name: string,
873
+ callback: () => Promise<boolean>,
874
+ ): Promise<boolean> => {
875
+ isInsideLock = true;
876
+
877
+ try {
878
+ return await callback();
879
+ } finally {
880
+ isInsideLock = false;
881
+ }
882
+ },
883
+ );
884
+
885
+ setNavigatorLocks({ request: request } as unknown as LockManager);
886
+
887
+ server.onRequest = (): void => {
888
+ requestsMadeInsideLock.push(isInsideLock);
889
+ };
890
+
891
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
892
+
893
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
894
+
895
+ expect(request).toHaveBeenCalledTimes(1);
896
+ expect(request.mock.calls[0]![0]).toBe(
897
+ "oneuptime-session-refresh:dashboard",
898
+ );
899
+ expect(requestsMadeInsideLock).toEqual([true]);
900
+ });
901
+
902
+ /*
903
+ * The race the lock exists for. Two tabs notice the lapse together; the
904
+ * other tab wins the lock and refreshes. By the time this tab is granted the
905
+ * lock the refresh token it would send has already been rotated - sending it
906
+ * gets a 401 and a response that clears the cookies the winner just set. So
907
+ * it must look at the stamp the winner left and stop there.
908
+ */
909
+ test("a tab granted the lock after another tab refreshed reuses that refresh", async () => {
910
+ const otherTabFinished: Gate = createGate();
911
+
912
+ const request: MockFunction = getJestMockFunction().mockImplementation(
913
+ async (
914
+ _name: string,
915
+ callback: () => Promise<boolean>,
916
+ ): Promise<boolean> => {
917
+ // The other tab holds the lock until it has finished refreshing.
918
+ await otherTabFinished.promise;
919
+ return await callback();
920
+ },
921
+ );
922
+
923
+ setNavigatorLocks({ request: request } as unknown as LockManager);
924
+
925
+ const refreshed: Promise<boolean> = BaseAPI.refreshSession();
926
+
927
+ await settle();
928
+
929
+ // The other tab's refresh lands and stamps the shared storage.
930
+ localStorage.setItem(SESSION_REFRESHED_AT_KEY, String(nowInMs));
931
+ nowInMs += 300;
932
+ otherTabFinished.open();
933
+
934
+ await expect(refreshed).resolves.toBe(true);
935
+ expect(server.sent).toHaveLength(0);
936
+ });
937
+
938
+ test("a 401 end to end goes through the lock as well", async () => {
939
+ const request: MockFunction = getJestMockFunction().mockImplementation(
940
+ async (
941
+ _name: string,
942
+ callback: () => Promise<boolean>,
943
+ ): Promise<boolean> => {
944
+ return await callback();
945
+ },
946
+ );
947
+
948
+ setNavigatorLocks({ request: request } as unknown as LockManager);
949
+
950
+ const url: string = endpoint("/incident/get-list");
951
+
952
+ server
953
+ .on(HTTPMethod.POST, url, [{ status: 401 }, { status: 200 }])
954
+ .on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
955
+
956
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
957
+ await BaseAPI.post<JSONObject>({ url: URL.fromString(url) });
958
+
959
+ expect(response.statusCode).toBe(200);
960
+ expect(request).toHaveBeenCalledWith(
961
+ "oneuptime-session-refresh:dashboard",
962
+ expect.any(Function),
963
+ );
964
+ });
965
+
966
+ test("without Web Locks the refresh simply runs", async () => {
967
+ setNavigatorLocks(undefined);
968
+
969
+ expect(
970
+ (window.navigator as unknown as { locks?: LockManager }).locks,
971
+ ).toBeUndefined();
972
+
973
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
974
+
975
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
976
+
977
+ expect(refreshRequests()).toHaveLength(1);
978
+ });
979
+
980
+ test("a lock manager without request() is treated as no lock manager", async () => {
981
+ setNavigatorLocks({} as unknown as LockManager);
982
+
983
+ server.on(HTTPMethod.POST, REFRESH_URL, [{ status: 200 }]);
984
+
985
+ await expect(BaseAPI.refreshSession()).resolves.toBe(true);
986
+
987
+ expect(refreshRequests()).toHaveLength(1);
988
+ });
989
+ });
990
+
991
+ /*
992
+ * Subclasses: the status page and the public dashboard extend BaseAPI and swap
993
+ * WHERE the session is refreshed, and under WHICH name. The single flight is
994
+ * keyed by that name, not by class - two classes refreshing one session must
995
+ * share one refresh, and two sessions must never wait on each other.
996
+ */
997
+ describe("Sessions are named, and the name is what is shared", () => {
998
+ const OTHER_REFRESH_URL: string =
999
+ "https://status.example.com/status-page-identity-api/refresh-token/abc";
1000
+
1001
+ class SameSessionAPI extends BaseAPI {}
1002
+
1003
+ class OtherSessionAPI extends BaseAPI {
1004
+ protected static override getRefreshSessionUrl(): URL | null {
1005
+ return URL.fromString(OTHER_REFRESH_URL);
1006
+ }
1007
+
1008
+ protected static override getSessionName(): string {
1009
+ return "status-page-abc";
1010
+ }
1011
+ }
1012
+
1013
+ class NoSessionAPI extends BaseAPI {
1014
+ protected static override getRefreshSessionUrl(): URL | null {
1015
+ return null;
1016
+ }
1017
+ }
1018
+
1019
+ test("two classes on the same session share one in-flight refresh", async () => {
1020
+ const refreshGate: Gate = createGate();
1021
+
1022
+ server.on(HTTPMethod.POST, REFRESH_URL, [
1023
+ { status: 200, gate: refreshGate.promise },
1024
+ ]);
1025
+
1026
+ const results: Promise<Array<boolean>> = Promise.all([
1027
+ BaseAPI.refreshSession(),
1028
+ SameSessionAPI.refreshSession(),
1029
+ ]);
1030
+
1031
+ await settle();
1032
+
1033
+ refreshGate.open();
1034
+
1035
+ await expect(results).resolves.toEqual([true, true]);
1036
+ expect(refreshRequests()).toHaveLength(1);
1037
+ });
1038
+
1039
+ test("a different session refreshes on its own, at its own URL, under its own stamp", async () => {
1040
+ const refreshGate: Gate = createGate();
1041
+
1042
+ server
1043
+ .on(HTTPMethod.POST, REFRESH_URL, [
1044
+ { status: 200, gate: refreshGate.promise },
1045
+ ])
1046
+ .on(HTTPMethod.POST, OTHER_REFRESH_URL, [{ status: 200 }]);
1047
+
1048
+ const dashboard: Promise<boolean> = BaseAPI.refreshSession();
1049
+
1050
+ // The status page does not wait behind the dashboard's slow refresh.
1051
+ await expect(OtherSessionAPI.refreshSession()).resolves.toBe(true);
1052
+
1053
+ expect(localStorage.getItem("session-refreshed-at:status-page-abc")).toBe(
1054
+ String(nowInMs),
1055
+ );
1056
+ expect(localStorage.getItem(SESSION_REFRESHED_AT_KEY)).toBeNull();
1057
+
1058
+ refreshGate.open();
1059
+
1060
+ await expect(dashboard).resolves.toBe(true);
1061
+
1062
+ expect(refreshRequests()).toHaveLength(1);
1063
+ expect(server.requestsTo(HTTPMethod.POST, OTHER_REFRESH_URL)).toHaveLength(
1064
+ 1,
1065
+ );
1066
+ });
1067
+
1068
+ test("a class with no refresh URL reports false without a request", async () => {
1069
+ await expect(NoSessionAPI.refreshSession()).resolves.toBe(false);
1070
+
1071
+ expect(server.sent).toHaveLength(0);
1072
+ });
1073
+
1074
+ test("and its 401s go straight to its error handling", async () => {
1075
+ const url: string = endpoint("/dashboard/view");
1076
+
1077
+ server.on(HTTPMethod.GET, url, [{ status: 401 }]);
1078
+
1079
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
1080
+ await NoSessionAPI.get<JSONObject>({ url: URL.fromString(url) });
1081
+
1082
+ expect(response.statusCode).toBe(401);
1083
+ expect(server.sent).toHaveLength(1);
1084
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
1085
+ expect(navigatedRoutes()).toEqual([LOGIN_ROUTE]);
1086
+ });
1087
+ });