@oneuptime/common 12.0.12 → 12.0.13

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 (234) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
  2. package/Models/DatabaseModels/NetworkDevice.ts +64 -0
  3. package/Models/DatabaseModels/StatusPage.ts +59 -0
  4. package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
  5. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
  6. package/Server/API/AIChatAPI.ts +55 -56
  7. package/Server/API/CommonAPI.ts +89 -0
  8. package/Server/API/MicrosoftTeamsAPI.ts +20 -9
  9. package/Server/API/SlackAPI.ts +13 -10
  10. package/Server/API/StatusPageAPI.ts +2197 -2128
  11. package/Server/API/TelemetryAPI.ts +72 -3
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  15. package/Server/Middleware/UserAuthorization.ts +11 -2
  16. package/Server/Services/ApiKeyPermissionService.ts +116 -2
  17. package/Server/Services/DatabaseService.ts +17 -6
  18. package/Server/Services/LogAggregationService.ts +34 -0
  19. package/Server/Services/LogService.ts +21 -0
  20. package/Server/Services/NetworkSiteService.ts +12 -0
  21. package/Server/Services/SpanService.ts +21 -0
  22. package/Server/Services/TraceAggregationService.ts +15 -0
  23. package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
  24. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
  25. package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
  26. package/Server/Utils/APIKey/AccessPermission.ts +16 -40
  27. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  28. package/Server/Utils/LogRedaction.ts +576 -0
  29. package/Server/Utils/Logger.ts +123 -46
  30. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  31. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  32. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  33. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  34. package/Server/Utils/StartServer.ts +30 -9
  35. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  36. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  37. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  38. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  39. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  40. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  41. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  42. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  43. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  44. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  45. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  46. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  47. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  48. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  49. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  50. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  52. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  53. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  54. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  55. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  56. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  57. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  58. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  59. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  60. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  62. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  63. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  65. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  66. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  67. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  68. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  69. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  70. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  71. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  72. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  73. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  74. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  75. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  76. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  77. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  78. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  79. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  80. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  81. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  82. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  83. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  84. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  85. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  86. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  87. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  88. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  89. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  90. package/Types/API/Hostname.ts +79 -0
  91. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  92. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  93. package/Types/Database/DatabaseProperty.ts +38 -0
  94. package/Types/Database/TableColumn.ts +34 -7
  95. package/Types/Events/Recurring.ts +16 -1
  96. package/Types/Monitor/MonitorStep.ts +13 -1
  97. package/Types/Monitor/MonitorType.ts +345 -40
  98. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  99. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  100. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  101. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  102. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  103. package/UI/Components/Footer/Footer.tsx +32 -6
  104. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  105. package/UI/Components/Forms/Types/Field.ts +8 -0
  106. package/UI/Components/Loader/PageLoader.tsx +29 -8
  107. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  108. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  109. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  110. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  111. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  112. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  113. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  114. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  115. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  116. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  118. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  120. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  122. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  124. package/build/dist/Server/API/AIChatAPI.js +48 -35
  125. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  126. package/build/dist/Server/API/CommonAPI.js +59 -0
  127. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  128. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  129. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  130. package/build/dist/Server/API/SlackAPI.js +13 -10
  131. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  132. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  133. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  134. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  135. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  137. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  138. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  139. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  142. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  143. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  144. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  145. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  146. package/build/dist/Server/Services/DatabaseService.js +15 -6
  147. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  148. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  149. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  150. package/build/dist/Server/Services/LogService.js +16 -0
  151. package/build/dist/Server/Services/LogService.js.map +1 -1
  152. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  153. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  154. package/build/dist/Server/Services/SpanService.js +16 -0
  155. package/build/dist/Server/Services/SpanService.js.map +1 -1
  156. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  157. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  158. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  159. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  160. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  161. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  162. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  163. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  164. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  165. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  166. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  167. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  168. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  169. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  170. package/build/dist/Server/Utils/Logger.js +98 -45
  171. package/build/dist/Server/Utils/Logger.js.map +1 -1
  172. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  173. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  174. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  175. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  176. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  177. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  178. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  179. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  180. package/build/dist/Server/Utils/StartServer.js +10 -7
  181. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  182. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  183. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  184. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  185. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  186. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  187. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  188. package/build/dist/Types/API/Hostname.js +63 -0
  189. package/build/dist/Types/API/Hostname.js.map +1 -1
  190. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  191. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  192. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  193. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  194. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  195. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  196. package/build/dist/Types/Database/TableColumn.js +24 -6
  197. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  198. package/build/dist/Types/Events/Recurring.js +13 -1
  199. package/build/dist/Types/Events/Recurring.js.map +1 -1
  200. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  201. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  202. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  203. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  204. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  205. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  206. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  207. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  208. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  209. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  210. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  211. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  212. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  213. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  214. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  215. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  216. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  217. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  218. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  219. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  220. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  221. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  222. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  223. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  224. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  225. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  226. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  227. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  228. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  229. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  230. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  231. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  232. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  233. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  234. package/package.json +1 -1
