@oneuptime/common 12.0.29 → 12.0.31

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 (664) hide show
  1. package/Models/AnalyticsModels/SecurityEvent.ts +48 -16
  2. package/Models/AnalyticsModels/ThreatIntelIndicator.ts +10 -5
  3. package/Models/DatabaseModels/AlertSeverity.ts +44 -0
  4. package/Models/DatabaseModels/DetectionRule.ts +16 -5
  5. package/Models/DatabaseModels/GoogleSecOpsConnection.ts +14 -5
  6. package/Models/DatabaseModels/IncidentSeverity.ts +44 -0
  7. package/Models/DatabaseModels/Index.ts +13 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +113 -3
  9. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +88 -0
  10. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +22 -0
  11. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +499 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  14. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  15. package/Models/DatabaseModels/ThreatIntelFeed.ts +16 -5
  16. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  17. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  18. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  19. package/Server/API/GlobalConfigAPI.ts +362 -183
  20. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  21. package/Server/API/TelemetryAPI.ts +42 -4
  22. package/Server/EnvironmentConfig.ts +250 -0
  23. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.ts +59 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.ts +25 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  28. package/Server/Middleware/MultipartFormData.ts +118 -32
  29. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  30. package/Server/Services/AlertEpisodeLabelRuleEngineService.ts +9 -1
  31. package/Server/Services/AlertEpisodeOnCallRuleEngineService.ts +9 -1
  32. package/Server/Services/AlertEpisodeOwnerRuleEngineService.ts +9 -1
  33. package/Server/Services/AlertEpisodePrivacyRuleEngineService.ts +9 -1
  34. package/Server/Services/AlertGroupingEngineService.ts +9 -1
  35. package/Server/Services/AlertLabelRuleEngineService.ts +9 -1
  36. package/Server/Services/AlertOnCallRuleEngineService.ts +9 -1
  37. package/Server/Services/AlertOwnerRuleEngineService.ts +9 -1
  38. package/Server/Services/AlertPrivacyRuleEngineService.ts +9 -1
  39. package/Server/Services/AlertReminderRuleService.ts +9 -1
  40. package/Server/Services/AutoRemediationRuleEngineService.ts +9 -1
  41. package/Server/Services/CephClusterLabelRuleEngineService.ts +9 -1
  42. package/Server/Services/CephClusterOwnerRuleEngineService.ts +9 -1
  43. package/Server/Services/CloudResourceLabelRuleEngineService.ts +9 -1
  44. package/Server/Services/CloudResourceOwnerRuleEngineService.ts +9 -1
  45. package/Server/Services/DashboardLabelRuleEngineService.ts +9 -1
  46. package/Server/Services/DashboardOwnerRuleEngineService.ts +9 -1
  47. package/Server/Services/DockerHostLabelRuleEngineService.ts +9 -1
  48. package/Server/Services/DockerHostOwnerRuleEngineService.ts +9 -1
  49. package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +9 -1
  50. package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +9 -1
  51. package/Server/Services/HostLabelRuleEngineService.ts +9 -1
  52. package/Server/Services/HostOwnerRuleEngineService.ts +9 -1
  53. package/Server/Services/IncidentEpisodeLabelRuleEngineService.ts +9 -1
  54. package/Server/Services/IncidentEpisodeOnCallRuleEngineService.ts +9 -1
  55. package/Server/Services/IncidentEpisodeOwnerRuleEngineService.ts +9 -1
  56. package/Server/Services/IncidentEpisodePrivacyRuleEngineService.ts +9 -1
  57. package/Server/Services/IncidentGroupingEngineService.ts +9 -1
  58. package/Server/Services/IncidentLabelRuleEngineService.ts +9 -1
  59. package/Server/Services/IncidentOnCallRuleEngineService.ts +9 -1
  60. package/Server/Services/IncidentOwnerRuleEngineService.ts +9 -1
  61. package/Server/Services/IncidentPrivacyRuleEngineService.ts +9 -1
  62. package/Server/Services/IncidentReminderRuleService.ts +9 -1
  63. package/Server/Services/IncidentSlaRuleService.ts +9 -1
  64. package/Server/Services/IncomingCallPolicyLabelRuleEngineService.ts +9 -1
  65. package/Server/Services/IncomingCallPolicyOwnerRuleEngineService.ts +9 -1
  66. package/Server/Services/Index.ts +14 -0
  67. package/Server/Services/IoTFleetLabelRuleEngineService.ts +9 -1
  68. package/Server/Services/IoTFleetOwnerRuleEngineService.ts +9 -1
  69. package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +9 -1
  70. package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +9 -1
  71. package/Server/Services/KubernetesResourceService.ts +5 -1
  72. package/Server/Services/MonitorLabelRuleEngineService.ts +9 -1
  73. package/Server/Services/MonitorOwnerRuleEngineService.ts +9 -1
  74. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +35 -0
  75. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +96 -0
  76. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +9 -1
  77. package/Server/Services/NetworkDeviceOidTemplateService.ts +201 -0
  78. package/Server/Services/NetworkDeviceOwnerRuleEngineService.ts +9 -1
  79. package/Server/Services/NetworkDeviceService.ts +209 -1
  80. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  81. package/Server/Services/OnCallDutyPolicyLabelRuleEngineService.ts +9 -1
  82. package/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.ts +9 -1
  83. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  84. package/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.ts +9 -1
  85. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  86. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  87. package/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.ts +9 -1
  88. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  89. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  90. package/Server/Services/PodmanHostLabelRuleEngineService.ts +9 -1
  91. package/Server/Services/PodmanHostOwnerRuleEngineService.ts +9 -1
  92. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  93. package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +9 -1
  94. package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +9 -1
  95. package/Server/Services/RumApplicationLabelRuleEngineService.ts +9 -1
  96. package/Server/Services/RumApplicationOwnerRuleEngineService.ts +9 -1
  97. package/Server/Services/RunbookLabelRuleEngineService.ts +9 -1
  98. package/Server/Services/RunbookOwnerRuleEngineService.ts +9 -1
  99. package/Server/Services/RunbookRuleEngineService.ts +9 -1
  100. package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +9 -1
  101. package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +9 -1
  102. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +9 -1
  103. package/Server/Services/ServerlessFunctionLabelRuleEngineService.ts +9 -1
  104. package/Server/Services/ServerlessFunctionOwnerRuleEngineService.ts +9 -1
  105. package/Server/Services/ServiceLabelRuleEngineService.ts +9 -1
  106. package/Server/Services/ServiceOwnerRuleEngineService.ts +9 -1
  107. package/Server/Services/StatusPageLabelRuleEngineService.ts +9 -1
  108. package/Server/Services/StatusPageMonitorRuleEngineService.ts +7 -0
  109. package/Server/Services/StatusPageOwnerRuleEngineService.ts +9 -1
  110. package/Server/Services/TeamMemberService.ts +489 -1
  111. package/Server/Services/TeamService.ts +50 -0
  112. package/Server/Services/TelemetrySourceMapService.ts +266 -23
  113. package/Server/Services/UserNotificationSettingService.ts +44 -1
  114. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  115. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  116. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  117. package/Server/Services/UserService.ts +27 -0
  118. package/Server/Services/WorkflowLabelRuleEngineService.ts +9 -1
  119. package/Server/Services/WorkflowOwnerRuleEngineService.ts +9 -1
  120. package/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.ts +186 -0
  121. package/Server/Utils/Monitor/CapturedMetricAttributeUtil.ts +245 -0
  122. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +21 -2
  123. package/Server/Utils/Monitor/MonitorMetricUtil.ts +55 -21
  124. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +32 -0
  125. package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +15 -2
  126. package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +18 -1
  127. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +10 -0
  128. package/Server/Utils/Monitor/SeriesResourceLabels.ts +34 -0
  129. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  130. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  131. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  132. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  133. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  134. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  135. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  136. package/Server/Utils/Response.ts +134 -0
  137. package/Server/Utils/Rules/RuleEngineRuleRead.ts +48 -0
  138. package/Server/Utils/StartServer.ts +10 -0
  139. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  140. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +14 -1
  141. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +14 -2
  142. package/Server/Utils/Telemetry/SourceMapResolver.ts +16 -5
  143. package/Server/Utils/Telemetry/StackTraceParser.ts +399 -0
  144. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +125 -0
  145. package/Tests/App/Dashboard/HeaderImageAssets.test.tsx +93 -0
  146. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  147. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  148. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  149. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  150. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  151. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3 -3
  152. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  153. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  154. package/Tests/App/Dashboard/UserElementEmail.test.tsx +12 -4
  155. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +1 -1
  156. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  157. package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +14 -0
  158. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +469 -0
  159. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  160. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  161. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  162. package/Tests/Models/SecurityDomainAccessControl.test.ts +429 -0
  163. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  164. package/Tests/Server/API/GlobalConfigLicense.test.ts +657 -0
  165. package/Tests/Server/API/Helpers.ts +8 -0
  166. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  167. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +84 -6
  168. package/Tests/Server/API/TelemetryResolveStackTraceAPI.test.ts +805 -0
  169. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  170. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  171. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +394 -0
  172. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +1144 -0
  173. package/Tests/Server/Middleware/MultipartFormData.test.ts +618 -3
  174. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  175. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  176. package/Tests/Server/Services/MonitorLabelRuleEngineScale.test.ts +520 -0
  177. package/Tests/Server/Services/NetworkDeviceOidTemplateService.test.ts +332 -0
  178. package/Tests/Server/Services/NetworkDeviceOidTemplateWriteGuards.test.ts +409 -0
  179. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  180. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  181. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  182. package/Tests/Server/Services/RuleEngineRuleFetchLimit.test.ts +239 -0
  183. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  184. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  185. package/Tests/Server/Services/TelemetrySourceMapService.test.ts +1210 -2
  186. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  187. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  188. package/Tests/Server/Services/UserServiceSeatLimit.test.ts +182 -0
  189. package/Tests/Server/Types/AnalyticsDatabase/SecurityEventModelPermission.test.ts +255 -0
  190. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  191. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +212 -0
  192. package/Tests/Server/Types/Database/Permissions/SecurityRoleAccess.test.ts +413 -0
  193. package/Tests/Server/Utils/CodeRepository.test.ts +689 -0
  194. package/Tests/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.test.ts +495 -0
  195. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +77 -0
  196. package/Tests/Server/Utils/Monitor/CapturedMetricAttributeUtil.test.ts +284 -0
  197. package/Tests/Server/Utils/Monitor/MonitorCustomMetricAttributes.test.ts +503 -0
  198. package/Tests/Server/Utils/Monitor/MonitorTemplatePrototypePollution.test.ts +106 -0
  199. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +33 -4
  200. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +14 -4
  201. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +44 -0
  202. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  203. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  204. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  205. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  206. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  207. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  208. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  209. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  210. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  211. package/Tests/Server/Utils/OpenAPISpec.test.ts +536 -0
  212. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  213. package/Tests/Server/Utils/Rules/RuleEngineRuleRead.test.ts +115 -0
  214. package/Tests/Server/Utils/StackTraceParser.test.ts +610 -0
  215. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  216. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  217. package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
  218. package/Tests/Server/Utils/Telemetry/SourceMapLimits.test.ts +656 -0
  219. package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +100 -0
  220. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  221. package/Tests/Types/Monitor/SnmpMonitor/SnmpOidListUtil.test.ts +454 -0
  222. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  223. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  224. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  225. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  226. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  227. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  228. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  229. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  230. package/Tests/Types/SecurityRolePermissions.test.ts +193 -0
  231. package/Tests/UI/Components/Charts/AnnotationLayout.test.ts +773 -0
  232. package/Tests/UI/Components/Charts/ChartAnnotationRail.test.tsx +789 -0
  233. package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +254 -0
  234. package/Tests/UI/Components/Charts/TimeAnnotation.test.ts +471 -0
  235. package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +405 -0
  236. package/Tests/UI/Components/ErrorMessageRefreshControl.test.tsx +83 -0
  237. package/Tests/UI/Components/MarkdownConverters.test.ts +686 -0
  238. package/Tests/UI/Components/ModelTable/BaseModelTableDeleteAndEmptyStates.test.tsx +341 -0
  239. package/Tests/UI/Components/OrderedStatesList.test.tsx +5 -5
  240. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +1 -1
  241. package/Tests/UI/Components/TableSortAndMobileColumns.test.tsx +195 -0
  242. package/Tests/UI/Components/XAxis.test.ts +270 -5
  243. package/Tests/UI/Components/XAxisDaylightSaving.test.ts +113 -0
  244. package/Tests/UI/Styles/ChartFocusRing.test.ts +153 -0
  245. package/Tests/UI/Styles/ChartFocusRingCoverage.test.tsx +377 -0
  246. package/Tests/UI/Styles/ThemeStylesheet.ts +162 -0
  247. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  248. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  249. package/Tests/Utils/EnterpriseLicenseSeats.test.ts +507 -0
  250. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +951 -1
  251. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +238 -0
  252. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +1056 -0
  253. package/Tests/Utils/NetworkDiscovery/ReverseDnsNameUtil.test.ts +882 -0
  254. package/Types/Calendar/ICalendar.ts +442 -0
  255. package/Types/Date.ts +39 -0
  256. package/Types/Email/EmailTemplateType.ts +2 -0
  257. package/Types/Monitor/SnmpMonitor/SnmpOidListUtil.ts +333 -0
  258. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  259. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  260. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  261. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  262. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  263. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  264. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  265. package/Types/Permission.ts +96 -0
  266. package/Types/Rum/SessionReplay.ts +21 -0
  267. package/Types/Telemetry/SourceMap.ts +8 -0
  268. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.tsx +176 -0
  269. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.ts +745 -0
  270. package/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.tsx +576 -0
  271. package/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.tsx +204 -0
  272. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +491 -544
  273. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +324 -372
  274. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +487 -540
  275. package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +7 -3
  276. package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +14 -3
  277. package/UI/Components/Charts/Types/ChartEventKind.ts +13 -0
  278. package/UI/Components/Charts/Types/ReferenceRegionProps.ts +6 -0
  279. package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +20 -3
  280. package/UI/Components/Charts/Utils/TimeAnnotation.ts +69 -35
  281. package/UI/Components/Charts/Utils/XAxis.ts +125 -18
  282. package/UI/Components/EditionLabel/EditionLabel.tsx +280 -26
  283. package/UI/Components/ErrorMessage/ErrorMessage.tsx +21 -10
  284. package/UI/Components/Feed/FeedItem.tsx +4 -5
  285. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -2
  286. package/UI/Components/Header/UserProfile/UserProfile.tsx +1 -2
  287. package/UI/Components/Image/Image.tsx +20 -3
  288. package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
  289. package/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +1 -2
  290. package/UI/Components/ModelTable/BaseModelTable.tsx +150 -24
  291. package/UI/Components/Table/TableHeader.tsx +64 -48
  292. package/UI/Components/Table/TableRow.tsx +13 -3
  293. package/UI/Styles/Theme.css +38 -0
  294. package/Utils/EnterpriseLicense/EnterpriseLicenseSeats.ts +271 -0
  295. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +6 -0
  296. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +141 -10
  297. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +55 -3
  298. package/Utils/NetworkDiscovery/ReverseDnsNameUtil.ts +151 -0
  299. package/Utils/Rules/RuleEngineLimits.ts +32 -0
  300. package/build/dist/Models/AnalyticsModels/SecurityEvent.js +48 -16
  301. package/build/dist/Models/AnalyticsModels/SecurityEvent.js.map +1 -1
  302. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +10 -5
  303. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -1
  304. package/build/dist/Models/DatabaseModels/AlertSeverity.js +46 -1
  305. package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
  306. package/build/dist/Models/DatabaseModels/DetectionRule.js +16 -5
  307. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  308. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +14 -5
  309. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
  310. package/build/dist/Models/DatabaseModels/IncidentSeverity.js +46 -1
  311. package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
  312. package/build/dist/Models/DatabaseModels/Index.js +13 -0
  313. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  314. package/build/dist/Models/DatabaseModels/NetworkDevice.js +115 -3
  315. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  316. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +88 -0
  317. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
  318. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  319. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +519 -0
  320. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -0
  321. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  322. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  323. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  324. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  325. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  326. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  327. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js +16 -5
  328. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -1
  329. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  330. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  331. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  332. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  333. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  334. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  335. package/build/dist/Server/API/GlobalConfigAPI.js +293 -176
  336. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  337. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  338. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  339. package/build/dist/Server/API/TelemetryAPI.js +31 -3
  340. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  341. package/build/dist/Server/EnvironmentConfig.js +185 -0
  342. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  343. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  344. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  345. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  346. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  347. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js +26 -0
  348. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js.map +1 -0
  349. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js +14 -0
  350. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js.map +1 -0
  351. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  352. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  353. package/build/dist/Server/Middleware/MultipartFormData.js +93 -28
  354. package/build/dist/Server/Middleware/MultipartFormData.js.map +1 -1
  355. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  356. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  357. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js +8 -1
  358. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js.map +1 -1
  359. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js +8 -1
  360. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js.map +1 -1
  361. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js +8 -1
  362. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js.map +1 -1
  363. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js +8 -1
  364. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js.map +1 -1
  365. package/build/dist/Server/Services/AlertGroupingEngineService.js +8 -1
  366. package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
  367. package/build/dist/Server/Services/AlertLabelRuleEngineService.js +8 -1
  368. package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
  369. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js +8 -1
  370. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js.map +1 -1
  371. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +8 -1
  372. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
  373. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js +8 -1
  374. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js.map +1 -1
  375. package/build/dist/Server/Services/AlertReminderRuleService.js +8 -1
  376. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  377. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +8 -1
  378. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  379. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +8 -1
  380. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
  381. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +8 -1
  382. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
  383. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +8 -1
  384. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
  385. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +8 -1
  386. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
  387. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js +8 -1
  388. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js.map +1 -1
  389. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js +8 -1
  390. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js.map +1 -1
  391. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +8 -1
  392. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
  393. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +8 -1
  394. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
  395. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +8 -1
  396. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
  397. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +8 -1
  398. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
  399. package/build/dist/Server/Services/HostLabelRuleEngineService.js +8 -1
  400. package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
  401. package/build/dist/Server/Services/HostOwnerRuleEngineService.js +8 -1
  402. package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
  403. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js +8 -1
  404. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js.map +1 -1
  405. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js +8 -1
  406. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js.map +1 -1
  407. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js +8 -1
  408. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js.map +1 -1
  409. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js +8 -1
  410. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js.map +1 -1
  411. package/build/dist/Server/Services/IncidentGroupingEngineService.js +8 -1
  412. package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
  413. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +8 -1
  414. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
  415. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js +8 -1
  416. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js.map +1 -1
  417. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +8 -1
  418. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
  419. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js +8 -1
  420. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js.map +1 -1
  421. package/build/dist/Server/Services/IncidentReminderRuleService.js +8 -1
  422. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  423. package/build/dist/Server/Services/IncidentSlaRuleService.js +8 -1
  424. package/build/dist/Server/Services/IncidentSlaRuleService.js.map +1 -1
  425. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js +8 -1
  426. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js.map +1 -1
  427. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js +8 -1
  428. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js.map +1 -1
  429. package/build/dist/Server/Services/Index.js +14 -0
  430. package/build/dist/Server/Services/Index.js.map +1 -1
  431. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +8 -1
  432. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -1
  433. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +8 -1
  434. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -1
  435. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +8 -1
  436. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
  437. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +8 -1
  438. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
  439. package/build/dist/Server/Services/KubernetesResourceService.js +5 -1
  440. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  441. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js +8 -1
  442. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js.map +1 -1
  443. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js +8 -1
  444. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js.map +1 -1
  445. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +30 -21
  446. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  447. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +64 -0
  448. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
  449. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +8 -1
  450. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  451. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js +180 -0
  452. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js.map +1 -0
  453. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js +8 -1
  454. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js.map +1 -1
  455. package/build/dist/Server/Services/NetworkDeviceService.js +154 -1
  456. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  457. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  458. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  459. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js +8 -1
  460. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js.map +1 -1
  461. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js +8 -1
  462. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js.map +1 -1
  463. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  464. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  465. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js +8 -1
  466. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js.map +1 -1
  467. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  468. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  469. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  470. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  471. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js +8 -1
  472. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js.map +1 -1
  473. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  474. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  475. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  476. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  477. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js +8 -1
  478. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
  479. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +8 -1
  480. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js.map +1 -1
  481. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  482. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  483. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js +8 -1
  484. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
  485. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +8 -1
  486. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
  487. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js +8 -1
  488. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js.map +1 -1
  489. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js +8 -1
  490. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js.map +1 -1
  491. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js +8 -1
  492. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js.map +1 -1
  493. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js +8 -1
  494. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js.map +1 -1
  495. package/build/dist/Server/Services/RunbookRuleEngineService.js +8 -1
  496. package/build/dist/Server/Services/RunbookRuleEngineService.js.map +1 -1
  497. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +8 -1
  498. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
  499. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +8 -1
  500. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
  501. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +8 -1
  502. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  503. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js +8 -1
  504. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js.map +1 -1
  505. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js +8 -1
  506. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js.map +1 -1
  507. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +8 -1
  508. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
  509. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +8 -1
  510. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
  511. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js +8 -1
  512. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js.map +1 -1
  513. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +6 -0
  514. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -1
  515. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js +8 -1
  516. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js.map +1 -1
  517. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  518. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  519. package/build/dist/Server/Services/TeamService.js +43 -0
  520. package/build/dist/Server/Services/TeamService.js.map +1 -1
  521. package/build/dist/Server/Services/TelemetrySourceMapService.js +213 -19
  522. package/build/dist/Server/Services/TelemetrySourceMapService.js.map +1 -1
  523. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  524. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  525. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  526. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  527. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  528. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  529. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  530. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  531. package/build/dist/Server/Services/UserService.js +25 -0
  532. package/build/dist/Server/Services/UserService.js.map +1 -1
  533. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js +8 -1
  534. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js.map +1 -1
  535. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js +8 -1
  536. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js.map +1 -1
  537. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js +145 -0
  538. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js.map +1 -0
  539. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js +201 -0
  540. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js.map +1 -0
  541. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +19 -2
  542. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  543. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +42 -23
  544. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  545. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +29 -0
  546. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  547. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +11 -2
  548. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -1
  549. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +16 -1
  550. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
  551. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +10 -0
  552. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  553. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +33 -0
  554. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  555. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  556. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  557. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  558. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  559. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  560. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  561. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  562. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  563. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  564. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  565. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  566. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  567. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  568. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  569. package/build/dist/Server/Utils/Response.js +97 -0
  570. package/build/dist/Server/Utils/Response.js.map +1 -1
  571. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js +37 -0
  572. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js.map +1 -0
  573. package/build/dist/Server/Utils/StartServer.js +9 -1
  574. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  575. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  576. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  577. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +12 -1
  578. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -1
  579. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +14 -2
  580. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  581. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +15 -5
  582. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -1
  583. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +307 -0
  584. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js.map +1 -1
  585. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  586. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  587. package/build/dist/Types/Date.js +27 -0
  588. package/build/dist/Types/Date.js.map +1 -1
  589. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  590. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  591. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js +253 -0
  592. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js.map +1 -0
  593. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  594. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  595. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  596. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  597. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  598. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  599. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  600. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  601. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  602. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  603. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  604. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  605. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  606. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  607. package/build/dist/Types/Permission.js +86 -0
  608. package/build/dist/Types/Permission.js.map +1 -1
  609. package/build/dist/Types/Rum/SessionReplay.js +19 -0
  610. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  611. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js +83 -0
  612. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js.map +1 -0
  613. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js +531 -0
  614. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js.map +1 -0
  615. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js +264 -0
  616. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js.map +1 -0
  617. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js +122 -0
  618. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js.map +1 -0
  619. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +213 -233
  620. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  621. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +139 -152
  622. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  623. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +212 -232
  624. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  625. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js +14 -0
  626. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js.map +1 -0
  627. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +57 -28
  628. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
  629. package/build/dist/UI/Components/Charts/Utils/XAxis.js +103 -17
  630. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  631. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +198 -23
  632. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  633. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js +15 -5
  634. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js.map +1 -1
  635. package/build/dist/UI/Components/Feed/FeedItem.js +4 -5
  636. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  637. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -2
  638. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  639. package/build/dist/UI/Components/Header/UserProfile/UserProfile.js.map +1 -1
  640. package/build/dist/UI/Components/Image/Image.js +6 -1
  641. package/build/dist/UI/Components/Image/Image.js.map +1 -1
  642. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
  643. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
  644. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js +1 -2
  645. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js.map +1 -1
  646. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +104 -23
  647. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  648. package/build/dist/UI/Components/Table/TableHeader.js +20 -22
  649. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  650. package/build/dist/UI/Components/Table/TableRow.js +13 -4
  651. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  652. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js +153 -0
  653. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js.map +1 -0
  654. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
  655. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +127 -7
  656. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
  657. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +50 -3
  658. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  659. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js +132 -0
  660. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js.map +1 -0
  661. package/build/dist/Utils/Rules/RuleEngineLimits.js +31 -0
  662. package/build/dist/Utils/Rules/RuleEngineLimits.js.map +1 -0
  663. package/jest.config.json +1 -0
  664. package/package.json +2 -2
