@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,510 @@
1
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
2
+ import HostService from "../../../Server/Services/HostService";
3
+ import Host from "../../../Models/DatabaseModels/Host";
4
+ import ObjectID from "../../../Types/ObjectID";
5
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
6
+
7
+ /*
8
+ * Regression suite for issue #3006.
9
+ *
10
+ * HostService.updateLastSeen writes liveness (lastSeenAt +
11
+ * otelCollectorStatus) in the SAME hook-free UPDATE as a dozen optional
12
+ * columns harvested from OpenTelemetry resource attributes. Because that
13
+ * raw path bypasses DatabaseService's length validation, an oversized
14
+ * attribute reached Postgres unchecked and failed the whole statement —
15
+ * so a host with telemetry still arriving stopped advancing lastSeenAt and
16
+ * MarkDisconnectedHosts flipped it to "disconnected" 15 minutes later.
17
+ *
18
+ * Two independent guards are pinned here:
19
+ * 1. collector-supplied strings are clamped to their column widths, so
20
+ * the statement does not fail in the first place, and
21
+ * 2. if the enriched write fails ANYWAY (for any reason at all), the
22
+ * liveness columns are retried on their own. Enrichment is what gets
23
+ * dropped, never the heartbeat.
24
+ *
25
+ * Guard 1 has since moved DOWN a layer: updateColumnsByIdWithoutHooks
26
+ * clamps for every caller, so no service can forget it (see
27
+ * HookFreeWriteLengthClamp.test.ts, which pins that for all eleven
28
+ * telemetry models). The clamp assertions below therefore run the real
29
+ * write path against a faked repository and inspect the values actually
30
+ * BOUND to the SQL — the same end-to-end claim, checked where it is now
31
+ * true rather than where it used to be.
32
+ *
33
+ * Everything external is mocked — no Postgres, no Redis.
34
+ */
35
+
36
+ const HOST_ID: ObjectID = ObjectID.generate();
37
+
38
+ /** Longer than every ShortText column (100) and the old LongText bound (500). */
39
+ const HUGE_IP_LIST: string = Array.from(
40
+ { length: 55 },
41
+ (_unused: unknown, i: number) => {
42
+ return `fe80::42:acff:fe11:${(0x1000 + i).toString(16)}`;
43
+ },
44
+ ).join(", ");
45
+
46
+ type WriteCall = { id: ObjectID; data: Record<string, unknown> };
47
+
48
+ /** The (inline) shape of updateLastSeen's optional metadata argument. */
49
+ type UpdateLastSeenExtra = Parameters<typeof HostService.updateLastSeen>[1];
50
+
51
+ /** The argument to the hook-free UPDATE the service ultimately issues. */
52
+ type ColumnWrite = Parameters<
53
+ typeof HostService.updateColumnsByIdWithoutHooks
54
+ >[0];
55
+
56
+ let writes: Array<WriteCall> = [];
57
+ let cache: Map<string, string> = new Map<string, string>();
58
+ let deletedCacheKeys: Array<string> = [];
59
+
60
+ function mockCache(): void {
61
+ jest
62
+ .spyOn(GlobalCache, "getString")
63
+ .mockImplementation(async (namespace: string, key: string) => {
64
+ return cache.get(`${namespace}:${key}`) ?? null;
65
+ });
66
+ jest
67
+ .spyOn(GlobalCache, "setString")
68
+ .mockImplementation(
69
+ async (namespace: string, key: string, value: string) => {
70
+ cache.set(`${namespace}:${key}`, value);
71
+ },
72
+ );
73
+ jest
74
+ .spyOn(GlobalCache, "deleteKey")
75
+ .mockImplementation(async (namespace: string, key: string) => {
76
+ deletedCacheKeys.push(`${namespace}:${key}`);
77
+ cache.delete(`${namespace}:${key}`);
78
+ });
79
+ }
80
+
81
+ function recordWrite(input: {
82
+ id: ObjectID;
83
+ data: unknown;
84
+ }): Record<string, unknown> {
85
+ const data: Record<string, unknown> = {
86
+ ...(input.data as Record<string, unknown>),
87
+ };
88
+ writes.push({ id: input.id, data: data });
89
+ return data;
90
+ }
91
+
92
+ /** Records every hook-free UPDATE instead of issuing it. */
93
+ function mockWritesSucceeding(): void {
94
+ jest
95
+ .spyOn(HostService, "updateColumnsByIdWithoutHooks")
96
+ .mockImplementation(async (input: ColumnWrite) => {
97
+ recordWrite(input);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Fails any write that carries more than the two liveness columns — the
103
+ * shape of a Postgres rejection caused by one bad optional column.
104
+ */
105
+ function mockEnrichedWriteFailing(
106
+ message: string = `value too long for type character varying(100)`,
107
+ ): void {
108
+ jest
109
+ .spyOn(HostService, "updateColumnsByIdWithoutHooks")
110
+ .mockImplementation(async (input: ColumnWrite) => {
111
+ const data: Record<string, unknown> = recordWrite(input);
112
+ if (Object.keys(data).length > 2) {
113
+ throw new Error(message);
114
+ }
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Runs the REAL updateColumnsByIdWithoutHooks against a faked repository,
120
+ * recording the values actually BOUND to the generated SQL. The length
121
+ * clamp lives inside that method now, so this is the only way to observe
122
+ * it end-to-end from the service without a database — mocking the method
123
+ * out (as the other blocks do) would mock the clamp out with it.
124
+ */
125
+ function mockRealWritePath(): void {
126
+ const host: Host = new Host();
127
+ let bound: Record<string, unknown> = {};
128
+
129
+ jest.spyOn(HostService, "getRepository").mockReturnValue({
130
+ metadata: {
131
+ tableName: "Host",
132
+ findColumnWithPropertyName: (
133
+ propertyName: string,
134
+ ): { databaseName: string } | undefined => {
135
+ return host.hasColumn(propertyName)
136
+ ? { databaseName: propertyName }
137
+ : undefined;
138
+ },
139
+ updateDateColumn: { databaseName: "updatedAt" },
140
+ primaryColumns: [{ databaseName: "_id" }],
141
+ },
142
+ manager: {
143
+ connection: {
144
+ driver: {
145
+ preparePersistentValue: (
146
+ value: unknown,
147
+ column: { databaseName: string },
148
+ ): unknown => {
149
+ bound[column.databaseName] = value;
150
+ return value;
151
+ },
152
+ },
153
+ },
154
+ query: async (
155
+ _sql: string,
156
+ params: Array<unknown>,
157
+ ): Promise<Array<unknown>> => {
158
+ writes.push({
159
+ id: new ObjectID(String(params[params.length - 1])),
160
+ data: bound,
161
+ });
162
+ bound = {};
163
+ return [];
164
+ },
165
+ },
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
167
+ } as any);
168
+ }
169
+
170
+ function lastWrite(): Record<string, unknown> {
171
+ return writes[writes.length - 1]?.data as Record<string, unknown>;
172
+ }
173
+
174
+ describe("HostService.updateLastSeen", () => {
175
+ beforeEach(() => {
176
+ writes = [];
177
+ cache = new Map<string, string>();
178
+ deletedCacheKeys = [];
179
+ mockCache();
180
+ });
181
+
182
+ afterEach(() => {
183
+ jest.restoreAllMocks();
184
+ });
185
+
186
+ describe("liveness columns", () => {
187
+ beforeEach(() => {
188
+ mockWritesSucceeding();
189
+ });
190
+
191
+ test("always writes lastSeenAt and marks the collector connected", async () => {
192
+ await HostService.updateLastSeen(HOST_ID);
193
+
194
+ expect(writes).toHaveLength(1);
195
+ expect(lastWrite()["otelCollectorStatus"]).toBe("connected");
196
+ expect(lastWrite()["lastSeenAt"]).toBeInstanceOf(Date);
197
+ });
198
+
199
+ test("writes to the host it was given", async () => {
200
+ await HostService.updateLastSeen(HOST_ID);
201
+ expect(writes[0]?.id.toString()).toBe(HOST_ID.toString());
202
+ });
203
+
204
+ test("skips the write when the same metadata was written recently", async () => {
205
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
206
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
207
+
208
+ expect(writes).toHaveLength(1);
209
+ });
210
+
211
+ test("writes again as soon as any metadata changes", async () => {
212
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
213
+ await HostService.updateLastSeen(HOST_ID, { osType: "windows" });
214
+
215
+ expect(writes).toHaveLength(2);
216
+ });
217
+ });
218
+
219
+ describe("oversized collector metadata", () => {
220
+ beforeEach(() => {
221
+ // The clamp lives in the write path, so run the real one.
222
+ mockRealWritePath();
223
+ });
224
+
225
+ test("stores a 55-address host.ip list untouched — the column is text now", async () => {
226
+ await HostService.updateLastSeen(HOST_ID, {
227
+ hostIpAddresses: HUGE_IP_LIST,
228
+ });
229
+
230
+ expect(HUGE_IP_LIST.length).toBeGreaterThan(500);
231
+ expect(lastWrite()["hostIpAddresses"]).toBe(HUGE_IP_LIST);
232
+ });
233
+
234
+ test("clamps an oversized ShortText attribute instead of failing the write", async () => {
235
+ const longHostId: string = "i".repeat(400);
236
+
237
+ await HostService.updateLastSeen(HOST_ID, { hostId: longHostId });
238
+
239
+ expect(lastWrite()["hostId"]).toHaveLength(100);
240
+ expect(lastWrite()["hostId"]).toBe("i".repeat(100));
241
+ });
242
+
243
+ test("clamps an oversized LongText attribute to 500", async () => {
244
+ const longOsVersion: string = "v".repeat(900);
245
+
246
+ await HostService.updateLastSeen(HOST_ID, { osVersion: longOsVersion });
247
+
248
+ expect(lastWrite()["osVersion"]).toHaveLength(500);
249
+ });
250
+
251
+ test("clamping never costs the liveness columns", async () => {
252
+ await HostService.updateLastSeen(HOST_ID, {
253
+ hostId: "i".repeat(400),
254
+ hostArch: "a".repeat(400),
255
+ });
256
+
257
+ expect(lastWrite()["otelCollectorStatus"]).toBe("connected");
258
+ expect(lastWrite()["lastSeenAt"]).toBeInstanceOf(Date);
259
+ });
260
+
261
+ const CLAMPED_COLUMNS: Array<[string, number]> = [
262
+ ["osType", 100],
263
+ ["hostId", 100],
264
+ ["hostArch", 100],
265
+ ["hostType", 100],
266
+ ["containerRuntime", 100],
267
+ ["agentVersion", 100],
268
+ ["deploymentEnvironment", 100],
269
+ ["runtimeName", 100],
270
+ ["runtimeVersion", 100],
271
+ ["cloudProvider", 100],
272
+ ["cloudPlatform", 100],
273
+ ["cloudRegion", 100],
274
+ ["cloudAccountId", 100],
275
+ ["osVersion", 500],
276
+ ];
277
+
278
+ test.each(CLAMPED_COLUMNS)(
279
+ "%s is clamped to %i characters",
280
+ async (column: string, maxLength: number) => {
281
+ await HostService.updateLastSeen(HOST_ID, {
282
+ [column]: "x".repeat(maxLength + 250),
283
+ } as UpdateLastSeenExtra);
284
+
285
+ expect(lastWrite()[column]).toHaveLength(maxLength);
286
+ },
287
+ );
288
+
289
+ test("values that already fit are passed through byte-for-byte", async () => {
290
+ await HostService.updateLastSeen(HOST_ID, {
291
+ osType: "linux",
292
+ osVersion: "Debian GNU/Linux 12 (bookworm)",
293
+ hostArch: "amd64",
294
+ });
295
+
296
+ expect(lastWrite()["osType"]).toBe("linux");
297
+ expect(lastWrite()["osVersion"]).toBe("Debian GNU/Linux 12 (bookworm)");
298
+ expect(lastWrite()["hostArch"]).toBe("amd64");
299
+ });
300
+
301
+ test("numeric and relation columns are left alone by the clamp", async () => {
302
+ const dockerHostId: ObjectID = ObjectID.generate();
303
+
304
+ await HostService.updateLastSeen(HOST_ID, {
305
+ cpuCores: 64,
306
+ totalMemoryBytes: 274877906944,
307
+ processCount: 1337,
308
+ dockerHostId: dockerHostId,
309
+ });
310
+
311
+ expect(lastWrite()["cpuCores"]).toBe(64);
312
+ expect(lastWrite()["totalMemoryBytes"]).toBe(274877906944);
313
+ expect(lastWrite()["processCount"]).toBe(1337);
314
+ expect(lastWrite()["dockerHostId"]).toBe(dockerHostId);
315
+ });
316
+ });
317
+
318
+ describe("fallback when the enriched write fails anyway", () => {
319
+ beforeEach(() => {
320
+ mockEnrichedWriteFailing();
321
+ });
322
+
323
+ test("does not throw — ingest keeps going", async () => {
324
+ await expect(
325
+ HostService.updateLastSeen(HOST_ID, {
326
+ hostIpAddresses: HUGE_IP_LIST,
327
+ }),
328
+ ).resolves.toBeUndefined();
329
+ });
330
+
331
+ test("retries with liveness only, so lastSeenAt still advances", async () => {
332
+ await HostService.updateLastSeen(HOST_ID, {
333
+ hostIpAddresses: HUGE_IP_LIST,
334
+ });
335
+
336
+ expect(writes).toHaveLength(2);
337
+ expect(Object.keys(lastWrite()).sort()).toEqual([
338
+ "lastSeenAt",
339
+ "otelCollectorStatus",
340
+ ]);
341
+ expect(lastWrite()["otelCollectorStatus"]).toBe("connected");
342
+ expect(lastWrite()["lastSeenAt"]).toBeInstanceOf(Date);
343
+ });
344
+
345
+ test("busts the throttle cache so the next batch is not skipped", async () => {
346
+ await HostService.updateLastSeen(HOST_ID, {
347
+ hostIpAddresses: HUGE_IP_LIST,
348
+ });
349
+
350
+ expect(deletedCacheKeys).toHaveLength(1);
351
+ expect(deletedCacheKeys[0]).toContain(HOST_ID.toString());
352
+ });
353
+
354
+ test("the next batch retries rather than short-circuiting on a cache hit", async () => {
355
+ await HostService.updateLastSeen(HOST_ID, {
356
+ hostIpAddresses: HUGE_IP_LIST,
357
+ });
358
+ writes = [];
359
+
360
+ await HostService.updateLastSeen(HOST_ID, {
361
+ hostIpAddresses: HUGE_IP_LIST,
362
+ });
363
+
364
+ // Enriched attempt + liveness fallback, not a silent no-op.
365
+ expect(writes).toHaveLength(2);
366
+ });
367
+
368
+ test("a failing cache delete does not stop the liveness retry", async () => {
369
+ jest
370
+ .spyOn(GlobalCache, "deleteKey")
371
+ .mockRejectedValue(new Error("redis down"));
372
+
373
+ await HostService.updateLastSeen(HOST_ID, {
374
+ hostIpAddresses: HUGE_IP_LIST,
375
+ });
376
+
377
+ expect(Object.keys(lastWrite()).sort()).toEqual([
378
+ "lastSeenAt",
379
+ "otelCollectorStatus",
380
+ ]);
381
+ });
382
+
383
+ test("surfaces a genuinely broken database instead of hiding it", async () => {
384
+ // Both attempts fail — the caller must find out.
385
+ jest
386
+ .spyOn(HostService, "updateColumnsByIdWithoutHooks")
387
+ .mockRejectedValue(new Error("connection terminated"));
388
+
389
+ await expect(
390
+ HostService.updateLastSeen(HOST_ID, { osType: "linux" }),
391
+ ).rejects.toThrow("connection terminated");
392
+ });
393
+ });
394
+
395
+ describe("cache outages", () => {
396
+ beforeEach(() => {
397
+ mockWritesSucceeding();
398
+ });
399
+
400
+ test("a read failure still writes — never mark a live host disconnected", async () => {
401
+ jest
402
+ .spyOn(GlobalCache, "getString")
403
+ .mockRejectedValue(new Error("redis down"));
404
+
405
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
406
+
407
+ expect(writes).toHaveLength(1);
408
+ });
409
+
410
+ test("a write failure still writes", async () => {
411
+ jest
412
+ .spyOn(GlobalCache, "setString")
413
+ .mockRejectedValue(new Error("redis down"));
414
+
415
+ await HostService.updateLastSeen(HOST_ID, { osType: "linux" });
416
+
417
+ expect(writes).toHaveLength(1);
418
+ });
419
+ });
420
+ });
421
+
422
+ describe("DatabaseService.truncateStringColumnsToMaxLength", () => {
423
+ test("clamps only what exceeds its column width", () => {
424
+ const data: Record<string, unknown> = {
425
+ osType: "l".repeat(101),
426
+ hostArch: "amd64",
427
+ };
428
+
429
+ HostService.truncateStringColumnsToMaxLength(data);
430
+
431
+ expect(data["osType"]).toHaveLength(100);
432
+ expect(data["hostArch"]).toBe("amd64");
433
+ });
434
+
435
+ test("leaves text columns untouched at any length", () => {
436
+ const huge: string = "1.2.3.4, ".repeat(5000);
437
+ const data: Record<string, unknown> = { hostIpAddresses: huge };
438
+
439
+ HostService.truncateStringColumnsToMaxLength(data);
440
+
441
+ expect(data["hostIpAddresses"]).toBe(huge);
442
+ });
443
+
444
+ test("leaves non-string values untouched", () => {
445
+ const date: Date = new Date();
446
+ const id: ObjectID = ObjectID.generate();
447
+ const data: Record<string, unknown> = {
448
+ lastSeenAt: date,
449
+ cpuCores: 8,
450
+ isArchived: false,
451
+ dockerHostId: id,
452
+ };
453
+
454
+ HostService.truncateStringColumnsToMaxLength(data);
455
+
456
+ expect(data).toEqual({
457
+ lastSeenAt: date,
458
+ cpuCores: 8,
459
+ isArchived: false,
460
+ dockerHostId: id,
461
+ });
462
+ });
463
+
464
+ test("ignores keys that are not columns on the model", () => {
465
+ const data: Record<string, unknown> = { notAColumn: "z".repeat(5000) };
466
+
467
+ HostService.truncateStringColumnsToMaxLength(data);
468
+
469
+ expect(data["notAColumn"]).toHaveLength(5000);
470
+ });
471
+
472
+ test("ignores empty strings", () => {
473
+ const data: Record<string, unknown> = { osType: "" };
474
+
475
+ HostService.truncateStringColumnsToMaxLength(data);
476
+
477
+ expect(data["osType"]).toBe("");
478
+ });
479
+
480
+ test("mutates in place and returns the same object", () => {
481
+ const data: Record<string, unknown> = { osType: "l".repeat(200) };
482
+
483
+ expect(HostService.truncateStringColumnsToMaxLength(data)).toBe(data);
484
+ });
485
+
486
+ test("a value exactly at the limit is not clipped", () => {
487
+ const exact: string = "l".repeat(100);
488
+ const data: Record<string, unknown> = { osType: exact };
489
+
490
+ HostService.truncateStringColumnsToMaxLength(data);
491
+
492
+ expect(data["osType"]).toBe(exact);
493
+ });
494
+
495
+ test("every clamped value stays a prefix of the original", () => {
496
+ const original: string = "abcdefghij".repeat(40);
497
+ const data: Record<string, unknown> = { osType: original };
498
+
499
+ HostService.truncateStringColumnsToMaxLength(data);
500
+
501
+ expect(original.startsWith(data["osType"] as string)).toBe(true);
502
+ });
503
+
504
+ test("Host really does have the columns this suite assumes", () => {
505
+ const host: Host = new Host();
506
+ for (const column of ["osType", "osVersion", "hostIpAddresses"]) {
507
+ expect(host.hasColumn(column)).toBe(true);
508
+ }
509
+ });
510
+ });