@@ -584,9 +584,37 @@ describe("MicrosoftTeamsUtil.removeTeamFromBotActivity", () => {
584
584
  });
585
585
 
586
586
  expect(removeSpy).toHaveBeenCalledTimes(1);
587
+ /*
588
+ * Uninstall passes both ids: the bot is already out of the team so the
589
+ * Graph id cannot be looked up, and records written before it was captured
590
+ * are only findable by the thread id. TEAM_ID here is a thread id, matching
591
+ * what channelData.team.id carries.
592
+ */
587
593
  expect(removeSpy).toHaveBeenCalledWith({
588
594
  tenantId: TENANT_ID,
589
- teamId: TEAM_ID,
595
+ teamsThreadId: TEAM_ID,
596
+ graphTeamId: undefined,
597
+ });
598
+ });
599
+
600
+ test("an uninstall activity that still carries aadGroupId passes it through", async () => {
601
+ const removeSpy: jest.SpyInstance = jest
602
+ .spyOn(MicrosoftTeamsUtil, "removeTeamFromProjectAuthTokens")
603
+ .mockResolvedValue(undefined as never);
604
+
605
+ await MicrosoftTeamsUtil.removeTeamFromBotActivity({
606
+ activity: teamActivity({
607
+ channelData: {
608
+ team: { id: TEAM_ID, aadGroupId: "graph-team-1" },
609
+ tenant: { id: TENANT_ID },
610
+ },
611
+ }),
612
+ });
613
+
614
+ expect(removeSpy).toHaveBeenCalledWith({
615
+ tenantId: TENANT_ID,
616
+ teamsThreadId: TEAM_ID,
617
+ graphTeamId: "graph-team-1",
590
618
  });
591
619
  });
592
620
 
@@ -884,7 +912,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
884
912
 
885
913
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
886
914
  tenantId: "tenant-abc",
887
- teamId: TEAM_ID,
915
+ teamsThreadId: TEAM_ID,
888
916
  });
889
917
 
890
918
  expect(findBySpy).toHaveBeenCalledWith(
@@ -925,7 +953,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
925
953
 
926
954
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
927
955
  tenantId: "tenant-abc",
928
- teamId: teamToRemove.id,
956
+ teamsThreadId: teamToRemove.id,
929
957
  });
930
958
 
931
959
  expect(updateSpy).toHaveBeenCalledTimes(1);
@@ -950,7 +978,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
950
978
 
951
979
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
952
980
  tenantId: "tenant-abc",
953
- teamId: team.id,
981
+ teamsThreadId: team.id,
954
982
  });
955
983
 
956
984
  const savedMiscData: MicrosoftTeamsMiscData = getUpdateArgs(updateSpy, 0)
@@ -976,7 +1004,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
976
1004
 
977
1005
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
978
1006
  tenantId: "tenant-abc",
979
- teamId: TEAM_ID,
1007
+ teamsThreadId: TEAM_ID,
980
1008
  });
981
1009
 
982
1010
  expect(updateSpy).not.toHaveBeenCalled();
@@ -991,7 +1019,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
991
1019
 
992
1020
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
993
1021
  tenantId: "tenant-abc",
994
- teamId: TEAM_ID,
1022
+ teamsThreadId: TEAM_ID,
995
1023
  });
996
1024
 