@@ -0,0 +1,4461 @@
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
+ * The HTTP face of the on-call calendar feeds.
14
+ *
15
+ * Two levels of test live here on purpose. The pure helpers (forwarded-proto
16
+ * resolution, the `?schedule=` filter, the client classifier, the bookkeeping
17
+ * throttle, the /my-shifts window, the FeedStatus builders) are tested as
18
+ * functions. Everything else goes through a REAL Express application built
19
+ * from the routes the module registered on the mock router, over an ephemeral
20
+ * HTTP server: the properties that matter most -- the one response rule (404
21
+ * vs empty VCALENDAR vs 503), the header set and the header that must be
22
+ * absent, 304 through req.fresh, HEAD being served without being counted,
23
+ * the http->https 301 -- are Express behaviours layered on the route's own,
24
+ * and a fake `res` object would only prove what the fake was written to do.
25
+ *
26
+ * The renderer, the rate-limit counter, the caches, the lock and every
27
+ * database service are stubbed at their public seams; what is under test is
28
+ * the route: what it looks up, what it never selects, what it hands the
29
+ * renderer, what it writes back, and what it never logs.
30
+ */
31
+
32
+ type EnvOverrides = {
33
+ disableFeed: boolean;
34
+ httpProtocol: string;
35
+ host: string;
36
+ trustedProxyHops: number;
37
+ billingEnabled: boolean;
38
+ provisionSsl: boolean;
39
+ };
40
+
41
+ type EnvMockGlobal = typeof globalThis & {
42
+ __oneuptimeOnCallCalendarApiEnv: EnvOverrides;
43
+ };
44
+
45
+ jest.mock("../../../Server/Utils/Express", () => {
46
+ return {
47
+ getRouter: () => {
48
+ return mockRouter;
49
+ },
50
+ };
51
+ });
52
+
53
+ jest.mock("../../../Server/EnvironmentConfig", () => {
54
+ const actual: Record<string, unknown> = jest.requireActual(
55
+ "../../../Server/EnvironmentConfig",
56
+ ) as Record<string, unknown>;
57
+ const mocked: Record<string, unknown> = { ...actual };
58
+ const mockGlobal: EnvMockGlobal = globalThis as EnvMockGlobal;
59
+
60
+ mockGlobal.__oneuptimeOnCallCalendarApiEnv = {
61
+ disableFeed: false,
62
+ httpProtocol: "https://",
63
+ host: "oneuptime.example.com",
64
+ trustedProxyHops: 0,
65
+ billingEnabled: false,
66
+ provisionSsl: true,
67
+ };
68
+
69
+ Object.defineProperty(mocked, "DisableOnCallCalendarFeed", {
70
+ configurable: true,
71
+ enumerable: true,
72
+ get: (): boolean => {
73
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.disableFeed;
74
+ },
75
+ });
76
+
77
+ Object.defineProperty(mocked, "HttpProtocol", {
78
+ configurable: true,
79
+ enumerable: true,
80
+ get: (): string => {
81
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.httpProtocol;
82
+ },
83
+ });
84
+
85
+ Object.defineProperty(mocked, "Host", {
86
+ configurable: true,
87
+ enumerable: true,
88
+ get: (): string => {
89
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.host;
90
+ },
91
+ });
92
+
93
+ Object.defineProperty(mocked, "TrustedProxyHops", {
94
+ configurable: true,
95
+ enumerable: true,
96
+ get: (): number => {
97
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.trustedProxyHops;
98
+ },
99
+ });
100
+
101
+ Object.defineProperty(mocked, "ProvisionSsl", {
102
+ configurable: true,
103
+ enumerable: true,
104
+ get: (): boolean => {
105
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.provisionSsl;
106
+ },
107
+ });
108
+
109
+ Object.defineProperty(mocked, "IsBillingEnabled", {
110
+ configurable: true,
111
+ enumerable: true,
112
+ get: (): boolean => {
113
+ return mockGlobal.__oneuptimeOnCallCalendarApiEnv.billingEnabled;
114
+ },
115
+ });
116
+
117
+ return mocked;
118
+ });
119
+
120
+ /*
121
+ * Logger is replaced wholesale (it drags in the telemetry SDK) with
122
+ * recorders, and getLogAttributesFromRequest keeps the real module's
123
+ * behaviour -- requestId, projectId and userId only, never the URL -- so the
124
+ * "no token in logs" assertions also cover the attributes every error log
125
+ * line carries.
126
+ */
127
+ jest.mock("../../../Server/Utils/Logger", () => {
128
+ return {
129
+ __esModule: true,
130
+ default: {
131
+ debug: jest.fn(),
132
+ info: jest.fn(),
133
+ warn: jest.fn(),
134
+ error: jest.fn(),
135
+ },
136
+ getLogAttributesFromRequest: (
137
+ req:
138
+ | {
139
+ requestId?: string;
140
+ tenantId?: { toString: () => string } | string;
141
+ userAuthorization?: { userId?: { toString: () => string } };
142
+ }
143
+ | null
144
+ | undefined,
145
+ ): Record<string, string> => {
146
+ const attributes: Record<string, string> = {};
147
+
148
+ if (!req) {
149
+ return attributes;
150
+ }
151
+
152
+ if (req.requestId) {
153
+ attributes["requestId"] = req.requestId;
154
+ }
155
+
156
+ if (req.tenantId) {
157
+ attributes["projectId"] = req.tenantId.toString();
158
+ }
159
+
160
+ if (req.userAuthorization?.userId) {
161
+ attributes["userId"] = req.userAuthorization.userId.toString();
162
+ }
163
+
164
+ return attributes;
165
+ },
166
+ };
167
+ });
168
+
169
+ jest.mock("../../../Server/Infrastructure/Redis", () => {
170
+ return {
171
+ __esModule: true,
172
+ default: {
173
+ getClient: jest.fn(() => {
174
+ return null;
175
+ }),
176
+ isConnected: jest.fn(() => {
177
+ return false;
178
+ }),
179
+ },
180
+ };
181
+ });
182
+
183
+ /*
184
+ * The session routes are registered with the real UserMiddleware functions;
185
+ * here they pass every request through and CommonAPI.getDatabaseCommonInteractionProps
186
+ * (spied below) decides who the caller is. The routes' own guards are what is
187
+ * under test, not the middleware.
188
+ */
189
+ jest.mock("../../../Server/Middleware/UserAuthorization", () => {
190
+ return {
191
+ __esModule: true,
192
+ default: {
193
+ getUserMiddleware: async (
194
+ _req: unknown,
195
+ _res: unknown,
196
+ next: () => void,
197
+ ): Promise<void> => {
198
+ next();
199
+ },
200
+ requireUserAuthentication: async (
201
+ _req: unknown,
202
+ _res: unknown,
203
+ next: () => void,
204
+ ): Promise<void> => {
205
+ next();
206
+ },
207
+ },
208
+ };
209
+ });
210
+
211
+ /*
212
+ * PasswordHash carries a pre-existing TS5.9 diagnostic that fails any suite
213
+ * whose runtime require graph reaches it, and the services below import
214
+ * DatabaseService, which does.
215
+ */
216
+ jest.mock("../../../Server/Utils/PasswordHash", () => {
217
+ return {
218
+ __esModule: true,
219
+ default: {
220
+ hash: jest.fn(),
221
+ verify: jest.fn(),
222
+ generateSalt: jest.fn(),
223
+ needsUpgrade: jest.fn(),
224
+ applyPepper: jest.fn(),
225
+ },
226
+ };
227
+ });
228
+
229
+ import {
230
+ BOOKKEEPING_INTERVAL_MS,
231
+ FEED_ALLOWED_METHODS,
232
+ FEED_CURRENT_ROUTE,
233
+ FEED_ROTATE_ROUTE,
234
+ FeedStatus,
235
+ KILL_SWITCH_RETRY_AFTER_SECONDS,
236
+ MY_SHIFTS_DEFAULT_DAYS,
237
+ MY_SHIFTS_MAX_DAYS,
238
+ MY_SHIFTS_ROUTE,
239
+ PERSONAL_FEED_FALLBACK_ROUTE,
240
+ PERSONAL_FEED_ROUTE,
241
+ PROJECT_FEED_CURRENT_ROUTE,
242
+ PROJECT_FEED_FALLBACK_ROUTE,
243
+ PROJECT_FEED_PUBLISH_ROUTE,
244
+ PROJECT_FEED_ROTATE_ROUTE,
245
+ PROJECT_FEED_ROUTE,
246
+ ROTATE_LOCK_NAMESPACE,
247
+ ROTATE_LOCK_TIMEOUT_MS,
248
+ SCHEDULE_FEED_CURRENT_ROUTE,
249
+ SCHEDULE_FEED_FALLBACK_ROUTE,
250
+ SCHEDULE_FEED_PUBLISH_ROUTE,
251
+ SCHEDULE_FEED_ROTATE_ROUTE,
252
+ SCHEDULE_FEED_ROUTE,
253
+ assertJsonRequest,
254
+ buildAbsentFeedStatus,
255
+ buildFeedStatus,
256
+ classifyCalendarClient,
257
+ isPreviousTokenInGrace,
258
+ readMyShiftsWindow,
259
+ readScheduleFilter,
260
+ resolveTrustedForwardedProto,
261
+ shouldRecordFetch,
262
+ } from "../../../Server/API/OnCallCalendarAPI";
263
+ import CommonAPI from "../../../Server/API/CommonAPI";
264
+ import OnCallCalendarFeedCache from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
265
+ import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
266
+ import Semaphore from "../../../Server/Infrastructure/Semaphore";
267
+ import OnCallCalendarFeedRateLimit, {
268
+ OnCallCalendarFeedRateLimitOutcome,
269
+ OnCallCalendarFeedRateLimitScope,
270
+ } from "../../../Server/Middleware/OnCallCalendarFeedRateLimit";
271
+ import OnCallDutyPolicyScheduleCalendarFeedService from "../../../Server/Services/OnCallDutyPolicyScheduleCalendarFeedService";
272
+ import ProjectOnCallCalendarFeedService from "../../../Server/Services/ProjectOnCallCalendarFeedService";
273
+ import UserOnCallCalendarFeedService from "../../../Server/Services/UserOnCallCalendarFeedService";
274
+ import {
275
+ ExpressRequest,
276
+ ExpressResponse,
277
+ NextFunction,
278
+ } from "../../../Server/Utils/Express";
279
+ import logger from "../../../Server/Utils/Logger";
280
+ import CalendarFeedToken, {
281
+ CalendarFeedRotation,
282
+ } from "../../../Server/Utils/OnCall/CalendarFeedToken";
283
+ import OnCallCalendarFeedRenderer, {
284
+ FEED_DISABLED_REASON,
285
+ FeedRenderOutcome,
286
+ FeedRenderStatus,
287
+ NO_SCHEDULES_REASON,
288
+ PLAN_REASON,
289
+ RENDER_CAP_RETRY_AFTER_SECONDS,
290
+ TOKEN_ROTATED_REASON,
291
+ } from "../../../Server/Utils/OnCall/OnCallCalendarFeedRenderer";
292
+ import OnCallCalendarFeedUrls, {
293
+ HOST_WARNING,
294
+ PROTOCOL_WARNING,
295
+ } from "../../../Server/Utils/OnCall/OnCallCalendarFeedUrls";
296
+ import Response from "../../../Server/Utils/Response";
297
+ import Protocol from "../../../Types/API/Protocol";
298
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
299
+ import OneUptimeDate from "../../../Types/Date";
300
+ import Dictionary from "../../../Types/Dictionary";
301
+ import BadDataException from "../../../Types/Exception/BadDataException";
302
+ import Exception from "../../../Types/Exception/Exception";
303
+ import ExceptionCode from "../../../Types/Exception/ExceptionCode";
304
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
305
+ import ObjectID from "../../../Types/ObjectID";
306
+ import {
307
+ DEFAULT_FUTURE_DAYS,
308
+ DEFAULT_PAST_DAYS,
309
+ MAX_FUTURE_DAYS,
310
+ MAX_PAST_DAYS,
311
+ } from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
312
+ import { MaterializedShift } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
313
+ import OnCallCalendarFeedUtil, {
314
+ FeedRenderResult,
315
+ OnCallCalendarFeedKind,
316
+ } from "../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
317
+ import Permission, {
318
+ UserPermission,
319
+ UserTenantAccessPermission,
320
+ } from "../../../Types/Permission";
321
+ import {
322
+ DASHBOARD_URL,
323
+ at,
324
+ shift,
325
+ } from "../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
326
+ import express from "express";
327
+ import fs from "fs";
328
+ import http from "http";
329
+ import { AddressInfo } from "net";
330
+ import path from "path";
331
+
332
+ // -- Harness ----------------------------------------------------------------
333
+
334
+ type RouteHandler = (
335
+ req: ExpressRequest,
336
+ res: ExpressResponse,
337
+ next: NextFunction,
338
+ ) => void | Promise<void>;
339
+
340
+ /*
341
+ * Helpers.ts keeps its Route type private; this is the slice of it this file
342
+ * uses, so a change to the harness's internals cannot silently retype these
343
+ * assertions.
344
+ */
345
+ interface RegisteredRoute {
346
+ method: string;
347
+ uri: string;
348
+ middlewares: Array<RouteHandler>;
349
+ handlerFunction: RouteHandler;
350
+ }
351
+
352
+ /* The app mounts every custom router under /<APP_NAME> = /api. */
353
+ const API_PREFIX: string = "/api";
354
+
355
+ const NOW: Date = at("2026-09-01T12:00:00Z");
356
+
357
+ /* A token that is valid in shape (43 chars) but belongs to nobody. */
358
+ const UNKNOWN_TOKEN: string = `${"unknown".repeat(6)}_`;
359
+
360
+ interface HttpResult {
361
+ status: number;
362
+ headers: http.IncomingHttpHeaders;
363
+ body: string;
364
+ }
365
+
366
+ interface CapturedFindOneBy {
367
+ query: Record<string, unknown>;
368
+ select?: Record<string, unknown> | undefined;
369
+ props?:
370
+ | { isRoot?: boolean | undefined; ignoreHooks?: boolean | undefined }
371
+ | undefined;
372
+ }
373
+
374
+ interface CapturedUpdate {
375
+ id?: ObjectID | undefined;
376
+ query?: Record<string, unknown> | undefined;
377
+ data: Record<string, unknown>;
378
+ props?:
379
+ | { isRoot?: boolean | undefined; ignoreHooks?: boolean | undefined }
380
+ | undefined;
381
+ }
382
+
383
+ interface CapturedCreate {
384
+ data: Record<string, unknown>;
385
+ props?:
386
+ | { isRoot?: boolean | undefined; ignoreHooks?: boolean | undefined }
387
+ | undefined;
388
+ }
389
+
390
+ /*
391
+ * A feed row as the three services hand one back: a plain object with the
392
+ * columns the routes read. The routes only ever read properties, so a model
393
+ * instance would add nothing but decorator noise.
394
+ */
395
+ interface FeedRowFixture {
396
+ id: ObjectID;
397
+ projectId: ObjectID;
398
+ userId?: ObjectID | undefined;
399
+ onCallDutyPolicyScheduleId?: ObjectID | undefined;
400
+ tokenHash: string;
401
+ previousTokenHash?: string | undefined;
402
+ previousTokenExpiresAt?: Date | undefined;
403
+ tokenHint?: string | undefined;
404
+ isEnabled: boolean;
405
+ includeCoveringShifts?: boolean | undefined;
406
+ includeCoverageGaps?: boolean | undefined;
407
+ minimumGapMinutes?: number | undefined;
408
+ rotateWhenMemberLeaves?: boolean | undefined;
409
+ pastDays: number;
410
+ futureDays: number;
411
+ lastFetchedAt?: Date | undefined;
412
+ lastFetchedClient?: string | undefined;
413
+ fetchCount: number;
414
+ lastRenderTruncated?: boolean | undefined;
415
+ rotatedAt?: Date | undefined;
416
+ }
417
+
418
+ function getEnv(): EnvOverrides {
419
+ return (globalThis as EnvMockGlobal).__oneuptimeOnCallCalendarApiEnv;
420
+ }
421
+
422
+ function setEnv(overrides: Partial<EnvOverrides>): void {
423
+ (globalThis as EnvMockGlobal).__oneuptimeOnCallCalendarApiEnv = {
424
+ ...getEnv(),
425
+ ...overrides,
426
+ };
427
+ }
428
+
429
+ function resetEnv(): void {
430
+ (globalThis as EnvMockGlobal).__oneuptimeOnCallCalendarApiEnv = {
431
+ disableFeed: false,
432
+ httpProtocol: Protocol.HTTPS,
433
+ host: "oneuptime.example.com",
434
+ trustedProxyHops: 0,
435
+ billingEnabled: false,
436
+ provisionSsl: true,
437
+ };
438
+ }
439
+
440
+ function registeredRoutes(): Array<RegisteredRoute> {
441
+ return mockRouter.routes as unknown as Array<RegisteredRoute>;
442
+ }
443
+
444
+ function routeFor(method: string, uri: string): RegisteredRoute {
445
+ const route: RegisteredRoute | undefined = registeredRoutes().find(
446
+ (candidate: RegisteredRoute): boolean => {
447
+ return candidate.method === method && candidate.uri === uri;
448
+ },
449
+ );
450
+
451
+ if (!route) {
452
+ throw new Error(`No ${method} route registered for ${uri}`);
453
+ }
454
+
455
+ return route;
456
+ }
457
+
458
+ function buildMemberProps(data: {
459
+ projectId: ObjectID | undefined;
460
+ userId: ObjectID | undefined;
461
+ }): DatabaseCommonInteractionProps {
462
+ const memberPermission: UserPermission = {
463
+ _type: "UserPermission",
464
+ permission: Permission.ProjectMember,
465
+ labelIds: [],
466
+ };
467
+
468
+ const permissionMap: Dictionary<UserTenantAccessPermission> = {};
469
+
470
+ if (data.projectId) {
471
+ const tenantPermission: UserTenantAccessPermission = {
472
+ _type: "UserTenantAccessPermission",
473
+ projectId: data.projectId,
474
+ permissions: [memberPermission],
475
+ };
476
+
477
+ permissionMap[data.projectId.toString()] = tenantPermission;
478
+ }
479
+
480
+ return {
481
+ tenantId: data.projectId,
482
+ userId: data.userId,
483
+ userTenantAccessPermission: permissionMap,
484
+ };
485
+ }
486
+
487
+ function personalRow(overrides?: Partial<FeedRowFixture>): FeedRowFixture {
488
+ return {
489
+ id: ObjectID.generate(),
490
+ projectId: ObjectID.generate(),
491
+ userId: ObjectID.generate(),
492
+ tokenHash: CalendarFeedToken.hash(CalendarFeedToken.mint()),
493
+ tokenHint: "k3Qx",
494
+ isEnabled: true,
495
+ includeCoveringShifts: true,
496
+ pastDays: DEFAULT_PAST_DAYS,
497
+ futureDays: DEFAULT_FUTURE_DAYS,
498
+ lastFetchedAt: undefined,
499
+ fetchCount: 0,
500
+ lastRenderTruncated: false,
501
+ rotatedAt: at("2026-08-01T00:00:00Z"),
502
+ ...overrides,
503
+ };
504
+ }
505
+
506
+ function scheduleRow(overrides?: Partial<FeedRowFixture>): FeedRowFixture {
507
+ return {
508
+ id: ObjectID.generate(),
509
+ projectId: ObjectID.generate(),
510
+ onCallDutyPolicyScheduleId: ObjectID.generate(),
511
+ tokenHash: CalendarFeedToken.hash(CalendarFeedToken.mint()),
512
+ tokenHint: "s4Rt",
513
+ isEnabled: true,
514
+ includeCoverageGaps: false,
515
+ minimumGapMinutes: 60,
516
+ rotateWhenMemberLeaves: false,
517
+ pastDays: DEFAULT_PAST_DAYS,
518
+ futureDays: DEFAULT_FUTURE_DAYS,
519
+ lastFetchedAt: undefined,
520
+ fetchCount: 0,
521
+ lastRenderTruncated: false,
522
+ rotatedAt: at("2026-08-01T00:00:00Z"),
523
+ ...overrides,
524
+ };
525
+ }
526
+
527
+ function projectRow(overrides?: Partial<FeedRowFixture>): FeedRowFixture {
528
+ return {
529
+ id: ObjectID.generate(),
530
+ projectId: ObjectID.generate(),
531
+ tokenHash: CalendarFeedToken.hash(CalendarFeedToken.mint()),
532
+ tokenHint: "p9Zz",
533
+ isEnabled: true,
534
+ includeCoverageGaps: true,
535
+ minimumGapMinutes: 30,
536
+ rotateWhenMemberLeaves: true,
537
+ pastDays: DEFAULT_PAST_DAYS,
538
+ futureDays: DEFAULT_FUTURE_DAYS,
539
+ lastFetchedAt: undefined,
540
+ fetchCount: 0,
541
+ lastRenderTruncated: false,
542
+ rotatedAt: at("2026-08-01T00:00:00Z"),
543
+ ...overrides,
544
+ };
545
+ }
546
+
547
+ /*
548
+ * TypeORM returns ONLY the selected columns; everything else on the entity
549
+ * stays undefined. The fake has to do the same, or a route that reads a
550
+ * column it never selected passes here and fails in production -- which is
551
+ * exactly what happened with tokenHash: no status read can select it (its
552
+ * read access list is empty, so the non-root schedule/project reads would be
553
+ * refused), and the status builder demanded it.
554
+ *
555
+ * `_id` in a select maps to the fixture's `id`, the way the model's `_id`
556
+ * maps to its id column.
557
+ */
558
+ function project(
559
+ row: FeedRowFixture,
560
+ select: Record<string, unknown> | undefined,
561
+ ): FeedRowFixture {
562
+ if (!select) {
563
+ return row;
564
+ }
565
+
566
+ const projected: Record<string, unknown> = {};
567
+
568
+ for (const [column, wanted] of Object.entries(select)) {
569
+ if (!wanted) {
570
+ continue;
571
+ }
572
+
573
+ const key: string = column === "_id" ? "id" : column;
574
+
575
+ projected[key] = (row as unknown as Record<string, unknown>)[key];
576
+ }
577
+
578
+ return projected as unknown as FeedRowFixture;
579
+ }
580
+
581
+ /*
582
+ * A findOneBy implementation over a fixed set of rows, answering the three
583
+ * query shapes the routes use: { tokenHash }, { previousTokenHash } (public
584
+ * lookups) and { projectId, userId | onCallDutyPolicyScheduleId } (session
585
+ * lookups). The row it hands back carries only the selected columns.
586
+ */
587
+ function lookupFrom(
588
+ rows: Array<FeedRowFixture>,
589
+ ): (args: CapturedFindOneBy) => Promise<FeedRowFixture | null> {
590
+ return async (args: CapturedFindOneBy): Promise<FeedRowFixture | null> => {
591
+ const query: Record<string, unknown> = args.query;
592
+
593
+ const found: FeedRowFixture | null = matchRow(rows, query);
594
+
595
+ return found ? project(found, args.select) : null;
596
+ };
597
+ }
598
+
599
+ function matchRow(
600
+ rows: Array<FeedRowFixture>,
601
+ query: Record<string, unknown>,
602
+ ): FeedRowFixture | null {
603
+ for (const row of rows) {
604
+ if (typeof query["tokenHash"] === "string") {
605
+ if (row.tokenHash === query["tokenHash"]) {
606
+ return row;
607
+ }
608
+
609
+ continue;
610
+ }
611
+
612
+ if (typeof query["previousTokenHash"] === "string") {
613
+ if (
614
+ row.previousTokenHash &&
615
+ row.previousTokenHash === query["previousTokenHash"]
616
+ ) {
617
+ return row;
618
+ }
619
+
620
+ continue;
621
+ }
622
+
623
+ if (
624
+ query["projectId"] &&
625
+ String(query["projectId"]) !== row.projectId.toString()
626
+ ) {
627
+ continue;
628
+ }
629
+
630
+ if (query["_id"] && String(query["_id"]) !== row.id.toString()) {
631
+ continue;
632
+ }
633
+
634
+ if (query["userId"]) {
635
+ if (row.userId && String(query["userId"]) === row.userId.toString()) {
636
+ return row;
637
+ }
638
+
639
+ continue;
640
+ }
641
+
642
+ if (query["onCallDutyPolicyScheduleId"]) {
643
+ if (
644
+ row.onCallDutyPolicyScheduleId &&
645
+ String(query["onCallDutyPolicyScheduleId"]) ===
646
+ row.onCallDutyPolicyScheduleId.toString()
647
+ ) {
648
+ return row;
649
+ }
650
+
651
+ continue;
652
+ }
653
+
654
+ if (query["projectId"] || query["_id"]) {
655
+ return row;
656
+ }
657
+ }
658
+
659
+ return null;
660
+ }
661
+
662
+ function renderedOutcome(
663
+ kind: OnCallCalendarFeedKind,
664
+ overrides?: Partial<FeedRenderOutcome>,
665
+ ): FeedRenderOutcome {
666
+ const rendered: FeedRenderResult = OnCallCalendarFeedUtil.render({
667
+ kind,
668
+ shifts: [
669
+ shift({
670
+ start: at("2026-09-02T07:00:00Z"),
671
+ end: at("2026-09-02T15:00:00Z"),
672
+ }),
673
+ ],
674
+ dashboardUrl: DASHBOARD_URL,
675
+ });
676
+
677
+ return {
678
+ status: FeedRenderStatus.Rendered,
679
+ kind,
680
+ body: rendered.body,
681
+ etag: Response.getCalendarETag(rendered.body),
682
+ lastModified: rendered.lastModifiedAt || NOW,
683
+ stale: false,
684
+ truncated: false,
685
+ eventCount: rendered.eventCount,
686
+ cacheHit: false,
687
+ reason: null,
688
+ retryAfterSeconds: null,
689
+ ...overrides,
690
+ };
691
+ }
692
+
693
+ function callsOf<T>(spy: jest.SpyInstance): Array<T> {
694
+ return spy.mock.calls.map((args: Array<unknown>): T => {
695
+ return args[0] as T;
696
+ });
697
+ }
698
+
699
+ function everyLogArgument(): Array<string> {
700
+ const recorders: Array<jest.Mock> = [
701
+ logger.debug as unknown as jest.Mock,
702
+ logger.info as unknown as jest.Mock,
703
+ logger.warn as unknown as jest.Mock,
704
+ logger.error as unknown as jest.Mock,
705
+ ];
706
+
707
+ const rendered: Array<string> = [];
708
+
709
+ for (const recorder of recorders) {
710
+ for (const call of recorder.mock.calls) {
711
+ for (const argument of call) {
712
+ rendered.push(String(argument));
713
+
714
+ if (argument instanceof Error) {
715
+ rendered.push(argument.message);
716
+ rendered.push(argument.stack || "");
717
+ }
718
+
719
+ try {
720
+ rendered.push(JSON.stringify(argument) || "");
721
+ } catch {
722
+ // circular; String() above is enough
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ return rendered;
729
+ }
730
+
731
+ function flushBackgroundWork(): Promise<void> {
732
+ return new Promise<void>((resolve: () => void) => {
733
+ setTimeout(resolve, 0);
734
+ });
735
+ }
736
+
737
+ // -- Spies ------------------------------------------------------------------
738
+
739
+ let propsSpy: jest.SpyInstance;
740
+ let nowSpy: jest.SpyInstance;
741
+ let consumeSpy: jest.SpyInstance;
742
+ let renderSpy: jest.SpyInstance;
743
+ let materializeSpy: jest.SpyInstance;
744
+
745
+ let personalFindOneBy: jest.SpyInstance;
746
+ let personalFindOneById: jest.SpyInstance;
747
+ let personalUpdateOneById: jest.SpyInstance;
748
+ let personalCreateForUser: jest.SpyInstance;
749
+ let personalRotate: jest.SpyInstance;
750
+
751
+ let scheduleFindOneBy: jest.SpyInstance;
752
+ let scheduleFindOneById: jest.SpyInstance;
753
+ let scheduleUpdateOneById: jest.SpyInstance;
754
+ let scheduleUpdateOneBy: jest.SpyInstance;
755
+ let scheduleCreate: jest.SpyInstance;
756
+ let scheduleRotate: jest.SpyInstance;
757
+
758
+ let projectFindOneBy: jest.SpyInstance;
759
+ let projectFindOneById: jest.SpyInstance;
760
+ let projectUpdateOneById: jest.SpyInstance;
761
+ let projectUpdateOneBy: jest.SpyInstance;
762
+ let projectCreate: jest.SpyInstance;
763
+ let projectRotate: jest.SpyInstance;
764
+
765
+ let purgeForUser: jest.SpyInstance;
766
+ let purgeForSchedule: jest.SpyInstance;
767
+ let purgeForProject: jest.SpyInstance;
768
+ let tryAcquireRenderSlot: jest.SpyInstance;
769
+ let releaseRenderSlot: jest.SpyInstance;
770
+
771
+ let scheduleModelFindOneBy: jest.SpyInstance;
772
+ let semaphoreLock: jest.SpyInstance;
773
+ let semaphoreRelease: jest.SpyInstance;
774
+
775
+ let projectId: ObjectID;
776
+ let userId: ObjectID;
777
+
778
+ // -- Server -----------------------------------------------------------------
779
+
780
+ let server: http.Server;
781
+ let baseUrl: string;
782
+
783
+ function request(
784
+ requestPath: string,
785
+ init?: { method?: string; headers?: Record<string, string> },
786
+ ): Promise<HttpResult> {
787
+ return new Promise<HttpResult>(
788
+ (resolve: (result: HttpResult) => void, reject: (error: Error) => void) => {
789
+ const clientRequest: http.ClientRequest = http.request(
790
+ `${baseUrl}${requestPath}`,
791
+ { method: init?.method || "GET", headers: init?.headers || {} },
792
+ (response: http.IncomingMessage) => {
793
+ const chunks: Array<Buffer> = [];
794
+
795
+ response.on("data", (chunk: Buffer) => {
796
+ chunks.push(chunk);
797
+ });
798
+ response.on("end", () => {
799
+ resolve({
800
+ status: response.statusCode || 0,
801
+ headers: response.headers,
802
+ body: Buffer.concat(chunks).toString("utf8"),
803
+ });
804
+ });
805
+ response.on("error", reject);
806
+ },
807
+ );
808
+
809
+ clientRequest.on("error", reject);
810
+ clientRequest.end();
811
+ },
812
+ );
813
+ }
814
+
815
+ function header(result: HttpResult, name: string): string | undefined {
816
+ const value: string | Array<string> | undefined =
817
+ result.headers[name.toLowerCase()];
818
+
819
+ if (value === undefined) {
820
+ return undefined;
821
+ }
822
+
823
+ return Array.isArray(value) ? value.join(", ") : value;
824
+ }
825
+
826
+ function json(result: HttpResult): Record<string, unknown> {
827
+ return JSON.parse(result.body) as Record<string, unknown>;
828
+ }
829
+
830
+ function feedPath(kind: OnCallCalendarFeedKind, token: string): string {
831
+ return OnCallCalendarFeedUrls.getFeedPath(kind, token);
832
+ }
833
+
834
+ function postJson(
835
+ requestPath: string,
836
+ headers?: Record<string, string>,
837
+ ): Promise<HttpResult> {
838
+ return request(requestPath, {
839
+ method: "POST",
840
+ headers: { "Content-Type": "application/json", ...(headers || {}) },
841
+ });
842
+ }
843
+
844
+ beforeAll(async () => {
845
+ const app: express.Express = express();
846
+
847
+ /*
848
+ * The module registered its routes on mockRouter when it was imported;
849
+ * replaying them onto a real application gives every test Express's own
850
+ * routing, conditional-request and HEAD semantics.
851
+ */
852
+ for (const route of registeredRoutes()) {
853
+ const chain: Array<express.RequestHandler> = [
854
+ ...route.middlewares,
855
+ route.handlerFunction,
856
+ ] as unknown as Array<express.RequestHandler>;
857
+
858
+ const fullPath: string = `${API_PREFIX}${route.uri}`;
859
+
860
+ if (route.method === "GET") {
861
+ app.get(fullPath, ...chain);
862
+ } else if (route.method === "POST") {
863
+ app.post(fullPath, ...chain);
864
+ } else if (route.method === "ALL") {
865
+ app.all(fullPath, ...chain);
866
+ }
867
+ }
868
+
869
+ /* The app's error middleware, reduced to what the routes rely on. */
870
+ app.use(
871
+ (
872
+ err: unknown,
873
+ _req: express.Request,
874
+ res: express.Response,
875
+ _next: express.NextFunction,
876
+ ): void => {
877
+ const code: number = (err as Exception).code as number;
878
+ const status: number =
879
+ Number.isInteger(code) && code >= 400 && code <= 599 ? code : 500;
880
+
881
+ res.status(status).send({ message: (err as Error).message });
882
+ },
883
+ );
884
+
885
+ server = http.createServer(app);
886
+
887
+ await new Promise<void>((resolve: () => void) => {
888
+ server.listen(0, "127.0.0.1", resolve);
889
+ });
890
+
891
+ const address: AddressInfo = server.address() as AddressInfo;
892
+ baseUrl = `http://127.0.0.1:${address.port}`;
893
+ });
894
+
895
+ afterAll(async () => {
896
+ await new Promise<void>((resolve: () => void) => {
897
+ server.close(() => {
898
+ resolve();
899
+ });
900
+ });
901
+ });
902
+
903
+ beforeEach(() => {
904
+ jest.clearAllMocks();
905
+ resetEnv();
906
+
907
+ projectId = ObjectID.generate();
908
+ userId = ObjectID.generate();
909
+
910
+ nowSpy = jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(NOW);
911
+
912
+ propsSpy = jest
913
+ .spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
914
+ .mockResolvedValue(buildMemberProps({ projectId, userId }));
915
+
916
+ consumeSpy = jest
917
+ .spyOn(OnCallCalendarFeedRateLimit, "consume")
918
+ .mockResolvedValue({
919
+ outcome: OnCallCalendarFeedRateLimitOutcome.Allowed,
920
+ });
921
+
922
+ renderSpy = jest.spyOn(OnCallCalendarFeedRenderer, "render");
923
+ materializeSpy = jest.spyOn(
924
+ OnCallCalendarFeedRenderer,
925
+ "materializeUserShifts",
926
+ );
927
+
928
+ personalFindOneBy = jest
929
+ .spyOn(UserOnCallCalendarFeedService, "findOneBy")
930
+ .mockResolvedValue(null);
931
+ personalFindOneById = jest
932
+ .spyOn(UserOnCallCalendarFeedService, "findOneById")
933
+ .mockResolvedValue(null);
934
+ personalUpdateOneById = jest
935
+ .spyOn(UserOnCallCalendarFeedService, "updateOneById")
936
+ .mockResolvedValue(undefined as never);
937
+ personalCreateForUser = jest.spyOn(
938
+ UserOnCallCalendarFeedService,
939
+ "createForUser",
940
+ );
941
+ personalRotate = jest.spyOn(UserOnCallCalendarFeedService, "rotateTokenById");
942
+
943
+ scheduleFindOneBy = jest
944
+ .spyOn(OnCallDutyPolicyScheduleCalendarFeedService, "findOneBy")
945
+ .mockResolvedValue(null);
946
+ scheduleFindOneById = jest
947
+ .spyOn(OnCallDutyPolicyScheduleCalendarFeedService, "findOneById")
948
+ .mockResolvedValue(null);
949
+ scheduleUpdateOneById = jest
950
+ .spyOn(OnCallDutyPolicyScheduleCalendarFeedService, "updateOneById")
951
+ .mockResolvedValue(undefined as never);
952
+ scheduleUpdateOneBy = jest
953
+ .spyOn(OnCallDutyPolicyScheduleCalendarFeedService, "updateOneBy")
954
+ .mockResolvedValue(undefined as never);
955
+ scheduleCreate = jest
956
+ .spyOn(OnCallDutyPolicyScheduleCalendarFeedService, "create")
957
+ .mockResolvedValue(undefined as never);
958
+ scheduleRotate = jest.spyOn(
959
+ OnCallDutyPolicyScheduleCalendarFeedService,
960
+ "rotateTokenById",
961
+ );
962
+
963
+ projectFindOneBy = jest
964
+ .spyOn(ProjectOnCallCalendarFeedService, "findOneBy")
965
+ .mockResolvedValue(null);
966
+ projectFindOneById = jest
967
+ .spyOn(ProjectOnCallCalendarFeedService, "findOneById")
968
+ .mockResolvedValue(null);
969
+ projectUpdateOneById = jest
970
+ .spyOn(ProjectOnCallCalendarFeedService, "updateOneById")
971
+ .mockResolvedValue(undefined as never);
972
+ projectUpdateOneBy = jest
973
+ .spyOn(ProjectOnCallCalendarFeedService, "updateOneBy")
974
+ .mockResolvedValue(undefined as never);
975
+ projectCreate = jest
976
+ .spyOn(ProjectOnCallCalendarFeedService, "create")
977
+ .mockResolvedValue(undefined as never);
978
+ projectRotate = jest.spyOn(
979
+ ProjectOnCallCalendarFeedService,
980
+ "rotateTokenById",
981
+ );
982
+
983
+ /*
984
+ * The schedule itself, read with the CALLER's props before a shared feed is
985
+ * published: that read is what applies the label scoping. The default here
986
+ * is "the caller may see it".
987
+ */
988
+ scheduleModelFindOneBy = jest
989
+ .spyOn(OnCallDutyPolicyScheduleService, "findOneBy")
990
+ .mockResolvedValue({ id: ObjectID.generate() } as never);
991
+
992
+ purgeForUser = jest
993
+ .spyOn(OnCallCalendarFeedCache, "purgeForUser")
994
+ .mockResolvedValue(undefined);
995
+ purgeForSchedule = jest
996
+ .spyOn(OnCallCalendarFeedCache, "purgeForSchedule")
997
+ .mockResolvedValue(undefined);
998
+ purgeForProject = jest
999
+ .spyOn(OnCallCalendarFeedCache, "purgeForProject")
1000
+ .mockResolvedValue(undefined);
1001
+ tryAcquireRenderSlot = jest
1002
+ .spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
1003
+ .mockReturnValue(true);
1004
+ releaseRenderSlot = jest
1005
+ .spyOn(OnCallCalendarFeedCache, "releaseRenderSlot")
1006
+ .mockReturnValue(undefined);
1007
+
1008
+ semaphoreLock = jest
1009
+ .spyOn(Semaphore, "lock")
1010
+ .mockResolvedValue({ release: jest.fn() } as never);
1011
+ semaphoreRelease = jest
1012
+ .spyOn(Semaphore, "release")
1013
+ .mockResolvedValue(undefined as never);
1014
+ });
1015
+
1016
+ afterEach(() => {
1017
+ jest.restoreAllMocks();
1018
+ });
1019
+
1020
+ // -- Registration -----------------------------------------------------------
1021
+
1022
+ describe("OnCallCalendarAPI: route registration", () => {
1023
+ test("registers the three public capability routes as GET with kill switch, scheme guard and rate limiter in that order", () => {
1024
+ for (const uri of [
1025
+ PERSONAL_FEED_ROUTE,
1026
+ SCHEDULE_FEED_ROUTE,
1027
+ PROJECT_FEED_ROUTE,
1028
+ ]) {
1029
+ const route: RegisteredRoute = routeFor("GET", uri);
1030
+
1031
+ expect(route.middlewares).toHaveLength(3);
1032
+ expect(route.middlewares[0]?.name).toBe("killSwitchMiddleware");
1033
+ expect(route.middlewares[1]?.name).toBe("schemeGuardMiddleware");
1034
+ expect(typeof route.middlewares[2]).toBe("function");
1035
+ }
1036
+ });
1037
+
1038
+ test("the public routes live under exactly the three path prefixes the Nginx access-log exemption matches", () => {
1039
+ const nginxLocation: RegExp =
1040
+ /^\/api\/on-call-calendar\/(user|schedule|project)\//;
1041
+
1042
+ expect(`${API_PREFIX}${PERSONAL_FEED_ROUTE}`).toMatch(nginxLocation);
1043
+ expect(`${API_PREFIX}${SCHEDULE_FEED_ROUTE}`).toMatch(nginxLocation);
1044
+ expect(`${API_PREFIX}${PROJECT_FEED_ROUTE}`).toMatch(nginxLocation);
1045
+
1046
+ for (const uri of [
1047
+ FEED_CURRENT_ROUTE,
1048
+ FEED_ROTATE_ROUTE,
1049
+ SCHEDULE_FEED_CURRENT_ROUTE,
1050
+ SCHEDULE_FEED_PUBLISH_ROUTE,
1051
+ SCHEDULE_FEED_ROTATE_ROUTE,
1052
+ PROJECT_FEED_CURRENT_ROUTE,
1053
+ PROJECT_FEED_PUBLISH_ROUTE,
1054
+ PROJECT_FEED_ROTATE_ROUTE,
1055
+ MY_SHIFTS_ROUTE,
1056
+ ]) {
1057
+ expect(`${API_PREFIX}${uri}`).not.toMatch(nginxLocation);
1058
+ }
1059
+ });
1060
+
1061
+ test("the public route templates are the URL builder's paths with :token in place of the token", () => {
1062
+ /* The builder percent-encodes the token, so the placeholder is decoded. */
1063
+ expect(PERSONAL_FEED_ROUTE).toBe(
1064
+ decodeURIComponent(
1065
+ OnCallCalendarFeedUrls.getFeedRoutePath(
1066
+ OnCallCalendarFeedKind.Personal,
1067
+ ":token",
1068
+ ),
1069
+ ),
1070
+ );
1071
+ expect(SCHEDULE_FEED_ROUTE).toBe(
1072
+ decodeURIComponent(
1073
+ OnCallCalendarFeedUrls.getFeedRoutePath(
1074
+ OnCallCalendarFeedKind.Schedule,
1075
+ ":token",
1076
+ ),
1077
+ ),
1078
+ );
1079
+ expect(PROJECT_FEED_ROUTE).toBe(
1080
+ decodeURIComponent(
1081
+ OnCallCalendarFeedUrls.getFeedRoutePath(
1082
+ OnCallCalendarFeedKind.Project,
1083
+ ":token",
1084
+ ),
1085
+ ),
1086
+ );
1087
+ });
1088
+
1089
+ test("registers the session routes with the user middleware pair", () => {
1090
+ const expected: Array<[string, string]> = [
1091
+ ["GET", FEED_CURRENT_ROUTE],
1092
+ ["POST", FEED_ROTATE_ROUTE],
1093
+ ["GET", SCHEDULE_FEED_CURRENT_ROUTE],
1094
+ ["POST", SCHEDULE_FEED_PUBLISH_ROUTE],
1095
+ ["POST", SCHEDULE_FEED_ROTATE_ROUTE],
1096
+ ["GET", PROJECT_FEED_CURRENT_ROUTE],
1097
+ ["POST", PROJECT_FEED_PUBLISH_ROUTE],
1098
+ ["POST", PROJECT_FEED_ROTATE_ROUTE],
1099
+ ["GET", MY_SHIFTS_ROUTE],
1100
+ ];
1101
+
1102
+ for (const [method, uri] of expected) {
1103
+ const route: RegisteredRoute = routeFor(method, uri);
1104
+
1105
+ expect(route.middlewares).toHaveLength(2);
1106
+ expect(route.uri.startsWith("/on-call-calendar/")).toBe(true);
1107
+ }
1108
+ });
1109
+
1110
+ test("registers no route the spec does not name", () => {
1111
+ const uris: Array<string> = registeredRoutes()
1112
+ .filter((route: RegisteredRoute): boolean => {
1113
+ return route.uri.startsWith("/on-call-calendar");
1114
+ })
1115
+ .map((route: RegisteredRoute): string => {
1116
+ return `${route.method} ${route.uri}`;
1117
+ })
1118
+ .sort();
1119
+
1120
+ expect(uris).toEqual(
1121
+ [
1122
+ `GET ${PERSONAL_FEED_ROUTE}`,
1123
+ `GET ${SCHEDULE_FEED_ROUTE}`,
1124
+ `GET ${PROJECT_FEED_ROUTE}`,
1125
+ `GET ${FEED_CURRENT_ROUTE}`,
1126
+ `POST ${FEED_ROTATE_ROUTE}`,
1127
+ `GET ${SCHEDULE_FEED_CURRENT_ROUTE}`,
1128
+ `POST ${SCHEDULE_FEED_PUBLISH_ROUTE}`,
1129
+ `POST ${SCHEDULE_FEED_ROTATE_ROUTE}`,
1130
+ `GET ${PROJECT_FEED_CURRENT_ROUTE}`,
1131
+ `POST ${PROJECT_FEED_PUBLISH_ROUTE}`,
1132
+ `POST ${PROJECT_FEED_ROTATE_ROUTE}`,
1133
+ `GET ${MY_SHIFTS_ROUTE}`,
1134
+ `ALL ${PERSONAL_FEED_FALLBACK_ROUTE}`,
1135
+ `ALL ${SCHEDULE_FEED_FALLBACK_ROUTE}`,
1136
+ `ALL ${PROJECT_FEED_FALLBACK_ROUTE}`,
1137
+ ].sort(),
1138
+ );
1139
+ });
1140
+
1141
+ /*
1142
+ * The fallbacks must come AFTER the three exact GET routes (Express matches
1143
+ * in registration order) and they must cover every method.
1144
+ */
1145
+ test("registers the token-path fallbacks after the routes they back up", () => {
1146
+ const order: Array<string> = registeredRoutes().map(
1147
+ (route: RegisteredRoute): string => {
1148
+ return `${route.method} ${route.uri}`;
1149
+ },
1150
+ );
1151
+
1152
+ for (const [exact, fallback] of [
1153
+ [PERSONAL_FEED_ROUTE, PERSONAL_FEED_FALLBACK_ROUTE],
1154
+ [SCHEDULE_FEED_ROUTE, SCHEDULE_FEED_FALLBACK_ROUTE],
1155
+ [PROJECT_FEED_ROUTE, PROJECT_FEED_FALLBACK_ROUTE],
1156
+ ]) {
1157
+ expect(order.indexOf(`GET ${exact}`)).toBeGreaterThanOrEqual(0);
1158
+ expect(order.indexOf(`ALL ${fallback}`)).toBeGreaterThan(
1159
+ order.indexOf(`GET ${exact}`),
1160
+ );
1161
+ }
1162
+ });
1163
+
1164
+ test("App/FeatureSet/BaseAPI/Index.ts mounts the router and the generic CRUD of the five models", () => {
1165
+ const indexPath: string = path.join(
1166
+ __dirname,
1167
+ "..",
1168
+ "..",
1169
+ "..",
1170
+ "..",
1171
+ "App",
1172
+ "FeatureSet",
1173
+ "BaseAPI",
1174
+ "Index.ts",
1175
+ );
1176
+
1177
+ const source: string = fs.readFileSync(indexPath, "utf8");
1178
+
1179
+ expect(source).toContain(
1180
+ 'import OnCallCalendarAPI from "Common/Server/API/OnCallCalendarAPI";',
1181
+ );
1182
+ expect(source).toContain(
1183
+ "app.use(`/${APP_NAME.toLocaleLowerCase()}`, OnCallCalendarAPI);",
1184
+ );
1185
+
1186
+ const models: Array<string> = [
1187
+ "UserOnCallCalendarFeed",
1188
+ "OnCallDutyPolicyScheduleCalendarFeed",
1189
+ "ProjectOnCallCalendarFeed",
1190
+ "UserOnCallShiftReminder",
1191
+ "UserOnCallShiftReminderLog",
1192
+ ];
1193
+
1194
+ for (const model of models) {
1195
+ expect(source).toContain(
1196
+ `import ${model} from "Common/Models/DatabaseModels/${model}";`,
1197
+ );
1198
+ expect(source).toContain(
1199
+ `import ${model}Service, {\n Service as ${model}ServiceType,\n} from "Common/Server/Services/${model}Service";`,
1200
+ );
1201
+
1202
+ /*
1203
+ * One BaseAPI mount per model. Prettier wraps the generic differently
1204
+ * per name length, so match on the constructor arguments, which are
1205
+ * always `(Model, ModelService)`, optionally split across lines.
1206
+ */
1207
+ const mount: RegExp = new RegExp(
1208
+ `new BaseAPI<\\s*${model},\\s*${model}ServiceType\\s*>\\(\\s*${model},\\s*${model}Service,?\\s*\\)\\.getRouter\\(\\)`,
1209
+ );
1210
+
1211
+ expect(source).toMatch(mount);
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ // -- Pure helpers -----------------------------------------------------------
1217
+
1218
+ describe("resolveTrustedForwardedProto", () => {
1219
+ function requestWith(
1220
+ value: string | Array<string> | undefined,
1221
+ ): ExpressRequest {
1222
+ return {
1223
+ headers: value === undefined ? {} : { "x-forwarded-proto": value },
1224
+ } as unknown as ExpressRequest;
1225
+ }
1226
+
1227
+ test("with no trusted proxy hops there is no trustworthy header, so undefined", () => {
1228
+ expect(
1229
+ resolveTrustedForwardedProto(requestWith("http"), {
1230
+ trustedProxyHops: 0,
1231
+ }),
1232
+ ).toBeUndefined();
1233
+ });
1234
+
1235
+ test("one hop reads the rightmost entry (the one our proxy wrote)", () => {
1236
+ expect(
1237
+ resolveTrustedForwardedProto(requestWith("https, http"), {
1238
+ trustedProxyHops: 1,
1239
+ }),
1240
+ ).toBe("http");
1241
+ });
1242
+
1243
+ test("two hops reads the second entry from the right", () => {
1244
+ expect(
1245
+ resolveTrustedForwardedProto(requestWith("http, https, http"), {
1246
+ trustedProxyHops: 2,
1247
+ }),
1248
+ ).toBe("https");
1249
+ });
1250
+
1251
+ test("a caller-supplied leftmost entry cannot be reached with one hop", () => {
1252
+ expect(
1253
+ resolveTrustedForwardedProto(requestWith("http, https"), {
1254
+ trustedProxyHops: 1,
1255
+ }),
1256
+ ).toBe("https");
1257
+ });
1258
+
1259
+ test("fewer entries than hops means our proxy wrote none, so undefined", () => {
1260
+ expect(
1261
+ resolveTrustedForwardedProto(requestWith("http"), {
1262
+ trustedProxyHops: 2,
1263
+ }),
1264
+ ).toBeUndefined();
1265
+ });
1266
+
1267
+ test("a missing or blank header is undefined", () => {
1268
+ expect(
1269
+ resolveTrustedForwardedProto(requestWith(undefined), {
1270
+ trustedProxyHops: 1,
1271
+ }),
1272
+ ).toBeUndefined();
1273
+ expect(
1274
+ resolveTrustedForwardedProto(requestWith(" "), {
1275
+ trustedProxyHops: 1,
1276
+ }),
1277
+ ).toBeUndefined();
1278
+ });
1279
+
1280
+ test("array-valued headers are joined, and the value is trimmed and lower-cased", () => {
1281
+ expect(
1282
+ resolveTrustedForwardedProto(requestWith(["https", " HTTP "]), {
1283
+ trustedProxyHops: 1,
1284
+ }),
1285
+ ).toBe("http");
1286
+ });
1287
+
1288
+ test("defaults to the configured TRUSTED_PROXY_HOPS", () => {
1289
+ setEnv({ trustedProxyHops: 1 });
1290
+ expect(resolveTrustedForwardedProto(requestWith("http"))).toBe("http");
1291
+
1292
+ setEnv({ trustedProxyHops: 0 });
1293
+ expect(resolveTrustedForwardedProto(requestWith("http"))).toBeUndefined();
1294
+ });
1295
+ });
1296
+
1297
+ describe("readScheduleFilter", () => {
1298
+ function requestWithQuery(query: Record<string, unknown>): ExpressRequest {
1299
+ return { query } as unknown as ExpressRequest;
1300
+ }
1301
+
1302
+ test("absent -> undefined (no filter)", () => {
1303
+ expect(readScheduleFilter(requestWithQuery({}))).toBeUndefined();
1304
+ expect(
1305
+ readScheduleFilter(requestWithQuery({ nocache3: "1" })),
1306
+ ).toBeUndefined();
1307
+ });
1308
+
1309
+ test("blank -> undefined", () => {
1310
+ expect(
1311
+ readScheduleFilter(requestWithQuery({ schedule: "" })),
1312
+ ).toBeUndefined();
1313
+ expect(
1314
+ readScheduleFilter(requestWithQuery({ schedule: " " })),
1315
+ ).toBeUndefined();
1316
+ });
1317
+
1318
+ test("not a UUID -> null (the route answers 404, never the whole feed)", () => {
1319
+ expect(
1320
+ readScheduleFilter(requestWithQuery({ schedule: "abc" })),
1321
+ ).toBeNull();
1322
+ expect(
1323
+ readScheduleFilter(requestWithQuery({ schedule: "1; DROP TABLE" })),
1324
+ ).toBeNull();
1325
+ });
1326
+
1327
+ test("a UUID -> the ObjectID", () => {
1328
+ const id: ObjectID = ObjectID.generate();
1329
+ const filter: ObjectID | undefined | null = readScheduleFilter(
1330
+ requestWithQuery({ schedule: id.toString() }),
1331
+ );
1332
+
1333
+ expect(filter?.toString()).toBe(id.toString());
1334
+ });
1335
+
1336
+ test("a repeated parameter uses the first value", () => {
1337
+ const id: ObjectID = ObjectID.generate();
1338
+ const filter: ObjectID | undefined | null = readScheduleFilter(
1339
+ requestWithQuery({ schedule: [id.toString(), "garbage"] }),
1340
+ );
1341
+
1342
+ expect(filter?.toString()).toBe(id.toString());
1343
+ });
1344
+ });
1345
+
1346
+ describe("classifyCalendarClient", () => {
1347
+ test.each([
1348
+ ["Google-Calendar-Importer", "Google Calendar"],
1349
+ ["Mozilla/5.0 (compatible; Google-Calendar-Importer)", "Google Calendar"],
1350
+ [
1351
+ "Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.5)",
1352
+ "Microsoft Outlook",
1353
+ ],
1354
+ ["Outlook-iOS/2.0", "Microsoft Outlook"],
1355
+ ["CalendarAgent/1000 CFNetwork/1500 Darwin/23.0", "Apple Calendar"],
1356
+ ["iOS/17.5 (21F79) dataaccessd/1.0", "Apple Calendar"],
1357
+ ["Mozilla/5.0 Thunderbird/115.0", "Thunderbird"],
1358
+ ["okhttp/4.12.0", "Android app"],
1359
+ ["Mozilla/5.0 (Windows NT 10.0) Chrome/126.0", "Browser"],
1360
+ ["curl/8.5.0", "Command line"],
1361
+ ["Wget/1.21", "Command line"],
1362
+ ["SomethingNobodyKnows/1.0", "Other"],
1363
+ ])("%s -> %s", (userAgent: string, expected: string) => {
1364
+ expect(classifyCalendarClient(userAgent)).toBe(expected);
1365
+ });
1366
+
1367
+ test("no User-Agent -> null", () => {
1368
+ expect(classifyCalendarClient(undefined)).toBeNull();
1369
+ expect(classifyCalendarClient("")).toBeNull();
1370
+ expect(classifyCalendarClient(" ")).toBeNull();
1371
+ });
1372
+
1373
+ test("array headers are joined", () => {
1374
+ expect(classifyCalendarClient(["Mozilla/5.0", "Thunderbird/115"])).toBe(
1375
+ "Thunderbird",
1376
+ );
1377
+ });
1378
+
1379
+ test("never returns the raw string", () => {
1380
+ const raw: string =
1381
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8; device-id=ABCDEF) Chrome/126";
1382
+
1383
+ expect(classifyCalendarClient(raw)).not.toContain("ABCDEF");
1384
+ });
1385
+ });
1386
+
1387
+ describe("isPreviousTokenInGrace", () => {
1388
+ test("a future expiry is inside the grace", () => {
1389
+ expect(
1390
+ isPreviousTokenInGrace(
1391
+ { previousTokenExpiresAt: at("2026-09-02T12:00:00Z") },
1392
+ NOW,
1393
+ ),
1394
+ ).toBe(true);
1395
+ });
1396
+
1397
+ test("a past expiry is not", () => {
1398
+ expect(
1399
+ isPreviousTokenInGrace(
1400
+ { previousTokenExpiresAt: at("2026-09-01T11:59:59Z") },
1401
+ NOW,
1402
+ ),
1403
+ ).toBe(false);
1404
+ });
1405
+
1406
+ test("an expiry equal to now is not (strictly greater)", () => {
1407
+ expect(isPreviousTokenInGrace({ previousTokenExpiresAt: NOW }, NOW)).toBe(
1408
+ false,
1409
+ );
1410
+ });
1411
+
1412
+ test("no expiry at all is not", () => {
1413
+ expect(isPreviousTokenInGrace({}, NOW)).toBe(false);
1414
+ expect(
1415
+ isPreviousTokenInGrace({ previousTokenExpiresAt: undefined }, NOW),
1416
+ ).toBe(false);
1417
+ });
1418
+
1419
+ test("a string date (as a raw row might carry) is understood", () => {
1420
+ expect(
1421
+ isPreviousTokenInGrace(
1422
+ {
1423
+ previousTokenExpiresAt: "2026-10-01T00:00:00.000Z" as unknown as Date,
1424
+ },
1425
+ NOW,
1426
+ ),
1427
+ ).toBe(true);
1428
+ });
1429
+ });
1430
+
1431
+ describe("shouldRecordFetch", () => {
1432
+ test("HEAD is never recorded", () => {
1433
+ expect(
1434
+ shouldRecordFetch({ method: "HEAD", lastFetchedAt: null, now: NOW }),
1435
+ ).toBe(false);
1436
+ });
1437
+
1438
+ test("a first GET is recorded", () => {
1439
+ expect(
1440
+ shouldRecordFetch({ method: "GET", lastFetchedAt: null, now: NOW }),
1441
+ ).toBe(true);
1442
+ expect(
1443
+ shouldRecordFetch({
1444
+ method: undefined,
1445
+ lastFetchedAt: undefined,
1446
+ now: NOW,
1447
+ }),
1448
+ ).toBe(true);
1449
+ });
1450
+
1451
+ test("a GET inside the 5-minute interval is not", () => {
1452
+ expect(
1453
+ shouldRecordFetch({
1454
+ method: "get",
1455
+ lastFetchedAt: new Date(NOW.getTime() - BOOKKEEPING_INTERVAL_MS + 1000),
1456
+ now: NOW,
1457
+ }),
1458
+ ).toBe(false);
1459
+ });
1460
+
1461
+ test("a GET at or past the interval is", () => {
1462
+ expect(
1463
+ shouldRecordFetch({
1464
+ method: "GET",
1465
+ lastFetchedAt: new Date(NOW.getTime() - BOOKKEEPING_INTERVAL_MS),
1466
+ now: NOW,
1467
+ }),
1468
+ ).toBe(true);
1469
+ expect(
1470
+ shouldRecordFetch({
1471
+ method: "GET",
1472
+ lastFetchedAt: new Date(NOW.getTime() - 6 * 60 * 1000),
1473
+ now: NOW,
1474
+ }),
1475
+ ).toBe(true);
1476
+ });
1477
+
1478
+ test("the interval is five minutes", () => {
1479
+ expect(BOOKKEEPING_INTERVAL_MS).toBe(5 * 60 * 1000);
1480
+ });
1481
+ });
1482
+
1483
+ describe("readMyShiftsWindow", () => {
1484
+ function requestWithQuery(query: Record<string, unknown>): ExpressRequest {
1485
+ return { query } as unknown as ExpressRequest;
1486
+ }
1487
+
1488
+ test("defaults to now -> now + 30 days", () => {
1489
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1490
+ requestWithQuery({}),
1491
+ NOW,
1492
+ );
1493
+
1494
+ expect(window.from.toISOString()).toBe(NOW.toISOString());
1495
+ expect(window.to.toISOString()).toBe(
1496
+ OneUptimeDate.addRemoveDays(NOW, MY_SHIFTS_DEFAULT_DAYS).toISOString(),
1497
+ );
1498
+ expect(MY_SHIFTS_DEFAULT_DAYS).toBe(30);
1499
+ });
1500
+
1501
+ test("explicit from and to are honoured", () => {
1502
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1503
+ requestWithQuery({
1504
+ from: "2026-09-10T00:00:00.000Z",
1505
+ to: "2026-09-12T00:00:00.000Z",
1506
+ }),
1507
+ NOW,
1508
+ );
1509
+
1510
+ expect(window.from.toISOString()).toBe("2026-09-10T00:00:00.000Z");
1511
+ expect(window.to.toISOString()).toBe("2026-09-12T00:00:00.000Z");
1512
+ });
1513
+
1514
+ test("a span beyond 120 days is cut to 120 days, not refused", () => {
1515
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1516
+ requestWithQuery({
1517
+ from: "2026-09-01T00:00:00.000Z",
1518
+ to: "2027-09-01T00:00:00.000Z",
1519
+ }),
1520
+ NOW,
1521
+ );
1522
+
1523
+ expect(window.to.toISOString()).toBe(
1524
+ OneUptimeDate.addRemoveDays(
1525
+ at("2026-09-01T00:00:00.000Z"),
1526
+ MY_SHIFTS_MAX_DAYS,
1527
+ ).toISOString(),
1528
+ );
1529
+ expect(MY_SHIFTS_MAX_DAYS).toBe(120);
1530
+ });
1531
+
1532
+ /*
1533
+ * Regression: `from` was unbounded. A far-future `from` is a fresh
1534
+ * schedule-cache entry AND a fresh LayerUtil expansion that walks one
1535
+ * rotation period at a time from the layer's start to the window -- the
1536
+ * full 200,000-iteration cap per restricted layer, seconds of synchronous
1537
+ * CPU, while holding one of the four render slots the public feeds share.
1538
+ * The window is now clamped into the range the feeds themselves address.
1539
+ */
1540
+ test("a far-future from is pulled back inside the feed window", () => {
1541
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1542
+ requestWithQuery({ from: "2500-01-01T00:00:00.000Z" }),
1543
+ NOW,
1544
+ );
1545
+
1546
+ const latest: Date = OneUptimeDate.addRemoveDays(NOW, MAX_FUTURE_DAYS);
1547
+
1548
+ expect(window.from.getTime()).toBeLessThanOrEqual(latest.getTime());
1549
+ expect(window.to.getTime()).toBeLessThanOrEqual(latest.getTime());
1550
+ expect(window.to.getTime()).toBeGreaterThan(window.from.getTime());
1551
+ });
1552
+
1553
+ test("a far-past from is pulled forward to the earliest the feeds render", () => {
1554
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1555
+ requestWithQuery({
1556
+ from: "1970-01-01T00:00:00.000Z",
1557
+ to: "1970-03-01T00:00:00.000Z",
1558
+ }),
1559
+ NOW,
1560
+ );
1561
+
1562
+ expect(window.from.toISOString()).toBe(
1563
+ OneUptimeDate.addRemoveDays(NOW, -MAX_PAST_DAYS).toISOString(),
1564
+ );
1565
+ expect(window.to.getTime()).toBeGreaterThan(window.from.getTime());
1566
+ expect(window.to.getTime()).toBeLessThanOrEqual(
1567
+ OneUptimeDate.addRemoveDays(NOW, MAX_FUTURE_DAYS).getTime(),
1568
+ );
1569
+ });
1570
+
1571
+ test("a window inside the range is left exactly as asked", () => {
1572
+ const from: Date = OneUptimeDate.addRemoveDays(NOW, -1);
1573
+ const to: Date = OneUptimeDate.addRemoveDays(NOW, 20);
1574
+
1575
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1576
+ requestWithQuery({ from: from.toISOString(), to: to.toISOString() }),
1577
+ NOW,
1578
+ );
1579
+
1580
+ expect(window.from.toISOString()).toBe(from.toISOString());
1581
+ expect(window.to.toISOString()).toBe(to.toISOString());
1582
+ });
1583
+
1584
+ test("every window it returns is one the feeds could render", () => {
1585
+ const earliest: Date = OneUptimeDate.addRemoveDays(NOW, -MAX_PAST_DAYS);
1586
+ const latest: Date = OneUptimeDate.addRemoveDays(NOW, MAX_FUTURE_DAYS);
1587
+
1588
+ for (const from of [
1589
+ "1900-01-01T00:00:00.000Z",
1590
+ "2026-01-01T00:00:00.000Z",
1591
+ "2026-09-01T12:00:00.000Z",
1592
+ "2027-06-01T00:00:00.000Z",
1593
+ "2500-01-01T00:00:00.000Z",
1594
+ ]) {
1595
+ const window: { from: Date; to: Date } = readMyShiftsWindow(
1596
+ requestWithQuery({ from }),
1597
+ NOW,
1598
+ );
1599
+
1600
+ expect(window.from.getTime()).toBeGreaterThanOrEqual(earliest.getTime());
1601
+ expect(window.to.getTime()).toBeLessThanOrEqual(latest.getTime());
1602
+ expect(window.to.getTime()).toBeGreaterThan(window.from.getTime());
1603
+ expect(window.to.getTime() - window.from.getTime()).toBeLessThanOrEqual(
1604
+ MY_SHIFTS_MAX_DAYS * 24 * 60 * 60 * 1000,
1605
+ );
1606
+ }
1607
+ });
1608
+
1609
+ test("to <= from is a BadDataException", () => {
1610
+ expect(() => {
1611
+ return readMyShiftsWindow(
1612
+ requestWithQuery({
1613
+ from: "2026-09-10T00:00:00.000Z",
1614
+ to: "2026-09-10T00:00:00.000Z",
1615
+ }),
1616
+ NOW,
1617
+ );
1618
+ }).toThrow(BadDataException);
1619
+ });
1620
+
1621
+ test("garbage dates are a BadDataException, not a 500", () => {
1622
+ expect(() => {
1623
+ return readMyShiftsWindow(requestWithQuery({ from: "yesterday" }), NOW);
1624
+ }).toThrow(BadDataException);
1625
+ expect(() => {
1626
+ return readMyShiftsWindow(requestWithQuery({ to: "soon" }), NOW);
1627
+ }).toThrow(BadDataException);
1628
+ });
1629
+ });
1630
+
1631
+ describe("assertJsonRequest", () => {
1632
+ function requestWithContentType(
1633
+ value: string | Array<string> | undefined,
1634
+ ): ExpressRequest {
1635
+ return {
1636
+ headers: value === undefined ? {} : { "content-type": value },
1637
+ } as unknown as ExpressRequest;
1638
+ }
1639
+
1640
+ test("application/json passes, with or without parameters", () => {
1641
+ expect(() => {
1642
+ return assertJsonRequest(requestWithContentType("application/json"));
1643
+ }).not.toThrow();
1644
+ expect(() => {
1645
+ return assertJsonRequest(
1646
+ requestWithContentType("Application/JSON; charset=utf-8"),
1647
+ );
1648
+ }).not.toThrow();
1649
+ });
1650
+
1651
+ test("a missing or non-JSON content type is a 415", () => {
1652
+ for (const value of [
1653
+ undefined,
1654
+ "",
1655
+ "text/plain",
1656
+ "application/x-www-form-urlencoded",
1657
+ "multipart/form-data; boundary=x",
1658
+ ]) {
1659
+ let thrown: unknown = null;
1660
+
1661
+ try {
1662
+ assertJsonRequest(requestWithContentType(value));
1663
+ } catch (err) {
1664
+ thrown = err;
1665
+ }
1666
+
1667
+ expect(thrown).toBeInstanceOf(Exception);
1668
+ expect((thrown as Exception).code).toBe(415);
1669
+ }
1670
+ });
1671
+
1672
+ test("an array header uses its first value", () => {
1673
+ expect(() => {
1674
+ return assertJsonRequest(
1675
+ requestWithContentType(["application/json", "text/plain"]),
1676
+ );
1677
+ }).not.toThrow();
1678
+ });
1679
+ });
1680
+
1681
+ describe("buildAbsentFeedStatus", () => {
1682
+ test("personal: exists false, no urls, personal defaults", () => {
1683
+ const status: FeedStatus = buildAbsentFeedStatus(
1684
+ OnCallCalendarFeedKind.Personal,
1685
+ );
1686
+
1687
+ expect(status).toEqual({
1688
+ exists: false,
1689
+ feedId: null,
1690
+ isEnabled: false,
1691
+ needsRegeneration: false,
1692
+ tokenHint: null,
1693
+ rotatedAt: null,
1694
+ previousTokenExpiresAt: null,
1695
+ lastFetchedAt: null,
1696
+ lastFetchedClient: null,
1697
+ fetchCount: 0,
1698
+ lastRenderTruncated: false,
1699
+ settings: {
1700
+ includeCoveringShifts: true,
1701
+ pastDays: DEFAULT_PAST_DAYS,
1702
+ futureDays: DEFAULT_FUTURE_DAYS,
1703
+ },
1704
+ urls: null,
1705
+ hostWarning: null,
1706
+ protocolWarning: null,
1707
+ });
1708
+ });
1709
+
1710
+ test("shared kinds carry the shared defaults", () => {
1711
+ for (const kind of [
1712
+ OnCallCalendarFeedKind.Schedule,
1713
+ OnCallCalendarFeedKind.Project,
1714
+ ]) {
1715
+ const status: FeedStatus = buildAbsentFeedStatus(kind);
1716
+
1717
+ expect(status.exists).toBe(false);
1718
+ expect(status.settings).toEqual({
1719
+ includeCoverageGaps: false,
1720
+ minimumGapMinutes: 60,
1721
+ pastDays: DEFAULT_PAST_DAYS,
1722
+ futureDays: DEFAULT_FUTURE_DAYS,
1723
+ rotateWhenMemberLeaves: false,
1724
+ });
1725
+ }
1726
+ });
1727
+
1728
+ test("the deployment warnings come from the environment", () => {
1729
+ setEnv({ host: "localhost:3002", httpProtocol: Protocol.HTTP });
1730
+
1731
+ const status: FeedStatus = buildAbsentFeedStatus(
1732
+ OnCallCalendarFeedKind.Personal,
1733
+ );
1734
+
1735
+ expect(status.hostWarning).toBe(HOST_WARNING);
1736
+ expect(status.protocolWarning).toBe(PROTOCOL_WARNING);
1737
+ });
1738
+ });
1739
+
1740
+ describe("buildFeedStatus", () => {
1741
+ const token: string = CalendarFeedToken.mint();
1742
+
1743
+ test("a freshly minted plaintext token yields the three URLs", () => {
1744
+ const row: FeedRowFixture = personalRow({
1745
+ tokenHash: CalendarFeedToken.hash(token),
1746
+ });
1747
+
1748
+ const status: FeedStatus = buildFeedStatus({
1749
+ kind: OnCallCalendarFeedKind.Personal,
1750
+ feed: row,
1751
+ plaintextToken: token,
1752
+ });
1753
+
1754
+ expect(status.exists).toBe(true);
1755
+ expect(status.feedId).toBe(row.id.toString());
1756
+ expect(status.needsRegeneration).toBe(false);
1757
+ expect(status.urls).toEqual(
1758
+ OnCallCalendarFeedUrls.buildFeedUrls({
1759
+ kind: OnCallCalendarFeedKind.Personal,
1760
+ token,
1761
+ }),
1762
+ );
1763
+ expect(status.urls?.https).toContain(
1764
+ `/api/on-call-calendar/user/${token}/`,
1765
+ );
1766
+ expect(status.urls?.webcal.startsWith("webcals://")).toBe(true);
1767
+ expect(status.tokenHint).toBe(row.tokenHint);
1768
+ expect(status.rotatedAt).toBe(row.rotatedAt?.toISOString());
1769
+ expect(status.settings).toEqual({
1770
+ includeCoveringShifts: true,
1771
+ pastDays: DEFAULT_PAST_DAYS,
1772
+ futureDays: DEFAULT_FUTURE_DAYS,
1773
+ });
1774
+ });
1775
+
1776
+ test("a decrypted token that hashes to tokenHash yields URLs", () => {
1777
+ const status: FeedStatus = buildFeedStatus({
1778
+ kind: OnCallCalendarFeedKind.Schedule,
1779
+ feed: scheduleRow({ tokenHash: CalendarFeedToken.hash(token) }),
1780
+ decryptedToken: token,
1781
+ });
1782
+
1783
+ expect(status.needsRegeneration).toBe(false);
1784
+ expect(status.urls?.https).toContain(
1785
+ `/api/on-call-calendar/schedule/${token}/schedule.ics`,
1786
+ );
1787
+ });
1788
+
1789
+ /*
1790
+ * The status rows the routes read never carry tokenHash: no status read can
1791
+ * select it. The hash to verify against comes from the decrypting root read
1792
+ * instead, as `verifiedTokenHash`.
1793
+ */
1794
+ test("a status row without tokenHash verifies against the hash the decrypting read returned", () => {
1795
+ const status: FeedStatus = buildFeedStatus({
1796
+ kind: OnCallCalendarFeedKind.Schedule,
1797
+ feed: {
1798
+ _id: ObjectID.generate().toString(),
1799
+ isEnabled: true,
1800
+ tokenHint: "abcd",
1801
+ pastDays: DEFAULT_PAST_DAYS,
1802
+ futureDays: DEFAULT_FUTURE_DAYS,
1803
+ },
1804
+ decryptedToken: token,
1805
+ verifiedTokenHash: CalendarFeedToken.hash(token),
1806
+ });
1807
+
1808
+ expect(status.needsRegeneration).toBe(false);
1809
+ expect(status.urls?.https).toContain(token);
1810
+ });
1811
+
1812
+ test("a verified hash that the decrypted token does not match is still needsRegeneration", () => {
1813
+ const status: FeedStatus = buildFeedStatus({
1814
+ kind: OnCallCalendarFeedKind.Schedule,
1815
+ feed: { _id: ObjectID.generate().toString(), isEnabled: true },
1816
+ decryptedToken: token,
1817
+ verifiedTokenHash: CalendarFeedToken.hash(CalendarFeedToken.mint()),
1818
+ });
1819
+
1820
+ expect(status.needsRegeneration).toBe(true);
1821
+ expect(status.urls).toBeNull();
1822
+ });
1823
+
1824
+ test("no hash from anywhere is needsRegeneration, never a URL on trust alone", () => {
1825
+ const status: FeedStatus = buildFeedStatus({
1826
+ kind: OnCallCalendarFeedKind.Schedule,
1827
+ feed: { _id: ObjectID.generate().toString(), isEnabled: true },
1828
+ decryptedToken: token,
1829
+ });
1830
+
1831
+ expect(status.needsRegeneration).toBe(true);
1832
+ expect(status.urls).toBeNull();
1833
+ });
1834
+
1835
+ test("a decrypted token that does NOT hash to tokenHash means the secret changed: needsRegeneration, no urls", () => {
1836
+ const status: FeedStatus = buildFeedStatus({
1837
+ kind: OnCallCalendarFeedKind.Personal,
1838
+ feed: personalRow({
1839
+ tokenHash: CalendarFeedToken.hash(CalendarFeedToken.mint()),
1840
+ }),
1841
+ decryptedToken: token,
1842
+ });
1843
+
1844
+ expect(status.needsRegeneration).toBe(true);
1845
+ expect(status.urls).toBeNull();
1846
+ expect(status.exists).toBe(true);
1847
+ });
1848
+
1849
+ test("a failed decrypt is needsRegeneration, no urls", () => {
1850
+ const status: FeedStatus = buildFeedStatus({
1851
+ kind: OnCallCalendarFeedKind.Project,
1852
+ feed: projectRow(),
1853
+ decryptFailed: true,
1854
+ });
1855
+
1856
+ expect(status.needsRegeneration).toBe(true);
1857
+ expect(status.urls).toBeNull();
1858
+ });
1859
+
1860
+ test("garbage in the decrypted column (wrong shape) is needsRegeneration", () => {
1861
+ const status: FeedStatus = buildFeedStatus({
1862
+ kind: OnCallCalendarFeedKind.Personal,
1863
+ feed: personalRow(),
1864
+ decryptedToken: "\u0000\u0001not-a-token",
1865
+ });
1866
+
1867
+ expect(status.needsRegeneration).toBe(true);
1868
+ expect(status.urls).toBeNull();
1869
+ });
1870
+
1871
+ test("shared feeds expose the shared settings", () => {
1872
+ const status: FeedStatus = buildFeedStatus({
1873
+ kind: OnCallCalendarFeedKind.Project,
1874
+ feed: projectRow({
1875
+ includeCoverageGaps: true,
1876
+ minimumGapMinutes: 15,
1877
+ rotateWhenMemberLeaves: true,
1878
+ pastDays: 5,
1879
+ futureDays: 45,
1880
+ }),
1881
+ plaintextToken: token,
1882
+ });
1883
+
1884
+ expect(status.settings).toEqual({
1885
+ includeCoverageGaps: true,
1886
+ minimumGapMinutes: 15,
1887
+ pastDays: 5,
1888
+ futureDays: 45,
1889
+ rotateWhenMemberLeaves: true,
1890
+ });
1891
+ });
1892
+
1893
+ test("bookkeeping columns are copied as ISO strings and numbers", () => {
1894
+ const status: FeedStatus = buildFeedStatus({
1895
+ kind: OnCallCalendarFeedKind.Personal,
1896
+ feed: personalRow({
1897
+ isEnabled: false,
1898
+ lastFetchedAt: at("2026-08-31T09:00:00Z"),
1899
+ lastFetchedClient: "Google Calendar",
1900
+ fetchCount: 143,
1901
+ lastRenderTruncated: true,
1902
+ previousTokenExpiresAt: at("2026-09-30T00:00:00Z"),
1903
+ }),
1904
+ plaintextToken: token,
1905
+ });
1906
+
1907
+ expect(status.isEnabled).toBe(false);
1908
+ expect(status.lastFetchedAt).toBe("2026-08-31T09:00:00.000Z");
1909
+ expect(status.lastFetchedClient).toBe("Google Calendar");
1910
+ expect(status.fetchCount).toBe(143);
1911
+ expect(status.lastRenderTruncated).toBe(true);
1912
+ expect(status.previousTokenExpiresAt).toBe("2026-09-30T00:00:00.000Z");
1913
+ });
1914
+
1915
+ test("the feed id falls back to _id when the row is raw", () => {
1916
+ const rawId: string = ObjectID.generate().toString();
1917
+
1918
+ const status: FeedStatus = buildFeedStatus({
1919
+ kind: OnCallCalendarFeedKind.Personal,
1920
+ feed: { _id: rawId, tokenHash: CalendarFeedToken.hash(token) },
1921
+ plaintextToken: token,
1922
+ });
1923
+
1924
+ expect(status.feedId).toBe(rawId);
1925
+ });
1926
+
1927
+ test("the response never carries the hash", () => {
1928
+ const status: FeedStatus = buildFeedStatus({
1929
+ kind: OnCallCalendarFeedKind.Personal,
1930
+ feed: personalRow({ tokenHash: CalendarFeedToken.hash(token) }),
1931
+ plaintextToken: token,
1932
+ });
1933
+
1934
+ expect(JSON.stringify(status)).not.toContain(CalendarFeedToken.hash(token));
1935
+ });
1936
+ });
1937
+
1938
+ // -- Public routes: the personal feed -----------------------------------------
1939
+
1940
+ describe("GET /on-call-calendar/user/:token/shifts.ics", () => {
1941
+ let token: string;
1942
+ let row: FeedRowFixture;
1943
+
1944
+ beforeEach(() => {
1945
+ token = CalendarFeedToken.mint();
1946
+ row = personalRow({
1947
+ projectId,
1948
+ userId,
1949
+ tokenHash: CalendarFeedToken.hash(token),
1950
+ });
1951
+
1952
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
1953
+ renderSpy.mockResolvedValue(
1954
+ renderedOutcome(OnCallCalendarFeedKind.Personal),
1955
+ );
1956
+ });
1957
+
1958
+ test("a malformed token is 404 with a generic body, and no database lookup happens", async () => {
1959
+ for (const bad of [
1960
+ "short",
1961
+ token.slice(0, 42),
1962
+ `${token}x`,
1963
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO+P",
1964
+ ]) {
1965
+ const result: HttpResult = await request(
1966
+ `${API_PREFIX}/on-call-calendar/user/${encodeURIComponent(bad)}/shifts.ics`,
1967
+ );
1968
+
1969
+ expect(result.status).toBe(404);
1970
+ expect(json(result)).toEqual({ message: "Not found." });
1971
+ }
1972
+
1973
+ expect(personalFindOneBy).not.toHaveBeenCalled();
1974
+ expect(renderSpy).not.toHaveBeenCalled();
1975
+ });
1976
+
1977
+ test("an unknown but well-formed token is 404 with the SAME generic body, after both lookups", async () => {
1978
+ expect(CalendarFeedToken.isValidShape(UNKNOWN_TOKEN)).toBe(true);
1979
+
1980
+ const result: HttpResult = await request(
1981
+ feedPath(OnCallCalendarFeedKind.Personal, UNKNOWN_TOKEN),
1982
+ );
1983
+
1984
+ expect(result.status).toBe(404);
1985
+ expect(json(result)).toEqual({ message: "Not found." });
1986
+ expect(header(result, "content-type")).not.toContain("text/calendar");
1987
+ expect(personalFindOneBy).toHaveBeenCalledTimes(2);
1988
+ expect(renderSpy).not.toHaveBeenCalled();
1989
+ });
1990
+
1991
+ test("the public lookup never selects the encrypted token column, current or previous", async () => {
1992
+ await request(feedPath(OnCallCalendarFeedKind.Personal, UNKNOWN_TOKEN));
1993
+
1994
+ const calls: Array<CapturedFindOneBy> =
1995
+ callsOf<CapturedFindOneBy>(personalFindOneBy);
1996
+
1997
+ expect(calls).toHaveLength(2);
1998
+ expect(calls[0]?.query).toEqual({
1999
+ tokenHash: CalendarFeedToken.hash(UNKNOWN_TOKEN),
2000
+ });
2001
+ expect(calls[1]?.query).toEqual({
2002
+ previousTokenHash: CalendarFeedToken.hash(UNKNOWN_TOKEN),
2003
+ });
2004
+
2005
+ for (const call of calls) {
2006
+ expect(call.select?.["token"]).toBeUndefined();
2007
+ expect(call.select?.["tokenHash"]).toBeUndefined();
2008
+ expect(call.select?.["previousTokenHash"]).toBeUndefined();
2009
+ expect(call.props?.isRoot).toBe(true);
2010
+ expect(call.props?.ignoreHooks).toBe(true);
2011
+ }
2012
+ });
2013
+
2014
+ test("a known token renders: 200, the calendar header set, and no Pragma", async () => {
2015
+ const outcome: FeedRenderOutcome = renderedOutcome(
2016
+ OnCallCalendarFeedKind.Personal,
2017
+ );
2018
+ renderSpy.mockResolvedValue(outcome);
2019
+
2020
+ const result: HttpResult = await request(
2021
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2022
+ );
2023
+
2024
+ expect(result.status).toBe(200);
2025
+ expect(result.body).toBe(outcome.body);
2026
+ expect(result.body).toContain("BEGIN:VEVENT");
2027
+ expect(header(result, "content-type")).toBe("text/calendar; charset=utf-8");
2028
+ expect(header(result, "content-disposition")).toBe(
2029
+ 'inline; filename="oneuptime-on-call.ics"',
2030
+ );
2031
+ expect(header(result, "cache-control")).toBe("private, max-age=300");
2032
+ expect(header(result, "expires")).toBeDefined();
2033
+ expect(header(result, "etag")).toBe(outcome.etag);
2034
+ expect(header(result, "etag")?.startsWith("W/")).toBe(false);
2035
+ expect(header(result, "last-modified")).toBe(
2036
+ outcome.lastModified.toUTCString(),
2037
+ );
2038
+ expect(header(result, "x-content-type-options")).toBe("nosniff");
2039
+ expect(header(result, "x-robots-tag")).toBe("noindex");
2040
+ expect(header(result, "pragma")).toBeUndefined();
2041
+ expect(header(result, "warning")).toBeUndefined();
2042
+ });
2043
+
2044
+ test("hands the renderer the row's settings and never the token", async () => {
2045
+ row.includeCoveringShifts = false;
2046
+ row.pastDays = 7;
2047
+ row.futureDays = 30;
2048
+
2049
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2050
+
2051
+ expect(renderSpy).toHaveBeenCalledTimes(1);
2052
+
2053
+ const renderRequest: Record<string, unknown> = renderSpy.mock
2054
+ .calls[0]?.[0] as Record<string, unknown>;
2055
+
2056
+ expect(renderRequest["kind"]).toBe(OnCallCalendarFeedKind.Personal);
2057
+ expect(String(renderRequest["feedId"])).toBe(row.id.toString());
2058
+ expect(String(renderRequest["projectId"])).toBe(projectId.toString());
2059
+ expect(String(renderRequest["userId"])).toBe(userId.toString());
2060
+ expect(renderRequest["tokenHash"]).toBe(CalendarFeedToken.hash(token));
2061
+ expect(renderRequest["includeCoveringShifts"]).toBe(false);
2062
+ expect(renderRequest["pastDays"]).toBe(7);
2063
+ expect(renderRequest["futureDays"]).toBe(30);
2064
+ expect(renderRequest["scheduleFilterId"]).toBeUndefined();
2065
+ expect((renderRequest["now"] as Date).toISOString()).toBe(
2066
+ NOW.toISOString(),
2067
+ );
2068
+ expect(JSON.stringify(renderRequest)).not.toContain(token);
2069
+ });
2070
+
2071
+ test("?schedule=<uuid> becomes the render filter", async () => {
2072
+ const scheduleId: ObjectID = ObjectID.generate();
2073
+
2074
+ const result: HttpResult = await request(
2075
+ `${feedPath(OnCallCalendarFeedKind.Personal, token)}?schedule=${scheduleId.toString()}`,
2076
+ );
2077
+
2078
+ expect(result.status).toBe(200);
2079
+
2080
+ const renderRequest: Record<string, unknown> = renderSpy.mock
2081
+ .calls[0]?.[0] as Record<string, unknown>;
2082
+
2083
+ expect(String(renderRequest["scheduleFilterId"])).toBe(
2084
+ scheduleId.toString(),
2085
+ );
2086
+ });
2087
+
2088
+ test("?schedule=<not a uuid> is 404: a filtered link never quietly renders the whole feed", async () => {
2089
+ const result: HttpResult = await request(
2090
+ `${feedPath(OnCallCalendarFeedKind.Personal, token)}?schedule=payments`,
2091
+ );
2092
+
2093
+ expect(result.status).toBe(404);
2094
+ expect(renderSpy).not.toHaveBeenCalled();
2095
+ expect(personalFindOneBy).not.toHaveBeenCalled();
2096
+ });
2097
+
2098
+ test("unknown query parameters (?nocache3, cache busters) are ignored", async () => {
2099
+ const result: HttpResult = await request(
2100
+ `${feedPath(OnCallCalendarFeedKind.Personal, token)}?nocache3&_=1725192000&project=${ObjectID.generate().toString()}`,
2101
+ );
2102
+
2103
+ expect(result.status).toBe(200);
2104
+ expect(renderSpy).toHaveBeenCalledTimes(1);
2105
+ });
2106
+
2107
+ test("the rotated-out token inside its grace serves an EMPTY calendar (200) that says why, and is not counted", async () => {
2108
+ const previousToken: string = CalendarFeedToken.mint();
2109
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
2110
+ row.previousTokenExpiresAt = at("2026-09-20T00:00:00Z");
2111
+
2112
+ const result: HttpResult = await request(
2113
+ feedPath(OnCallCalendarFeedKind.Personal, previousToken),
2114
+ );
2115
+
2116
+ expect(result.status).toBe(200);
2117
+ expect(header(result, "content-type")).toBe("text/calendar; charset=utf-8");
2118
+ expect(result.body).toContain("BEGIN:VCALENDAR");
2119
+ expect(result.body).not.toContain("BEGIN:VEVENT");
2120
+ expect(result.body.replace(/\r\n /g, "")).toContain(
2121
+ TOKEN_ROTATED_REASON.slice(0, 40),
2122
+ );
2123
+ expect(renderSpy).not.toHaveBeenCalled();
2124
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2125
+ });
2126
+
2127
+ test("the rotated-out token past its grace is 404", async () => {
2128
+ const previousToken: string = CalendarFeedToken.mint();
2129
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
2130
+ row.previousTokenExpiresAt = at("2026-08-01T00:00:00Z");
2131
+
2132
+ const result: HttpResult = await request(
2133
+ feedPath(OnCallCalendarFeedKind.Personal, previousToken),
2134
+ );
2135
+
2136
+ expect(result.status).toBe(404);
2137
+ expect(json(result)).toEqual({ message: "Not found." });
2138
+ });
2139
+
2140
+ test("a rotated-out token whose row has no expiry recorded is 404", async () => {
2141
+ const previousToken: string = CalendarFeedToken.mint();
2142
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
2143
+ row.previousTokenExpiresAt = undefined;
2144
+
2145
+ const result: HttpResult = await request(
2146
+ feedPath(OnCallCalendarFeedKind.Personal, previousToken),
2147
+ );
2148
+
2149
+ expect(result.status).toBe(404);
2150
+ });
2151
+
2152
+ test("a disabled feed serves an EMPTY calendar (200) and is not counted", async () => {
2153
+ row.isEnabled = false;
2154
+
2155
+ const result: HttpResult = await request(
2156
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2157
+ );
2158
+
2159
+ expect(result.status).toBe(200);
2160
+ expect(result.body).not.toContain("BEGIN:VEVENT");
2161
+ expect(result.body.replace(/\r\n /g, "")).toContain(
2162
+ FEED_DISABLED_REASON.slice(0, 40),
2163
+ );
2164
+ expect(renderSpy).not.toHaveBeenCalled();
2165
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2166
+ });
2167
+
2168
+ test("an Empty outcome from the renderer (below plan, no schedules) is 200 with the renderer's body", async () => {
2169
+ renderSpy.mockResolvedValue(
2170
+ OnCallCalendarFeedRenderer.buildEmptyOutcome({
2171
+ kind: OnCallCalendarFeedKind.Personal,
2172
+ reason: PLAN_REASON,
2173
+ now: NOW,
2174
+ }),
2175
+ );
2176
+
2177
+ const result: HttpResult = await request(
2178
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2179
+ );
2180
+
2181
+ expect(result.status).toBe(200);
2182
+ expect(header(result, "content-type")).toBe("text/calendar; charset=utf-8");
2183
+ expect(result.body).not.toContain("BEGIN:VEVENT");
2184
+ expect(result.body.replace(/\r\n /g, "")).toContain(
2185
+ PLAN_REASON.slice(0, 40),
2186
+ );
2187
+ });
2188
+
2189
+ /*
2190
+ * Regression: bookkeeping used to run only for a Rendered outcome, so an
2191
+ * enabled, current feed whose calendar is EMPTY -- the user is not on a
2192
+ * schedule yet, or the project is below plan -- was polled all day and the
2193
+ * settings page still said "Nothing has fetched this link yet. Is this
2194
+ * server reachable from where your calendar app runs?". That is exactly the
2195
+ * question a user staring at an empty calendar is asking, and the answer
2196
+ * was wrong.
2197
+ */
2198
+ test("an empty calendar served for an enabled, current feed still counts as a fetch", async () => {
2199
+ renderSpy.mockResolvedValue(
2200
+ OnCallCalendarFeedRenderer.buildEmptyOutcome({
2201
+ kind: OnCallCalendarFeedKind.Personal,
2202
+ reason: NO_SCHEDULES_REASON,
2203
+ now: NOW,
2204
+ }),
2205
+ );
2206
+
2207
+ const result: HttpResult = await request(
2208
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2209
+ { headers: { "User-Agent": "Mozilla/5.0 (compatible; Google-Calendar" } },
2210
+ );
2211
+
2212
+ expect(result.status).toBe(200);
2213
+ expect(personalUpdateOneById).toHaveBeenCalledTimes(1);
2214
+
2215
+ const update: CapturedUpdate = personalUpdateOneById.mock
2216
+ .calls[0]?.[0] as CapturedUpdate;
2217
+
2218
+ expect(update.data["fetchCount"]).toBe(1);
2219
+ expect(update.data["lastFetchedClient"]).toBe("Google Calendar");
2220
+ expect(update.data["lastRenderTruncated"]).toBe(false);
2221
+ expect(update.props?.isRoot).toBe(true);
2222
+ });
2223
+
2224
+ test("an Unavailable outcome (render cap, nothing cached) is 503 + Retry-After 60 and not a calendar", async () => {
2225
+ renderSpy.mockResolvedValue(
2226
+ OnCallCalendarFeedRenderer.buildUnavailableOutcome(
2227
+ OnCallCalendarFeedKind.Personal,
2228
+ RENDER_CAP_RETRY_AFTER_SECONDS,
2229
+ ),
2230
+ );
2231
+
2232
+ const result: HttpResult = await request(
2233
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2234
+ );
2235
+
2236
+ expect(result.status).toBe(503);
2237
+ expect(header(result, "retry-after")).toBe("60");
2238
+ expect(header(result, "content-type")).not.toContain("text/calendar");
2239
+ expect(header(result, "content-disposition")).toBeUndefined();
2240
+ expect(header(result, "x-robots-tag")).toBeUndefined();
2241
+ expect(header(result, "warning")).toBeUndefined();
2242
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2243
+ });
2244
+
2245
+ test("a stale (last-good) outcome carries Warning: 110 and records lastRenderTruncated", async () => {
2246
+ renderSpy.mockResolvedValue(
2247
+ renderedOutcome(OnCallCalendarFeedKind.Personal, {
2248
+ stale: true,
2249
+ truncated: true,
2250
+ }),
2251
+ );
2252
+
2253
+ const result: HttpResult = await request(
2254
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2255
+ { headers: { "User-Agent": "Google-Calendar-Importer" } },
2256
+ );
2257
+
2258
+ expect(result.status).toBe(200);
2259
+ expect(header(result, "warning")).toBe('110 - "Response is Stale"');
2260
+
2261
+ await flushBackgroundWork();
2262
+
2263
+ const update: CapturedUpdate | undefined = callsOf<CapturedUpdate>(
2264
+ personalUpdateOneById,
2265
+ )[0];
2266
+
2267
+ expect(update?.data["lastRenderTruncated"]).toBe(true);
2268
+ });
2269
+
2270
+ test("the kill switch is 503 + Retry-After 3600 and costs neither a counter round trip nor a lookup", async () => {
2271
+ setEnv({ disableFeed: true });
2272
+
2273
+ const result: HttpResult = await request(
2274
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2275
+ );
2276
+
2277
+ expect(result.status).toBe(503);
2278
+ expect(header(result, "retry-after")).toBe(
2279
+ String(KILL_SWITCH_RETRY_AFTER_SECONDS),
2280
+ );
2281
+ expect(KILL_SWITCH_RETRY_AFTER_SECONDS).toBe(3600);
2282
+ expect(consumeSpy).not.toHaveBeenCalled();
2283
+ expect(personalFindOneBy).not.toHaveBeenCalled();
2284
+ expect(renderSpy).not.toHaveBeenCalled();
2285
+ });
2286
+
2287
+ test("the kill switch answers even a malformed token with 503 (no enumeration of good vs bad tokens while off)", async () => {
2288
+ setEnv({ disableFeed: true });
2289
+
2290
+ const result: HttpResult = await request(
2291
+ `${API_PREFIX}/on-call-calendar/user/nope/shifts.ics`,
2292
+ );
2293
+
2294
+ expect(result.status).toBe(503);
2295
+ });
2296
+
2297
+ test("a rate-limited request is 429 + Retry-After before any lookup", async () => {
2298
+ consumeSpy.mockResolvedValue({
2299
+ outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
2300
+ retryAfterSeconds: 17,
2301
+ scope: OnCallCalendarFeedRateLimitScope.Token,
2302
+ isFirstRejectionInWindow: true,
2303
+ });
2304
+
2305
+ const result: HttpResult = await request(
2306
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2307
+ );
2308
+
2309
+ expect(result.status).toBe(429);
2310
+ expect(header(result, "retry-after")).toBe("17");
2311
+ expect(personalFindOneBy).not.toHaveBeenCalled();
2312
+ expect(renderSpy).not.toHaveBeenCalled();
2313
+ });
2314
+
2315
+ test("the limiter is consumed with the token's key, not the token, and the caller address", async () => {
2316
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2317
+
2318
+ expect(consumeSpy).toHaveBeenCalledTimes(1);
2319
+
2320
+ const consumed: { tokenKey: string; clientIp: string } = consumeSpy.mock
2321
+ .calls[0]?.[0] as { tokenKey: string; clientIp: string };
2322
+
2323
+ expect(consumed.tokenKey).toBe(
2324
+ OnCallCalendarFeedRateLimit.hashToken(token),
2325
+ );
2326
+ expect(consumed.tokenKey.startsWith("t:")).toBe(true);
2327
+ expect(consumed.tokenKey).not.toContain(token);
2328
+ expect(consumed.clientIp).toBeTruthy();
2329
+ });
2330
+
2331
+ test("an unavailable counter fails open", async () => {
2332
+ consumeSpy.mockResolvedValue({
2333
+ outcome: OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
2334
+ });
2335
+
2336
+ const result: HttpResult = await request(
2337
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2338
+ );
2339
+
2340
+ expect(result.status).toBe(200);
2341
+ });
2342
+
2343
+ describe("scheme guard", () => {
2344
+ test("https instance + trusted X-Forwarded-Proto: http -> 301 to the https URL built from HOST, before any lookup", async () => {
2345
+ setEnv({
2346
+ httpProtocol: Protocol.HTTPS,
2347
+ host: "oneuptime.example.com",
2348
+ trustedProxyHops: 1,
2349
+ });
2350
+
2351
+ const requestPath: string = `${feedPath(
2352
+ OnCallCalendarFeedKind.Personal,
2353
+ token,
2354
+ )}?schedule=${ObjectID.generate().toString()}`;
2355
+
2356
+ const result: HttpResult = await request(requestPath, {
2357
+ headers: { "X-Forwarded-Proto": "http", Host: "evil.example.net" },
2358
+ });
2359
+
2360
+ expect(result.status).toBe(301);
2361
+ expect(header(result, "location")).toBe(
2362
+ `https://oneuptime.example.com${requestPath}`,
2363
+ );
2364
+ expect(header(result, "cache-control")).toBe("no-store");
2365
+ expect(personalFindOneBy).not.toHaveBeenCalled();
2366
+ expect(consumeSpy).not.toHaveBeenCalled();
2367
+ });
2368
+
2369
+ test("the redirect target never comes from the request's Host header", async () => {
2370
+ setEnv({ httpProtocol: Protocol.HTTPS, trustedProxyHops: 1 });
2371
+
2372
+ const result: HttpResult = await request(
2373
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2374
+ { headers: { "X-Forwarded-Proto": "http", Host: "evil.example.net" } },
2375
+ );
2376
+
2377
+ expect(header(result, "location")).not.toContain("evil.example.net");
2378
+ });
2379
+
2380
+ /*
2381
+ * Regression: the guard used to fire on the trusted X-Forwarded-Proto
2382
+ * alone. Every proxying location in the shipped Nginx config sets
2383
+ * `X-Forwarded-Proto $scheme`, REPLACING whatever an outer proxy sent, so
2384
+ * on an install that terminates TLS on an external reverse proxy
2385
+ * (PROVISION_SSL=false with HTTP_PROTOCOL=https, which config.example.env
2386
+ * documents) Nginx always reported `http`. The 301 went to the very URL
2387
+ * the client had just asked for: an endless redirect loop, and all three
2388
+ * feed routes unusable while the dashboard handed out those same URLs.
2389
+ */
2390
+ test("an install whose TLS is terminated outside Nginx serves the feed instead of looping", async () => {
2391
+ setEnv({
2392
+ httpProtocol: Protocol.HTTPS,
2393
+ trustedProxyHops: 1,
2394
+ provisionSsl: false,
2395
+ });
2396
+
2397
+ const result: HttpResult = await request(
2398
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2399
+ { headers: { "X-Forwarded-Proto": "http" } },
2400
+ );
2401
+
2402
+ expect(result.status).toBe(200);
2403
+ expect(result.body).toContain("BEGIN:VCALENDAR");
2404
+ expect(header(result, "location")).toBeUndefined();
2405
+ });
2406
+
2407
+ test("with no trusted proxy hops the header is not trusted and the feed is served", async () => {
2408
+ setEnv({ httpProtocol: Protocol.HTTPS, trustedProxyHops: 0 });
2409
+
2410
+ const result: HttpResult = await request(
2411
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2412
+ { headers: { "X-Forwarded-Proto": "http" } },
2413
+ );
2414
+
2415
+ expect(result.status).toBe(200);
2416
+ });
2417
+
2418
+ test("a request that already arrived over https is served", async () => {
2419
+ setEnv({ httpProtocol: Protocol.HTTPS, trustedProxyHops: 1 });
2420
+
2421
+ const result: HttpResult = await request(
2422
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2423
+ { headers: { "X-Forwarded-Proto": "https" } },
2424
+ );
2425
+
2426
+ expect(result.status).toBe(200);
2427
+ });
2428
+
2429
+ test("an http instance never redirects", async () => {
2430
+ setEnv({ httpProtocol: Protocol.HTTP, trustedProxyHops: 1 });
2431
+
2432
+ const result: HttpResult = await request(
2433
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2434
+ { headers: { "X-Forwarded-Proto": "http" } },
2435
+ );
2436
+
2437
+ expect(result.status).toBe(200);
2438
+ });
2439
+
2440
+ test("an https instance with an empty HOST cannot build a target and serves the feed", async () => {
2441
+ setEnv({ httpProtocol: Protocol.HTTPS, trustedProxyHops: 1, host: "" });
2442
+
2443
+ const result: HttpResult = await request(
2444
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2445
+ { headers: { "X-Forwarded-Proto": "http" } },
2446
+ );
2447
+
2448
+ expect(result.status).toBe(200);
2449
+ });
2450
+ });
2451
+
2452
+ describe("conditional requests and HEAD (Express semantics on top of the route)", () => {
2453
+ test("If-None-Match with the current ETag is 304 with no body", async () => {
2454
+ const outcome: FeedRenderOutcome = renderedOutcome(
2455
+ OnCallCalendarFeedKind.Personal,
2456
+ );
2457
+ renderSpy.mockResolvedValue(outcome);
2458
+
2459
+ const result: HttpResult = await request(
2460
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2461
+ { headers: { "If-None-Match": outcome.etag } },
2462
+ );
2463
+
2464
+ expect(result.status).toBe(304);
2465
+ expect(result.body).toBe("");
2466
+ expect(header(result, "etag")).toBe(outcome.etag);
2467
+ });
2468
+
2469
+ test("a weak W/ If-None-Match (Outlook) still matches", async () => {
2470
+ const outcome: FeedRenderOutcome = renderedOutcome(
2471
+ OnCallCalendarFeedKind.Personal,
2472
+ );
2473
+ renderSpy.mockResolvedValue(outcome);
2474
+
2475
+ const result: HttpResult = await request(
2476
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2477
+ { headers: { "If-None-Match": `W/${outcome.etag}` } },
2478
+ );
2479
+
2480
+ expect(result.status).toBe(304);
2481
+ expect(result.body).toBe("");
2482
+ });
2483
+
2484
+ test("a stale If-None-Match gets the full body", async () => {
2485
+ const result: HttpResult = await request(
2486
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2487
+ { headers: { "If-None-Match": '"0123456789abcdef0123456789abcdef"' } },
2488
+ );
2489
+
2490
+ expect(result.status).toBe(200);
2491
+ expect(result.body).toContain("BEGIN:VCALENDAR");
2492
+ });
2493
+
2494
+ test("If-Modified-Since at or after Last-Modified is 304", async () => {
2495
+ const outcome: FeedRenderOutcome = renderedOutcome(
2496
+ OnCallCalendarFeedKind.Personal,
2497
+ );
2498
+ renderSpy.mockResolvedValue(outcome);
2499
+
2500
+ const result: HttpResult = await request(
2501
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2502
+ {
2503
+ headers: { "If-Modified-Since": outcome.lastModified.toUTCString() },
2504
+ },
2505
+ );
2506
+
2507
+ expect(result.status).toBe(304);
2508
+ });
2509
+
2510
+ test("HEAD is answered with the headers and no body, and is NOT counted as a fetch", async () => {
2511
+ const result: HttpResult = await request(
2512
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2513
+ { method: "HEAD" },
2514
+ );
2515
+
2516
+ expect(result.status).toBe(200);
2517
+ expect(result.body).toBe("");
2518
+ expect(header(result, "content-type")).toBe(
2519
+ "text/calendar; charset=utf-8",
2520
+ );
2521
+ expect(header(result, "etag")).toBeDefined();
2522
+
2523
+ await flushBackgroundWork();
2524
+
2525
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2526
+ });
2527
+ });
2528
+
2529
+ describe("bookkeeping", () => {
2530
+ test("a first GET writes lastFetchedAt, the coarse client family, fetchCount + 1 and lastRenderTruncated as root", async () => {
2531
+ row.fetchCount = 41;
2532
+
2533
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token), {
2534
+ headers: { "User-Agent": "Google-Calendar-Importer" },
2535
+ });
2536
+
2537
+ await flushBackgroundWork();
2538
+
2539
+ expect(personalUpdateOneById).toHaveBeenCalledTimes(1);
2540
+
2541
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
2542
+ personalUpdateOneById,
2543
+ )[0] as CapturedUpdate;
2544
+
2545
+ expect(String(update.id)).toBe(row.id.toString());
2546
+ expect(update.data).toEqual({
2547
+ lastFetchedAt: NOW,
2548
+ lastFetchedClient: "Google Calendar",
2549
+ fetchCount: 42,
2550
+ lastRenderTruncated: false,
2551
+ });
2552
+ expect(update.props?.isRoot).toBe(true);
2553
+ expect(update.props?.ignoreHooks).toBe(true);
2554
+ });
2555
+
2556
+ test("the raw User-Agent is never stored", async () => {
2557
+ const userAgent: string =
2558
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8; device-id=ABCDEF) okhttp/4.12";
2559
+
2560
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token), {
2561
+ headers: { "User-Agent": userAgent },
2562
+ });
2563
+
2564
+ await flushBackgroundWork();
2565
+
2566
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
2567
+ personalUpdateOneById,
2568
+ )[0] as CapturedUpdate;
2569
+
2570
+ expect(update.data["lastFetchedClient"]).toBe("Android app");
2571
+ expect(JSON.stringify(update)).not.toContain("ABCDEF");
2572
+ });
2573
+
2574
+ test("a GET inside the five-minute interval is not written", async () => {
2575
+ row.lastFetchedAt = new Date(NOW.getTime() - 60 * 1000);
2576
+
2577
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2578
+ await flushBackgroundWork();
2579
+
2580
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2581
+ });
2582
+
2583
+ test("a GET after the interval is written", async () => {
2584
+ row.lastFetchedAt = new Date(NOW.getTime() - 6 * 60 * 1000);
2585
+
2586
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2587
+ await flushBackgroundWork();
2588
+
2589
+ expect(personalUpdateOneById).toHaveBeenCalledTimes(1);
2590
+ });
2591
+
2592
+ test("a no-User-Agent fetch is recorded as Other", async () => {
2593
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2594
+ await flushBackgroundWork();
2595
+
2596
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
2597
+ personalUpdateOneById,
2598
+ )[0] as CapturedUpdate;
2599
+
2600
+ expect(update.data["lastFetchedClient"]).toBe("Other");
2601
+ });
2602
+
2603
+ test("a failing bookkeeping write never reaches the response, and its log line carries no token", async () => {
2604
+ personalUpdateOneById.mockRejectedValue(new Error("postgres is away"));
2605
+
2606
+ const result: HttpResult = await request(
2607
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2608
+ );
2609
+
2610
+ await flushBackgroundWork();
2611
+
2612
+ expect(result.status).toBe(200);
2613
+ expect(logger.warn).toHaveBeenCalled();
2614
+
2615
+ for (const line of everyLogArgument()) {
2616
+ expect(line).not.toContain(token);
2617
+ }
2618
+ });
2619
+ });
2620
+
2621
+ test("a renderer that throws is passed to the error handler, not answered as a calendar", async () => {
2622
+ renderSpy.mockRejectedValue(new Error(`boom ${"x".repeat(3)}`));
2623
+
2624
+ const result: HttpResult = await request(
2625
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2626
+ );
2627
+
2628
+ expect(result.status).toBe(500);
2629
+ expect(header(result, "content-type")).not.toContain("text/calendar");
2630
+ expect(result.body).not.toContain(token);
2631
+ });
2632
+
2633
+ test("a row with no user cannot be served (404)", async () => {
2634
+ row.userId = undefined;
2635
+
2636
+ const result: HttpResult = await request(
2637
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2638
+ );
2639
+
2640
+ expect(result.status).toBe(404);
2641
+ expect(renderSpy).not.toHaveBeenCalled();
2642
+ });
2643
+
2644
+ test("across every branch, the token and its hash never reach a log line", async () => {
2645
+ const previousToken: string = CalendarFeedToken.mint();
2646
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
2647
+ row.previousTokenExpiresAt = at("2026-09-20T00:00:00Z");
2648
+ personalUpdateOneById.mockRejectedValue(new Error("write failed"));
2649
+
2650
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2651
+ await request(feedPath(OnCallCalendarFeedKind.Personal, previousToken));
2652
+ await request(feedPath(OnCallCalendarFeedKind.Personal, UNKNOWN_TOKEN));
2653
+ await request(`${API_PREFIX}/on-call-calendar/user/bad/shifts.ics`);
2654
+
2655
+ renderSpy.mockResolvedValue(
2656
+ OnCallCalendarFeedRenderer.buildUnavailableOutcome(
2657
+ OnCallCalendarFeedKind.Personal,
2658
+ 60,
2659
+ ),
2660
+ );
2661
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2662
+
2663
+ renderSpy.mockRejectedValue(new Error("render exploded"));
2664
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2665
+
2666
+ consumeSpy.mockResolvedValue({
2667
+ outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
2668
+ retryAfterSeconds: 5,
2669
+ scope: OnCallCalendarFeedRateLimitScope.Ip,
2670
+ isFirstRejectionInWindow: true,
2671
+ });
2672
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2673
+
2674
+ setEnv({ disableFeed: true });
2675
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
2676
+
2677
+ await flushBackgroundWork();
2678
+
2679
+ const lines: Array<string> = everyLogArgument();
2680
+
2681
+ expect(lines.length).toBeGreaterThan(0);
2682
+
2683
+ for (const line of lines) {
2684
+ expect(line).not.toContain(token);
2685
+ expect(line).not.toContain(previousToken);
2686
+ expect(line).not.toContain(UNKNOWN_TOKEN);
2687
+ expect(line).not.toContain(CalendarFeedToken.hash(token));
2688
+ expect(line).not.toContain(CalendarFeedToken.hash(previousToken));
2689
+ }
2690
+ });
2691
+ });
2692
+
2693
+ // -- Near misses on a token path ---------------------------------------------
2694
+
2695
+ /*
2696
+ * Regression: only the three exact GET routes existed, so a link pasted
2697
+ * without the trailing `shifts.ics`, a typo in the filename, or any
2698
+ * POST/PUT/DELETE to a feed URL fell through to the application's own
2699
+ * catch-alls, which answer `Page not found - ${req.url}` -- the plaintext
2700
+ * capability token, at ERROR level, in stdout, in the master-admin support
2701
+ * bundle's recent-log buffer and in the OTel log exporter. Redaction cannot
2702
+ * save it: the message has no credential keyword, no key=value shape and no
2703
+ * scheme, so the hint regex never fires. These fallbacks answer first, and
2704
+ * their message is a constant.
2705
+ */
2706
+ describe("near misses on a token-bearing path never echo or log the token", () => {
2707
+ let token: string;
2708
+
2709
+ beforeEach(() => {
2710
+ token = CalendarFeedToken.mint();
2711
+
2712
+ personalFindOneBy.mockImplementation(
2713
+ lookupFrom([
2714
+ personalRow({
2715
+ projectId,
2716
+ userId,
2717
+ tokenHash: CalendarFeedToken.hash(token),
2718
+ }),
2719
+ ]) as never,
2720
+ );
2721
+ });
2722
+
2723
+ function nearMisses(): Array<{ path: string; method: string }> {
2724
+ const kinds: Array<string> = ["user", "schedule", "project"];
2725
+ const out: Array<{ path: string; method: string }> = [];
2726
+
2727
+ for (const kind of kinds) {
2728
+ /* The link pasted without its filename. */
2729
+ out.push({
2730
+ path: `${API_PREFIX}/on-call-calendar/${kind}/${token}`,
2731
+ method: "GET",
2732
+ });
2733
+ /* A typo in the filename. */
2734
+ out.push({
2735
+ path: `${API_PREFIX}/on-call-calendar/${kind}/${token}/shifts.ical`,
2736
+ method: "GET",
2737
+ });
2738
+ /* A trailing slash. */
2739
+ out.push({
2740
+ path: `${API_PREFIX}/on-call-calendar/${kind}/${token}/`,
2741
+ method: "GET",
2742
+ });
2743
+ /* A client or a scanner using the wrong method on the real URL. */
2744
+ for (const method of ["POST", "PUT", "DELETE"]) {
2745
+ out.push({
2746
+ path: `${API_PREFIX}/on-call-calendar/${kind}/${token}/shifts.ics`,
2747
+ method,
2748
+ });
2749
+ }
2750
+ }
2751
+
2752
+ return out;
2753
+ }
2754
+
2755
+ test("a GET that misses the exact route is a generic 404 that does not name the URL", async () => {
2756
+ const result: HttpResult = await request(
2757
+ `${API_PREFIX}/on-call-calendar/user/${token}`,
2758
+ );
2759
+
2760
+ expect(result.status).toBe(404);
2761
+ expect(json(result)).toEqual({ message: "Not found." });
2762
+ expect(result.body).not.toContain(token);
2763
+ expect(personalFindOneBy).not.toHaveBeenCalled();
2764
+ expect(renderSpy).not.toHaveBeenCalled();
2765
+ });
2766
+
2767
+ test("a wrong method on the real URL is 405 with Allow: GET, HEAD", async () => {
2768
+ const result: HttpResult = await postJson(
2769
+ `${API_PREFIX}/on-call-calendar/user/${token}/shifts.ics`,
2770
+ );
2771
+
2772
+ expect(result.status).toBe(405);
2773
+ expect(header(result, "allow")).toBe(FEED_ALLOWED_METHODS);
2774
+ expect(result.body).not.toContain(token);
2775
+ expect(renderSpy).not.toHaveBeenCalled();
2776
+ });
2777
+
2778
+ test("OPTIONS is answered as OPTIONS should be, not as a 405", async () => {
2779
+ const result: HttpResult = await request(
2780
+ `${API_PREFIX}/on-call-calendar/user/${token}/shifts.ics`,
2781
+ { method: "OPTIONS" },
2782
+ );
2783
+
2784
+ expect(result.status).toBe(200);
2785
+ expect(header(result, "allow")).toBe(FEED_ALLOWED_METHODS);
2786
+ expect(result.body).not.toContain(token);
2787
+ });
2788
+
2789
+ test("no near miss on any of the three kinds puts the token in the body or a log line", async () => {
2790
+ for (const { path, method } of nearMisses()) {
2791
+ const result: HttpResult = await request(path, { method });
2792
+
2793
+ expect([404, 405]).toContain(result.status);
2794
+ expect(result.body).not.toContain(token);
2795
+ }
2796
+
2797
+ await flushBackgroundWork();
2798
+
2799
+ const lines: Array<string> = everyLogArgument();
2800
+
2801
+ for (const line of lines) {
2802
+ expect(line).not.toContain(token);
2803
+ expect(line).not.toContain(CalendarFeedToken.hash(token));
2804
+ }
2805
+ });
2806
+
2807
+ test("the exact route still wins over the fallback", async () => {
2808
+ renderSpy.mockResolvedValue(
2809
+ renderedOutcome(OnCallCalendarFeedKind.Personal),
2810
+ );
2811
+
2812
+ const result: HttpResult = await request(
2813
+ feedPath(OnCallCalendarFeedKind.Personal, token),
2814
+ );
2815
+
2816
+ expect(result.status).toBe(200);
2817
+ expect(result.body).toContain("BEGIN:VCALENDAR");
2818
+ });
2819
+
2820
+ test("the fallbacks do not shadow the session routes", async () => {
2821
+ const result: HttpResult = await request(
2822
+ `${API_PREFIX}${SCHEDULE_FEED_CURRENT_ROUTE.replace(
2823
+ ":scheduleId",
2824
+ ObjectID.generate().toString(),
2825
+ )}`,
2826
+ );
2827
+
2828
+ expect(result.status).toBe(200);
2829
+ expect(json(result)["exists"]).toBe(false);
2830
+ });
2831
+ });
2832
+
2833
+ // -- Public routes: the schedule feed ------------------------------------------
2834
+
2835
+ describe("GET /on-call-calendar/schedule/:token/schedule.ics", () => {
2836
+ let token: string;
2837
+ let row: FeedRowFixture;
2838
+
2839
+ beforeEach(() => {
2840
+ token = CalendarFeedToken.mint();
2841
+ row = scheduleRow({
2842
+ projectId,
2843
+ tokenHash: CalendarFeedToken.hash(token),
2844
+ includeCoverageGaps: true,
2845
+ minimumGapMinutes: 45,
2846
+ pastDays: 3,
2847
+ futureDays: 60,
2848
+ });
2849
+
2850
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
2851
+ renderSpy.mockResolvedValue(
2852
+ renderedOutcome(OnCallCalendarFeedKind.Schedule),
2853
+ );
2854
+ });
2855
+
2856
+ test("a known token renders the schedule feed with the row's gap settings", async () => {
2857
+ const result: HttpResult = await request(
2858
+ feedPath(OnCallCalendarFeedKind.Schedule, token),
2859
+ );
2860
+
2861
+ expect(result.status).toBe(200);
2862
+ expect(header(result, "content-type")).toBe("text/calendar; charset=utf-8");
2863
+ expect(header(result, "pragma")).toBeUndefined();
2864
+
2865
+ const renderRequest: Record<string, unknown> = renderSpy.mock
2866
+ .calls[0]?.[0] as Record<string, unknown>;
2867
+
2868
+ expect(renderRequest["kind"]).toBe(OnCallCalendarFeedKind.Schedule);
2869
+ expect(String(renderRequest["scheduleId"])).toBe(
2870
+ row.onCallDutyPolicyScheduleId?.toString(),
2871
+ );
2872
+ expect(String(renderRequest["projectId"])).toBe(projectId.toString());
2873
+ expect(renderRequest["includeCoverageGaps"]).toBe(true);
2874
+ expect(renderRequest["minimumGapMinutes"]).toBe(45);
2875
+ expect(renderRequest["pastDays"]).toBe(3);
2876
+ expect(renderRequest["futureDays"]).toBe(60);
2877
+ expect(renderRequest["tokenHash"]).toBe(CalendarFeedToken.hash(token));
2878
+ expect(renderRequest["userId"]).toBeUndefined();
2879
+ });
2880
+
2881
+ test("the lookup never selects the token", async () => {
2882
+ await request(feedPath(OnCallCalendarFeedKind.Schedule, UNKNOWN_TOKEN));
2883
+
2884
+ for (const call of callsOf<CapturedFindOneBy>(scheduleFindOneBy)) {
2885
+ expect(call.select?.["token"]).toBeUndefined();
2886
+ expect(call.select?.["tokenHash"]).toBeUndefined();
2887
+ expect(call.props?.isRoot).toBe(true);
2888
+ }
2889
+ });
2890
+
2891
+ test("unknown -> 404; rotated-in-grace -> empty; disabled -> empty", async () => {
2892
+ const unknown: HttpResult = await request(
2893
+ feedPath(OnCallCalendarFeedKind.Schedule, UNKNOWN_TOKEN),
2894
+ );
2895
+ expect(unknown.status).toBe(404);
2896
+
2897
+ const previousToken: string = CalendarFeedToken.mint();
2898
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
2899
+ row.previousTokenExpiresAt = at("2026-09-20T00:00:00Z");
2900
+
2901
+ const rotated: HttpResult = await request(
2902
+ feedPath(OnCallCalendarFeedKind.Schedule, previousToken),
2903
+ );
2904
+ expect(rotated.status).toBe(200);
2905
+ expect(rotated.body).not.toContain("BEGIN:VEVENT");
2906
+ expect(rotated.body.replace(/\r\n /g, "")).toContain(
2907
+ TOKEN_ROTATED_REASON.slice(0, 40),
2908
+ );
2909
+
2910
+ row.isEnabled = false;
2911
+
2912
+ const disabled: HttpResult = await request(
2913
+ feedPath(OnCallCalendarFeedKind.Schedule, token),
2914
+ );
2915
+ expect(disabled.status).toBe(200);
2916
+ expect(disabled.body).not.toContain("BEGIN:VEVENT");
2917
+ expect(disabled.body.replace(/\r\n /g, "")).toContain(
2918
+ FEED_DISABLED_REASON.slice(0, 40),
2919
+ );
2920
+
2921
+ expect(renderSpy).not.toHaveBeenCalled();
2922
+ });
2923
+
2924
+ test("a row without a schedule id (FK gone) is 404", async () => {
2925
+ row.onCallDutyPolicyScheduleId = undefined;
2926
+
2927
+ const result: HttpResult = await request(
2928
+ feedPath(OnCallCalendarFeedKind.Schedule, token),
2929
+ );
2930
+
2931
+ expect(result.status).toBe(404);
2932
+ });
2933
+
2934
+ test("bookkeeping goes to the schedule feed service", async () => {
2935
+ await request(feedPath(OnCallCalendarFeedKind.Schedule, token), {
2936
+ headers: { "User-Agent": "CalendarAgent/1000" },
2937
+ });
2938
+
2939
+ await flushBackgroundWork();
2940
+
2941
+ expect(scheduleUpdateOneById).toHaveBeenCalledTimes(1);
2942
+ expect(personalUpdateOneById).not.toHaveBeenCalled();
2943
+ expect(projectUpdateOneById).not.toHaveBeenCalled();
2944
+
2945
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
2946
+ scheduleUpdateOneById,
2947
+ )[0] as CapturedUpdate;
2948
+
2949
+ expect(update.data["lastFetchedClient"]).toBe("Apple Calendar");
2950
+ expect(update.data["fetchCount"]).toBe(1);
2951
+ });
2952
+
2953
+ test("the kill switch and the scheme guard apply here too", async () => {
2954
+ setEnv({ disableFeed: true });
2955
+
2956
+ const off: HttpResult = await request(
2957
+ feedPath(OnCallCalendarFeedKind.Schedule, token),
2958
+ );
2959
+ expect(off.status).toBe(503);
2960
+ expect(header(off, "retry-after")).toBe("3600");
2961
+
2962
+ setEnv({
2963
+ disableFeed: false,
2964
+ httpProtocol: Protocol.HTTPS,
2965
+ trustedProxyHops: 1,
2966
+ });
2967
+
2968
+ const redirected: HttpResult = await request(
2969
+ feedPath(OnCallCalendarFeedKind.Schedule, token),
2970
+ { headers: { "X-Forwarded-Proto": "http" } },
2971
+ );
2972
+ expect(redirected.status).toBe(301);
2973
+ });
2974
+ });
2975
+
2976
+ // -- Public routes: the project feed --------------------------------------------
2977
+
2978
+ describe("GET /on-call-calendar/project/:token/project.ics", () => {
2979
+ let token: string;
2980
+ let row: FeedRowFixture;
2981
+
2982
+ beforeEach(() => {
2983
+ token = CalendarFeedToken.mint();
2984
+ row = projectRow({
2985
+ projectId,
2986
+ tokenHash: CalendarFeedToken.hash(token),
2987
+ });
2988
+
2989
+ projectFindOneBy.mockImplementation(lookupFrom([row]) as never);
2990
+ renderSpy.mockResolvedValue(
2991
+ renderedOutcome(OnCallCalendarFeedKind.Project),
2992
+ );
2993
+ });
2994
+
2995
+ test("a known token renders the project feed", async () => {
2996
+ const result: HttpResult = await request(
2997
+ feedPath(OnCallCalendarFeedKind.Project, token),
2998
+ );
2999
+
3000
+ expect(result.status).toBe(200);
3001
+ expect(header(result, "content-type")).toBe("text/calendar; charset=utf-8");
3002
+
3003
+ const renderRequest: Record<string, unknown> = renderSpy.mock
3004
+ .calls[0]?.[0] as Record<string, unknown>;
3005
+
3006
+ expect(renderRequest["kind"]).toBe(OnCallCalendarFeedKind.Project);
3007
+ expect(String(renderRequest["projectId"])).toBe(projectId.toString());
3008
+ expect(renderRequest["includeCoverageGaps"]).toBe(true);
3009
+ expect(renderRequest["minimumGapMinutes"]).toBe(30);
3010
+ expect(renderRequest["scheduleId"]).toBeUndefined();
3011
+ expect(renderRequest["userId"]).toBeUndefined();
3012
+ });
3013
+
3014
+ test("the lookup never selects the token", async () => {
3015
+ await request(feedPath(OnCallCalendarFeedKind.Project, UNKNOWN_TOKEN));
3016
+
3017
+ const calls: Array<CapturedFindOneBy> =
3018
+ callsOf<CapturedFindOneBy>(projectFindOneBy);
3019
+
3020
+ expect(calls).toHaveLength(2);
3021
+
3022
+ for (const call of calls) {
3023
+ expect(call.select?.["token"]).toBeUndefined();
3024
+ expect(call.select?.["tokenHash"]).toBeUndefined();
3025
+ expect(call.props?.isRoot).toBe(true);
3026
+ }
3027
+ });
3028
+
3029
+ test("unknown -> 404; rotated-in-grace -> empty; disabled -> empty; rotated-past-grace -> 404", async () => {
3030
+ expect(
3031
+ (await request(feedPath(OnCallCalendarFeedKind.Project, UNKNOWN_TOKEN)))
3032
+ .status,
3033
+ ).toBe(404);
3034
+
3035
+ const previousToken: string = CalendarFeedToken.mint();
3036
+ row.previousTokenHash = CalendarFeedToken.hash(previousToken);
3037
+ row.previousTokenExpiresAt = at("2026-09-20T00:00:00Z");
3038
+
3039
+ const rotated: HttpResult = await request(
3040
+ feedPath(OnCallCalendarFeedKind.Project, previousToken),
3041
+ );
3042
+ expect(rotated.status).toBe(200);
3043
+ expect(rotated.body).not.toContain("BEGIN:VEVENT");
3044
+
3045
+ row.previousTokenExpiresAt = at("2026-08-01T00:00:00Z");
3046
+ expect(
3047
+ (await request(feedPath(OnCallCalendarFeedKind.Project, previousToken)))
3048
+ .status,
3049
+ ).toBe(404);
3050
+
3051
+ row.isEnabled = false;
3052
+ const disabled: HttpResult = await request(
3053
+ feedPath(OnCallCalendarFeedKind.Project, token),
3054
+ );
3055
+ expect(disabled.status).toBe(200);
3056
+ expect(disabled.body).not.toContain("BEGIN:VEVENT");
3057
+
3058
+ expect(renderSpy).not.toHaveBeenCalled();
3059
+ expect(projectUpdateOneById).not.toHaveBeenCalled();
3060
+ });
3061
+
3062
+ test("bookkeeping goes to the project feed service", async () => {
3063
+ await request(feedPath(OnCallCalendarFeedKind.Project, token), {
3064
+ headers: { "User-Agent": "Mozilla/5.0 Thunderbird/128" },
3065
+ });
3066
+
3067
+ await flushBackgroundWork();
3068
+
3069
+ expect(projectUpdateOneById).toHaveBeenCalledTimes(1);
3070
+
3071
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
3072
+ projectUpdateOneById,
3073
+ )[0] as CapturedUpdate;
3074
+
3075
+ expect(update.data["lastFetchedClient"]).toBe("Thunderbird");
3076
+ });
3077
+
3078
+ test("HEAD is served and not counted", async () => {
3079
+ const result: HttpResult = await request(
3080
+ feedPath(OnCallCalendarFeedKind.Project, token),
3081
+ { method: "HEAD" },
3082
+ );
3083
+
3084
+ expect(result.status).toBe(200);
3085
+ expect(result.body).toBe("");
3086
+
3087
+ await flushBackgroundWork();
3088
+
3089
+ expect(projectUpdateOneById).not.toHaveBeenCalled();
3090
+ });
3091
+ });
3092
+
3093
+ // -- Session routes: the personal feed ------------------------------------------
3094
+
3095
+ describe("GET /on-call-calendar/feed/current", () => {
3096
+ test("no feed yet: exists false with the defaults and the deployment warnings", async () => {
3097
+ setEnv({ host: "localhost", httpProtocol: Protocol.HTTP });
3098
+
3099
+ const result: HttpResult = await request(
3100
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3101
+ );
3102
+
3103
+ expect(result.status).toBe(200);
3104
+ expect(json(result)).toEqual({
3105
+ ...buildAbsentFeedStatus(OnCallCalendarFeedKind.Personal),
3106
+ hostWarning: HOST_WARNING,
3107
+ protocolWarning: PROTOCOL_WARNING,
3108
+ });
3109
+
3110
+ const lookup: CapturedFindOneBy = callsOf<CapturedFindOneBy>(
3111
+ personalFindOneBy,
3112
+ )[0] as CapturedFindOneBy;
3113
+
3114
+ expect(String(lookup.query["projectId"])).toBe(projectId.toString());
3115
+ expect(String(lookup.query["userId"])).toBe(userId.toString());
3116
+ expect(lookup.select?.["token"]).toBeUndefined();
3117
+ expect(personalFindOneById).not.toHaveBeenCalled();
3118
+ });
3119
+
3120
+ test("an existing feed: the ONE decrypting read, verified against tokenHash, yields the URLs", async () => {
3121
+ const token: string = CalendarFeedToken.mint();
3122
+ const row: FeedRowFixture = personalRow({
3123
+ projectId,
3124
+ userId,
3125
+ tokenHash: CalendarFeedToken.hash(token),
3126
+ fetchCount: 12,
3127
+ lastFetchedAt: at("2026-08-31T10:00:00Z"),
3128
+ lastFetchedClient: "Google Calendar",
3129
+ });
3130
+
3131
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3132
+ personalFindOneById.mockResolvedValue({
3133
+ id: row.id,
3134
+ token,
3135
+ tokenHash: row.tokenHash,
3136
+ } as never);
3137
+
3138
+ const result: HttpResult = await request(
3139
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3140
+ );
3141
+
3142
+ expect(result.status).toBe(200);
3143
+
3144
+ const status: Record<string, unknown> = json(result);
3145
+
3146
+ expect(status["exists"]).toBe(true);
3147
+ expect(status["feedId"]).toBe(row.id.toString());
3148
+ expect(status["needsRegeneration"]).toBe(false);
3149
+ expect(status["tokenHint"]).toBe(row.tokenHint);
3150
+ expect(status["fetchCount"]).toBe(12);
3151
+ expect(status["lastFetchedAt"]).toBe("2026-08-31T10:00:00.000Z");
3152
+ expect(status["lastFetchedClient"]).toBe("Google Calendar");
3153
+ expect(status["hostWarning"]).toBeNull();
3154
+ expect(status["protocolWarning"]).toBeNull();
3155
+
3156
+ const urls: Record<string, string> = status["urls"] as Record<
3157
+ string,
3158
+ string
3159
+ >;
3160
+
3161
+ expect(urls["https"]).toBe(
3162
+ `https://oneuptime.example.com/api/on-call-calendar/user/${token}/shifts.ics`,
3163
+ );
3164
+ expect(urls["webcal"]).toBe(
3165
+ `webcals://oneuptime.example.com/api/on-call-calendar/user/${token}/shifts.ics`,
3166
+ );
3167
+ expect(urls["googleAdd"]).toContain(
3168
+ encodeURIComponent(urls["https"] || ""),
3169
+ );
3170
+
3171
+ const decryptingRead: {
3172
+ id: ObjectID;
3173
+ select: Record<string, unknown>;
3174
+ props: { isRoot?: boolean };
3175
+ } = personalFindOneById.mock.calls[0]?.[0] as {
3176
+ id: ObjectID;
3177
+ select: Record<string, unknown>;
3178
+ props: { isRoot?: boolean };
3179
+ };
3180
+
3181
+ expect(String(decryptingRead.id)).toBe(row.id.toString());
3182
+ expect(decryptingRead.select["token"]).toBe(true);
3183
+ expect(decryptingRead.select["tokenHash"]).toBe(true);
3184
+ expect(decryptingRead.props.isRoot).toBe(true);
3185
+ });
3186
+
3187
+ /*
3188
+ * Regression: the status builder verified the decrypted token against the
3189
+ * STATUS ROW's tokenHash, and no status read can select that column -- its
3190
+ * read access list is empty, so the non-root schedule and project reads
3191
+ * would be refused outright. The row therefore never carried it and every
3192
+ * /current and /publish answered `needsRegeneration: true, urls: null` for
3193
+ * a perfectly good feed: the settings page told every user on every page
3194
+ * load to regenerate a link that worked, and regenerating it blanked the
3195
+ * calendar they had already subscribed. The hash to verify against comes
3196
+ * from the decrypting root read, which selects it.
3197
+ */
3198
+ test("the status read does not select tokenHash, and the link is shown anyway", async () => {
3199
+ const token: string = CalendarFeedToken.mint();
3200
+ const row: FeedRowFixture = personalRow({
3201
+ projectId,
3202
+ userId,
3203
+ tokenHash: CalendarFeedToken.hash(token),
3204
+ });
3205
+
3206
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3207
+ personalFindOneById.mockResolvedValue({
3208
+ id: row.id,
3209
+ token,
3210
+ tokenHash: row.tokenHash,
3211
+ } as never);
3212
+
3213
+ const status: Record<string, unknown> = json(
3214
+ await request(`${API_PREFIX}${FEED_CURRENT_ROUTE}`),
3215
+ );
3216
+
3217
+ const statusRead: CapturedFindOneBy = personalFindOneBy.mock
3218
+ .calls[0]?.[0] as CapturedFindOneBy;
3219
+
3220
+ expect(statusRead.select?.["tokenHash"]).toBeUndefined();
3221
+ expect(statusRead.select?.["token"]).toBeUndefined();
3222
+ expect(status["needsRegeneration"]).toBe(false);
3223
+ expect(status["urls"]).not.toBeNull();
3224
+ });
3225
+
3226
+ test("a decrypted token that does not match the hash the root read returned is needsRegeneration", async () => {
3227
+ const token: string = CalendarFeedToken.mint();
3228
+ const row: FeedRowFixture = personalRow({
3229
+ projectId,
3230
+ userId,
3231
+ tokenHash: CalendarFeedToken.hash(token),
3232
+ });
3233
+
3234
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3235
+
3236
+ /* Decrypts to something, but not to what tokenHash says. */
3237
+ personalFindOneById.mockResolvedValue({
3238
+ id: row.id,
3239
+ token: CalendarFeedToken.mint(),
3240
+ tokenHash: row.tokenHash,
3241
+ } as never);
3242
+
3243
+ const status: Record<string, unknown> = json(
3244
+ await request(`${API_PREFIX}${FEED_CURRENT_ROUTE}`),
3245
+ );
3246
+
3247
+ expect(status["needsRegeneration"]).toBe(true);
3248
+ expect(status["urls"]).toBeNull();
3249
+ });
3250
+
3251
+ test("webcal is webcal:// on an http instance", async () => {
3252
+ setEnv({ httpProtocol: Protocol.HTTP });
3253
+
3254
+ const token: string = CalendarFeedToken.mint();
3255
+ const row: FeedRowFixture = personalRow({
3256
+ projectId,
3257
+ userId,
3258
+ tokenHash: CalendarFeedToken.hash(token),
3259
+ });
3260
+
3261
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3262
+ personalFindOneById.mockResolvedValue({
3263
+ id: row.id,
3264
+ token,
3265
+ tokenHash: row.tokenHash,
3266
+ } as never);
3267
+
3268
+ const status: Record<string, unknown> = json(
3269
+ await request(`${API_PREFIX}${FEED_CURRENT_ROUTE}`),
3270
+ );
3271
+
3272
+ const urls: Record<string, string> = status["urls"] as Record<
3273
+ string,
3274
+ string
3275
+ >;
3276
+
3277
+ expect(urls["webcal"]?.startsWith("webcal://")).toBe(true);
3278
+ expect(urls["https"]?.startsWith("http://")).toBe(true);
3279
+ expect(status["protocolWarning"]).toBe(PROTOCOL_WARNING);
3280
+ });
3281
+
3282
+ test("a rotated ENCRYPTION_SECRET (decrypt throws) is needsRegeneration, still 200, never 500", async () => {
3283
+ const row: FeedRowFixture = personalRow({ projectId, userId });
3284
+
3285
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3286
+ personalFindOneById.mockRejectedValue(
3287
+ new Error("Unsupported state or unable to authenticate data"),
3288
+ );
3289
+
3290
+ const result: HttpResult = await request(
3291
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3292
+ );
3293
+
3294
+ expect(result.status).toBe(200);
3295
+
3296
+ const status: Record<string, unknown> = json(result);
3297
+
3298
+ expect(status["exists"]).toBe(true);
3299
+ expect(status["needsRegeneration"]).toBe(true);
3300
+ expect(status["urls"]).toBeNull();
3301
+ expect(status["tokenHint"]).toBe(row.tokenHint);
3302
+ });
3303
+
3304
+ test("a rotated ENCRYPTION_SECRET (decrypt yields bytes that do not hash to tokenHash) is needsRegeneration", async () => {
3305
+ const row: FeedRowFixture = personalRow({ projectId, userId });
3306
+
3307
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3308
+ personalFindOneById.mockResolvedValue({
3309
+ id: row.id,
3310
+ token: CalendarFeedToken.mint(),
3311
+ tokenHash: row.tokenHash,
3312
+ } as never);
3313
+
3314
+ const status: Record<string, unknown> = json(
3315
+ await request(`${API_PREFIX}${FEED_CURRENT_ROUTE}`),
3316
+ );
3317
+
3318
+ expect(status["needsRegeneration"]).toBe(true);
3319
+ expect(status["urls"]).toBeNull();
3320
+ });
3321
+
3322
+ test("an empty token column is needsRegeneration", async () => {
3323
+ const row: FeedRowFixture = personalRow({ projectId, userId });
3324
+
3325
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3326
+ personalFindOneById.mockResolvedValue({
3327
+ id: row.id,
3328
+ token: "",
3329
+ tokenHash: row.tokenHash,
3330
+ } as never);
3331
+
3332
+ const status: Record<string, unknown> = json(
3333
+ await request(`${API_PREFIX}${FEED_CURRENT_ROUTE}`),
3334
+ );
3335
+
3336
+ expect(status["needsRegeneration"]).toBe(true);
3337
+ });
3338
+
3339
+ test("the response never carries the hash", async () => {
3340
+ const token: string = CalendarFeedToken.mint();
3341
+ const row: FeedRowFixture = personalRow({
3342
+ projectId,
3343
+ userId,
3344
+ tokenHash: CalendarFeedToken.hash(token),
3345
+ });
3346
+
3347
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3348
+ personalFindOneById.mockResolvedValue({
3349
+ id: row.id,
3350
+ token,
3351
+ tokenHash: row.tokenHash,
3352
+ } as never);
3353
+
3354
+ const result: HttpResult = await request(
3355
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3356
+ );
3357
+
3358
+ expect(result.body).not.toContain(row.tokenHash);
3359
+ });
3360
+
3361
+ test("without a tenant header the caller is refused and nothing is read", async () => {
3362
+ propsSpy.mockResolvedValue(
3363
+ buildMemberProps({ projectId: undefined, userId }),
3364
+ );
3365
+
3366
+ const result: HttpResult = await request(
3367
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3368
+ );
3369
+
3370
+ expect(result.status).toBe(ExceptionCode.BadDataException);
3371
+ expect(personalFindOneBy).not.toHaveBeenCalled();
3372
+ });
3373
+
3374
+ test("a caller who is not a member of the claimed project is refused", async () => {
3375
+ propsSpy.mockResolvedValue({
3376
+ tenantId: projectId,
3377
+ userId,
3378
+ userTenantAccessPermission: {},
3379
+ });
3380
+
3381
+ const result: HttpResult = await request(
3382
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3383
+ );
3384
+
3385
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
3386
+ expect(personalFindOneBy).not.toHaveBeenCalled();
3387
+ });
3388
+
3389
+ test("an anonymous caller (no user) is refused", async () => {
3390
+ propsSpy.mockResolvedValue(
3391
+ buildMemberProps({ projectId, userId: undefined }),
3392
+ );
3393
+
3394
+ const result: HttpResult = await request(
3395
+ `${API_PREFIX}${FEED_CURRENT_ROUTE}`,
3396
+ );
3397
+
3398
+ expect(result.status).toBeGreaterThanOrEqual(400);
3399
+ expect(personalFindOneBy).not.toHaveBeenCalled();
3400
+ });
3401
+ });
3402
+
3403
+ describe("POST /on-call-calendar/feed/rotate", () => {
3404
+ test("a non-JSON POST is 415 and mints nothing", async () => {
3405
+ const result: HttpResult = await request(
3406
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3407
+ {
3408
+ method: "POST",
3409
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3410
+ },
3411
+ );
3412
+
3413
+ expect(result.status).toBe(415);
3414
+ expect(personalCreateForUser).not.toHaveBeenCalled();
3415
+ expect(personalRotate).not.toHaveBeenCalled();
3416
+ expect(semaphoreLock).not.toHaveBeenCalled();
3417
+ });
3418
+
3419
+ test("a POST with no content type is 415", async () => {
3420
+ const result: HttpResult = await request(
3421
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3422
+ {
3423
+ method: "POST",
3424
+ },
3425
+ );
3426
+
3427
+ expect(result.status).toBe(415);
3428
+ });
3429
+
3430
+ test("first time: mints the row as root under the (project, user) lock, purges, and returns the plaintext once", async () => {
3431
+ const token: string = CalendarFeedToken.mint();
3432
+ let created: FeedRowFixture | null = null;
3433
+
3434
+ personalCreateForUser.mockImplementation(
3435
+ async (data: { projectId: ObjectID; userId: ObjectID }) => {
3436
+ created = personalRow({
3437
+ projectId: data.projectId,
3438
+ userId: data.userId,
3439
+ tokenHash: CalendarFeedToken.hash(token),
3440
+ tokenHint: token.slice(-4),
3441
+ rotatedAt: NOW,
3442
+ });
3443
+
3444
+ return {
3445
+ feed: created,
3446
+ minted: {
3447
+ token,
3448
+ tokenHash: CalendarFeedToken.hash(token),
3449
+ tokenHint: token.slice(-4),
3450
+ },
3451
+ };
3452
+ },
3453
+ );
3454
+
3455
+ personalFindOneBy.mockImplementation(
3456
+ async (args: CapturedFindOneBy): Promise<FeedRowFixture | null> => {
3457
+ return created ? lookupFrom([created])(args) : null;
3458
+ },
3459
+ );
3460
+
3461
+ const result: HttpResult = await postJson(
3462
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3463
+ );
3464
+
3465
+ expect(result.status).toBe(200);
3466
+
3467
+ expect(personalCreateForUser).toHaveBeenCalledTimes(1);
3468
+ expect(personalRotate).not.toHaveBeenCalled();
3469
+
3470
+ const createArgs: { projectId: ObjectID; userId: ObjectID } =
3471
+ personalCreateForUser.mock.calls[0]?.[0] as {
3472
+ projectId: ObjectID;
3473
+ userId: ObjectID;
3474
+ };
3475
+
3476
+ expect(createArgs.projectId.toString()).toBe(projectId.toString());
3477
+ expect(createArgs.userId.toString()).toBe(userId.toString());
3478
+
3479
+ expect(semaphoreLock).toHaveBeenCalledWith({
3480
+ key: `${projectId.toString()}-${userId.toString()}`,
3481
+ namespace: ROTATE_LOCK_NAMESPACE,
3482
+ lockTimeout: ROTATE_LOCK_TIMEOUT_MS,
3483
+ });
3484
+ expect(ROTATE_LOCK_NAMESPACE).toBe("OnCallCalendarFeed.rotate");
3485
+ expect(semaphoreRelease).toHaveBeenCalledTimes(1);
3486
+
3487
+ expect(purgeForUser).toHaveBeenCalledWith(
3488
+ projectId.toString(),
3489
+ userId.toString(),
3490
+ );
3491
+
3492
+ const status: Record<string, unknown> = json(result);
3493
+
3494
+ expect(status["exists"]).toBe(true);
3495
+ expect(status["needsRegeneration"]).toBe(false);
3496
+ expect(status["tokenHint"]).toBe(token.slice(-4));
3497
+ expect((status["urls"] as Record<string, string>)["https"]).toContain(
3498
+ `/api/on-call-calendar/user/${token}/shifts.ics`,
3499
+ );
3500
+
3501
+ /* The plaintext came from the mint, so no decrypting read was needed. */
3502
+ expect(personalFindOneById).not.toHaveBeenCalled();
3503
+ });
3504
+
3505
+ test("existing feed: rotates by id, moving the old hash into its grace, and returns the new plaintext", async () => {
3506
+ const oldToken: string = CalendarFeedToken.mint();
3507
+ const newToken: string = CalendarFeedToken.mint();
3508
+ const row: FeedRowFixture = personalRow({
3509
+ projectId,
3510
+ userId,
3511
+ tokenHash: CalendarFeedToken.hash(oldToken),
3512
+ });
3513
+
3514
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3515
+
3516
+ personalRotate.mockImplementation(
3517
+ async (data: { id: ObjectID }): Promise<CalendarFeedRotation> => {
3518
+ expect(data.id.toString()).toBe(row.id.toString());
3519
+
3520
+ row.tokenHash = CalendarFeedToken.hash(newToken);
3521
+ row.previousTokenHash = CalendarFeedToken.hash(oldToken);
3522
+ row.previousTokenExpiresAt = at("2026-10-01T12:00:00Z");
3523
+ row.tokenHint = newToken.slice(-4);
3524
+ row.rotatedAt = NOW;
3525
+
3526
+ return {
3527
+ token: newToken,
3528
+ tokenHash: row.tokenHash,
3529
+ tokenHint: row.tokenHint,
3530
+ rotatedAt: NOW,
3531
+ previousTokenHash: row.previousTokenHash,
3532
+ previousTokenExpiresAt: row.previousTokenExpiresAt,
3533
+ };
3534
+ },
3535
+ );
3536
+
3537
+ const result: HttpResult = await postJson(
3538
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3539
+ );
3540
+
3541
+ expect(result.status).toBe(200);
3542
+ expect(personalCreateForUser).not.toHaveBeenCalled();
3543
+ expect(personalRotate).toHaveBeenCalledTimes(1);
3544
+ expect(purgeForUser).toHaveBeenCalledTimes(1);
3545
+
3546
+ const status: Record<string, unknown> = json(result);
3547
+
3548
+ expect(status["tokenHint"]).toBe(newToken.slice(-4));
3549
+ expect(status["rotatedAt"]).toBe(NOW.toISOString());
3550
+ expect(status["previousTokenExpiresAt"]).toBe("2026-10-01T12:00:00.000Z");
3551
+ expect((status["urls"] as Record<string, string>)["https"]).toContain(
3552
+ newToken,
3553
+ );
3554
+ expect(result.body).not.toContain(oldToken);
3555
+ });
3556
+
3557
+ test("the lock is released even when the mint throws, and the error propagates", async () => {
3558
+ personalCreateForUser.mockRejectedValue(new Error("unique violation"));
3559
+
3560
+ const result: HttpResult = await postJson(
3561
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3562
+ );
3563
+
3564
+ expect(result.status).toBe(500);
3565
+ expect(semaphoreRelease).toHaveBeenCalledTimes(1);
3566
+ expect(purgeForUser).not.toHaveBeenCalled();
3567
+ });
3568
+
3569
+ test("when the lock cannot be taken (Redis away) the rotation still proceeds on the unique index", async () => {
3570
+ semaphoreLock.mockRejectedValue(new Error("redis is away"));
3571
+
3572
+ const token: string = CalendarFeedToken.mint();
3573
+ const row: FeedRowFixture = personalRow({
3574
+ projectId,
3575
+ userId,
3576
+ tokenHash: CalendarFeedToken.hash(token),
3577
+ });
3578
+
3579
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
3580
+ personalRotate.mockResolvedValue({
3581
+ token,
3582
+ tokenHash: row.tokenHash,
3583
+ tokenHint: token.slice(-4),
3584
+ rotatedAt: NOW,
3585
+ previousTokenHash: null,
3586
+ previousTokenExpiresAt: null,
3587
+ });
3588
+
3589
+ const result: HttpResult = await postJson(
3590
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3591
+ );
3592
+
3593
+ expect(result.status).toBe(200);
3594
+ expect(personalRotate).toHaveBeenCalledTimes(1);
3595
+ expect(semaphoreRelease).not.toHaveBeenCalled();
3596
+ expect(logger.warn).toHaveBeenCalled();
3597
+ });
3598
+
3599
+ test("a non-member is refused before the lock", async () => {
3600
+ propsSpy.mockResolvedValue({
3601
+ tenantId: projectId,
3602
+ userId,
3603
+ userTenantAccessPermission: {},
3604
+ });
3605
+
3606
+ const result: HttpResult = await postJson(
3607
+ `${API_PREFIX}${FEED_ROTATE_ROUTE}`,
3608
+ );
3609
+
3610
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
3611
+ expect(semaphoreLock).not.toHaveBeenCalled();
3612
+ expect(personalCreateForUser).not.toHaveBeenCalled();
3613
+ });
3614
+ });
3615
+
3616
+ // -- Session routes: the schedule feed -----------------------------------------
3617
+
3618
+ describe("schedule-feed session routes", () => {
3619
+ let scheduleId: ObjectID;
3620
+
3621
+ function currentPath(): string {
3622
+ return `${API_PREFIX}${SCHEDULE_FEED_CURRENT_ROUTE.replace(
3623
+ ":scheduleId",
3624
+ scheduleId.toString(),
3625
+ )}`;
3626
+ }
3627
+
3628
+ function publishPath(): string {
3629
+ return `${API_PREFIX}${SCHEDULE_FEED_PUBLISH_ROUTE.replace(
3630
+ ":scheduleId",
3631
+ scheduleId.toString(),
3632
+ )}`;
3633
+ }
3634
+
3635
+ function rotatePath(): string {
3636
+ return `${API_PREFIX}${SCHEDULE_FEED_ROTATE_ROUTE.replace(
3637
+ ":scheduleId",
3638
+ scheduleId.toString(),
3639
+ )}`;
3640
+ }
3641
+
3642
+ beforeEach(() => {
3643
+ scheduleId = ObjectID.generate();
3644
+ });
3645
+
3646
+ test("GET /current reads with the CALLER's props (non-root) so TableAccessControl and label scoping apply", async () => {
3647
+ const result: HttpResult = await request(currentPath());
3648
+
3649
+ expect(result.status).toBe(200);
3650
+ expect(json(result)["exists"]).toBe(false);
3651
+ expect(json(result)["settings"]).toEqual({
3652
+ includeCoverageGaps: false,
3653
+ minimumGapMinutes: 60,
3654
+ pastDays: DEFAULT_PAST_DAYS,
3655
+ futureDays: DEFAULT_FUTURE_DAYS,
3656
+ rotateWhenMemberLeaves: false,
3657
+ });
3658
+
3659
+ const lookup: CapturedFindOneBy = callsOf<CapturedFindOneBy>(
3660
+ scheduleFindOneBy,
3661
+ )[0] as CapturedFindOneBy;
3662
+
3663
+ expect(String(lookup.query["onCallDutyPolicyScheduleId"])).toBe(
3664
+ scheduleId.toString(),
3665
+ );
3666
+ expect(String(lookup.query["projectId"])).toBe(projectId.toString());
3667
+ expect(lookup.props?.isRoot).toBeUndefined();
3668
+ expect(lookup.props).toBe(await propsSpy.mock.results[0]?.value);
3669
+ expect(lookup.select?.["token"]).toBeUndefined();
3670
+ });
3671
+
3672
+ test("GET /current with a published feed decrypts once and returns the schedule URL", async () => {
3673
+ const token: string = CalendarFeedToken.mint();
3674
+ const row: FeedRowFixture = scheduleRow({
3675
+ projectId,
3676
+ onCallDutyPolicyScheduleId: scheduleId,
3677
+ tokenHash: CalendarFeedToken.hash(token),
3678
+ rotateWhenMemberLeaves: true,
3679
+ });
3680
+
3681
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3682
+ scheduleFindOneById.mockResolvedValue({
3683
+ id: row.id,
3684
+ token,
3685
+ tokenHash: row.tokenHash,
3686
+ } as never);
3687
+
3688
+ const status: Record<string, unknown> = json(await request(currentPath()));
3689
+
3690
+ expect(status["exists"]).toBe(true);
3691
+ expect(status["feedId"]).toBe(row.id.toString());
3692
+ expect(status["needsRegeneration"]).toBe(false);
3693
+ expect((status["urls"] as Record<string, string>)["https"]).toBe(
3694
+ `https://oneuptime.example.com/api/on-call-calendar/schedule/${token}/schedule.ics`,
3695
+ );
3696
+ expect(
3697
+ (status["settings"] as Record<string, unknown>)["rotateWhenMemberLeaves"],
3698
+ ).toBe(true);
3699
+ expect(scheduleFindOneById).toHaveBeenCalledTimes(1);
3700
+ });
3701
+
3702
+ test("GET /current with a rotated secret is needsRegeneration", async () => {
3703
+ const row: FeedRowFixture = scheduleRow({
3704
+ projectId,
3705
+ onCallDutyPolicyScheduleId: scheduleId,
3706
+ });
3707
+
3708
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3709
+ scheduleFindOneById.mockRejectedValue(new Error("bad decrypt"));
3710
+
3711
+ const status: Record<string, unknown> = json(await request(currentPath()));
3712
+
3713
+ expect(status["exists"]).toBe(true);
3714
+ expect(status["needsRegeneration"]).toBe(true);
3715
+ expect(status["urls"]).toBeNull();
3716
+ });
3717
+
3718
+ test("a schedule id that is not a UUID is a 400, not a Postgres error", async () => {
3719
+ const result: HttpResult = await request(
3720
+ `${API_PREFIX}/on-call-calendar/schedule-feed/not-a-uuid/current`,
3721
+ );
3722
+
3723
+ expect(result.status).toBe(ExceptionCode.BadDataException);
3724
+ expect(scheduleFindOneBy).not.toHaveBeenCalled();
3725
+ });
3726
+
3727
+ test("POST /publish (first time) is a NON-root create carrying only projectId and the schedule id, then a purge", async () => {
3728
+ const token: string = CalendarFeedToken.mint();
3729
+ let created: FeedRowFixture | null = null;
3730
+
3731
+ scheduleCreate.mockImplementation(async () => {
3732
+ created = scheduleRow({
3733
+ projectId,
3734
+ onCallDutyPolicyScheduleId: scheduleId,
3735
+ tokenHash: CalendarFeedToken.hash(token),
3736
+ });
3737
+
3738
+ return created;
3739
+ });
3740
+
3741
+ scheduleFindOneBy.mockImplementation(
3742
+ async (args: CapturedFindOneBy): Promise<FeedRowFixture | null> => {
3743
+ return created ? lookupFrom([created])(args) : null;
3744
+ },
3745
+ );
3746
+ scheduleFindOneById.mockImplementation(async () => {
3747
+ return created
3748
+ ? { id: created.id, token, tokenHash: created.tokenHash }
3749
+ : null;
3750
+ });
3751
+
3752
+ const result: HttpResult = await postJson(publishPath());
3753
+
3754
+ expect(result.status).toBe(200);
3755
+ expect(scheduleCreate).toHaveBeenCalledTimes(1);
3756
+
3757
+ const create: CapturedCreate = callsOf<CapturedCreate>(
3758
+ scheduleCreate,
3759
+ )[0] as CapturedCreate;
3760
+
3761
+ expect(String(create.data["projectId"])).toBe(projectId.toString());
3762
+ expect(String(create.data["onCallDutyPolicyScheduleId"])).toBe(
3763
+ scheduleId.toString(),
3764
+ );
3765
+ expect(create.data["token"]).toBeUndefined();
3766
+ expect(create.data["tokenHash"]).toBeUndefined();
3767
+ expect(create.props?.isRoot).toBeUndefined();
3768
+ expect(create.props).toBe(await propsSpy.mock.results[0]?.value);
3769
+
3770
+ expect(scheduleUpdateOneBy).not.toHaveBeenCalled();
3771
+ expect(purgeForSchedule).toHaveBeenCalledWith(scheduleId.toString());
3772
+
3773
+ const status: Record<string, unknown> = json(result);
3774
+
3775
+ expect(status["exists"]).toBe(true);
3776
+ expect((status["urls"] as Record<string, string>)["https"]).toContain(
3777
+ `/api/on-call-calendar/schedule/${token}/schedule.ics`,
3778
+ );
3779
+ });
3780
+
3781
+ test("POST /publish on an existing feed re-enables it through a NON-root update and keeps the link", async () => {
3782
+ const token: string = CalendarFeedToken.mint();
3783
+ const row: FeedRowFixture = scheduleRow({
3784
+ projectId,
3785
+ onCallDutyPolicyScheduleId: scheduleId,
3786
+ tokenHash: CalendarFeedToken.hash(token),
3787
+ isEnabled: false,
3788
+ });
3789
+
3790
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3791
+ scheduleFindOneById.mockResolvedValue({
3792
+ id: row.id,
3793
+ token,
3794
+ tokenHash: row.tokenHash,
3795
+ } as never);
3796
+ scheduleUpdateOneBy.mockImplementation(async (args: CapturedUpdate) => {
3797
+ row.isEnabled = args.data["isEnabled"] as boolean;
3798
+ return 1;
3799
+ });
3800
+
3801
+ const result: HttpResult = await postJson(publishPath());
3802
+
3803
+ expect(result.status).toBe(200);
3804
+ expect(scheduleCreate).not.toHaveBeenCalled();
3805
+ expect(scheduleRotate).not.toHaveBeenCalled();
3806
+
3807
+ const update: CapturedUpdate = callsOf<CapturedUpdate>(
3808
+ scheduleUpdateOneBy,
3809
+ )[0] as CapturedUpdate;
3810
+
3811
+ expect(String(update.query?.["_id"])).toBe(row.id.toString());
3812
+ expect(String(update.query?.["projectId"])).toBe(projectId.toString());
3813
+ expect(update.data).toEqual({ isEnabled: true });
3814
+ expect(update.props?.isRoot).toBeUndefined();
3815
+
3816
+ const status: Record<string, unknown> = json(result);
3817
+
3818
+ expect(status["isEnabled"]).toBe(true);
3819
+ expect((status["urls"] as Record<string, string>)["https"]).toContain(
3820
+ token,
3821
+ );
3822
+ });
3823
+
3824
+ /*
3825
+ * Regression: publish relied on the CREATE for its permission gate, and
3826
+ * @CanAccessIfCanReadOn -- the label scoping that decides WHICH schedules an
3827
+ * editor may touch -- is only applied to query operations. A label-
3828
+ * restricted editor who knew a schedule's id could therefore mint an
3829
+ * enabled feed row for a schedule outside their labels (they could not read
3830
+ * the link afterwards, but the row was there for the next editor to find
3831
+ * "already published"). The route now reads the schedule with the caller's
3832
+ * props first.
3833
+ */
3834
+ test("POST /publish reads the schedule with the CALLER's props before creating", async () => {
3835
+ scheduleFindOneBy.mockResolvedValue(null as never);
3836
+
3837
+ await postJson(publishPath());
3838
+
3839
+ expect(scheduleModelFindOneBy).toHaveBeenCalledTimes(1);
3840
+
3841
+ const read: CapturedFindOneBy = scheduleModelFindOneBy.mock
3842
+ .calls[0]?.[0] as CapturedFindOneBy;
3843
+
3844
+ expect(String(read.query["_id"])).toBe(scheduleId.toString());
3845
+ expect(String(read.query["projectId"])).toBe(projectId.toString());
3846
+ expect(read.props?.isRoot).toBeUndefined();
3847
+ expect(read.props).toBe(await propsSpy.mock.results[0]?.value);
3848
+
3849
+ /* The schedule read happens BEFORE the feed is created. */
3850
+ expect(scheduleModelFindOneBy.mock.invocationCallOrder[0]).toBeLessThan(
3851
+ scheduleCreate.mock.invocationCallOrder[0] as number,
3852
+ );
3853
+ });
3854
+
3855
+ test("POST /publish for a schedule the caller cannot read is 404 and creates nothing", async () => {
3856
+ scheduleFindOneBy.mockResolvedValue(null as never);
3857
+ scheduleModelFindOneBy.mockResolvedValue(null as never);
3858
+
3859
+ const result: HttpResult = await postJson(publishPath());
3860
+
3861
+ expect(result.status).toBe(404);
3862
+ expect(scheduleCreate).not.toHaveBeenCalled();
3863
+ expect(purgeForSchedule).not.toHaveBeenCalled();
3864
+ expect(json(result)).toEqual({ message: "On-call schedule not found." });
3865
+ });
3866
+
3867
+ test("POST /publish on an EXISTING feed needs no second schedule read (the feed read is already scoped)", async () => {
3868
+ const token: string = CalendarFeedToken.mint();
3869
+ const row: FeedRowFixture = scheduleRow({
3870
+ projectId,
3871
+ onCallDutyPolicyScheduleId: scheduleId,
3872
+ tokenHash: CalendarFeedToken.hash(token),
3873
+ });
3874
+
3875
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3876
+ scheduleFindOneById.mockResolvedValue({
3877
+ id: row.id,
3878
+ token,
3879
+ tokenHash: row.tokenHash,
3880
+ } as never);
3881
+
3882
+ const result: HttpResult = await postJson(publishPath());
3883
+
3884
+ expect(result.status).toBe(200);
3885
+ expect(scheduleModelFindOneBy).not.toHaveBeenCalled();
3886
+ });
3887
+
3888
+ test("POST /publish by a caller without Edit permission is refused by the service, nothing is purged", async () => {
3889
+ scheduleCreate.mockRejectedValue(
3890
+ new NotAuthorizedException(
3891
+ "You do not have permission to create OnCallDutyPolicyScheduleCalendarFeed.",
3892
+ ),
3893
+ );
3894
+
3895
+ const result: HttpResult = await postJson(publishPath());
3896
+
3897
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
3898
+ expect(purgeForSchedule).not.toHaveBeenCalled();
3899
+ expect(scheduleRotate).not.toHaveBeenCalled();
3900
+ });
3901
+
3902
+ test("POST /publish with the wrong content type is 415", async () => {
3903
+ const result: HttpResult = await request(publishPath(), {
3904
+ method: "POST",
3905
+ headers: { "Content-Type": "text/plain" },
3906
+ });
3907
+
3908
+ expect(result.status).toBe(415);
3909
+ expect(scheduleCreate).not.toHaveBeenCalled();
3910
+ });
3911
+
3912
+ test("POST /rotate: a NON-root permission probe on an updatable column, THEN the root mint, THEN the purge", async () => {
3913
+ const oldToken: string = CalendarFeedToken.mint();
3914
+ const newToken: string = CalendarFeedToken.mint();
3915
+ const row: FeedRowFixture = scheduleRow({
3916
+ projectId,
3917
+ onCallDutyPolicyScheduleId: scheduleId,
3918
+ tokenHash: CalendarFeedToken.hash(oldToken),
3919
+ });
3920
+
3921
+ const order: Array<string> = [];
3922
+
3923
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3924
+ scheduleUpdateOneBy.mockImplementation(async () => {
3925
+ order.push("probe");
3926
+ return 1;
3927
+ });
3928
+ scheduleRotate.mockImplementation(
3929
+ async (data: { id: ObjectID }): Promise<CalendarFeedRotation> => {
3930
+ order.push("mint");
3931
+ expect(data.id.toString()).toBe(row.id.toString());
3932
+
3933
+ row.tokenHash = CalendarFeedToken.hash(newToken);
3934
+ row.tokenHint = newToken.slice(-4);
3935
+ row.rotatedAt = NOW;
3936
+
3937
+ return {
3938
+ token: newToken,
3939
+ tokenHash: row.tokenHash,
3940
+ tokenHint: row.tokenHint,
3941
+ rotatedAt: NOW,
3942
+ previousTokenHash: CalendarFeedToken.hash(oldToken),
3943
+ previousTokenExpiresAt: at("2026-10-01T12:00:00Z"),
3944
+ };
3945
+ },
3946
+ );
3947
+ purgeForSchedule.mockImplementation(async () => {
3948
+ order.push("purge");
3949
+ });
3950
+
3951
+ const result: HttpResult = await postJson(rotatePath());
3952
+
3953
+ expect(result.status).toBe(200);
3954
+ expect(order).toEqual(["probe", "mint", "purge"]);
3955
+
3956
+ const probe: CapturedUpdate = callsOf<CapturedUpdate>(
3957
+ scheduleUpdateOneBy,
3958
+ )[0] as CapturedUpdate;
3959
+
3960
+ expect(probe.data).toEqual({ isEnabled: true });
3961
+ expect(probe.props?.isRoot).toBeUndefined();
3962
+ expect(probe.data["rotatedAt"]).toBeUndefined();
3963
+ expect(probe.data["token"]).toBeUndefined();
3964
+
3965
+ expect(purgeForSchedule).toHaveBeenCalledWith(scheduleId.toString());
3966
+
3967
+ const status: Record<string, unknown> = json(result);
3968
+
3969
+ expect((status["urls"] as Record<string, string>)["https"]).toContain(
3970
+ newToken,
3971
+ );
3972
+ expect(result.body).not.toContain(oldToken);
3973
+ expect(scheduleFindOneById).not.toHaveBeenCalled();
3974
+ });
3975
+
3976
+ test("POST /rotate keeps a disabled feed disabled (the probe writes the current value back)", async () => {
3977
+ const row: FeedRowFixture = scheduleRow({
3978
+ projectId,
3979
+ onCallDutyPolicyScheduleId: scheduleId,
3980
+ isEnabled: false,
3981
+ });
3982
+
3983
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
3984
+ scheduleRotate.mockResolvedValue({
3985
+ token: CalendarFeedToken.mint(),
3986
+ tokenHash: row.tokenHash,
3987
+ tokenHint: "abcd",
3988
+ rotatedAt: NOW,
3989
+ previousTokenHash: null,
3990
+ previousTokenExpiresAt: null,
3991
+ });
3992
+
3993
+ await postJson(rotatePath());
3994
+
3995
+ const probe: CapturedUpdate = callsOf<CapturedUpdate>(
3996
+ scheduleUpdateOneBy,
3997
+ )[0] as CapturedUpdate;
3998
+
3999
+ expect(probe.data).toEqual({ isEnabled: false });
4000
+ });
4001
+
4002
+ test("POST /rotate by a caller without Edit permission never reaches the mint", async () => {
4003
+ const row: FeedRowFixture = scheduleRow({
4004
+ projectId,
4005
+ onCallDutyPolicyScheduleId: scheduleId,
4006
+ });
4007
+
4008
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
4009
+ scheduleUpdateOneBy.mockRejectedValue(
4010
+ new NotAuthorizedException("You do not have permission to edit this."),
4011
+ );
4012
+
4013
+ const result: HttpResult = await postJson(rotatePath());
4014
+
4015
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
4016
+ expect(scheduleRotate).not.toHaveBeenCalled();
4017
+ expect(purgeForSchedule).not.toHaveBeenCalled();
4018
+ });
4019
+
4020
+ test("POST /rotate on an unpublished feed is 404 with a message that says to publish first", async () => {
4021
+ const result: HttpResult = await postJson(rotatePath());
4022
+
4023
+ expect(result.status).toBe(404);
4024
+ expect(json(result)["message"]).toContain("Publish");
4025
+ expect(scheduleRotate).not.toHaveBeenCalled();
4026
+ });
4027
+
4028
+ test("a feed in ANOTHER project is invisible: the caller-scoped lookup carries the caller's project", async () => {
4029
+ const row: FeedRowFixture = scheduleRow({
4030
+ projectId: ObjectID.generate(),
4031
+ onCallDutyPolicyScheduleId: scheduleId,
4032
+ });
4033
+
4034
+ scheduleFindOneBy.mockImplementation(lookupFrom([row]) as never);
4035
+
4036
+ const result: HttpResult = await request(currentPath());
4037
+
4038
+ expect(json(result)["exists"]).toBe(false);
4039
+ });
4040
+ });
4041
+
4042
+ // -- Session routes: the project feed ------------------------------------------
4043
+
4044
+ describe("project-feed session routes", () => {
4045
+ test("GET /current reads the project's row with the caller's props", async () => {
4046
+ const result: HttpResult = await request(
4047
+ `${API_PREFIX}${PROJECT_FEED_CURRENT_ROUTE}`,
4048
+ );
4049
+
4050
+ expect(result.status).toBe(200);
4051
+ expect(json(result)["exists"]).toBe(false);
4052
+
4053
+ const lookup: CapturedFindOneBy = callsOf<CapturedFindOneBy>(
4054
+ projectFindOneBy,
4055
+ )[0] as CapturedFindOneBy;
4056
+
4057
+ expect(lookup.query).toEqual({ projectId });
4058
+ expect(lookup.props?.isRoot).toBeUndefined();
4059
+ expect(lookup.select?.["token"]).toBeUndefined();
4060
+ });
4061
+
4062
+ test("GET /current with a published feed returns the project URL and the shared settings", async () => {
4063
+ const token: string = CalendarFeedToken.mint();
4064
+ const row: FeedRowFixture = projectRow({
4065
+ projectId,
4066
+ tokenHash: CalendarFeedToken.hash(token),
4067
+ });
4068
+
4069
+ projectFindOneBy.mockImplementation(lookupFrom([row]) as never);
4070
+ projectFindOneById.mockResolvedValue({
4071
+ id: row.id,
4072
+ token,
4073
+ tokenHash: row.tokenHash,
4074
+ } as never);
4075
+
4076
+ const status: Record<string, unknown> = json(
4077
+ await request(`${API_PREFIX}${PROJECT_FEED_CURRENT_ROUTE}`),
4078
+ );
4079
+
4080
+ expect(status["exists"]).toBe(true);
4081
+ expect((status["urls"] as Record<string, string>)["https"]).toBe(
4082
+ `https://oneuptime.example.com/api/on-call-calendar/project/${token}/project.ics`,
4083
+ );
4084
+ expect(status["settings"]).toEqual({
4085
+ includeCoverageGaps: true,
4086
+ minimumGapMinutes: 30,
4087
+ pastDays: DEFAULT_PAST_DAYS,
4088
+ futureDays: DEFAULT_FUTURE_DAYS,
4089
+ rotateWhenMemberLeaves: true,
4090
+ });
4091
+ });
4092
+
4093
+ test("POST /publish creates a row carrying only projectId, non-root, then purges the project", async () => {
4094
+ const token: string = CalendarFeedToken.mint();
4095
+ let created: FeedRowFixture | null = null;
4096
+
4097
+ projectCreate.mockImplementation(async () => {
4098
+ created = projectRow({
4099
+ projectId,
4100
+ tokenHash: CalendarFeedToken.hash(token),
4101
+ });
4102
+
4103
+ return created;
4104
+ });
4105
+ projectFindOneBy.mockImplementation(
4106
+ async (args: CapturedFindOneBy): Promise<FeedRowFixture | null> => {
4107
+ return created ? lookupFrom([created])(args) : null;
4108
+ },
4109
+ );
4110
+ projectFindOneById.mockImplementation(async () => {
4111
+ return created
4112
+ ? { id: created.id, token, tokenHash: created.tokenHash }
4113
+ : null;
4114
+ });
4115
+
4116
+ const result: HttpResult = await postJson(
4117
+ `${API_PREFIX}${PROJECT_FEED_PUBLISH_ROUTE}`,
4118
+ );
4119
+
4120
+ expect(result.status).toBe(200);
4121
+
4122
+ const create: CapturedCreate = callsOf<CapturedCreate>(
4123
+ projectCreate,
4124
+ )[0] as CapturedCreate;
4125
+
4126
+ expect(String(create.data["projectId"])).toBe(projectId.toString());
4127
+ expect(create.data["onCallDutyPolicyScheduleId"]).toBeUndefined();
4128
+ expect(create.data["token"]).toBeUndefined();
4129
+ expect(create.props?.isRoot).toBeUndefined();
4130
+
4131
+ expect(purgeForProject).toHaveBeenCalledWith(projectId.toString());
4132
+ expect(purgeForSchedule).not.toHaveBeenCalled();
4133
+
4134
+ expect((json(result)["urls"] as Record<string, string>)["https"]).toContain(
4135
+ `/api/on-call-calendar/project/${token}/project.ics`,
4136
+ );
4137
+ });
4138
+
4139
+ test("POST /publish without permission is refused and purges nothing", async () => {
4140
+ projectCreate.mockRejectedValue(new NotAuthorizedException("no"));
4141
+
4142
+ const result: HttpResult = await postJson(
4143
+ `${API_PREFIX}${PROJECT_FEED_PUBLISH_ROUTE}`,
4144
+ );
4145
+
4146
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
4147
+ expect(purgeForProject).not.toHaveBeenCalled();
4148
+ });
4149
+
4150
+ test("POST /rotate probes non-root, mints root, purges the project, returns the plaintext once", async () => {
4151
+ const newToken: string = CalendarFeedToken.mint();
4152
+ const row: FeedRowFixture = projectRow({ projectId });
4153
+
4154
+ projectFindOneBy.mockImplementation(lookupFrom([row]) as never);
4155
+ projectRotate.mockImplementation(
4156
+ async (data: { id: ObjectID }): Promise<CalendarFeedRotation> => {
4157
+ expect(data.id.toString()).toBe(row.id.toString());
4158
+
4159
+ row.tokenHash = CalendarFeedToken.hash(newToken);
4160
+ row.tokenHint = newToken.slice(-4);
4161
+
4162
+ return {
4163
+ token: newToken,
4164
+ tokenHash: row.tokenHash,
4165
+ tokenHint: row.tokenHint,
4166
+ rotatedAt: NOW,
4167
+ previousTokenHash: "old",
4168
+ previousTokenExpiresAt: at("2026-10-01T12:00:00Z"),
4169
+ };
4170
+ },
4171
+ );
4172
+
4173
+ const result: HttpResult = await postJson(
4174
+ `${API_PREFIX}${PROJECT_FEED_ROTATE_ROUTE}`,
4175
+ );
4176
+
4177
+ expect(result.status).toBe(200);
4178
+
4179
+ const probe: CapturedUpdate = callsOf<CapturedUpdate>(
4180
+ projectUpdateOneBy,
4181
+ )[0] as CapturedUpdate;
4182
+
4183
+ expect(probe.data).toEqual({ isEnabled: true });
4184
+ expect(probe.props?.isRoot).toBeUndefined();
4185
+ expect(String(probe.query?.["_id"])).toBe(row.id.toString());
4186
+
4187
+ expect(projectRotate).toHaveBeenCalledTimes(1);
4188
+ expect(purgeForProject).toHaveBeenCalledWith(projectId.toString());
4189
+ expect((json(result)["urls"] as Record<string, string>)["https"]).toContain(
4190
+ newToken,
4191
+ );
4192
+ expect(projectFindOneById).not.toHaveBeenCalled();
4193
+ });
4194
+
4195
+ test("POST /rotate without permission never mints", async () => {
4196
+ const row: FeedRowFixture = projectRow({ projectId });
4197
+
4198
+ projectFindOneBy.mockImplementation(lookupFrom([row]) as never);
4199
+ projectUpdateOneBy.mockRejectedValue(new NotAuthorizedException("no"));
4200
+
4201
+ const result: HttpResult = await postJson(
4202
+ `${API_PREFIX}${PROJECT_FEED_ROTATE_ROUTE}`,
4203
+ );
4204
+
4205
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
4206
+ expect(projectRotate).not.toHaveBeenCalled();
4207
+ expect(purgeForProject).not.toHaveBeenCalled();
4208
+ });
4209
+
4210
+ test("POST /rotate on an unpublished project feed is 404", async () => {
4211
+ const result: HttpResult = await postJson(
4212
+ `${API_PREFIX}${PROJECT_FEED_ROTATE_ROUTE}`,
4213
+ );
4214
+
4215
+ expect(result.status).toBe(404);
4216
+ expect(projectRotate).not.toHaveBeenCalled();
4217
+ });
4218
+
4219
+ test("the three POST routes are 415 without JSON", async () => {
4220
+ for (const uri of [
4221
+ PROJECT_FEED_PUBLISH_ROUTE,
4222
+ PROJECT_FEED_ROTATE_ROUTE,
4223
+ FEED_ROTATE_ROUTE,
4224
+ ]) {
4225
+ const result: HttpResult = await request(`${API_PREFIX}${uri}`, {
4226
+ method: "POST",
4227
+ headers: { "Content-Type": "text/html" },
4228
+ });
4229
+
4230
+ expect(result.status).toBe(415);
4231
+ }
4232
+ });
4233
+ });
4234
+
4235
+ // -- Session route: /my-shifts --------------------------------------------------
4236
+
4237
+ describe("GET /on-call-calendar/my-shifts", () => {
4238
+ const sample: MaterializedShift = shift({
4239
+ start: at("2026-09-02T07:00:00Z"),
4240
+ end: at("2026-09-02T15:00:00Z"),
4241
+ projectName: "Acme",
4242
+ });
4243
+
4244
+ beforeEach(() => {
4245
+ materializeSpy.mockResolvedValue({
4246
+ shifts: [sample],
4247
+ truncated: false,
4248
+ generatedAt: NOW,
4249
+ });
4250
+ });
4251
+
4252
+ test("with a tenant header the scope is that project, and the window defaults to now -> +30 d", async () => {
4253
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4254
+
4255
+ expect(result.status).toBe(200);
4256
+
4257
+ const args: {
4258
+ userId: ObjectID;
4259
+ projectIds?: Array<ObjectID>;
4260
+ from: Date;
4261
+ to: Date;
4262
+ now: Date;
4263
+ } = materializeSpy.mock.calls[0]?.[0] as {
4264
+ userId: ObjectID;
4265
+ projectIds?: Array<ObjectID>;
4266
+ from: Date;
4267
+ to: Date;
4268
+ now: Date;
4269
+ };
4270
+
4271
+ expect(args.userId.toString()).toBe(userId.toString());
4272
+ expect(args.projectIds?.map(String)).toEqual([projectId.toString()]);
4273
+ expect(args.from.toISOString()).toBe(NOW.toISOString());
4274
+ expect(args.to.toISOString()).toBe(
4275
+ OneUptimeDate.addRemoveDays(NOW, 30).toISOString(),
4276
+ );
4277
+ expect(args.now.toISOString()).toBe(NOW.toISOString());
4278
+ });
4279
+
4280
+ test("without a tenant header the scope is every project the caller is rostered in (mobile)", async () => {
4281
+ propsSpy.mockResolvedValue({ tenantId: undefined, userId });
4282
+
4283
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4284
+
4285
+ expect(result.status).toBe(200);
4286
+
4287
+ const args: { projectIds?: Array<ObjectID> } = materializeSpy.mock
4288
+ .calls[0]?.[0] as { projectIds?: Array<ObjectID> };
4289
+
4290
+ expect(args.projectIds).toBeUndefined();
4291
+ });
4292
+
4293
+ test("with a tenant header the caller must be a member of that project", async () => {
4294
+ propsSpy.mockResolvedValue({
4295
+ tenantId: projectId,
4296
+ userId,
4297
+ userTenantAccessPermission: {},
4298
+ });
4299
+
4300
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4301
+
4302
+ expect(result.status).toBe(ExceptionCode.NotAuthorizedException);
4303
+ expect(materializeSpy).not.toHaveBeenCalled();
4304
+ });
4305
+
4306
+ test("an anonymous caller is refused", async () => {
4307
+ propsSpy.mockResolvedValue({ tenantId: undefined, userId: undefined });
4308
+
4309
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4310
+
4311
+ expect(result.status).toBe(ExceptionCode.BadDataException);
4312
+ expect(materializeSpy).not.toHaveBeenCalled();
4313
+ });
4314
+
4315
+ test("the payload is { shifts: MaterializedShiftJson[], truncated, generatedAt }", async () => {
4316
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4317
+
4318
+ const payload: Record<string, unknown> = json(result);
4319
+
4320
+ expect(payload["truncated"]).toBe(false);
4321
+ expect(payload["generatedAt"]).toBe(NOW.toISOString());
4322
+
4323
+ const shifts: Array<Record<string, unknown>> = payload["shifts"] as Array<
4324
+ Record<string, unknown>
4325
+ >;
4326
+
4327
+ expect(shifts).toHaveLength(1);
4328
+ expect(shifts[0]).toMatchObject({
4329
+ shiftKey: sample.shiftKey,
4330
+ projectId: sample.projectId,
4331
+ projectName: "Acme",
4332
+ scheduleId: sample.scheduleId,
4333
+ scheduleName: sample.scheduleName,
4334
+ scheduleTimezone: "Europe/Stockholm",
4335
+ userId: sample.userId,
4336
+ userName: sample.userName,
4337
+ start: "2026-09-02T07:00:00.000Z",
4338
+ end: "2026-09-02T15:00:00.000Z",
4339
+ coverageSeconds: 8 * 3600,
4340
+ isPast: false,
4341
+ shiftConfigVersion: sample.shiftConfigVersion,
4342
+ });
4343
+ expect(typeof shifts[0]?.["lastModifiedAt"]).toBe("string");
4344
+ expect(Array.isArray(shifts[0]?.["policies"])).toBe(true);
4345
+ expect("override" in (shifts[0] || {})).toBe(false);
4346
+ });
4347
+
4348
+ test("?from and ?to are parsed and the span capped at 120 days", async () => {
4349
+ await request(
4350
+ `${API_PREFIX}${MY_SHIFTS_ROUTE}?from=2026-09-05T00:00:00.000Z&to=2027-06-01T00:00:00.000Z`,
4351
+ );
4352
+
4353
+ const args: { from: Date; to: Date } = materializeSpy.mock
4354
+ .calls[0]?.[0] as { from: Date; to: Date };
4355
+
4356
+ expect(args.from.toISOString()).toBe("2026-09-05T00:00:00.000Z");
4357
+ expect(args.to.toISOString()).toBe(
4358
+ OneUptimeDate.addRemoveDays(
4359
+ at("2026-09-05T00:00:00.000Z"),
4360
+ MY_SHIFTS_MAX_DAYS,
4361
+ ).toISOString(),
4362
+ );
4363
+ });
4364
+
4365
+ test("a garbage ?from is 400", async () => {
4366
+ const result: HttpResult = await request(
4367
+ `${API_PREFIX}${MY_SHIFTS_ROUTE}?from=yesterday`,
4368
+ );
4369
+
4370
+ expect(result.status).toBe(ExceptionCode.BadDataException);
4371
+ expect(materializeSpy).not.toHaveBeenCalled();
4372
+ });
4373
+
4374
+ test("render cap reached: 503 + Retry-After 60, nothing materialized, no slot released", async () => {
4375
+ tryAcquireRenderSlot.mockReturnValue(false);
4376
+
4377
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4378
+
4379
+ expect(result.status).toBe(503);
4380
+ expect(header(result, "retry-after")).toBe("60");
4381
+ expect(materializeSpy).not.toHaveBeenCalled();
4382
+ expect(releaseRenderSlot).not.toHaveBeenCalled();
4383
+ });
4384
+
4385
+ test("the render slot is released after a successful materialization", async () => {
4386
+ await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4387
+
4388
+ expect(tryAcquireRenderSlot).toHaveBeenCalledTimes(1);
4389
+ expect(releaseRenderSlot).toHaveBeenCalledTimes(1);
4390
+ });
4391
+
4392
+ /*
4393
+ * Regression: this route shared the per-process render slots with the three
4394
+ * public feed routes and could take every one of them. Its caller is a
4395
+ * logged-in client that can retry (and, on mobile, falls back to its roster
4396
+ * list); a calendar client that gets a 503 shows a stale or empty calendar
4397
+ * to somebody who may be on call. Half the slots are kept for the feeds.
4398
+ */
4399
+ test("it leaves half the render slots for the public feeds", async () => {
4400
+ await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4401
+
4402
+ const options: { leaveFreeSlots?: number } = tryAcquireRenderSlot.mock
4403
+ .calls[0]?.[0] as { leaveFreeSlots?: number };
4404
+
4405
+ expect(options.leaveFreeSlots).toBe(
4406
+ Math.floor(OnCallCalendarFeedCache.getRenderConcurrency() / 2),
4407
+ );
4408
+ expect(options.leaveFreeSlots).toBeGreaterThan(0);
4409
+ });
4410
+
4411
+ test("the render slot is released when the materialization throws", async () => {
4412
+ materializeSpy.mockRejectedValue(new Error("layer exploded"));
4413
+
4414
+ const result: HttpResult = await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`);
4415
+
4416
+ expect(result.status).toBe(500);
4417
+ expect(releaseRenderSlot).toHaveBeenCalledTimes(1);
4418
+ });
4419
+
4420
+ test("truncated propagates", async () => {
4421
+ materializeSpy.mockResolvedValue({
4422
+ shifts: [],
4423
+ truncated: true,
4424
+ generatedAt: NOW,
4425
+ });
4426
+
4427
+ const payload: Record<string, unknown> = json(
4428
+ await request(`${API_PREFIX}${MY_SHIFTS_ROUTE}`),
4429
+ );
4430
+
4431
+ expect(payload["truncated"]).toBe(true);
4432
+ expect(payload["shifts"]).toEqual([]);
4433
+ });
4434
+ });
4435
+
4436
+ // -- Time --------------------------------------------------------------------
4437
+
4438
+ describe("clock", () => {
4439
+ test("every public fetch reads the clock once through OneUptimeDate (so grace and bookkeeping share one `now`)", async () => {
4440
+ const token: string = CalendarFeedToken.mint();
4441
+ const row: FeedRowFixture = personalRow({
4442
+ projectId,
4443
+ userId,
4444
+ tokenHash: CalendarFeedToken.hash(token),
4445
+ });
4446
+
4447
+ personalFindOneBy.mockImplementation(lookupFrom([row]) as never);
4448
+ renderSpy.mockResolvedValue(
4449
+ renderedOutcome(OnCallCalendarFeedKind.Personal),
4450
+ );
4451
+
4452
+ await request(feedPath(OnCallCalendarFeedKind.Personal, token));
4453
+
4454
+ expect(nowSpy).toHaveBeenCalled();
4455
+
4456
+ const renderRequest: Record<string, unknown> = renderSpy.mock
4457
+ .calls[0]?.[0] as Record<string, unknown>;
4458
+
4459
+ expect((renderRequest["now"] as Date).getTime()).toBe(NOW.getTime());
4460
+ });
4461
+ });