@oneuptime/common 12.0.11 → 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 (251) 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/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
  27. package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
  28. package/Server/Utils/APIKey/AccessPermission.ts +16 -40
  29. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  30. package/Server/Utils/LogRedaction.ts +576 -0
  31. package/Server/Utils/Logger.ts +123 -46
  32. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  33. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
  34. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  35. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  36. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  37. package/Server/Utils/StartServer.ts +30 -9
  38. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  39. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  40. package/Server/Utils/VM/VMRunner.ts +693 -874
  41. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  42. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  43. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  44. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  45. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  46. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  47. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  49. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  50. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  51. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  52. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  53. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  54. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  55. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  56. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  57. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  58. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  59. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  60. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  61. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  62. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  63. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  64. package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
  65. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  66. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  67. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  68. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  69. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  70. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
  71. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  72. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  73. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  74. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  76. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  77. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  78. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  79. package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
  80. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
  81. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  82. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  84. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  85. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  86. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  87. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  88. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  89. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  90. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  91. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  92. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  93. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  94. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  95. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  96. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  97. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  98. package/Types/API/Hostname.ts +79 -0
  99. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  100. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  101. package/Types/Database/DatabaseProperty.ts +38 -0
  102. package/Types/Database/TableColumn.ts +34 -7
  103. package/Types/Events/Recurring.ts +16 -1
  104. package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
  105. package/Types/Monitor/MonitorStep.ts +13 -1
  106. package/Types/Monitor/MonitorType.ts +345 -40
  107. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  108. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  109. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  110. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  111. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  112. package/UI/Components/Footer/Footer.tsx +32 -6
  113. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  114. package/UI/Components/Forms/Types/Field.ts +8 -0
  115. package/UI/Components/Loader/PageLoader.tsx +29 -8
  116. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  117. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  118. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  119. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  120. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  121. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  122. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  123. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  124. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  125. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  127. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  129. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  130. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  131. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  133. package/build/dist/Server/API/AIChatAPI.js +48 -35
  134. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  135. package/build/dist/Server/API/CommonAPI.js +59 -0
  136. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  137. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  138. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  139. package/build/dist/Server/API/SlackAPI.js +13 -10
  140. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  141. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  142. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  143. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  144. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  149. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  150. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  151. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  152. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  153. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  154. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  155. package/build/dist/Server/Services/DatabaseService.js +15 -6
  156. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  157. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  158. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  159. package/build/dist/Server/Services/LogService.js +16 -0
  160. package/build/dist/Server/Services/LogService.js.map +1 -1
  161. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  162. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  163. package/build/dist/Server/Services/SpanService.js +16 -0
  164. package/build/dist/Server/Services/SpanService.js.map +1 -1
  165. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  166. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  167. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  168. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  169. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  170. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  171. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  172. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  173. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
  174. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  175. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  176. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  177. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  178. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  179. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  180. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  181. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  182. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  183. package/build/dist/Server/Utils/Logger.js +98 -45
  184. package/build/dist/Server/Utils/Logger.js.map +1 -1
  185. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  186. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  187. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  189. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  190. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  192. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  193. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  194. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  195. package/build/dist/Server/Utils/StartServer.js +10 -7
  196. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  197. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  198. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  199. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  200. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  201. package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
  202. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  203. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  204. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  205. package/build/dist/Types/API/Hostname.js +63 -0
  206. package/build/dist/Types/API/Hostname.js.map +1 -1
  207. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  208. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  209. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  210. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  211. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  212. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  213. package/build/dist/Types/Database/TableColumn.js +24 -6
  214. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +13 -1
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  218. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  219. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  220. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  221. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  222. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  223. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  224. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  225. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  226. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  227. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  228. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  229. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  230. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  231. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  232. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  233. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  234. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  235. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  236. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  237. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  238. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  239. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  240. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  241. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  242. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  243. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  244. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  245. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  246. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  247. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  248. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  249. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  250. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  251. package/package.json +1 -1
@@ -0,0 +1,332 @@
1
+ import { AddNetworkDeviceReachabilityColumns1787600000000 } from "../../../Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns";
2
+ import SchemaMigrations from "../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
3
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
4
+ import { MigrationInterface, QueryRunner } from "typeorm";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { describe, expect, test } from "@jest/globals";
8
+
9
+ /*
10
+ * The migration behind the reachability fix (issue #3220).
11
+ *
12
+ * Reachability moved from "lastSeenAt is recent" to "the last poll
13
+ * succeeded", which needs two new columns. Four things have to hold or a
14
+ * deployed instance is worse off than before the fix:
15
+ *
16
+ * 1. up() adds exactly lastPolledAt and isReachable, both nullable —
17
+ * NULL is the "never polled" state the UI renders as Pending, so a
18
+ * NOT NULL column or a default would put every existing device into a
19
+ * real verdict it has not earned;
20
+ * 2. it backfills existing rows, because an unbackfilled fleet would read
21
+ * as entirely Pending on the first page load after an upgrade — every
22
+ * device gone from both the Up and Down lists;
23
+ * 3. down() removes exactly what up() added;
24
+ * 4. the migration is registered in SchemaMigrations/Index.ts. An
25
+ * unregistered migration never runs on boot, so the columns would be
26
+ * missing while every read and the walk pipeline write to them.
27
+ *
28
+ * Pure SQL-contract assertions against a fake QueryRunner. No Postgres.
29
+ *
30
+ * This file also carries the ORDERING guard, handed on from
31
+ * DeviceRoleAndDeclaredLinkParentMigration.test.ts: this is the newest
32
+ * registered migration, so its timestamp must sort above every other one.
33
+ * `generate-postgres-migration` stamps a WALL-CLOCK timestamp, while the
34
+ * recent migrations here use hand-picked round numbers that already run
35
+ * ahead of it — so a generated file lands BELOW several registered
36
+ * migrations unless somebody renumbers it, and runs out of order without
37
+ * anything saying so. Whoever adds the next migration should move this
38
+ * block to theirs.
39
+ */
40
+
41
+ const MIGRATION_DIRECTORY: string = path.join(
42
+ __dirname,
43
+ "../../../Server/Infrastructure/Postgres/SchemaMigrations",
44
+ );
45
+
46
+ const MIGRATION_TIMESTAMP: string = "1787600000000";
47
+
48
+ interface MigrationClass {
49
+ name: string;
50
+ }
51
+
52
+ function timestampOf(migrationClass: MigrationClass): number | null {
53
+ const match: RegExpMatchArray | null = migrationClass.name.match(/(\d+)$/);
54
+ return match ? Number(match[1]) : null;
55
+ }
56
+
57
+ type MakeQueryRunnerResult = {
58
+ runner: QueryRunner;
59
+ statements: Array<string>;
60
+ };
61
+
62
+ type MakeQueryRunnerFunction = () => MakeQueryRunnerResult;
63
+
64
+ const makeQueryRunner: MakeQueryRunnerFunction = (): MakeQueryRunnerResult => {
65
+ const statements: Array<string> = [];
66
+
67
+ const query: (...args: Array<unknown>) => Promise<undefined> = (
68
+ ...args: Array<unknown>
69
+ ): Promise<undefined> => {
70
+ statements.push(String(args[0]));
71
+ return Promise.resolve(undefined);
72
+ };
73
+
74
+ return {
75
+ runner: { query } as unknown as QueryRunner,
76
+ statements,
77
+ };
78
+ };
79
+
80
+ describe("AddNetworkDeviceReachabilityColumns1787600000000 — SQL contract", () => {
81
+ test("up() adds the two columns and backfills, in that order", async () => {
82
+ const { runner, statements } = makeQueryRunner();
83
+
84
+ await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
85
+
86
+ expect(statements).toEqual([
87
+ `ALTER TABLE "NetworkDevice" ADD "lastPolledAt" TIMESTAMP WITH TIME ZONE`,
88
+ `ALTER TABLE "NetworkDevice" ADD "isReachable" boolean`,
89
+ `UPDATE "NetworkDevice" SET "lastPolledAt" = "lastSeenAt", "isReachable" = true WHERE "lastSeenAt" IS NOT NULL`,
90
+ ]);
91
+ });
92
+
93
+ /*
94
+ * NULL is a meaning here, not an absence: it is what the reachability rule
95
+ * reads as "never polled" and the UI renders as a gray Pending pill. A
96
+ * DEFAULT would give every pre-existing device a verdict on the strength
97
+ * of nothing.
98
+ */
99
+ test("both columns are nullable with no default", async () => {
100
+ const { runner, statements } = makeQueryRunner();
101
+
102
+ await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
103
+
104
+ for (const statement of statements.filter((sql: string) => {
105
+ return sql.includes("ALTER TABLE");
106
+ })) {
107
+ expect(statement).not.toContain("NOT NULL");
108
+ expect(statement).not.toContain("DEFAULT");
109
+ }
110
+ });
111
+
112
+ /*
113
+ * A device with a lastSeenAt answered the last walk we have any record of
114
+ * — there is no record of a failure — so it is seeded reachable with that
115
+ * timestamp as its last attempt. Anything older than the staleness window
116
+ * still reads Down through the ordinary rule, so this is not a
117
+ * whitewash: it just declines to invent a failure that never happened.
118
+ */
119
+ test("the backfill only touches rows that have actually been seen", async () => {
120
+ const { runner, statements } = makeQueryRunner();
121
+
122
+ await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
123
+
124
+ const backfill: string = statements[2]!;
125
+
126
+ expect(backfill).toContain(`WHERE "lastSeenAt" IS NOT NULL`);
127
+ expect(backfill).toContain(`"lastPolledAt" = "lastSeenAt"`);
128
+ expect(backfill).toContain(`"isReachable" = true`);
129
+ });
130
+
131
+ test("the backfill runs after the columns exist", async () => {
132
+ const { runner, statements } = makeQueryRunner();
133
+
134
+ await new AddNetworkDeviceReachabilityColumns1787600000000().up(runner);
135
+
136
+ const lastAddIndex: number = Math.max(
137
+ statements.findIndex((sql: string) => {
138
+ return sql.includes(`ADD "lastPolledAt"`);
139
+ }),
140
+ statements.findIndex((sql: string) => {
141
+ return sql.includes(`ADD "isReachable"`);
142
+ }),
143
+ );
144
+ const backfillIndex: number = statements.findIndex((sql: string) => {
145
+ return sql.startsWith("UPDATE");
146
+ });
147
+
148
+ expect(backfillIndex).toBeGreaterThan(lastAddIndex);
149
+ });
150
+
151
+ test("down() drops exactly what up() added, and nothing else", async () => {
152
+ const { runner, statements } = makeQueryRunner();
153
+
154
+ await new AddNetworkDeviceReachabilityColumns1787600000000().down(runner);
155
+
156
+ expect(statements).toEqual([
157
+ `ALTER TABLE "NetworkDevice" DROP COLUMN "isReachable"`,
158
+ `ALTER TABLE "NetworkDevice" DROP COLUMN "lastPolledAt"`,
159
+ ]);
160
+ });
161
+
162
+ /*
163
+ * Not just "same count": the exact column names, derived from the two
164
+ * statement sets rather than restated, so adding a column to up() without
165
+ * adding it to down() fails here.
166
+ */
167
+ test("up() and down() name the same columns", async () => {
168
+ const { runner: upRunner, statements: upStatements } = makeQueryRunner();
169
+ const { runner: downRunner, statements: downStatements } =
170
+ makeQueryRunner();
171
+
172
+ await new AddNetworkDeviceReachabilityColumns1787600000000().up(upRunner);
173
+ await new AddNetworkDeviceReachabilityColumns1787600000000().down(
174
+ downRunner,
175
+ );
176
+
177
+ const columnsIn: (
178
+ statements: Array<string>,
179
+ keyword: string,
180
+ ) => Array<string> = (
181
+ statements: Array<string>,
182
+ keyword: string,
183
+ ): Array<string> => {
184
+ return statements
185
+ .filter((sql: string) => {
186
+ return sql.includes(keyword);
187
+ })
188
+ .map((sql: string) => {
189
+ return sql.split(`${keyword} "`)[1]!.split('"')[0]!;
190
+ })
191
+ .sort();
192
+ };
193
+
194
+ expect(columnsIn(downStatements, "DROP COLUMN")).toEqual(
195
+ columnsIn(upStatements, "ADD"),
196
+ );
197
+ });
198
+ });
199
+
200
+ describe("registration", () => {
201
+ /*
202
+ * The step AGENTS.md calls out and the one that fails silently: a
203
+ * migration that is not in this array never runs, so the columns are
204
+ * missing in a real deployment while every read path and the walk
205
+ * pipeline reference them.
206
+ */
207
+ test("the migration is registered so it runs on boot", () => {
208
+ const names: Array<string> = SchemaMigrations.map(
209
+ (migration: new () => MigrationInterface): string => {
210
+ return migration.name;
211
+ },
212
+ );
213
+
214
+ expect(names).toContain("AddNetworkDeviceReachabilityColumns1787600000000");
215
+ });
216
+
217
+ test("it is registered exactly once", () => {
218
+ const occurrences: number = SchemaMigrations.filter(
219
+ (migration: new () => MigrationInterface): boolean => {
220
+ return (
221
+ migration.name === "AddNetworkDeviceReachabilityColumns1787600000000"
222
+ );
223
+ },
224
+ ).length;
225
+
226
+ expect(occurrences).toBe(1);
227
+ });
228
+
229
+ /*
230
+ * TypeORM records applied migrations by the `name` property, not the file
231
+ * name. A mismatch re-runs the migration on every boot.
232
+ */
233
+ test("its declared name matches its class name", () => {
234
+ expect(new AddNetworkDeviceReachabilityColumns1787600000000().name).toBe(
235
+ "AddNetworkDeviceReachabilityColumns1787600000000",
236
+ );
237
+ });
238
+ });
239
+
240
+ // The guard described in the header. Hand it to the next migration.
241
+ describe("ordering", () => {
242
+ test("its timestamp sorts after every other registered migration", () => {
243
+ const ourTimestamp: number | null = timestampOf(
244
+ AddNetworkDeviceReachabilityColumns1787600000000,
245
+ );
246
+
247
+ expect(ourTimestamp).not.toBeNull();
248
+
249
+ const otherTimestamps: Array<number> = [];
250
+
251
+ for (const migrationClass of SchemaMigrations) {
252
+ if (migrationClass === AddNetworkDeviceReachabilityColumns1787600000000) {
253
+ continue;
254
+ }
255
+
256
+ const timestamp: number | null = timestampOf(
257
+ migrationClass as MigrationClass,
258
+ );
259
+
260
+ if (timestamp !== null) {
261
+ otherTimestamps.push(timestamp);
262
+ }
263
+ }
264
+
265
+ /*
266
+ * Math.max() of an empty list is -Infinity, which every timestamp beats.
267
+ * Prove the list was actually enumerated before leaning on the maximum.
268
+ */
269
+ expect(otherTimestamps.length).toBeGreaterThan(100);
270
+
271
+ expect(ourTimestamp).toBeGreaterThan(Math.max(...otherTimestamps));
272
+ });
273
+
274
+ test("it is registered last, matching that timestamp", () => {
275
+ expect(SchemaMigrations[SchemaMigrations.length - 1]).toBe(
276
+ AddNetworkDeviceReachabilityColumns1787600000000,
277
+ );
278
+ });
279
+
280
+ /*
281
+ * ...and the timestamp in the class name is the one on disk. A class
282
+ * renamed without renaming its file (or two migrations landing on the
283
+ * same timestamp) leaves the ordering above asserting something that is
284
+ * not what actually runs.
285
+ */
286
+ test("exactly one file on disk carries its timestamp, and it is this one", () => {
287
+ const matching: Array<string> = fs
288
+ .readdirSync(MIGRATION_DIRECTORY)
289
+ .filter((file: string): boolean => {
290
+ return file.startsWith(`${MIGRATION_TIMESTAMP}-`);
291
+ });
292
+
293
+ expect(matching).toEqual([
294
+ `${MIGRATION_TIMESTAMP}-AddNetworkDeviceReachabilityColumns.ts`,
295
+ ]);
296
+ });
297
+ });
298
+
299
+ /*
300
+ * The model side of the same contract. TypeORM generates the schema from
301
+ * these decorators, so a column that exists in the migration but not on the
302
+ * model (or vice versa) is exactly the drift the Postgres Schema Drift
303
+ * workflow fails on.
304
+ */
305
+ describe("the NetworkDevice model carries the columns the migration adds", () => {
306
+ test("lastPolledAt and isReachable are declared on the model", () => {
307
+ const device: NetworkDevice = new NetworkDevice();
308
+
309
+ expect("lastPolledAt" in device).toBe(true);
310
+ expect("isReachable" in device).toBe(true);
311
+ });
312
+
313
+ test("they default to undefined, not to a fabricated verdict", () => {
314
+ const device: NetworkDevice = new NetworkDevice();
315
+
316
+ expect(device.lastPolledAt).toBeUndefined();
317
+ expect(device.isReachable).toBeUndefined();
318
+ });
319
+
320
+ test("a walk can set them independently of lastSeenAt", () => {
321
+ const device: NetworkDevice = new NetworkDevice();
322
+ const now: Date = new Date("2026-08-18T12:00:00.000Z");
323
+
324
+ device.lastPolledAt = now;
325
+ device.isReachable = false;
326
+
327
+ expect(device.lastPolledAt).toEqual(now);
328
+ expect(device.isReachable).toBe(false);
329
+ // A failed poll never moves the last successful contact.
330
+ expect(device.lastSeenAt).toBeUndefined();
331
+ });
332
+ });
@@ -0,0 +1,347 @@
1
+ /**
2
+ * ApiKeyPermissionService.findPermissionsByApiKeyId cache tests.
3
+ *
4
+ * Every API-key-authenticated request builds its tenant permissions from this
5
+ * lookup, so it now consults a per-process 60s TTL cache before running the
6
+ * findBy (with its labels join). The contract pinned here is load-bearing in
7
+ * three ways:
8
+ * - the underlying query is unchanged: keyed by exactly the given apiKeyId,
9
+ * as root, selecting permission / labels._id / isBlockPermission — and the
10
+ * row -> DTO mapping preserves the old edge cases (undefined labels -> [],
11
+ * isBlockPermission passed through unchanged, never defaulted)
12
+ * - hits and misses both return freshly built objects, so a caller mutating
13
+ * its result cannot poison the permissions of every later request
14
+ * - the service's own lifecycle hooks (create/update/delete) clear the cache,
15
+ * so a permission edit takes effect immediately in the process that made it;
16
+ * the TTL only bounds staleness for OTHER processes.
17
+ *
18
+ * ApiKeyPermissionService.findBy is spied on, so no database is touched.
19
+ */
20
+
21
+ import ApiKeyPermissionService, {
22
+ ApiKeyPermissionRow,
23
+ } from "../../../Server/Services/ApiKeyPermissionService";
24
+ import ApiKeyPermission from "../../../Models/DatabaseModels/ApiKeyPermission";
25
+ import Label from "../../../Models/DatabaseModels/Label";
26
+ import ObjectID from "../../../Types/ObjectID";
27
+ import Permission from "../../../Types/Permission";
28
+ import { getJestSpyOn } from "../../Spy";
29
+ import {
30
+ afterEach,
31
+ beforeEach,
32
+ describe,
33
+ expect,
34
+ jest,
35
+ test,
36
+ } from "@jest/globals";
37
+
38
+ /*
39
+ * PasswordHash has a known, pre-existing TS5.9 compile failure under ts-jest
40
+ * (Buffer vs BinaryLike) that breaks every suite whose import graph reaches
41
+ * it. Nothing here touches password hashing; stub the module before the
42
+ * service import graph drags it into compilation.
43
+ */
44
+ jest.mock("../../../Server/Utils/PasswordHash", () => {
45
+ return {
46
+ __esModule: true,
47
+ default: class PasswordHashStub {},
48
+ };
49
+ });
50
+
51
+ const TTL_MS: number = 60 * 1000;
52
+
53
+ /*
54
+ * The hooks are protected on purpose — DatabaseService calls them, feature
55
+ * code does not. Reaching them through a cast is how their cache-clearing
56
+ * behaviour can be pinned without standing up Postgres.
57
+ */
58
+ type HookCallable = {
59
+ onBeforeCreate: (createBy: unknown) => Promise<unknown>;
60
+ onBeforeUpdate: (updateBy: unknown) => Promise<unknown>;
61
+ onBeforeDelete: (deleteBy: unknown) => Promise<unknown>;
62
+ };
63
+
64
+ function asHooks(service: unknown): HookCallable {
65
+ return service as unknown as HookCallable;
66
+ }
67
+
68
+ function makeModelRow(data: {
69
+ permission: Permission;
70
+ labelIds?: Array<ObjectID>;
71
+ isBlockPermission?: boolean;
72
+ }): ApiKeyPermission {
73
+ const row: ApiKeyPermission = new ApiKeyPermission();
74
+ row.permission = data.permission;
75
+ if (data.labelIds) {
76
+ row.labels = data.labelIds.map((id: ObjectID) => {
77
+ const label: Label = new Label();
78
+ label.id = id;
79
+ return label;
80
+ });
81
+ }
82
+ if (data.isBlockPermission !== undefined) {
83
+ row.isBlockPermission = data.isBlockPermission;
84
+ }
85
+ return row;
86
+ }
87
+
88
+ function spyOnFindBy(): jest.SpyInstance {
89
+ return getJestSpyOn(ApiKeyPermissionService, "findBy");
90
+ }
91
+
92
+ describe("ApiKeyPermissionService.findPermissionsByApiKeyId", () => {
93
+ const apiKeyId: ObjectID = ObjectID.generate();
94
+
95
+ beforeEach(() => {
96
+ /*
97
+ * The cache lives on the singleton service, so entries written by one
98
+ * test would leak into the next without an explicit clear.
99
+ */
100
+ ApiKeyPermissionService.clearCache();
101
+ });
102
+
103
+ afterEach(() => {
104
+ jest.restoreAllMocks();
105
+ });
106
+
107
+ test("runs the exact legacy query and maps rows onto DTOs", async () => {
108
+ const labelId: ObjectID = ObjectID.generate();
109
+ const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue([
110
+ makeModelRow({
111
+ permission: Permission.CreateProjectIncident,
112
+ labelIds: [labelId],
113
+ isBlockPermission: true,
114
+ }),
115
+ makeModelRow({
116
+ // labels intentionally left undefined; isBlockPermission too.
117
+ permission: Permission.ReadProjectMonitor,
118
+ }),
119
+ ] as never);
120
+
121
+ const rows: Array<ApiKeyPermissionRow> =
122
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
123
+
124
+ expect(findBy).toHaveBeenCalledTimes(1);
125
+ const arg: Record<string, any> = findBy.mock.calls[0]![0] as Record<
126
+ string,
127
+ any
128
+ >;
129
+ expect(arg["query"]["apiKeyId"].toString()).toBe(apiKeyId.toString());
130
+ expect(arg["props"]["isRoot"]).toBe(true);
131
+ expect(arg["select"]).toEqual({
132
+ permission: true,
133
+ labels: {
134
+ _id: true,
135
+ },
136
+ isBlockPermission: true,
137
+ });
138
+
139
+ expect(rows).toHaveLength(2);
140
+ // Label scope survives the string round-trip through the cache.
141
+ expect(rows[0]!.permission).toBe(Permission.CreateProjectIncident);
142
+ expect(
143
+ rows[0]!.labelIds.map((id: ObjectID) => {
144
+ return id.toString();
145
+ }),
146
+ ).toEqual([labelId.toString()]);
147
+ // A block row stays a block row.
148
+ expect(rows[0]!.isBlockPermission).toBe(true);
149
+ // Undefined labels become [] rather than crashing or staying undefined.
150
+ expect(rows[1]!.labelIds).toEqual([]);
151
+ // isBlockPermission is passed through unchanged, never defaulted.
152
+ expect(rows[1]!.isBlockPermission).toBeUndefined();
153
+ });
154
+
155
+ test("serves the second call for the same key from cache", async () => {
156
+ const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue([
157
+ makeModelRow({
158
+ permission: Permission.ProjectMember,
159
+ isBlockPermission: false,
160
+ }),
161
+ ] as never);
162
+
163
+ const first: Array<ApiKeyPermissionRow> =
164
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
165
+ const second: Array<ApiKeyPermissionRow> =
166
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
167
+
168
+ expect(findBy).toHaveBeenCalledTimes(1);
169
+ expect(second).toHaveLength(1);
170
+ expect(second[0]!.permission).toBe(Permission.ProjectMember);
171
+ expect(second[0]!.isBlockPermission).toBe(false);
172
+ expect(first).toEqual(second);
173
+ });
174
+
175
+ test("hits return fresh objects, so mutation cannot poison the cache", async () => {
176
+ const labelId: ObjectID = ObjectID.generate();
177
+ spyOnFindBy().mockResolvedValue([
178
+ makeModelRow({
179
+ permission: Permission.CreateProjectIncident,
180
+ labelIds: [labelId],
181
+ isBlockPermission: false,
182
+ }),
183
+ ] as never);
184
+
185
+ const first: Array<ApiKeyPermissionRow> =
186
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
187
+
188
+ // Mutate everything a caller could reach on the first result.
189
+ first[0]!.labelIds.pop();
190
+ first.pop();
191
+
192
+ const second: Array<ApiKeyPermissionRow> =
193
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
194
+
195
+ expect(second).toHaveLength(1);
196
+ expect(
197
+ second[0]!.labelIds.map((id: ObjectID) => {
198
+ return id.toString();
199
+ }),
200
+ ).toEqual([labelId.toString()]);
201
+ // Distinct object graphs on every call, hit or miss.
202
+ expect(second).not.toBe(first);
203
+ expect(second[0]).not.toBe(first[0]);
204
+ });
205
+
206
+ test("caches distinct api keys independently", async () => {
207
+ const otherApiKeyId: ObjectID = ObjectID.generate();
208
+ const findBy: jest.SpyInstance = spyOnFindBy().mockImplementation(((
209
+ options: Record<string, any>,
210
+ ): Promise<Array<ApiKeyPermission>> => {
211
+ const queriedId: string = options["query"]["apiKeyId"].toString();
212
+ return Promise.resolve([
213
+ makeModelRow({
214
+ permission:
215
+ queriedId === apiKeyId.toString()
216
+ ? Permission.ProjectMember
217
+ : Permission.ReadProjectMonitor,
218
+ isBlockPermission: false,
219
+ }),
220
+ ]);
221
+ }) as never);
222
+
223
+ const first: Array<ApiKeyPermissionRow> =
224
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
225
+ const other: Array<ApiKeyPermissionRow> =
226
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(otherApiKeyId);
227
+
228
+ expect(findBy).toHaveBeenCalledTimes(2);
229
+ expect(first[0]!.permission).toBe(Permission.ProjectMember);
230
+ expect(other[0]!.permission).toBe(Permission.ReadProjectMonitor);
231
+
232
+ // Both keys are now cached: repeat calls issue no further queries.
233
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
234
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(otherApiKeyId);
235
+ expect(findBy).toHaveBeenCalledTimes(2);
236
+ });
237
+
238
+ test("a key with zero permission rows still short-circuits the second call", async () => {
239
+ const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
240
+ [] as never,
241
+ );
242
+
243
+ await expect(
244
+ ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId),
245
+ ).resolves.toEqual([]);
246
+ await expect(
247
+ ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId),
248
+ ).resolves.toEqual([]);
249
+
250
+ expect(findBy).toHaveBeenCalledTimes(1);
251
+ });
252
+
253
+ test("expires after the 60s TTL and re-queries", async () => {
254
+ const base: number = Date.now();
255
+ let nowMs: number = base;
256
+ jest.spyOn(Date, "now").mockImplementation((): number => {
257
+ return nowMs;
258
+ });
259
+
260
+ const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
261
+ [] as never,
262
+ );
263
+
264
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
265
+ expect(findBy).toHaveBeenCalledTimes(1);
266
+
267
+ // Just inside the TTL: still served from cache.
268
+ nowMs = base + TTL_MS - 1;
269
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
270
+ expect(findBy).toHaveBeenCalledTimes(1);
271
+
272
+ // Past the TTL: the entry has expired and the key is re-queried.
273
+ nowMs = base + TTL_MS + 1;
274
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
275
+ expect(findBy).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ describe("invalidation", () => {
279
+ type PrimeCacheFunction = () => Promise<jest.SpyInstance>;
280
+
281
+ const primeCache: PrimeCacheFunction =
282
+ async (): Promise<jest.SpyInstance> => {
283
+ const findBy: jest.SpyInstance = spyOnFindBy().mockResolvedValue(
284
+ [] as never,
285
+ );
286
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
287
+ expect(findBy).toHaveBeenCalledTimes(1);
288
+ return findBy;
289
+ };
290
+
291
+ test("clearCache() drops every cached entry", async () => {
292
+ const findBy: jest.SpyInstance = await primeCache();
293
+
294
+ ApiKeyPermissionService.clearCache();
295
+
296
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
297
+ expect(findBy).toHaveBeenCalledTimes(2);
298
+ });
299
+
300
+ test("creating a permission clears the cache", async () => {
301
+ const findBy: jest.SpyInstance = await primeCache();
302
+
303
+ // The duplicate-permission check inside the hook must find nothing.
304
+ getJestSpyOn(ApiKeyPermissionService, "findOneBy").mockResolvedValue(
305
+ null,
306
+ );
307
+
308
+ const data: ApiKeyPermission = new ApiKeyPermission();
309
+ data.apiKeyId = apiKeyId;
310
+ data.projectId = ObjectID.generate();
311
+ data.permission = Permission.ProjectMember;
312
+
313
+ await asHooks(ApiKeyPermissionService).onBeforeCreate({
314
+ data: data,
315
+ props: { isRoot: true },
316
+ });
317
+
318
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
319
+ expect(findBy).toHaveBeenCalledTimes(2);
320
+ });
321
+
322
+ test("updating a permission clears the cache", async () => {
323
+ const findBy: jest.SpyInstance = await primeCache();
324
+
325
+ await asHooks(ApiKeyPermissionService).onBeforeUpdate({
326
+ query: {},
327
+ data: {},
328
+ props: { isRoot: true },
329
+ });
330
+
331
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
332
+ expect(findBy).toHaveBeenCalledTimes(2);
333
+ });
334
+
335
+ test("deleting a permission clears the cache", async () => {
336
+ const findBy: jest.SpyInstance = await primeCache();
337
+
338
+ await asHooks(ApiKeyPermissionService).onBeforeDelete({
339
+ query: {},
340
+ props: { isRoot: true },
341
+ });
342
+
343
+ await ApiKeyPermissionService.findPermissionsByApiKeyId(apiKeyId);
344
+ expect(findBy).toHaveBeenCalledTimes(2);
345
+ });
346
+ });
347
+ });