@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,1513 @@
1
+ import { mockRouter } from "./Helpers";
2
+ import {
3
+ afterAll,
4
+ afterEach,
5
+ beforeAll,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ test,
10
+ } from "@jest/globals";
11
+
12
+ /*
13
+ * GET /on-call-schedule-timeline, the data behind the "every schedule on one
14
+ * week / month grid" page.
15
+ *
16
+ * The pure helpers (the team filter, the window reader, the response
17
+ * builder) are tested as functions. The route itself goes through a REAL
18
+ * Express application built from what the module registered on the mock
19
+ * router, over an ephemeral HTTP server, with every database service, the
20
+ * renderer and the render-slot cache stubbed at their public seams. What is
21
+ * under test is the route: who may call it, which reads carry the CALLER's
22
+ * props (the gate) and which are root (only ever keyed on what the gate
23
+ * returned), what reaches the renderer, and what comes back.
24
+ */
25
+
26
+ jest.mock("../../../Server/Utils/Express", () => {
27
+ return {
28
+ getRouter: () => {
29
+ return mockRouter;
30
+ },
31
+ };
32
+ });
33
+
34
+ jest.mock("../../../Server/Utils/Logger", () => {
35
+ return {
36
+ __esModule: true,
37
+ default: {
38
+ debug: jest.fn(),
39
+ info: jest.fn(),
40
+ warn: jest.fn(),
41
+ error: jest.fn(),
42
+ },
43
+ getLogAttributesFromRequest: (): Record<string, string> => {
44
+ return {};
45
+ },
46
+ };
47
+ });
48
+
49
+ jest.mock("../../../Server/Infrastructure/Redis", () => {
50
+ return {
51
+ __esModule: true,
52
+ default: {
53
+ getClient: jest.fn(() => {
54
+ return null;
55
+ }),
56
+ isConnected: jest.fn(() => {
57
+ return false;
58
+ }),
59
+ },
60
+ };
61
+ });
62
+
63
+ /*
64
+ * The real UserMiddleware functions are replaced with pass-throughs; the
65
+ * spied CommonAPI.getDatabaseCommonInteractionProps decides who the caller
66
+ * is. The route's own guards are what is under test.
67
+ */
68
+ jest.mock("../../../Server/Middleware/UserAuthorization", () => {
69
+ return {
70
+ __esModule: true,
71
+ default: {
72
+ getUserMiddleware: async (
73
+ _req: unknown,
74
+ _res: unknown,
75
+ next: () => void,
76
+ ): Promise<void> => {
77
+ next();
78
+ },
79
+ requireUserAuthentication: async (
80
+ _req: unknown,
81
+ _res: unknown,
82
+ next: () => void,
83
+ ): Promise<void> => {
84
+ next();
85
+ },
86
+ },
87
+ };
88
+ });
89
+
90
+ // See OnCallCalendarAPI.test.ts: keeps a pre-existing TS diagnostic out.
91
+ jest.mock("../../../Server/Utils/PasswordHash", () => {
92
+ return {
93
+ __esModule: true,
94
+ default: {
95
+ hash: jest.fn(),
96
+ verify: jest.fn(),
97
+ generateSalt: jest.fn(),
98
+ needsUpgrade: jest.fn(),
99
+ applyPepper: jest.fn(),
100
+ },
101
+ };
102
+ });
103
+
104
+ import {
105
+ TIMELINE_RETRY_AFTER_SECONDS,
106
+ buildTimelineResponse,
107
+ readTimelineTeamFilter,
108
+ readTimelineWindow,
109
+ } from "../../../Server/API/OnCallScheduleTimelineAPI";
110
+ import CommonAPI from "../../../Server/API/CommonAPI";
111
+ import OnCallCalendarFeedCache from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
112
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
113
+ import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
114
+ import OnCallDutyPolicyScheduleOwnerTeamService from "../../../Server/Services/OnCallDutyPolicyScheduleOwnerTeamService";
115
+ import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
116
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
117
+ import TeamService from "../../../Server/Services/TeamService";
118
+ import { ExpressRequest } from "../../../Server/Utils/Express";
119
+ import OnCallCalendarFeedRenderer, {
120
+ CachedScheduleSegments,
121
+ ScheduleInfo,
122
+ } from "../../../Server/Utils/OnCall/OnCallCalendarFeedRenderer";
123
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
124
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
125
+ import OneUptimeDate from "../../../Types/Date";
126
+ import Dictionary from "../../../Types/Dictionary";
127
+ import BadDataException from "../../../Types/Exception/BadDataException";
128
+ import Exception from "../../../Types/Exception/Exception";
129
+ import ExceptionCode from "../../../Types/Exception/ExceptionCode";
130
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
131
+ import PaymentRequiredException from "../../../Types/Exception/PaymentRequiredException";
132
+ import ObjectID from "../../../Types/ObjectID";
133
+ import MaterializedShiftUtil, {
134
+ MaterializedShift,
135
+ } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
136
+ import {
137
+ SCHEDULE_TIMELINE_ROUTE,
138
+ ScheduleTimelineResponse,
139
+ ScheduleTimelineScheduleJson,
140
+ ScheduleTimelineShiftJson,
141
+ TIMELINE_MAX_PAST_DAYS,
142
+ TIMELINE_MAX_SCHEDULES,
143
+ TimelineWindow,
144
+ } from "../../../Types/OnCallDutyPolicy/ScheduleTimeline";
145
+ import Permission, {
146
+ UserPermission,
147
+ UserTenantAccessPermission,
148
+ } from "../../../Types/Permission";
149
+ import PositiveNumber from "../../../Types/PositiveNumber";
150
+ import UserType from "../../../Types/UserType";
151
+ import {
152
+ at,
153
+ shift,
154
+ } from "../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
155
+ import express from "express";
156
+ import http from "http";
157
+ import { AddressInfo } from "net";
158
+
159
+ // -- Harness ----------------------------------------------------------------
160
+
161
+ type RouteHandler = (
162
+ req: express.Request,
163
+ res: express.Response,
164
+ next: express.NextFunction,
165
+ ) => void | Promise<void>;
166
+
167
+ interface RegisteredRoute {
168
+ method: string;
169
+ uri: string;
170
+ middlewares: Array<RouteHandler>;
171
+ handlerFunction: RouteHandler;
172
+ }
173
+
174
+ interface HttpResult {
175
+ status: number;
176
+ headers: http.IncomingHttpHeaders;
177
+ body: string;
178
+ }
179
+
180
+ interface CapturedFind {
181
+ query: Record<string, unknown>;
182
+ select?: Record<string, unknown> | undefined;
183
+ sort?: Record<string, unknown> | undefined;
184
+ limit?: number | undefined;
185
+ skip?: number | undefined;
186
+ props: { isRoot?: boolean | undefined } & Record<string, unknown>;
187
+ }
188
+
189
+ const API_PREFIX: string = "/api";
190
+ const NOW: Date = at("2026-09-17T12:00:00.000Z");
191
+ const WEEK_FROM: string = "2026-09-14T04:00:00.000Z";
192
+ const WEEK_TO: string = "2026-09-21T04:00:00.000Z";
193
+
194
+ let server: http.Server;
195
+ let baseUrl: string;
196
+
197
+ let projectId: ObjectID;
198
+ let userId: ObjectID;
199
+ let scheduleA: ObjectID;
200
+ let scheduleB: ObjectID;
201
+ let teamSre: ObjectID;
202
+ let teamPayments: ObjectID;
203
+
204
+ let propsSpy: jest.SpyInstance;
205
+ let scheduleFindBy: jest.SpyInstance;
206
+ let scheduleCountBy: jest.SpyInstance;
207
+ let ownerTeamFindBy: jest.SpyInstance;
208
+ let teamFindBy: jest.SpyInstance;
209
+ let teamMemberFindBy: jest.SpyInstance;
210
+ let layerUserFindBy: jest.SpyInstance;
211
+ let loadSchedules: jest.SpyInstance;
212
+ let loadSegments: jest.SpyInstance;
213
+ let tryAcquireRenderSlot: jest.SpyInstance;
214
+ let releaseRenderSlot: jest.SpyInstance;
215
+
216
+ function registeredRoutes(): Array<RegisteredRoute> {
217
+ return mockRouter.routes as unknown as Array<RegisteredRoute>;
218
+ }
219
+
220
+ function buildMemberProps(data: {
221
+ projectId: ObjectID | undefined;
222
+ userId: ObjectID | undefined;
223
+ // Defaults to ProjectMember, which can read every on-call table.
224
+ permissions?: Array<Permission> | undefined;
225
+ // Team BLOCK rows (unlabelled), which override any Allow.
226
+ blocks?: Array<Permission> | undefined;
227
+ }): DatabaseCommonInteractionProps {
228
+ const grants: Array<UserPermission> = [
229
+ ...(data.permissions || [Permission.ProjectMember]).map(
230
+ (permission: Permission): UserPermission => {
231
+ return {
232
+ _type: "UserPermission",
233
+ permission,
234
+ labelIds: [],
235
+ isBlockPermission: false,
236
+ };
237
+ },
238
+ ),
239
+ ...(data.blocks || []).map((permission: Permission): UserPermission => {
240
+ return {
241
+ _type: "UserPermission",
242
+ permission,
243
+ labelIds: [],
244
+ isBlockPermission: true,
245
+ };
246
+ }),
247
+ ];
248
+
249
+ const permissionMap: Dictionary<UserTenantAccessPermission> = {};
250
+
251
+ if (data.projectId) {
252
+ permissionMap[data.projectId.toString()] = {
253
+ _type: "UserTenantAccessPermission",
254
+ projectId: data.projectId,
255
+ permissions: grants,
256
+ };
257
+ }
258
+
259
+ return {
260
+ tenantId: data.projectId,
261
+ userId: data.userId,
262
+ userTenantAccessPermission: permissionMap,
263
+ };
264
+ }
265
+
266
+ function request(requestPath: string): Promise<HttpResult> {
267
+ return new Promise<HttpResult>(
268
+ (resolve: (result: HttpResult) => void, reject: (error: Error) => void) => {
269
+ const clientRequest: http.ClientRequest = http.request(
270
+ `${baseUrl}${requestPath}`,
271
+ { method: "GET" },
272
+ (response: http.IncomingMessage) => {
273
+ const chunks: Array<Buffer> = [];
274
+
275
+ response.on("data", (chunk: Buffer) => {
276
+ chunks.push(chunk);
277
+ });
278
+ response.on("end", () => {
279
+ resolve({
280
+ status: response.statusCode || 0,
281
+ headers: response.headers,
282
+ body: Buffer.concat(chunks).toString("utf8"),
283
+ });
284
+ });
285
+ response.on("error", reject);
286
+ },
287
+ );
288
+
289
+ clientRequest.on("error", reject);
290
+ clientRequest.end();
291
+ },
292
+ );
293
+ }
294
+
295
+ function timelinePath(query?: Record<string, string>): string {
296
+ const params: URLSearchParams = new URLSearchParams({
297
+ from: WEEK_FROM,
298
+ to: WEEK_TO,
299
+ ...(query || {}),
300
+ });
301
+
302
+ return `${API_PREFIX}${SCHEDULE_TIMELINE_ROUTE}?${params.toString()}`;
303
+ }
304
+
305
+ function json(result: HttpResult): ScheduleTimelineResponse {
306
+ return JSON.parse(result.body) as ScheduleTimelineResponse;
307
+ }
308
+
309
+ // The values a QueryHelper.any(...) operator matches.
310
+ function anyValues(operator: unknown): Array<string> {
311
+ const parameters: Record<string, unknown> =
312
+ ((operator as { objectLiteralParameters?: Record<string, unknown> })
313
+ ?.objectLiteralParameters as Record<string, unknown>) || {};
314
+
315
+ return (Object.values(parameters)[0] as Array<string>) || [];
316
+ }
317
+
318
+ function captured(spy: jest.SpyInstance, call: number = 0): CapturedFind {
319
+ return spy.mock.calls[call]?.[0] as CapturedFind;
320
+ }
321
+
322
+ function segment(data: {
323
+ scheduleId: string;
324
+ scheduleName?: string;
325
+ shifts?: Array<MaterializedShift>;
326
+ truncated?: boolean;
327
+ timezone?: string | null;
328
+ }): CachedScheduleSegments {
329
+ return {
330
+ scheduleId: data.scheduleId,
331
+ scheduleName: data.scheduleName || "Schedule",
332
+ scheduleTimezone:
333
+ data.timezone === undefined ? "Europe/Stockholm" : data.timezone,
334
+ projectId: projectId.toString(),
335
+ projectName: "Acme",
336
+ shiftConfigVersion: 3,
337
+ lastModifiedAt: NOW.toISOString(),
338
+ truncated: Boolean(data.truncated),
339
+ shifts: MaterializedShiftUtil.toJSONArray(data.shifts || []),
340
+ envelope: [],
341
+ envelopeTruncated: false,
342
+ };
343
+ }
344
+
345
+ function scheduleRow(
346
+ id: ObjectID,
347
+ name: string,
348
+ ): { id: ObjectID; name: string } {
349
+ return { id, name };
350
+ }
351
+
352
+ beforeAll(async () => {
353
+ const app: express.Express = express();
354
+
355
+ for (const route of registeredRoutes()) {
356
+ const chain: Array<express.RequestHandler> = [
357
+ ...route.middlewares,
358
+ route.handlerFunction,
359
+ ] as unknown as Array<express.RequestHandler>;
360
+
361
+ if (route.method === "GET") {
362
+ app.get(`${API_PREFIX}${route.uri}`, ...chain);
363
+ }
364
+ }
365
+
366
+ app.use(
367
+ (
368
+ err: unknown,
369
+ _req: express.Request,
370
+ res: express.Response,
371
+ _next: express.NextFunction,
372
+ ): void => {
373
+ const code: number = (err as Exception).code as number;
374
+ const status: number =
375
+ Number.isInteger(code) && code >= 400 && code <= 599 ? code : 500;
376
+
377
+ res.status(status).send({ message: (err as Error).message });
378
+ },
379
+ );
380
+
381
+ server = http.createServer(app);
382
+
383
+ await new Promise<void>((resolve: () => void) => {
384
+ server.listen(0, "127.0.0.1", resolve);
385
+ });
386
+
387
+ const address: AddressInfo = server.address() as AddressInfo;
388
+ baseUrl = `http://127.0.0.1:${address.port}`;
389
+ });
390
+
391
+ afterAll(async () => {
392
+ await new Promise<void>((resolve: () => void) => {
393
+ server.close(() => {
394
+ resolve();
395
+ });
396
+ });
397
+ });
398
+
399
+ beforeEach(() => {
400
+ jest.clearAllMocks();
401
+
402
+ projectId = ObjectID.generate();
403
+ userId = ObjectID.generate();
404
+ scheduleA = ObjectID.generate();
405
+ scheduleB = ObjectID.generate();
406
+ teamSre = ObjectID.generate();
407
+ teamPayments = ObjectID.generate();
408
+
409
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(NOW);
410
+
411
+ propsSpy = jest
412
+ .spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
413
+ .mockResolvedValue(buildMemberProps({ projectId, userId }));
414
+
415
+ scheduleFindBy = jest
416
+ .spyOn(OnCallDutyPolicyScheduleService, "findBy")
417
+ .mockResolvedValue([
418
+ scheduleRow(scheduleA, "Payments primary"),
419
+ scheduleRow(scheduleB, "SRE primary"),
420
+ ] as never);
421
+
422
+ scheduleCountBy = jest
423
+ .spyOn(OnCallDutyPolicyScheduleService, "countBy")
424
+ .mockResolvedValue(new PositiveNumber(0) as never);
425
+
426
+ ownerTeamFindBy = jest
427
+ .spyOn(OnCallDutyPolicyScheduleOwnerTeamService, "findBy")
428
+ .mockResolvedValue([
429
+ { onCallDutyPolicyScheduleId: scheduleA, teamId: teamPayments },
430
+ { onCallDutyPolicyScheduleId: scheduleB, teamId: teamSre },
431
+ // A schedule owned by two teams.
432
+ { onCallDutyPolicyScheduleId: scheduleB, teamId: teamPayments },
433
+ ] as never);
434
+
435
+ teamFindBy = jest.spyOn(TeamService, "findBy").mockResolvedValue([
436
+ { id: teamSre, name: "SRE" },
437
+ { id: teamPayments, name: "Payments" },
438
+ ] as never);
439
+
440
+ teamMemberFindBy = jest
441
+ .spyOn(TeamMemberService, "findBy")
442
+ .mockResolvedValue([{ teamId: teamSre }] as never);
443
+
444
+ layerUserFindBy = jest
445
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
446
+ .mockResolvedValue([{ onCallDutyPolicyScheduleId: scheduleB }] as never);
447
+
448
+ loadSchedules = jest
449
+ .spyOn(OnCallCalendarFeedRenderer, "loadSchedules")
450
+ .mockImplementation(async (ids: Array<ObjectID>) => {
451
+ return ids.map((id: ObjectID): ScheduleInfo => {
452
+ return {
453
+ id,
454
+ name: "Schedule",
455
+ projectId,
456
+ shiftConfigVersion: 3,
457
+ };
458
+ });
459
+ });
460
+
461
+ loadSegments = jest
462
+ .spyOn(OnCallCalendarFeedRenderer, "loadScheduleSegmentsBatch")
463
+ .mockImplementation(async () => {
464
+ return [
465
+ segment({
466
+ scheduleId: scheduleA.toString(),
467
+ scheduleName: "Payments primary",
468
+ shifts: [
469
+ shift({
470
+ scheduleId: scheduleA.toString(),
471
+ start: at("2026-09-16T09:00:00.000Z"),
472
+ end: at("2026-09-23T09:00:00.000Z"),
473
+ userId: "user-b",
474
+ userName: "Bob Berg",
475
+ }),
476
+ shift({
477
+ scheduleId: scheduleA.toString(),
478
+ start: at("2026-09-09T09:00:00.000Z"),
479
+ end: at("2026-09-16T09:00:00.000Z"),
480
+ userId: "user-a",
481
+ userName: "Alice Andersson",
482
+ layerId: "layer-1",
483
+ layerName: "Weekly",
484
+ }),
485
+ // Before the window: the cache window is wider.
486
+ shift({
487
+ scheduleId: scheduleA.toString(),
488
+ start: at("2026-09-02T09:00:00.000Z"),
489
+ end: at("2026-09-09T09:00:00.000Z"),
490
+ }),
491
+ // Policy variant: never on the timeline.
492
+ shift({
493
+ scheduleId: scheduleA.toString(),
494
+ start: at("2026-09-17T09:00:00.000Z"),
495
+ end: at("2026-09-17T17:00:00.000Z"),
496
+ userId: "user-c",
497
+ policyVariantOf: {
498
+ policyId: "pol-2",
499
+ policyName: "Secondary",
500
+ globalUserId: "user-b",
501
+ },
502
+ }),
503
+ ],
504
+ }),
505
+ segment({
506
+ scheduleId: scheduleB.toString(),
507
+ scheduleName: "SRE primary",
508
+ timezone: null,
509
+ truncated: true,
510
+ }),
511
+ ];
512
+ });
513
+
514
+ tryAcquireRenderSlot = jest
515
+ .spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
516
+ .mockReturnValue(true);
517
+
518
+ releaseRenderSlot = jest
519
+ .spyOn(OnCallCalendarFeedCache, "releaseRenderSlot")
520
+ .mockReturnValue(undefined);
521
+ });
522
+
523
+ afterEach(() => {
524
+ jest.restoreAllMocks();
525
+ });
526
+
527
+ // -- Registration -----------------------------------------------------------
528
+
529
+ describe("registration", () => {
530
+ test("registers exactly one GET route, behind the session middleware", () => {
531
+ const routes: Array<RegisteredRoute> = registeredRoutes();
532
+
533
+ expect(
534
+ routes.map((route: RegisteredRoute) => {
535
+ return `${route.method} ${route.uri}`;
536
+ }),
537
+ ).toEqual([`GET ${SCHEDULE_TIMELINE_ROUTE}`]);
538
+
539
+ expect(routes[0]?.middlewares).toEqual([
540
+ UserMiddleware.getUserMiddleware,
541
+ UserMiddleware.requireUserAuthentication,
542
+ ]);
543
+ });
544
+ });
545
+
546
+ // -- Pure helpers -----------------------------------------------------------
547
+
548
+ describe("readTimelineTeamFilter", () => {
549
+ function reqWith(query: Record<string, unknown>): ExpressRequest {
550
+ return { query } as unknown as ExpressRequest;
551
+ }
552
+
553
+ test("absent or blank means no filter", () => {
554
+ expect(readTimelineTeamFilter(reqWith({}))).toBeNull();
555
+ expect(readTimelineTeamFilter(reqWith({ teamId: "" }))).toBeNull();
556
+ expect(readTimelineTeamFilter(reqWith({ teamId: " " }))).toBeNull();
557
+ expect(readTimelineTeamFilter({} as ExpressRequest)).toBeNull();
558
+ });
559
+
560
+ test("a UUID is parsed (first value of a repeated parameter)", () => {
561
+ const id: ObjectID = ObjectID.generate();
562
+
563
+ expect(
564
+ readTimelineTeamFilter(
565
+ reqWith({ teamId: ` ${id.toString()} ` }),
566
+ )?.toString(),
567
+ ).toBe(id.toString());
568
+ expect(
569
+ readTimelineTeamFilter(
570
+ reqWith({ teamId: [id.toString(), "other"] }),
571
+ )?.toString(),
572
+ ).toBe(id.toString());
573
+ });
574
+
575
+ test("anything else is a 400, never a silently unfiltered answer", () => {
576
+ expect(() => {
577
+ readTimelineTeamFilter(reqWith({ teamId: "sre" }));
578
+ }).toThrow(BadDataException);
579
+ expect(() => {
580
+ readTimelineTeamFilter(reqWith({ teamId: "1; drop table" }));
581
+ }).toThrow("teamId must be a valid id.");
582
+ });
583
+ });
584
+
585
+ describe("readTimelineWindow", () => {
586
+ test("reads and clamps from / to", () => {
587
+ const window: TimelineWindow = readTimelineWindow(
588
+ { query: { from: WEEK_FROM, to: WEEK_TO } } as unknown as ExpressRequest,
589
+ NOW,
590
+ );
591
+
592
+ expect(window.from.toISOString()).toBe(WEEK_FROM);
593
+ expect(window.to.toISOString()).toBe(WEEK_TO);
594
+ });
595
+
596
+ test("takes the first of a repeated parameter", () => {
597
+ const window: TimelineWindow = readTimelineWindow(
598
+ {
599
+ query: { from: [WEEK_FROM, "2020-01-01T00:00:00.000Z"], to: WEEK_TO },
600
+ } as unknown as ExpressRequest,
601
+ NOW,
602
+ );
603
+
604
+ expect(window.from.toISOString()).toBe(WEEK_FROM);
605
+ });
606
+
607
+ test("a missing `from` is a 400", () => {
608
+ expect(() => {
609
+ readTimelineWindow({ query: {} } as unknown as ExpressRequest, NOW);
610
+ }).toThrow(BadDataException);
611
+ });
612
+ });
613
+
614
+ describe("buildTimelineResponse", () => {
615
+ const window: TimelineWindow = {
616
+ from: at(WEEK_FROM),
617
+ to: at(WEEK_TO),
618
+ };
619
+
620
+ function build(
621
+ overrides?: Partial<Parameters<typeof buildTimelineResponse>[0]>,
622
+ ): ScheduleTimelineResponse {
623
+ return buildTimelineResponse({
624
+ window,
625
+ now: NOW,
626
+ schedules: [
627
+ { id: "s-1", name: "One" },
628
+ { id: "s-2", name: "Two" },
629
+ ],
630
+ segments: [],
631
+ ownerTeamIdsBySchedule: new Map(),
632
+ rosterScheduleIds: new Set(),
633
+ teamNames: new Map(),
634
+ memberTeamIds: new Set(),
635
+ totalScheduleCount: 2,
636
+ includeOverrides: true,
637
+ ...(overrides || {}),
638
+ });
639
+ }
640
+
641
+ test("every readable schedule gets a row, even with no segment", () => {
642
+ const response: ScheduleTimelineResponse = build();
643
+
644
+ expect(
645
+ response.schedules.map((schedule: ScheduleTimelineScheduleJson) => {
646
+ return [schedule.scheduleId, schedule.scheduleName, schedule.shifts];
647
+ }),
648
+ ).toEqual([
649
+ ["s-1", "One", []],
650
+ ["s-2", "Two", []],
651
+ ]);
652
+ expect(response.schedules[0]?.scheduleTimezone).toBeNull();
653
+ expect(response.truncated).toBe(false);
654
+ });
655
+
656
+ test("window and generation stamps are ISO strings", () => {
657
+ const response: ScheduleTimelineResponse = build();
658
+
659
+ expect(response.from).toBe(WEEK_FROM);
660
+ expect(response.to).toBe(WEEK_TO);
661
+ expect(response.generatedAt).toBe(NOW.toISOString());
662
+ });
663
+
664
+ test("the segment's name and zone win over the gate's (renames race)", () => {
665
+ const response: ScheduleTimelineResponse = build({
666
+ segments: [
667
+ segment({
668
+ scheduleId: "s-1",
669
+ scheduleName: "One (renamed)",
670
+ timezone: "Asia/Tokyo",
671
+ }),
672
+ ],
673
+ });
674
+
675
+ expect(response.schedules[0]?.scheduleName).toBe("One (renamed)");
676
+ expect(response.schedules[0]?.scheduleTimezone).toBe("Asia/Tokyo");
677
+ });
678
+
679
+ test("shifts are projected, filtered to the window and sorted", () => {
680
+ const shifts: Array<MaterializedShift> = [
681
+ shift({
682
+ start: at("2026-09-18T09:00:00.000Z"),
683
+ end: at("2026-09-19T09:00:00.000Z"),
684
+ shiftKey: "late",
685
+ }),
686
+ shift({
687
+ start: at("2026-09-15T09:00:00.000Z"),
688
+ end: at("2026-09-16T09:00:00.000Z"),
689
+ shiftKey: "early",
690
+ }),
691
+ shift({
692
+ start: at("2026-09-25T09:00:00.000Z"),
693
+ end: at("2026-09-26T09:00:00.000Z"),
694
+ shiftKey: "outside",
695
+ }),
696
+ ];
697
+
698
+ const response: ScheduleTimelineResponse = build({
699
+ segments: [segment({ scheduleId: "s-1", shifts })],
700
+ });
701
+
702
+ expect(
703
+ response.schedules[0]?.shifts.map((item: ScheduleTimelineShiftJson) => {
704
+ return item.shiftKey;
705
+ }),
706
+ ).toEqual(["early", "late"]);
707
+ });
708
+
709
+ test("any truncated segment marks the response truncated", () => {
710
+ const response: ScheduleTimelineResponse = build({
711
+ segments: [segment({ scheduleId: "s-2", truncated: true })],
712
+ });
713
+
714
+ expect(response.truncated).toBe(true);
715
+ expect(response.schedules[0]?.truncated).toBe(false);
716
+ expect(response.schedules[1]?.truncated).toBe(true);
717
+ });
718
+
719
+ test("owner teams without a name (deleted teams) are dropped", () => {
720
+ const response: ScheduleTimelineResponse = build({
721
+ ownerTeamIdsBySchedule: new Map([["s-1", ["t-1", "t-gone"]]]),
722
+ teamNames: new Map([["t-1", "SRE"]]),
723
+ });
724
+
725
+ expect(response.schedules[0]?.ownerTeamIds).toEqual(["t-1"]);
726
+ expect(response.teams).toEqual([
727
+ { teamId: "t-1", teamName: "SRE", isCurrentUserMember: false },
728
+ ]);
729
+ });
730
+
731
+ test("teams are only those owning a returned schedule, sorted by name, with membership", () => {
732
+ const response: ScheduleTimelineResponse = build({
733
+ ownerTeamIdsBySchedule: new Map([
734
+ ["s-1", ["t-z"]],
735
+ ["s-2", ["t-a", "t-z"]],
736
+ ]),
737
+ teamNames: new Map([
738
+ ["t-z", "Zebra"],
739
+ ["t-a", "Alpha"],
740
+ ["t-unused", "Unused"],
741
+ ]),
742
+ memberTeamIds: new Set(["t-z"]),
743
+ });
744
+
745
+ expect(response.teams).toEqual([
746
+ { teamId: "t-a", teamName: "Alpha", isCurrentUserMember: false },
747
+ { teamId: "t-z", teamName: "Zebra", isCurrentUserMember: true },
748
+ ]);
749
+ });
750
+
751
+ test("roster membership is per schedule", () => {
752
+ const response: ScheduleTimelineResponse = build({
753
+ rosterScheduleIds: new Set(["s-2"]),
754
+ });
755
+
756
+ expect(
757
+ response.schedules.map((schedule: ScheduleTimelineScheduleJson) => {
758
+ return schedule.isCurrentUserOnRoster;
759
+ }),
760
+ ).toEqual([false, true]);
761
+ });
762
+
763
+ test("without override access the shift stays but its provenance is stripped", () => {
764
+ const covered: MaterializedShift = shift({
765
+ start: at("2026-09-15T12:00:00.000Z"),
766
+ end: at("2026-09-15T18:00:00.000Z"),
767
+ userId: "user-b",
768
+ userName: "Bob Berg",
769
+ override: {
770
+ originalUserId: "user-a",
771
+ originalUserName: "Alice Andersson",
772
+ overrideStartsAt: at("2026-09-15T12:00:00.000Z"),
773
+ overrideEndsAt: at("2026-09-15T18:00:00.000Z"),
774
+ },
775
+ });
776
+
777
+ const withAccess: ScheduleTimelineResponse = build({
778
+ segments: [segment({ scheduleId: "s-1", shifts: [covered] })],
779
+ });
780
+ const withoutAccess: ScheduleTimelineResponse = build({
781
+ segments: [segment({ scheduleId: "s-1", shifts: [covered] })],
782
+ includeOverrides: false,
783
+ });
784
+
785
+ expect(withAccess.schedules[0]?.shifts[0]?.override?.originalUserName).toBe(
786
+ "Alice Andersson",
787
+ );
788
+ expect(withoutAccess.schedules[0]?.shifts[0]).toMatchObject({
789
+ userId: "user-b",
790
+ userName: "Bob Berg",
791
+ override: null,
792
+ });
793
+ });
794
+
795
+ test("schedulesTruncated compares the total with what was returned", () => {
796
+ expect(build({ totalScheduleCount: 2 }).schedulesTruncated).toBe(false);
797
+
798
+ const truncated: ScheduleTimelineResponse = build({
799
+ totalScheduleCount: 400,
800
+ });
801
+
802
+ expect(truncated.schedulesTruncated).toBe(true);
803
+ expect(truncated.totalScheduleCount).toBe(400);
804
+
805
+ // A count that raced below the rows is never reported as less.
806
+ expect(build({ totalScheduleCount: 1 }).totalScheduleCount).toBe(2);
807
+ });
808
+ });
809
+
810
+ // -- The route --------------------------------------------------------------
811
+
812
+ describe("GET /on-call-schedule-timeline: who may call it", () => {
813
+ test("a member of the tenant project is served", async () => {
814
+ const result: HttpResult = await request(timelinePath());
815
+
816
+ expect(result.status).toBe(200);
817
+ expect(propsSpy).toHaveBeenCalledTimes(1);
818
+ });
819
+
820
+ test("a caller who is not a member of the tenant project is refused", async () => {
821
+ propsSpy.mockResolvedValue({
822
+ tenantId: projectId,
823
+ userId,
824
+ userTenantAccessPermission: {},
825
+ });
826
+
827
+ const result: HttpResult = await request(timelinePath());
828
+
829
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
830
+ expect(scheduleFindBy).not.toHaveBeenCalled();
831
+ expect(loadSegments).not.toHaveBeenCalled();
832
+ });
833
+
834
+ /*
835
+ * No user and no API key is an expired dashboard session: it must be the
836
+ * 401 the browser client refreshes and replays on, never a 422.
837
+ */
838
+ test("an anonymous caller (only a tenant header) is asked to authenticate", async () => {
839
+ propsSpy.mockResolvedValue(
840
+ buildMemberProps({ projectId, userId: undefined }),
841
+ );
842
+
843
+ const result: HttpResult = await request(timelinePath());
844
+
845
+ expect(result.status).toBe(ExceptionCode.NotAuthenticatedException);
846
+ expect(result.status).toBe(401);
847
+ expect(
848
+ (JSON.parse(result.body) as Record<string, unknown>)["message"],
849
+ ).toBe(CommonAPI.AUTHENTICATION_REQUIRED_MESSAGE);
850
+ expect(scheduleFindBy).not.toHaveBeenCalled();
851
+ });
852
+
853
+ test("a project API key (credentials but no user) is refused, not treated as a member", async () => {
854
+ propsSpy.mockResolvedValue({
855
+ ...buildMemberProps({ projectId, userId: undefined }),
856
+ userType: UserType.API,
857
+ });
858
+
859
+ const result: HttpResult = await request(timelinePath());
860
+
861
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
862
+ expect(scheduleFindBy).not.toHaveBeenCalled();
863
+ });
864
+
865
+ test("a request without a tenant is a 400", async () => {
866
+ propsSpy.mockResolvedValue({ tenantId: undefined, userId });
867
+
868
+ const result: HttpResult = await request(timelinePath());
869
+
870
+ expect(result.status).toBe(ExceptionCode.BadDataException);
871
+ expect(scheduleFindBy).not.toHaveBeenCalled();
872
+ });
873
+
874
+ test("a role that can list schedules but not read their layers is refused before anything is read", async () => {
875
+ propsSpy.mockResolvedValue(
876
+ buildMemberProps({
877
+ projectId,
878
+ userId,
879
+ permissions: [Permission.ReadProjectOnCallDutyPolicySchedule],
880
+ }),
881
+ );
882
+
883
+ const result: HttpResult = await request(timelinePath());
884
+
885
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
886
+ expect(JSON.parse(result.body).message).toBe(
887
+ "You do not have permission to read this project's on-call schedule layers.",
888
+ );
889
+ expect(scheduleFindBy).not.toHaveBeenCalled();
890
+ expect(loadSchedules).not.toHaveBeenCalled();
891
+ expect(loadSegments).not.toHaveBeenCalled();
892
+ expect(tryAcquireRenderSlot).not.toHaveBeenCalled();
893
+ });
894
+
895
+ test("the layer read permission alone is enough to be served", async () => {
896
+ propsSpy.mockResolvedValue(
897
+ buildMemberProps({
898
+ projectId,
899
+ userId,
900
+ permissions: [
901
+ Permission.ReadProjectOnCallDutyPolicySchedule,
902
+ Permission.ReadOnCallDutyPolicyScheduleLayer,
903
+ ],
904
+ }),
905
+ );
906
+
907
+ const result: HttpResult = await request(timelinePath());
908
+
909
+ expect(result.status).toBe(200);
910
+ expect(json(result).schedules).toHaveLength(2);
911
+ });
912
+
913
+ test("override provenance is only sent to callers who can read user overrides", async () => {
914
+ loadSegments.mockResolvedValue([
915
+ segment({
916
+ scheduleId: scheduleA.toString(),
917
+ shifts: [
918
+ shift({
919
+ scheduleId: scheduleA.toString(),
920
+ start: at("2026-09-15T12:00:00.000Z"),
921
+ end: at("2026-09-15T18:00:00.000Z"),
922
+ userId: "user-b",
923
+ userName: "Bob Berg",
924
+ override: {
925
+ originalUserId: "user-a",
926
+ originalUserName: "Alice Andersson",
927
+ overrideStartsAt: at("2026-09-15T12:00:00.000Z"),
928
+ overrideEndsAt: at("2026-09-15T18:00:00.000Z"),
929
+ },
930
+ }),
931
+ ],
932
+ }),
933
+ ]);
934
+
935
+ const full: ScheduleTimelineResponse = json(await request(timelinePath()));
936
+
937
+ expect(full.schedules[0]?.shifts[0]?.override?.originalUserId).toBe(
938
+ "user-a",
939
+ );
940
+
941
+ propsSpy.mockResolvedValue(
942
+ buildMemberProps({
943
+ projectId,
944
+ userId,
945
+ permissions: [
946
+ Permission.ReadProjectOnCallDutyPolicySchedule,
947
+ Permission.ReadOnCallDutyPolicyScheduleLayer,
948
+ ],
949
+ }),
950
+ );
951
+
952
+ const restricted: ScheduleTimelineResponse = json(
953
+ await request(timelinePath()),
954
+ );
955
+
956
+ expect(restricted.schedules[0]?.shifts[0]).toMatchObject({
957
+ userName: "Bob Berg",
958
+ override: null,
959
+ });
960
+ expect(JSON.stringify(restricted)).not.toContain("Alice Andersson");
961
+ });
962
+
963
+ test("a team BLOCK on reading layers overrides the Allow, as on the CRUD read", async () => {
964
+ propsSpy.mockResolvedValue(
965
+ buildMemberProps({
966
+ projectId,
967
+ userId,
968
+ blocks: [Permission.ReadOnCallDutyPolicyScheduleLayer],
969
+ }),
970
+ );
971
+
972
+ const result: HttpResult = await request(timelinePath());
973
+
974
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
975
+ expect(JSON.parse(result.body).message).toContain(
976
+ "is in your team's permission block list",
977
+ );
978
+ expect(scheduleFindBy).not.toHaveBeenCalled();
979
+ expect(loadSegments).not.toHaveBeenCalled();
980
+ });
981
+
982
+ test("a team BLOCK on reading overrides strips override provenance", async () => {
983
+ loadSegments.mockResolvedValue([
984
+ segment({
985
+ scheduleId: scheduleA.toString(),
986
+ shifts: [
987
+ shift({
988
+ scheduleId: scheduleA.toString(),
989
+ start: at("2026-09-15T12:00:00.000Z"),
990
+ end: at("2026-09-15T18:00:00.000Z"),
991
+ userId: "user-b",
992
+ userName: "Bob Berg",
993
+ override: {
994
+ originalUserId: "user-a",
995
+ originalUserName: "Alice Andersson",
996
+ overrideStartsAt: at("2026-09-15T12:00:00.000Z"),
997
+ overrideEndsAt: at("2026-09-15T18:00:00.000Z"),
998
+ },
999
+ }),
1000
+ ],
1001
+ }),
1002
+ ]);
1003
+
1004
+ propsSpy.mockResolvedValue(
1005
+ buildMemberProps({
1006
+ projectId,
1007
+ userId,
1008
+ blocks: [Permission.ReadOnCallDutyPolicyUserOverride],
1009
+ }),
1010
+ );
1011
+
1012
+ const result: HttpResult = await request(timelinePath());
1013
+
1014
+ expect(result.status).toBe(200);
1015
+ expect(json(result).schedules[0]?.shifts[0]).toMatchObject({
1016
+ userName: "Bob Berg",
1017
+ override: null,
1018
+ });
1019
+ expect(result.body).not.toContain("Alice Andersson");
1020
+ });
1021
+
1022
+ test("a master admin is served whatever their team blocks", async () => {
1023
+ propsSpy.mockResolvedValue({
1024
+ ...buildMemberProps({
1025
+ projectId,
1026
+ userId,
1027
+ blocks: [
1028
+ Permission.ReadOnCallDutyPolicyScheduleLayer,
1029
+ Permission.ReadOnCallDutyPolicyUserOverride,
1030
+ ],
1031
+ }),
1032
+ isMasterAdmin: true,
1033
+ });
1034
+
1035
+ const result: HttpResult = await request(timelinePath());
1036
+
1037
+ expect(result.status).toBe(200);
1038
+ expect(json(result).schedules).toHaveLength(2);
1039
+ });
1040
+
1041
+ test("a permission error from the gate read is passed through", async () => {
1042
+ scheduleFindBy.mockRejectedValue(
1043
+ new NotAuthorizedException("You do not have permission to read."),
1044
+ );
1045
+
1046
+ const result: HttpResult = await request(timelinePath());
1047
+
1048
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
1049
+ expect(loadSegments).not.toHaveBeenCalled();
1050
+ });
1051
+ });
1052
+
1053
+ describe("GET /on-call-schedule-timeline: validation", () => {
1054
+ test("a missing `from` is a 400", async () => {
1055
+ const result: HttpResult = await request(
1056
+ `${API_PREFIX}${SCHEDULE_TIMELINE_ROUTE}?to=${WEEK_TO}`,
1057
+ );
1058
+
1059
+ expect(result.status).toBe(ExceptionCode.BadDataException);
1060
+ expect(JSON.parse(result.body).message).toBe("from is required.");
1061
+ expect(scheduleFindBy).not.toHaveBeenCalled();
1062
+ });
1063
+
1064
+ test("a garbage window is a 400", async () => {
1065
+ const result: HttpResult = await request(
1066
+ timelinePath({ from: "last week" }),
1067
+ );
1068
+
1069
+ expect(result.status).toBe(ExceptionCode.BadDataException);
1070
+ });
1071
+
1072
+ test("an inverted window is a 400", async () => {
1073
+ const result: HttpResult = await request(
1074
+ timelinePath({ from: WEEK_TO, to: WEEK_FROM }),
1075
+ );
1076
+
1077
+ expect(result.status).toBe(ExceptionCode.BadDataException);
1078
+ });
1079
+
1080
+ test("a malformed teamId is a 400", async () => {
1081
+ const result: HttpResult = await request(timelinePath({ teamId: "sre" }));
1082
+
1083
+ expect(result.status).toBe(ExceptionCode.BadDataException);
1084
+ expect(ownerTeamFindBy).not.toHaveBeenCalled();
1085
+ expect(scheduleFindBy).not.toHaveBeenCalled();
1086
+ });
1087
+
1088
+ test("a far-past window is clamped, not refused", async () => {
1089
+ const result: HttpResult = await request(
1090
+ timelinePath({
1091
+ from: "2025-01-06T00:00:00.000Z",
1092
+ to: "2025-01-13T00:00:00.000Z",
1093
+ }),
1094
+ );
1095
+
1096
+ expect(result.status).toBe(200);
1097
+
1098
+ const earliest: Date = OneUptimeDate.addRemoveDays(
1099
+ NOW,
1100
+ -TIMELINE_MAX_PAST_DAYS,
1101
+ );
1102
+
1103
+ expect(json(result).from).toBe(earliest.toISOString());
1104
+ });
1105
+ });
1106
+
1107
+ describe("GET /on-call-schedule-timeline: the gate", () => {
1108
+ test("schedules are read with the CALLER's props, scoped to the tenant, by name", async () => {
1109
+ await request(timelinePath());
1110
+
1111
+ const args: CapturedFind = captured(scheduleFindBy);
1112
+
1113
+ expect(args.props.isRoot).toBeFalsy();
1114
+ expect(args.props["tenantId"]?.toString()).toBe(projectId.toString());
1115
+ expect(args.props["userId"]?.toString()).toBe(userId.toString());
1116
+ expect(args.query["projectId"]?.toString()).toBe(projectId.toString());
1117
+ expect(args.query["_id"]).toBeUndefined();
1118
+ expect(args.select).toEqual({ _id: true, name: true });
1119
+ expect(args.sort).toEqual({ name: SortOrder.Ascending });
1120
+ expect(args.limit).toBe(TIMELINE_MAX_SCHEDULES + 1);
1121
+ });
1122
+
1123
+ test("owner teams are read with the caller's props too", async () => {
1124
+ await request(timelinePath());
1125
+
1126
+ const args: CapturedFind = captured(ownerTeamFindBy);
1127
+
1128
+ expect(args.props.isRoot).toBeFalsy();
1129
+ expect(args.query["projectId"]?.toString()).toBe(projectId.toString());
1130
+ expect(anyValues(args.query["onCallDutyPolicyScheduleId"]).sort()).toEqual(
1131
+ [scheduleA.toString(), scheduleB.toString()].sort(),
1132
+ );
1133
+ });
1134
+
1135
+ test("the root reads are keyed only on what the gate returned", async () => {
1136
+ await request(timelinePath());
1137
+
1138
+ const teamArgs: CapturedFind = captured(teamFindBy);
1139
+ expect(teamArgs.props.isRoot).toBe(true);
1140
+ expect(teamArgs.query["projectId"]?.toString()).toBe(projectId.toString());
1141
+ expect(anyValues(teamArgs.query["_id"]).sort()).toEqual(
1142
+ [teamSre.toString(), teamPayments.toString()].sort(),
1143
+ );
1144
+
1145
+ const memberArgs: CapturedFind = captured(teamMemberFindBy);
1146
+ expect(memberArgs.props.isRoot).toBe(true);
1147
+ expect(memberArgs.query["userId"]?.toString()).toBe(userId.toString());
1148
+ expect(memberArgs.query["projectId"]?.toString()).toBe(
1149
+ projectId.toString(),
1150
+ );
1151
+ expect(memberArgs.query["hasAcceptedInvitation"]).toBe(true);
1152
+
1153
+ const layerArgs: CapturedFind = captured(layerUserFindBy);
1154
+ expect(layerArgs.props.isRoot).toBe(true);
1155
+ expect(layerArgs.query["userId"]?.toString()).toBe(userId.toString());
1156
+ expect(
1157
+ anyValues(layerArgs.query["onCallDutyPolicyScheduleId"]).sort(),
1158
+ ).toEqual([scheduleA.toString(), scheduleB.toString()].sort());
1159
+
1160
+ const ids: Array<ObjectID> = loadSchedules.mock
1161
+ .calls[0]?.[0] as Array<ObjectID>;
1162
+ expect(ids.map(String)).toEqual([
1163
+ scheduleA.toString(),
1164
+ scheduleB.toString(),
1165
+ ]);
1166
+ });
1167
+
1168
+ test("a caller who can read no schedules gets an empty timeline and no render", async () => {
1169
+ scheduleFindBy.mockResolvedValue([]);
1170
+
1171
+ const result: HttpResult = await request(timelinePath());
1172
+
1173
+ expect(result.status).toBe(200);
1174
+ expect(json(result)).toMatchObject({
1175
+ schedules: [],
1176
+ teams: [],
1177
+ totalScheduleCount: 0,
1178
+ schedulesTruncated: false,
1179
+ truncated: false,
1180
+ });
1181
+ expect(ownerTeamFindBy).not.toHaveBeenCalled();
1182
+ expect(tryAcquireRenderSlot).not.toHaveBeenCalled();
1183
+ expect(loadSegments).not.toHaveBeenCalled();
1184
+ });
1185
+
1186
+ test("an owner-team permission error serves the timeline ungrouped", async () => {
1187
+ ownerTeamFindBy.mockRejectedValue(
1188
+ new NotAuthorizedException("No owner-team permission."),
1189
+ );
1190
+
1191
+ const result: HttpResult = await request(timelinePath());
1192
+
1193
+ expect(result.status).toBe(200);
1194
+
1195
+ const payload: ScheduleTimelineResponse = json(result);
1196
+
1197
+ expect(payload.schedules).toHaveLength(2);
1198
+ expect(payload.teams).toEqual([]);
1199
+ expect(
1200
+ payload.schedules.every((schedule: ScheduleTimelineScheduleJson) => {
1201
+ return schedule.ownerTeamIds.length === 0;
1202
+ }),
1203
+ ).toBe(true);
1204
+ expect(teamFindBy).not.toHaveBeenCalled();
1205
+ });
1206
+ });
1207
+
1208
+ describe("GET /on-call-schedule-timeline: owner-team failures", () => {
1209
+ test("a database error reading owner teams is an error, not an ungrouped 200", async () => {
1210
+ ownerTeamFindBy.mockRejectedValue(new Error("statement timeout"));
1211
+
1212
+ const result: HttpResult = await request(timelinePath());
1213
+
1214
+ expect(result.status).toBe(500);
1215
+ expect(loadSegments).not.toHaveBeenCalled();
1216
+ expect(releaseRenderSlot).not.toHaveBeenCalled();
1217
+ });
1218
+
1219
+ test("a plan refusal reading owner teams also serves the timeline ungrouped", async () => {
1220
+ ownerTeamFindBy.mockRejectedValue(
1221
+ new PaymentRequiredException("Upgrade your plan."),
1222
+ );
1223
+
1224
+ const result: HttpResult = await request(timelinePath());
1225
+
1226
+ expect(result.status).toBe(200);
1227
+ expect(json(result).teams).toEqual([]);
1228
+ });
1229
+ });
1230
+
1231
+ describe("GET /on-call-schedule-timeline: ?teamId=", () => {
1232
+ /*
1233
+ * The team owns A and B, but label scoping lets the caller read only B.
1234
+ * Everything after the gate must be keyed on what the gate returned, never
1235
+ * on the team's own list of schedule ids.
1236
+ */
1237
+ test("a team schedule the gate does not return is never read or rendered", async () => {
1238
+ ownerTeamFindBy
1239
+ .mockResolvedValueOnce([
1240
+ { onCallDutyPolicyScheduleId: scheduleA },
1241
+ { onCallDutyPolicyScheduleId: scheduleB },
1242
+ ] as never)
1243
+ .mockResolvedValueOnce([
1244
+ { onCallDutyPolicyScheduleId: scheduleB, teamId: teamSre },
1245
+ ] as never);
1246
+ scheduleFindBy.mockResolvedValue([scheduleRow(scheduleB, "SRE primary")]);
1247
+
1248
+ const result: HttpResult = await request(
1249
+ timelinePath({ teamId: teamSre.toString() }),
1250
+ );
1251
+
1252
+ expect(result.status).toBe(200);
1253
+
1254
+ // The gate was asked about both of the team's schedules...
1255
+ expect(anyValues(captured(scheduleFindBy).query["_id"]).sort()).toEqual(
1256
+ [scheduleA.toString(), scheduleB.toString()].sort(),
1257
+ );
1258
+
1259
+ // ...and everything after it only about the one it returned.
1260
+ expect(
1261
+ json(result).schedules.map((item: ScheduleTimelineScheduleJson) => {
1262
+ return item.scheduleId;
1263
+ }),
1264
+ ).toEqual([scheduleB.toString()]);
1265
+ expect(
1266
+ anyValues(
1267
+ captured(ownerTeamFindBy, 1).query["onCallDutyPolicyScheduleId"],
1268
+ ),
1269
+ ).toEqual([scheduleB.toString()]);
1270
+ expect(
1271
+ anyValues(captured(layerUserFindBy).query["onCallDutyPolicyScheduleId"]),
1272
+ ).toEqual([scheduleB.toString()]);
1273
+ expect(
1274
+ (loadSchedules.mock.calls[0]?.[0] as Array<ObjectID>).map(String),
1275
+ ).toEqual([scheduleB.toString()]);
1276
+ expect(
1277
+ (
1278
+ loadSegments.mock.calls[0]?.[0] as { schedules: Array<ScheduleInfo> }
1279
+ ).schedules.map((info: ScheduleInfo) => {
1280
+ return info.id.toString();
1281
+ }),
1282
+ ).toEqual([scheduleB.toString()]);
1283
+ });
1284
+
1285
+ test("narrows the gate to the team's schedules", async () => {
1286
+ ownerTeamFindBy
1287
+ .mockResolvedValueOnce([
1288
+ { onCallDutyPolicyScheduleId: scheduleB },
1289
+ { onCallDutyPolicyScheduleId: scheduleB },
1290
+ ] as never)
1291
+ .mockResolvedValueOnce([
1292
+ { onCallDutyPolicyScheduleId: scheduleB, teamId: teamSre },
1293
+ ] as never);
1294
+ scheduleFindBy.mockResolvedValue([scheduleRow(scheduleB, "SRE primary")]);
1295
+
1296
+ const result: HttpResult = await request(
1297
+ timelinePath({ teamId: teamSre.toString() }),
1298
+ );
1299
+
1300
+ expect(result.status).toBe(200);
1301
+
1302
+ const teamArgs: CapturedFind = captured(ownerTeamFindBy, 0);
1303
+ expect(teamArgs.props.isRoot).toBeFalsy();
1304
+ expect(teamArgs.query["teamId"]?.toString()).toBe(teamSre.toString());
1305
+ expect(teamArgs.query["projectId"]?.toString()).toBe(projectId.toString());
1306
+
1307
+ const scheduleArgs: CapturedFind = captured(scheduleFindBy);
1308
+ expect(anyValues(scheduleArgs.query["_id"])).toEqual([
1309
+ scheduleB.toString(),
1310
+ ]);
1311
+ expect(scheduleArgs.props.isRoot).toBeFalsy();
1312
+ });
1313
+
1314
+ test("a team that owns nothing is an empty timeline, with nothing else read", async () => {
1315
+ ownerTeamFindBy.mockResolvedValue([]);
1316
+
1317
+ const result: HttpResult = await request(
1318
+ timelinePath({ teamId: teamSre.toString() }),
1319
+ );
1320
+
1321
+ expect(result.status).toBe(200);
1322
+ expect(json(result).schedules).toEqual([]);
1323
+ expect(scheduleFindBy).not.toHaveBeenCalled();
1324
+ expect(tryAcquireRenderSlot).not.toHaveBeenCalled();
1325
+ });
1326
+ });
1327
+
1328
+ describe("GET /on-call-schedule-timeline: the cap on schedules", () => {
1329
+ test("past the cap, only the first 250 are rendered and the total is counted", async () => {
1330
+ const rows: Array<{ id: ObjectID; name: string }> = [];
1331
+
1332
+ for (let index: number = 0; index < TIMELINE_MAX_SCHEDULES + 1; index++) {
1333
+ rows.push(scheduleRow(ObjectID.generate(), `Schedule ${index}`));
1334
+ }
1335
+
1336
+ scheduleFindBy.mockResolvedValue(rows);
1337
+ scheduleCountBy.mockResolvedValue(new PositiveNumber(412));
1338
+ ownerTeamFindBy.mockResolvedValue([]);
1339
+ loadSegments.mockResolvedValue([]);
1340
+
1341
+ const result: HttpResult = await request(timelinePath());
1342
+ const payload: ScheduleTimelineResponse = json(result);
1343
+
1344
+ expect(payload.schedules).toHaveLength(TIMELINE_MAX_SCHEDULES);
1345
+ expect(payload.totalScheduleCount).toBe(412);
1346
+ expect(payload.schedulesTruncated).toBe(true);
1347
+
1348
+ const countArgs: CapturedFind = captured(scheduleCountBy);
1349
+ expect(countArgs.props.isRoot).toBeFalsy();
1350
+ expect(countArgs.query["projectId"]?.toString()).toBe(projectId.toString());
1351
+
1352
+ expect((loadSchedules.mock.calls[0]?.[0] as Array<ObjectID>).length).toBe(
1353
+ TIMELINE_MAX_SCHEDULES,
1354
+ );
1355
+ });
1356
+
1357
+ test("under the cap nothing is counted", async () => {
1358
+ await request(timelinePath());
1359
+
1360
+ expect(scheduleCountBy).not.toHaveBeenCalled();
1361
+ });
1362
+ });
1363
+
1364
+ describe("GET /on-call-schedule-timeline: rendering", () => {
1365
+ test("the cache window is the served window widened to UTC days", async () => {
1366
+ await request(timelinePath());
1367
+
1368
+ const args: {
1369
+ schedules: Array<ScheduleInfo>;
1370
+ windowStart: Date;
1371
+ windowEnd: Date;
1372
+ now: Date;
1373
+ } = loadSegments.mock.calls[0]?.[0] as {
1374
+ schedules: Array<ScheduleInfo>;
1375
+ windowStart: Date;
1376
+ windowEnd: Date;
1377
+ now: Date;
1378
+ };
1379
+
1380
+ expect(args.windowStart.toISOString()).toBe("2026-09-14T00:00:00.000Z");
1381
+ expect(args.windowEnd.toISOString()).toBe("2026-09-22T00:00:00.000Z");
1382
+ expect(args.now.toISOString()).toBe(NOW.toISOString());
1383
+ expect(args.schedules).toHaveLength(2);
1384
+ });
1385
+
1386
+ test("render cap reached: 503 + Retry-After, nothing rendered, no slot released", async () => {
1387
+ tryAcquireRenderSlot.mockReturnValue(false);
1388
+
1389
+ const result: HttpResult = await request(timelinePath());
1390
+
1391
+ expect(result.status).toBe(503);
1392
+ expect(result.headers["retry-after"]).toBe(
1393
+ String(TIMELINE_RETRY_AFTER_SECONDS),
1394
+ );
1395
+ expect(loadSegments).not.toHaveBeenCalled();
1396
+ expect(releaseRenderSlot).not.toHaveBeenCalled();
1397
+ });
1398
+
1399
+ test("it leaves half the render slots for the public calendar feeds", async () => {
1400
+ await request(timelinePath());
1401
+
1402
+ const options: { leaveFreeSlots?: number } = tryAcquireRenderSlot.mock
1403
+ .calls[0]?.[0] as { leaveFreeSlots?: number };
1404
+
1405
+ expect(options.leaveFreeSlots).toBe(
1406
+ Math.floor(OnCallCalendarFeedCache.getRenderConcurrency() / 2),
1407
+ );
1408
+ });
1409
+
1410
+ test("the slot is released after a render", async () => {
1411
+ await request(timelinePath());
1412
+
1413
+ expect(tryAcquireRenderSlot).toHaveBeenCalledTimes(1);
1414
+ expect(releaseRenderSlot).toHaveBeenCalledTimes(1);
1415
+ });
1416
+
1417
+ test("the slot is released when the render throws", async () => {
1418
+ loadSegments.mockRejectedValue(new Error("layer exploded"));
1419
+
1420
+ const result: HttpResult = await request(timelinePath());
1421
+
1422
+ expect(result.status).toBe(500);
1423
+ expect(releaseRenderSlot).toHaveBeenCalledTimes(1);
1424
+ });
1425
+ });
1426
+
1427
+ describe("GET /on-call-schedule-timeline: the payload", () => {
1428
+ test("schedules, shifts, teams and flags", async () => {
1429
+ const result: HttpResult = await request(timelinePath());
1430
+
1431
+ expect(result.status).toBe(200);
1432
+
1433
+ const payload: ScheduleTimelineResponse = json(result);
1434
+
1435
+ expect(payload.from).toBe(WEEK_FROM);
1436
+ expect(payload.to).toBe(WEEK_TO);
1437
+ expect(payload.generatedAt).toBe(NOW.toISOString());
1438
+ expect(payload.truncated).toBe(true);
1439
+ expect(payload.totalScheduleCount).toBe(2);
1440
+ expect(payload.schedulesTruncated).toBe(false);
1441
+
1442
+ const [payments, sre]: Array<ScheduleTimelineScheduleJson> =
1443
+ payload.schedules;
1444
+
1445
+ expect(payments?.scheduleId).toBe(scheduleA.toString());
1446
+ expect(payments?.scheduleName).toBe("Payments primary");
1447
+ expect(payments?.scheduleTimezone).toBe("Europe/Stockholm");
1448
+ expect(payments?.ownerTeamIds).toEqual([teamPayments.toString()]);
1449
+ expect(payments?.isCurrentUserOnRoster).toBe(false);
1450
+
1451
+ // Out-of-window and policy-variant shifts are gone; the rest are sorted.
1452
+ expect(
1453
+ payments?.shifts.map((item: ScheduleTimelineShiftJson) => {
1454
+ return [item.userName, item.start, item.end, item.layerName];
1455
+ }),
1456
+ ).toEqual([
1457
+ [
1458
+ "Alice Andersson",
1459
+ "2026-09-09T09:00:00.000Z",
1460
+ "2026-09-16T09:00:00.000Z",
1461
+ "Weekly",
1462
+ ],
1463
+ [
1464
+ "Bob Berg",
1465
+ "2026-09-16T09:00:00.000Z",
1466
+ "2026-09-23T09:00:00.000Z",
1467
+ null,
1468
+ ],
1469
+ ]);
1470
+
1471
+ expect(sre?.scheduleTimezone).toBeNull();
1472
+ expect(sre?.truncated).toBe(true);
1473
+ expect(sre?.isCurrentUserOnRoster).toBe(true);
1474
+ expect(sre?.ownerTeamIds.sort()).toEqual(
1475
+ [teamSre.toString(), teamPayments.toString()].sort(),
1476
+ );
1477
+ expect(sre?.shifts).toEqual([]);
1478
+
1479
+ expect(payload.teams).toEqual([
1480
+ {
1481
+ teamId: teamPayments.toString(),
1482
+ teamName: "Payments",
1483
+ isCurrentUserMember: false,
1484
+ },
1485
+ {
1486
+ teamId: teamSre.toString(),
1487
+ teamName: "SRE",
1488
+ isCurrentUserMember: true,
1489
+ },
1490
+ ]);
1491
+ });
1492
+
1493
+ test("the payload never carries content hashes or policy lists", async () => {
1494
+ const result: HttpResult = await request(timelinePath());
1495
+ const raw: Record<string, unknown> = JSON.parse(result.body);
1496
+ const firstShift: Record<string, unknown> = (
1497
+ (raw["schedules"] as Array<Record<string, unknown>>)[0]?.[
1498
+ "shifts"
1499
+ ] as Array<Record<string, unknown>>
1500
+ )[0] as Record<string, unknown>;
1501
+
1502
+ expect(Object.keys(firstShift).sort()).toEqual([
1503
+ "end",
1504
+ "layerId",
1505
+ "layerName",
1506
+ "override",
1507
+ "shiftKey",
1508
+ "start",
1509
+ "userId",
1510
+ "userName",
1511
+ ]);
1512
+ });
1513
+ });