997
1025
  expect(updateSpy).not.toHaveBeenCalled();
@@ -1014,7 +1042,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
1014
1042
 
1015
1043
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
1016
1044
  tenantId: "tenant-abc",
1017
- teamId: team.id,
1045
+ teamsThreadId: team.id,
1018
1046
  });
1019
1047
 
1020
1048
  expect(updateSpy).toHaveBeenCalledTimes(3);
@@ -1068,7 +1096,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
1068
1096
 
1069
1097
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
1070
1098
  tenantId: "tenant-abc",
1071
- teamId: team.id,
1099
+ teamsThreadId: team.id,
1072
1100
  });
1073
1101
 
1074
1102
  expect(updateSpy).toHaveBeenCalledTimes(1);
@@ -1104,7 +1132,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
1104
1132
 
1105
1133
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
1106
1134
  tenantId: "tenant-abc",
1107
- teamId: "19:team-aaa@thread.tacv2",
1135
+ teamsThreadId: "19:team-aaa@thread.tacv2",
1108
1136
  });
1109
1137
 
1110
1138
  expect(updateSpy).not.toHaveBeenCalled();
@@ -1120,7 +1148,7 @@ describe("MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens", () => {
1120
1148
 
1121
1149
  await MicrosoftTeamsUtil.removeTeamFromProjectAuthTokens({
1122
1150
  tenantId: "tenant-abc",
1123
- teamId: team.id,
1151
+ teamsThreadId: team.id,
1124
1152
  });
1125
1153
 
1126
1154
  expect(originalMiscData.installedTeams).toEqual({ [team.id]: team });
@@ -1495,9 +1523,15 @@ describe("MicrosoftTeamsUtil.handleBotMessageActivity - team install backfill wi
1495
1523
  });
1496
1524
 
1497
1525
  expect(spies.captureTeamSpy).toHaveBeenCalledTimes(1);
1526
+ /*
1527
+ * onlyIfMissingOrStale, because this runs on every inbound channel message:
1528
+ * once the team is recorded it must cost one read, not a write plus a
1529
+ * possible Bot Framework call to resolve the group id.
1530
+ */
1498
1531
  expect(spies.captureTeamSpy).toHaveBeenCalledWith({
1499
1532
  activity: activity,
1500
1533
  turnContext: turnContext,
1534
+ onlyIfMissingOrStale: true,
1501
1535
  });
1502
1536
  expect(spies.captureChatSpy).not.toHaveBeenCalled();
1503
1537
  });
@@ -0,0 +1,164 @@
1
+ import Hostname from "../../../Types/API/Hostname";
2
+ import { describe, expect, test } from "@jest/globals";
3
+
4
+ /*
5
+ * Hostname.fromAuthority splits a URL authority into a bare host and a real
6
+ * Port. It exists because URL.fromString hands the WHOLE authority to the
7
+ * Hostname constructor, so `URL.fromString("https://example.com:8443/")`
8
+ * yields a hostname of "example.com:8443" and no port at all.
9
+ *
10
+ * Anything that reads the structured accessors rather than toString() then
11
+ * dials a host that does not exist. The SSL Certificate Monitor is one such
12
+ * consumer, which is why a monitor on a non-443 port could never connect —
13
+ * see https://github.com/OneUptime/oneuptime/issues/3225.
14
+ *
15
+ * fromString() is NOT a substitute: it splits on the FIRST colon, which
16
+ * mangles every IPv6 literal.
17
+ */
18
+ describe("Hostname.fromAuthority", () => {
19
+ describe("host and port", () => {
20
+ test("splits a trailing port off the host", () => {
21
+ const hostname: Hostname = Hostname.fromAuthority("example.com:8443");
22
+
23
+ expect(hostname.hostname).toBe("example.com");
24
+ expect(hostname.port?.toNumber()).toBe(8443);
25
+ });
26
+
27
+ test("leaves a bare host without a port", () => {
28
+ const hostname: Hostname = Hostname.fromAuthority("example.com");
29
+
30
+ expect(hostname.hostname).toBe("example.com");
31
+ expect(hostname.port).toBeUndefined();
32
+ });
33
+
34
+ test("handles localhost with a port", () => {
35
+ const hostname: Hostname = Hostname.fromAuthority("localhost:5000");
36
+
37
+ expect(hostname.hostname).toBe("localhost");
38
+ expect(hostname.port?.toNumber()).toBe(5000);
39
+ });
40
+
41
+ test("handles an IPv4 literal with a port", () => {
42
+ const hostname: Hostname = Hostname.fromAuthority("127.0.0.1:8443");
43
+
44
+ expect(hostname.hostname).toBe("127.0.0.1");
45
+ expect(hostname.port?.toNumber()).toBe(8443);
46
+ });
47
+
48
+ test("round-trips back to the original authority", () => {
49
+ expect(Hostname.fromAuthority("example.com:8443").toString()).toBe(
50
+ "example.com:8443",
51
+ );
52
+ expect(Hostname.fromAuthority("example.com").toString()).toBe(
53
+ "example.com",
54
+ );
55
+ });
56
+ });
57
+
58
+ describe("IPv6 literals", () => {
59
+ test("keeps a bracketed IPv6 address intact and takes its port", () => {
60
+ const hostname: Hostname = Hostname.fromAuthority("[::1]:8443");
61
+
62
+ expect(hostname.hostname).toBe("[::1]");
63
+ expect(hostname.port?.toNumber()).toBe(8443);
64
+ });
65
+
66
+ test("keeps a bracketed IPv6 address with no port intact", () => {
67
+ const hostname: Hostname = Hostname.fromAuthority("[::1]");
68
+
69
+ expect(hostname.hostname).toBe("[::1]");
70
+ expect(hostname.port).toBeUndefined();
71
+ });
72
+
73
+ test("does not split an unbracketed IPv6 literal", () => {
74
+ /*
75
+ * Two or more colons cannot be a host:port, so every colon belongs to
76
+ * the address. fromString would have returned a host of "2001".
77
+ */
78
+ const hostname: Hostname = Hostname.fromAuthority("2001:db8::1");
79
+
80
+ expect(hostname.hostname).toBe("2001:db8::1");
81
+ expect(hostname.port).toBeUndefined();
82
+ });
83
+
84
+ test("a full bracketed IPv6 address survives with its port", () => {
85
+ const hostname: Hostname = Hostname.fromAuthority(
86
+ "[2001:db8::8a2e:370:7334]:9000",
87
+ );
88
+
89
+ expect(hostname.hostname).toBe("[2001:db8::8a2e:370:7334]");
90
+ expect(hostname.port?.toNumber()).toBe(9000);
91
+ });
92
+ });
93
+
94
+ describe("userinfo", () => {
95
+ test("drops userinfo and keeps the host", () => {
96
+ const hostname: Hostname = Hostname.fromAuthority(
97
+ "user:token@hooks.example.com",
98
+ );
99
+
100
+ expect(hostname.hostname).toBe("hooks.example.com");
101
+ expect(hostname.port).toBeUndefined();
102
+ });
103
+
104
+ test("drops userinfo and keeps host and port", () => {
105
+ const hostname: Hostname = Hostname.fromAuthority(
106
+ "user:token@example.com:8443",
107
+ );
108
+
109
+ expect(hostname.hostname).toBe("example.com");
110
+ expect(hostname.port?.toNumber()).toBe(8443);
111
+ });
112
+
113
+ test("splits on the LAST @, since userinfo may contain one", () => {
114
+ const hostname: Hostname = Hostname.fromAuthority(
115
+ "user@name:token@example.com:8443",
116
+ );
117
+
118
+ expect(hostname.hostname).toBe("example.com");
119
+ expect(hostname.port?.toNumber()).toBe(8443);
120
+ });
121
+ });
122
+
123
+ describe("malformed input", () => {
124
+ test("an empty authority yields an empty hostname rather than throwing", () => {
125
+ expect(Hostname.fromAuthority("").hostname).toBe("");
126
+ });
127
+
128
+ test("a trailing colon with no digits is not treated as a port", () => {
129
+ const hostname: Hostname = Hostname.fromAuthority("example.com:");
130
+
131
+ expect(hostname.hostname).toBe("example.com");
132
+ expect(hostname.port).toBeUndefined();
133
+ });
134
+
135
+ test("surrounding whitespace is trimmed", () => {
136
+ const hostname: Hostname = Hostname.fromAuthority(" example.com:443 ");
137
+
138
+ expect(hostname.hostname).toBe("example.com");
139
+ expect(hostname.port?.toNumber()).toBe(443);
140
+ });
141
+ });
142
+
143
+ describe("contrast with fromString", () => {
144
+ test("fromString rejects an IPv6 literal that fromAuthority handles", () => {
145
+ /*
146
+ * Pinned deliberately: it documents WHY fromAuthority exists, and
147
+ * guards against someone 'simplifying' one into the other.
148
+ *
149
+ * fromString splits on the FIRST colon, so "[::1]:8443" becomes a
150
+ * host of "[" - which the Hostname setter rejects outright.
151
+ */
152
+ expect(Hostname.fromAuthority("[::1]:8443").hostname).toBe("[::1]");
153
+ expect(() => {
154
+ return Hostname.fromString("[::1]:8443");
155
+ }).toThrow();
156
+ });
157
+
158
+ test("both agree on the simple host:port case", () => {
159
+ expect(Hostname.fromAuthority("example.com:8443").hostname).toBe(
160
+ Hostname.fromString("example.com:8443").hostname,
161
+ );
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,344 @@
1
+ import DatabaseBaseModel from "../../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
2
+ import Incident from "../../../../Models/DatabaseModels/Incident";
3
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
4
+ import Project from "../../../../Models/DatabaseModels/Project";
5
+ import StatusPage from "../../../../Models/DatabaseModels/StatusPage";
6
+ import { ColumnAccessControl } from "../../../../Types/BaseDatabase/AccessControl";
7
+ import ColumnBillingAccessControl from "../../../../Types/BaseDatabase/ColumnBillingAccessControl";
8
+ import { getColumnAccessControlForAllColumns } from "../../../../Types/Database/AccessControl/ColumnAccessControl";
9
+ import { getColumnBillingAccessControlForAllColumns } from "../../../../Types/Database/AccessControl/ColumnBillingAccessControl";
10
+ import Dictionary from "../../../../Types/Dictionary";
11
+ import "reflect-metadata";
12
+
13
+ type ModelCtor = new () => DatabaseBaseModel;
14
+
15
+ const MODELS: Array<[string, ModelCtor]> = [
16
+ ["Monitor", Monitor],
17
+ ["Incident", Incident],
18
+ ["Project", Project],
19
+ ];
20
+
21
+ // Monitor and Incident have no billing-decorated columns; StatusPage does.
22
+ const BILLING_MODELS: Array<[string, ModelCtor]> = [["StatusPage", StatusPage]];
23
+
24
+ const DEFAULT_COLUMNS: Array<string> = [
25
+ "_id",
26
+ "createdAt",
27
+ "deletedAt",
28
+ "updatedAt",
29
+ ];
30
+
31
+ /*
32
+ * Locate the module-private metadata symbol (e.g. Symbol(ColumnAccessControl))
33
+ * by scanning the metadata keys of every instance property, so the tests can
34
+ * rebuild the dictionary from scratch exactly the way the pre-cache
35
+ * implementation did — without importing any private state.
36
+ */
37
+ function findMetadataSymbol(
38
+ model: DatabaseBaseModel,
39
+ symbolName: string,
40
+ ): symbol | null {
41
+ for (const key of Object.keys(model)) {
42
+ const metadataKeys: Array<unknown> = Reflect.getMetadataKeys(
43
+ model,
44
+ key,
45
+ ) as Array<unknown>;
46
+ for (const metadataKey of metadataKeys) {
47
+ if (
48
+ typeof metadataKey === "symbol" &&
49
+ metadataKey.toString() === `Symbol(${symbolName})`
50
+ ) {
51
+ return metadataKey;
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // The exact algorithm both helpers used before caching was introduced.
59
+ function rebuildFromReflect<T>(
60
+ model: DatabaseBaseModel,
61
+ symbolName: string,
62
+ ): Dictionary<T> {
63
+ const metadataSymbol: symbol | null = findMetadataSymbol(model, symbolName);
64
+ const dictionary: Dictionary<T> = {};
65
+
66
+ if (!metadataSymbol) {
67
+ return dictionary;
68
+ }
69
+
70
+ for (const key of Object.keys(model)) {
71
+ const metadata: T | undefined = Reflect.getMetadata(
72
+ metadataSymbol,
73
+ model,
74
+ key,
75
+ ) as T | undefined;
76
+ if (metadata) {
77
+ dictionary[key] = metadata;
78
+ }
79
+ }
80
+
81
+ return dictionary;
82
+ }
83
+
84
+ function rebuildAccessControl(
85
+ model: DatabaseBaseModel,
86
+ ): Dictionary<ColumnAccessControl> {
87
+ return rebuildFromReflect<ColumnAccessControl>(model, "ColumnAccessControl");
88
+ }
89
+
90
+ function rebuildBillingAccessControl(
91
+ model: DatabaseBaseModel,
92
+ ): Dictionary<ColumnBillingAccessControl> {
93
+ return rebuildFromReflect<ColumnBillingAccessControl>(
94
+ model,
95
+ "ColumnBillingAccessControl",
96
+ );
97
+ }
98
+
99
+ describe("getColumnAccessControlForAllColumns helper caching", () => {
100
+ for (const [name, ModelClass] of MODELS) {
101
+ test(`matches a from-scratch Reflect rebuild for ${name} — keys, order, and value identity`, () => {
102
+ const model: DatabaseBaseModel = new ModelClass();
103
+ const expected: Dictionary<ColumnAccessControl> =
104
+ rebuildAccessControl(model);
105
+ const actual: Dictionary<ColumnAccessControl> =
106
+ getColumnAccessControlForAllColumns(model);
107
+
108
+ // Sanity: real models have plenty of decorated columns.
109
+ expect(Object.keys(expected).length).toBeGreaterThan(5);
110
+
111
+ expect(Object.keys(actual)).toEqual(Object.keys(expected));
112
+ for (const key of Object.keys(expected)) {
113
+ expect(actual[key]).toBe(expected[key]);
114
+ }
115
+ });
116
+ }
117
+
118
+ test("repeated calls return the same dictionary object", () => {
119
+ const monitor: Monitor = new Monitor();
120
+ const first: Dictionary<ColumnAccessControl> =
121
+ getColumnAccessControlForAllColumns(monitor);
122
+ const second: Dictionary<ColumnAccessControl> =
123
+ getColumnAccessControlForAllColumns(monitor);
124
+
125
+ expect(second).toBe(first);
126
+ });
127
+
128
+ test("different instances of the same class share the cached dictionary", () => {
129
+ const first: Dictionary<ColumnAccessControl> =
130
+ getColumnAccessControlForAllColumns(new Monitor());
131
+ const second: Dictionary<ColumnAccessControl> =
132
+ getColumnAccessControlForAllColumns(new Monitor());
133
+
134
+ expect(second).toBe(first);
135
+ });
136
+
137
+ test("second call performs zero Reflect metadata reads", () => {
138
+ const incident: Incident = new Incident();
139
+ getColumnAccessControlForAllColumns(incident); // warm the cache
140
+
141
+ const spy: jest.SpyInstance = jest.spyOn(Reflect, "getMetadata");
142
+ try {
143
+ getColumnAccessControlForAllColumns(incident);
144
+ expect(spy).not.toHaveBeenCalled();
145
+ } finally {
146
+ spy.mockRestore();
147
+ }
148
+ });
149
+
150
+ test("different classes get distinct dictionaries", () => {
151
+ const monitorAccessControl: Dictionary<ColumnAccessControl> =
152
+ getColumnAccessControlForAllColumns(new Monitor());
153
+ const incidentAccessControl: Dictionary<ColumnAccessControl> =
154
+ getColumnAccessControlForAllColumns(new Incident());
155
+ const projectAccessControl: Dictionary<ColumnAccessControl> =
156
+ getColumnAccessControlForAllColumns(new Project());
157
+
158
+ expect(monitorAccessControl).not.toBe(incidentAccessControl);
159
+ expect(monitorAccessControl).not.toBe(projectAccessControl);
160
+ expect(incidentAccessControl).not.toBe(projectAccessControl);
161
+
162
+ expect(monitorAccessControl["monitorType"]).toBeDefined();
163
+ expect(incidentAccessControl["monitorType"]).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ describe("DatabaseBaseModel.getColumnAccessControlForAllColumns override", () => {
168
+ test("adds default columns with record-level permissions, exactly as before", () => {
169
+ const monitor: Monitor = new Monitor();
170
+ const raw: Dictionary<ColumnAccessControl> = rebuildAccessControl(monitor);
171
+ const merged: Dictionary<ColumnAccessControl> =
172
+ monitor.getColumnAccessControlForAllColumns();
173
+
174
+ for (const key of DEFAULT_COLUMNS) {
175
+ expect(merged[key]).toEqual({
176
+ read: monitor.readRecordPermissions,
177
+ create: monitor.createRecordPermissions,
178
+ update: monitor.updateRecordPermissions,
179
+ });
180
+ }
181
+
182
+ // Decorated columns flow through as the exact same Reflect singletons.
183
+ for (const key of Object.keys(raw)) {
184
+ if (!DEFAULT_COLUMNS.includes(key)) {
185
+ expect(merged[key]).toBe(raw[key]);
186
+ }
187
+ }
188
+
189
+ expect(Object.keys(merged).sort()).toEqual(
190
+ [...Object.keys(raw), ...DEFAULT_COLUMNS].sort(),
191
+ );
192
+ });
193
+
194
+ test("never pollutes the raw helper dictionary with default columns", () => {
195
+ const monitor: Monitor = new Monitor();
196
+
197
+ const rawKeysBefore: Array<string> = Object.keys(
198
+ getColumnAccessControlForAllColumns(monitor),
199
+ );
200
+ for (const key of DEFAULT_COLUMNS) {
201
+ expect(rawKeysBefore).not.toContain(key);
202
+ }
203
+
204
+ monitor.getColumnAccessControlForAllColumns();
205
+ monitor.getColumnAccessControlForAllColumns();
206
+
207
+ /*
208
+ * AuditLogService.getRedactedFields consumes the raw helper result and
209
+ * must never see the record-level defaults the model override adds.
210
+ */
211
+ const rawAfter: Dictionary<ColumnAccessControl> =
212
+ getColumnAccessControlForAllColumns(new Monitor());
213
+ expect(Object.keys(rawAfter)).toEqual(rawKeysBefore);
214
+ for (const key of DEFAULT_COLUMNS) {
215
+ expect(rawAfter[key]).toBeUndefined();
216
+ }
217
+ });
218
+
219
+ test("mutating one override result does not affect the next call", () => {
220
+ const monitor: Monitor = new Monitor();
221
+
222
+ const first: Dictionary<ColumnAccessControl> =
223
+ monitor.getColumnAccessControlForAllColumns();
224
+ const originalName: ColumnAccessControl = first[
225
+ "name"
226
+ ] as ColumnAccessControl;
227
+ expect(originalName).toBeDefined();
228
+
229
+ delete first["name"];
230
+ first["_id"] = { read: [], create: [], update: [] };
231
+ first["injected"] = { read: [], create: [], update: [] };
232
+
233
+ const second: Dictionary<ColumnAccessControl> =
234
+ monitor.getColumnAccessControlForAllColumns();
235
+
236
+ expect(second).not.toBe(first);
237
+ expect(second["name"]).toBe(originalName);
238
+ expect(second["injected"]).toBeUndefined();
239
+ expect(second["_id"]).toEqual({
240
+ read: monitor.readRecordPermissions,
241
+ create: monitor.createRecordPermissions,
242
+ update: monitor.updateRecordPermissions,
243
+ });
244
+ });
245
+
246
+ test("getColumnAccessControlFor still resolves decorated, default, and unknown columns", () => {
247
+ const monitor: Monitor = new Monitor();
248
+ const raw: Dictionary<ColumnAccessControl> = rebuildAccessControl(monitor);
249
+
250
+ expect(monitor.getColumnAccessControlFor("name")).toBe(raw["name"]);
251
+ expect(monitor.getColumnAccessControlFor("_id")).toEqual({
252
+ read: monitor.readRecordPermissions,
253
+ create: monitor.createRecordPermissions,
254
+ update: monitor.updateRecordPermissions,
255
+ });
256
+ expect(monitor.getColumnAccessControlFor("doesNotExist")).toBeNull();
257
+ });
258
+ });
259
+
260
+ describe("getColumnBillingAccessControlForAllColumns helper caching", () => {
261
+ for (const [name, ModelClass] of [...MODELS, ...BILLING_MODELS]) {
262
+ test(`matches a from-scratch Reflect rebuild for ${name} — keys, order, and value identity`, () => {
263
+ const model: DatabaseBaseModel = new ModelClass();
264
+ const expected: Dictionary<ColumnBillingAccessControl> =
265
+ rebuildBillingAccessControl(model);
266
+ const actual: Dictionary<ColumnBillingAccessControl> =
267
+ getColumnBillingAccessControlForAllColumns(model);
268
+
269
+ expect(Object.keys(actual)).toEqual(Object.keys(expected));
270
+ for (const key of Object.keys(expected)) {
271
+ expect(actual[key]).toBe(expected[key]);
272
+ }
273
+ });
274
+ }
275
+
276
+ test("Project and StatusPage carry billing-decorated columns; Monitor's dictionary is empty", () => {
277
+ expect(
278
+ Object.keys(getColumnBillingAccessControlForAllColumns(new Project()))
279
+ .length,
280
+ ).toBeGreaterThan(0);
281
+ expect(
282
+ Object.keys(getColumnBillingAccessControlForAllColumns(new StatusPage()))
283
+ .length,
284
+ ).toBeGreaterThan(0);
285
+ expect(getColumnBillingAccessControlForAllColumns(new Monitor())).toEqual(
286
+ {},
287
+ );
288
+ });
289
+
290
+ test("repeated calls return the same dictionary object", () => {
291
+ const project: Project = new Project();
292
+ const first: Dictionary<ColumnBillingAccessControl> =
293
+ getColumnBillingAccessControlForAllColumns(project);
294
+ const second: Dictionary<ColumnBillingAccessControl> =
295
+ getColumnBillingAccessControlForAllColumns(new Project());
296
+
297
+ expect(second).toBe(first);
298
+ });
299
+
300
+ test("second call performs zero Reflect metadata reads", () => {
301
+ const project: Project = new Project();
302
+ getColumnBillingAccessControlForAllColumns(project); // warm the cache
303
+
304
+ const spy: jest.SpyInstance = jest.spyOn(Reflect, "getMetadata");
305
+ try {
306
+ getColumnBillingAccessControlForAllColumns(project);
307
+ expect(spy).not.toHaveBeenCalled();
308
+ } finally {
309
+ spy.mockRestore();
310
+ }
311
+ });
312
+
313
+ test("different classes get distinct dictionaries", () => {
314
+ const statusPageBilling: Dictionary<ColumnBillingAccessControl> =
315
+ getColumnBillingAccessControlForAllColumns(new StatusPage());
316
+ const projectBilling: Dictionary<ColumnBillingAccessControl> =
317
+ getColumnBillingAccessControlForAllColumns(new Project());
318
+
319
+ expect(statusPageBilling).not.toBe(projectBilling);
320
+ expect(Object.keys(statusPageBilling)).not.toEqual(
321
+ Object.keys(projectBilling),
322
+ );
323
+ });
324
+
325
+ test("model getColumnBillingAccessControl reads without mutating the cached dictionary", () => {
326
+ const project: Project = new Project();
327
+ const expected: Dictionary<ColumnBillingAccessControl> =
328
+ rebuildBillingAccessControl(project);
329
+ const keysBefore: Array<string> = Object.keys(
330
+ getColumnBillingAccessControlForAllColumns(project),
331
+ );
332
+
333
+ for (const key of keysBefore) {
334
+ expect(project.getColumnBillingAccessControl(key)).toBe(expected[key]);
335
+ }
336
+ expect(project.getColumnBillingAccessControl("doesNotExist")).toBe(
337
+ undefined,
338
+ );
339
+
340
+ expect(
341
+ Object.keys(getColumnBillingAccessControlForAllColumns(new Project())),
342
+ ).toEqual(keysBefore);
343
+ });
344
+ });