@oneuptime/common 12.0.28 → 12.0.29

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 (182) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +43 -0
  2. package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
  3. package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
  4. package/Server/API/DashboardDomainAPI.ts +74 -0
  5. package/Server/API/MicrosoftTeamsAPI.ts +81 -180
  6. package/Server/API/StatusPageDomainAPI.ts +74 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Middleware/ProjectAuthorization.ts +6 -1
  11. package/Server/Services/DashboardDomainService.ts +131 -0
  12. package/Server/Services/LogPipelineProcessorService.ts +88 -0
  13. package/Server/Services/MonitorTemplateService.ts +70 -11
  14. package/Server/Services/StatusPageDomainService.ts +163 -3
  15. package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
  16. package/Server/Types/Domain.ts +17 -0
  17. package/Server/Utils/APIKey/AccessPermission.ts +24 -0
  18. package/Server/Utils/Greenlock/Greenlock.ts +156 -58
  19. package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
  20. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
  21. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
  22. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
  23. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
  24. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
  25. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
  26. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
  27. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
  28. package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
  29. package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
  30. package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
  31. package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
  32. package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
  33. package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
  34. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
  35. package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
  36. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
  37. package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
  38. package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
  39. package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
  40. package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
  41. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
  42. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
  43. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
  44. package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
  45. package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
  46. package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
  47. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
  48. package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
  49. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
  50. package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
  51. package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
  52. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
  53. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
  54. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
  55. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
  56. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
  57. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
  58. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
  59. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
  60. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
  61. package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
  62. package/Tests/Types/API/HTTPResponse.test.ts +175 -0
  63. package/Tests/Types/API/Route.test.ts +36 -4
  64. package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
  65. package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
  66. package/Tests/Types/DateLocalTimeString.test.ts +259 -0
  67. package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
  68. package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
  69. package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
  70. package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
  71. package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
  72. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
  73. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
  74. package/Tests/UI/Utils/Navigation.test.ts +82 -0
  75. package/Tests/Utils/Array.test.ts +100 -0
  76. package/Tests/Utils/CertificateReissue.test.ts +225 -0
  77. package/Tests/Utils/Grok/Grok.test.ts +492 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
  79. package/Types/API/Route.ts +20 -5
  80. package/Types/Date.ts +143 -13
  81. package/Types/Rum/SessionReplay.ts +62 -1
  82. package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
  83. package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
  84. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
  85. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
  86. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
  87. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
  88. package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
  89. package/UI/Utils/Navigation.ts +42 -0
  90. package/Utils/Array.ts +37 -0
  91. package/Utils/CertificateReissue.ts +128 -0
  92. package/Utils/Grok/Grok.ts +461 -0
  93. package/Utils/Grok/GrokPatterns.ts +118 -0
  94. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
  95. package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
  96. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
  98. package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
  100. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  101. package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
  102. package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
  103. package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
  104. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  105. package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
  106. package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  113. package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
  114. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  115. package/build/dist/Server/Services/DashboardDomainService.js +104 -0
  116. package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
  117. package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
  118. package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
  119. package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
  120. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  121. package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
  122. package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
  123. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
  124. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
  125. package/build/dist/Server/Types/Domain.js +16 -0
  126. package/build/dist/Server/Types/Domain.js.map +1 -1
  127. package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
  128. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  129. package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
  130. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  131. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
  132. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
  133. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
  134. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
  135. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
  136. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  137. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
  138. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  139. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
  140. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  141. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
  142. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
  143. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
  144. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  145. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
  146. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  147. package/build/dist/Types/API/Route.js +16 -5
  148. package/build/dist/Types/API/Route.js.map +1 -1
  149. package/build/dist/Types/Date.js +115 -13
  150. package/build/dist/Types/Date.js.map +1 -1
  151. package/build/dist/Types/Rum/SessionReplay.js +38 -0
  152. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  153. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
  154. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
  155. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
  156. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  157. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
  158. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
  159. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
  160. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  161. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
  162. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  163. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
  164. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  165. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
  166. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
  167. package/build/dist/UI/Utils/Navigation.js +30 -0
  168. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  169. package/build/dist/Utils/Array.js +25 -0
  170. package/build/dist/Utils/Array.js.map +1 -1
  171. package/build/dist/Utils/CertificateReissue.js +97 -0
  172. package/build/dist/Utils/CertificateReissue.js.map +1 -0
  173. package/build/dist/Utils/Grok/Grok.js +324 -0
  174. package/build/dist/Utils/Grok/Grok.js.map +1 -0
  175. package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
  176. package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
  178. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  179. package/package.json +1 -1
  180. package/UI/Utils/JsonWebToken.ts +0 -14
  181. package/build/dist/UI/Utils/JsonWebToken.js +0 -10
  182. package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
@@ -0,0 +1,549 @@
1
+ import Monitor from "../../../Models/DatabaseModels/Monitor";
2
+ import MonitorTemplate from "../../../Models/DatabaseModels/MonitorTemplate";
3
+ import MonitorService from "../../../Server/Services/MonitorService";
4
+ import MonitorTemplateService, {
5
+ SyncLinkedMonitorsResult,
6
+ } from "../../../Server/Services/MonitorTemplateService";
7
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
8
+ import FindBy from "../../../Server/Types/Database/FindBy";
9
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
10
+ import Includes from "../../../Types/BaseDatabase/Includes";
11
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
12
+ import LIMIT_MAX from "../../../Types/Database/LimitMax";
13
+ import MonitorType from "../../../Types/Monitor/MonitorType";
14
+ import ObjectID from "../../../Types/ObjectID";
15
+ import { afterEach, describe, expect, it, jest } from "@jest/globals";
16
+ import type { SpyInstance } from "jest-mock";
17
+
18
+ const PROJECT_ID: ObjectID = new ObjectID(
19
+ "11111111-1111-4111-8111-111111111111",
20
+ );
21
+ const TEMPLATE_ID: ObjectID = new ObjectID(
22
+ "22222222-2222-4222-8222-222222222222",
23
+ );
24
+
25
+ function buildPingTemplate(): MonitorTemplate {
26
+ const template: MonitorTemplate = new MonitorTemplate();
27
+ template.id = TEMPLATE_ID;
28
+ template.projectId = PROJECT_ID;
29
+ // Not a Network Device template, so sync takes the bulk update path.
30
+ template.monitorType = MonitorType.Ping;
31
+ template.monitoringInterval = "*/10 * * * *";
32
+ template.minimumProbeAgreement = 2;
33
+ return template;
34
+ }
35
+
36
+ /*
37
+ * Stand in for a project holding more linked monitors than a single update
38
+ * can carry. Pages are served by skip so the service's paging loop is what
39
+ * decides how many rows it discovers.
40
+ */
41
+ function mockLinkedMonitorPages(totalLinkedMonitors: number): Array<ObjectID> {
42
+ /*
43
+ * A fixed pool rather than ids minted per page, so a test can assert which
44
+ * monitors were written and not merely how many — dropping one row while
45
+ * double-writing another keeps the counts intact.
46
+ */
47
+ const pool: Array<ObjectID> = Array.from(
48
+ { length: totalLinkedMonitors },
49
+ (): ObjectID => {
50
+ return ObjectID.generate();
51
+ },
52
+ );
53
+
54
+ jest
55
+ .spyOn(MonitorService, "findBy")
56
+ .mockImplementation(
57
+ async (findBy: FindBy<Monitor>): Promise<Array<Monitor>> => {
58
+ const skip: number = Number(findBy.skip?.toString() || 0);
59
+ const limit: number = Number(findBy.limit?.toString() || 0);
60
+
61
+ return pool.slice(skip, skip + limit).map((id: ObjectID): Monitor => {
62
+ const monitor: Monitor = new Monitor();
63
+ monitor.id = id;
64
+ monitor.projectId = PROJECT_ID;
65
+ monitor.monitorTemplateId = TEMPLATE_ID;
66
+ return monitor;
67
+ });
68
+ },
69
+ );
70
+
71
+ return pool;
72
+ }
73
+
74
+ function writtenIdsFrom(
75
+ updateSpy: SpyInstance<typeof MonitorService.updateBy>,
76
+ ): Array<string> {
77
+ return updateSpy.mock.calls.flatMap(
78
+ (call: [UpdateBy<Monitor>]): Array<string> => {
79
+ return (call[0].query as Record<string, Includes>)["_id"]!.values.map(
80
+ (id: string | ObjectID | number): string => {
81
+ return id.toString();
82
+ },
83
+ );
84
+ },
85
+ );
86
+ }
87
+
88
+ afterEach(() => {
89
+ jest.restoreAllMocks();
90
+ });
91
+
92
+ describe("MonitorTemplateService bulk sync coverage", () => {
93
+ /*
94
+ * Regression: the bulk path issued one updateBy capped at LIMIT_MAX with no
95
+ * paging, so a template linked to more monitors than that silently left the
96
+ * remainder on the old configuration and still reported success.
97
+ */
98
+ it("syncs every linked monitor when the fleet exceeds one update batch", async () => {
99
+ const totalLinkedMonitors: number = LIMIT_MAX * 2 + 500;
100
+
101
+ jest
102
+ .spyOn(MonitorTemplateService, "findOneById")
103
+ .mockResolvedValue(buildPingTemplate());
104
+ jest
105
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
106
+ .mockResolvedValue(totalLinkedMonitors);
107
+ mockLinkedMonitorPages(totalLinkedMonitors);
108
+
109
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
110
+ .spyOn(MonitorService, "updateBy")
111
+ .mockImplementation(
112
+ async (updateBy: UpdateBy<Monitor>): Promise<number> => {
113
+ const ids: Includes = (updateBy.query as Record<string, Includes>)[
114
+ "_id"
115
+ ]!;
116
+ return ids.values.length;
117
+ },
118
+ );
119
+
120
+ const result: SyncLinkedMonitorsResult =
121
+ await MonitorTemplateService.syncLinkedMonitors({
122
+ monitorTemplateId: TEMPLATE_ID,
123
+ fields: ["monitoringInterval"],
124
+ props: { isRoot: true },
125
+ });
126
+
127
+ expect(result).toEqual({
128
+ totalLinkedMonitors: totalLinkedMonitors,
129
+ syncedMonitors: totalLinkedMonitors,
130
+ });
131
+
132
+ // Three batches: LIMIT_MAX, LIMIT_MAX, then the 500 remainder.
133
+ expect(updateSpy).toHaveBeenCalledTimes(3);
134
+ expect(
135
+ updateSpy.mock.calls.map((call: [UpdateBy<Monitor>]): number => {
136
+ return (call[0].query as Record<string, Includes>)["_id"]!.values
137
+ .length;
138
+ }),
139
+ ).toEqual([LIMIT_MAX, LIMIT_MAX, 500]);
140
+
141
+ // Every batch stays scoped to this template and project.
142
+ for (const call of updateSpy.mock.calls) {
143
+ const query: Record<string, unknown> = call[0].query as Record<
144
+ string,
145
+ unknown
146
+ >;
147
+ expect(query["monitorTemplateId"]).toEqual(TEMPLATE_ID);
148
+ expect(query["projectId"]).toEqual(PROJECT_ID);
149
+ }
150
+ });
151
+
152
+ it("still issues a single batch for a fleet that fits in one update", async () => {
153
+ jest
154
+ .spyOn(MonitorTemplateService, "findOneById")
155
+ .mockResolvedValue(buildPingTemplate());
156
+ jest
157
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
158
+ .mockResolvedValue(3);
159
+ mockLinkedMonitorPages(3);
160
+
161
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
162
+ .spyOn(MonitorService, "updateBy")
163
+ .mockResolvedValue(3);
164
+
165
+ const result: SyncLinkedMonitorsResult =
166
+ await MonitorTemplateService.syncLinkedMonitors({
167
+ monitorTemplateId: TEMPLATE_ID,
168
+ fields: ["monitoringInterval"],
169
+ props: { isRoot: true },
170
+ });
171
+
172
+ expect(result).toEqual({ totalLinkedMonitors: 3, syncedMonitors: 3 });
173
+ expect(updateSpy).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it("writes nothing when the template has no linked monitors", async () => {
177
+ jest
178
+ .spyOn(MonitorTemplateService, "findOneById")
179
+ .mockResolvedValue(buildPingTemplate());
180
+ jest
181
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
182
+ .mockResolvedValue(0);
183
+
184
+ const findSpy: SpyInstance<typeof MonitorService.findBy> = jest.spyOn(
185
+ MonitorService,
186
+ "findBy",
187
+ );
188
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest.spyOn(
189
+ MonitorService,
190
+ "updateBy",
191
+ );
192
+
193
+ const result: SyncLinkedMonitorsResult =
194
+ await MonitorTemplateService.syncLinkedMonitors({
195
+ monitorTemplateId: TEMPLATE_ID,
196
+ fields: ["monitoringInterval"],
197
+ props: { isRoot: true },
198
+ });
199
+
200
+ expect(result).toEqual({ totalLinkedMonitors: 0, syncedMonitors: 0 });
201
+ expect(findSpy).not.toHaveBeenCalled();
202
+ expect(updateSpy).not.toHaveBeenCalled();
203
+ });
204
+
205
+ /*
206
+ * Boundary: a full first page cannot be assumed to be the last one, so the
207
+ * loop must ask again and only stop on the short (here empty) page. Getting
208
+ * this wrong is how the original truncation behaved.
209
+ */
210
+ it("covers a fleet of exactly one page without stopping early", async () => {
211
+ jest
212
+ .spyOn(MonitorTemplateService, "findOneById")
213
+ .mockResolvedValue(buildPingTemplate());
214
+ jest
215
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
216
+ .mockResolvedValue(LIMIT_MAX);
217
+ mockLinkedMonitorPages(LIMIT_MAX);
218
+
219
+ const findSpy: SpyInstance<typeof MonitorService.findBy> = jest.spyOn(
220
+ MonitorService,
221
+ "findBy",
222
+ );
223
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
224
+ .spyOn(MonitorService, "updateBy")
225
+ .mockImplementation(
226
+ async (updateBy: UpdateBy<Monitor>): Promise<number> => {
227
+ return (updateBy.query as Record<string, Includes>)["_id"]!.values
228
+ .length;
229
+ },
230
+ );
231
+
232
+ const result: SyncLinkedMonitorsResult =
233
+ await MonitorTemplateService.syncLinkedMonitors({
234
+ monitorTemplateId: TEMPLATE_ID,
235
+ fields: ["monitoringInterval"],
236
+ props: { isRoot: true },
237
+ });
238
+
239
+ expect(result).toEqual({
240
+ totalLinkedMonitors: LIMIT_MAX,
241
+ syncedMonitors: LIMIT_MAX,
242
+ });
243
+ // Full page, then the empty page that ends the loop.
244
+ expect(findSpy).toHaveBeenCalledTimes(2);
245
+ expect(updateSpy).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it("carries the single monitor that overflows a page into a second batch", async () => {
249
+ jest
250
+ .spyOn(MonitorTemplateService, "findOneById")
251
+ .mockResolvedValue(buildPingTemplate());
252
+ jest
253
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
254
+ .mockResolvedValue(LIMIT_MAX + 1);
255
+ mockLinkedMonitorPages(LIMIT_MAX + 1);
256
+
257
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
258
+ .spyOn(MonitorService, "updateBy")
259
+ .mockImplementation(
260
+ async (updateBy: UpdateBy<Monitor>): Promise<number> => {
261
+ return (updateBy.query as Record<string, Includes>)["_id"]!.values
262
+ .length;
263
+ },
264
+ );
265
+
266
+ const result: SyncLinkedMonitorsResult =
267
+ await MonitorTemplateService.syncLinkedMonitors({
268
+ monitorTemplateId: TEMPLATE_ID,
269
+ fields: ["monitoringInterval"],
270
+ props: { isRoot: true },
271
+ });
272
+
273
+ expect(result).toEqual({
274
+ totalLinkedMonitors: LIMIT_MAX + 1,
275
+ syncedMonitors: LIMIT_MAX + 1,
276
+ });
277
+ expect(
278
+ updateSpy.mock.calls.map((call: [UpdateBy<Monitor>]): number => {
279
+ return (call[0].query as Record<string, Includes>)["_id"]!.values
280
+ .length;
281
+ }),
282
+ ).toEqual([LIMIT_MAX, 1]);
283
+ });
284
+
285
+ /*
286
+ * countLinkedMonitors reads project-wide as root while the writes stay
287
+ * narrowed to the caller, so a label-scoped operator legitimately syncs
288
+ * fewer rows than are linked. The shortfall must reach the caller rather
289
+ * than being rounded up to the linked count.
290
+ */
291
+ it("reports a permission-narrowed write honestly instead of claiming full coverage", async () => {
292
+ jest
293
+ .spyOn(MonitorTemplateService, "findOneById")
294
+ .mockResolvedValue(buildPingTemplate());
295
+ jest
296
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
297
+ .mockResolvedValue(9);
298
+ mockLinkedMonitorPages(9);
299
+
300
+ jest.spyOn(MonitorService, "updateBy").mockResolvedValue(4);
301
+
302
+ const result: SyncLinkedMonitorsResult =
303
+ await MonitorTemplateService.syncLinkedMonitors({
304
+ monitorTemplateId: TEMPLATE_ID,
305
+ fields: ["monitoringInterval"],
306
+ props: { isRoot: false },
307
+ });
308
+
309
+ expect(result).toEqual({ totalLinkedMonitors: 9, syncedMonitors: 4 });
310
+ });
311
+
312
+ /*
313
+ * The per-monitor rebind path exists only to keep each Network Device
314
+ * monitor pointed at its own device, which is a monitorSteps concern. A
315
+ * sync that leaves steps alone has no reason to pay for it.
316
+ */
317
+ it("takes the bulk path for a Network Device template when steps are not synced", async () => {
318
+ const template: MonitorTemplate = buildPingTemplate();
319
+ template.monitorType = MonitorType.NetworkDevice;
320
+
321
+ jest
322
+ .spyOn(MonitorTemplateService, "findOneById")
323
+ .mockResolvedValue(template);
324
+ jest
325
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
326
+ .mockResolvedValue(2);
327
+ mockLinkedMonitorPages(2);
328
+
329
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
330
+ .spyOn(MonitorService, "updateBy")
331
+ .mockResolvedValue(2);
332
+ const perMonitorSpy: SpyInstance<typeof MonitorService.updateOneById> =
333
+ jest.spyOn(MonitorService, "updateOneById");
334
+
335
+ const result: SyncLinkedMonitorsResult =
336
+ await MonitorTemplateService.syncLinkedMonitors({
337
+ monitorTemplateId: TEMPLATE_ID,
338
+ fields: ["monitoringInterval"],
339
+ props: { isRoot: true },
340
+ });
341
+
342
+ expect(result).toEqual({ totalLinkedMonitors: 2, syncedMonitors: 2 });
343
+ expect(updateSpy).toHaveBeenCalledTimes(1);
344
+ expect(perMonitorSpy).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it("writes nothing when the template carries none of the requested fields", async () => {
348
+ // Deliberately never assigns monitoringInterval, so buildUpdateData skips it.
349
+ const template: MonitorTemplate = new MonitorTemplate();
350
+ template.id = TEMPLATE_ID;
351
+ template.projectId = PROJECT_ID;
352
+ template.monitorType = MonitorType.Ping;
353
+
354
+ jest
355
+ .spyOn(MonitorTemplateService, "findOneById")
356
+ .mockResolvedValue(template);
357
+ jest
358
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
359
+ .mockResolvedValue(5);
360
+
361
+ const findSpy: SpyInstance<typeof MonitorService.findBy> = jest.spyOn(
362
+ MonitorService,
363
+ "findBy",
364
+ );
365
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest.spyOn(
366
+ MonitorService,
367
+ "updateBy",
368
+ );
369
+
370
+ const result: SyncLinkedMonitorsResult =
371
+ await MonitorTemplateService.syncLinkedMonitors({
372
+ monitorTemplateId: TEMPLATE_ID,
373
+ fields: ["monitoringInterval"],
374
+ props: { isRoot: true },
375
+ });
376
+
377
+ expect(result).toEqual({ totalLinkedMonitors: 5, syncedMonitors: 0 });
378
+ expect(findSpy).not.toHaveBeenCalled();
379
+ expect(updateSpy).not.toHaveBeenCalled();
380
+ });
381
+
382
+ /*
383
+ * The read enumerates as root so the reported linked total is project-wide,
384
+ * while every write carries the caller so updateBy can narrow it. Losing
385
+ * either half is a security-relevant change, and a count assertion cannot
386
+ * see it.
387
+ */
388
+ it("enumerates ids as root while narrowing every write to the caller", async () => {
389
+ const callerProps: DatabaseCommonInteractionProps = {
390
+ tenantId: PROJECT_ID,
391
+ userId: ObjectID.generate(),
392
+ };
393
+
394
+ jest
395
+ .spyOn(MonitorTemplateService, "findOneById")
396
+ .mockResolvedValue(buildPingTemplate());
397
+ jest
398
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
399
+ .mockResolvedValue(3);
400
+ mockLinkedMonitorPages(3);
401
+
402
+ const findSpy: SpyInstance<typeof MonitorService.findBy> = jest.spyOn(
403
+ MonitorService,
404
+ "findBy",
405
+ );
406
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
407
+ .spyOn(MonitorService, "updateBy")
408
+ .mockResolvedValue(3);
409
+
410
+ await MonitorTemplateService.syncLinkedMonitors({
411
+ monitorTemplateId: TEMPLATE_ID,
412
+ fields: ["monitoringInterval"],
413
+ props: callerProps,
414
+ });
415
+
416
+ expect(findSpy.mock.calls[0]![0].props).toEqual({ isRoot: true });
417
+ for (const call of updateSpy.mock.calls) {
418
+ expect(call[0].props).toBe(callerProps);
419
+ }
420
+ });
421
+
422
+ /*
423
+ * skip/limit paging is only stable over a total order, and a fleet
424
+ * provisioned by one auto-import run shares a createdAt. Without the _id
425
+ * tiebreaker a boundary tie returns one row twice and another never.
426
+ */
427
+ it("pages linked ids under a total order scoped to the template", async () => {
428
+ const total: number = LIMIT_MAX * 2 + 5;
429
+
430
+ jest
431
+ .spyOn(MonitorTemplateService, "findOneById")
432
+ .mockResolvedValue(buildPingTemplate());
433
+ jest
434
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
435
+ .mockResolvedValue(total);
436
+ mockLinkedMonitorPages(total);
437
+
438
+ const findSpy: SpyInstance<typeof MonitorService.findBy> = jest.spyOn(
439
+ MonitorService,
440
+ "findBy",
441
+ );
442
+ jest.spyOn(MonitorService, "updateBy").mockResolvedValue(0);
443
+
444
+ await MonitorTemplateService.syncLinkedMonitors({
445
+ monitorTemplateId: TEMPLATE_ID,
446
+ fields: ["monitoringInterval"],
447
+ props: { isRoot: true },
448
+ });
449
+
450
+ expect(findSpy).toHaveBeenCalledWith(
451
+ expect.objectContaining({
452
+ query: { monitorTemplateId: TEMPLATE_ID, projectId: PROJECT_ID },
453
+ select: { _id: true },
454
+ sort: {
455
+ createdAt: SortOrder.Ascending,
456
+ _id: SortOrder.Ascending,
457
+ },
458
+ props: { isRoot: true },
459
+ }),
460
+ );
461
+
462
+ // A dropped skip increment would hang rather than fail; pin the walk.
463
+ expect(
464
+ findSpy.mock.calls.map((call: [FindBy<Monitor>]): number => {
465
+ return Number(call[0].skip?.toString() || 0);
466
+ }),
467
+ ).toEqual([0, LIMIT_MAX, LIMIT_MAX * 2]);
468
+ });
469
+
470
+ it("writes every linked monitor exactly once across batches", async () => {
471
+ const total: number = LIMIT_MAX + 250;
472
+
473
+ jest
474
+ .spyOn(MonitorTemplateService, "findOneById")
475
+ .mockResolvedValue(buildPingTemplate());
476
+ jest
477
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
478
+ .mockResolvedValue(total);
479
+ const pool: Array<ObjectID> = mockLinkedMonitorPages(total);
480
+
481
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
482
+ .spyOn(MonitorService, "updateBy")
483
+ .mockResolvedValue(0);
484
+
485
+ await MonitorTemplateService.syncLinkedMonitors({
486
+ monitorTemplateId: TEMPLATE_ID,
487
+ fields: ["monitoringInterval"],
488
+ props: { isRoot: true },
489
+ });
490
+
491
+ const written: Array<string> = writtenIdsFrom(updateSpy);
492
+
493
+ expect(written).toHaveLength(total);
494
+ expect(new Set(written).size).toBe(total);
495
+ expect(new Set(written)).toEqual(
496
+ new Set(
497
+ pool.map((id: ObjectID): string => {
498
+ return id.toString();
499
+ }),
500
+ ),
501
+ );
502
+ });
503
+
504
+ /*
505
+ * Batch width and the update limit are the same constant today. If they ever
506
+ * diverge, updateBy silently truncates each batch and the original bug comes
507
+ * back green.
508
+ */
509
+ it("never hands updateBy more ids than its own limit allows", async () => {
510
+ const total: number = LIMIT_MAX * 2 + 7;
511
+
512
+ jest
513
+ .spyOn(MonitorTemplateService, "findOneById")
514
+ .mockResolvedValue(buildPingTemplate());
515
+ jest
516
+ .spyOn(MonitorTemplateService, "countLinkedMonitors")
517
+ .mockResolvedValue(total);
518
+ mockLinkedMonitorPages(total);
519
+
520
+ const updateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
521
+ .spyOn(MonitorService, "updateBy")
522
+ .mockResolvedValue(0);
523
+
524
+ await MonitorTemplateService.syncLinkedMonitors({
525
+ monitorTemplateId: TEMPLATE_ID,
526
+ fields: ["monitoringInterval"],
527
+ props: { isRoot: true },
528
+ });
529
+
530
+ for (const call of updateSpy.mock.calls) {
531
+ const batchSize: number = (call[0].query as Record<string, Includes>)[
532
+ "_id"
533
+ ]!.values.length;
534
+ expect(Number(call[0].limit)).toBeGreaterThanOrEqual(batchSize);
535
+ }
536
+ });
537
+
538
+ it("rejects a field that is not syncable from a template", async () => {
539
+ await expect(
540
+ MonitorTemplateService.syncLinkedMonitors({
541
+ monitorTemplateId: TEMPLATE_ID,
542
+ fields: ["monitorName"],
543
+ props: { isRoot: true },
544
+ }),
545
+ ).rejects.toThrow(
546
+ 'Field "monitorName" is not syncable from a monitor template',
547
+ );
548
+ });
549
+ });
@@ -366,8 +366,21 @@ describe("MonitorTemplateService Network Device synchronization", () => {
366
366
  const bulkUpdateSpy: SpyInstance<typeof MonitorService.updateBy> = jest
367
367
  .spyOn(MonitorService, "updateBy")
368
368
  .mockResolvedValue(3);
369
- const findMonitorsSpy: SpyInstance<typeof MonitorService.findBy> =
370
- jest.spyOn(MonitorService, "findBy");
369
+ /*
370
+ * The bulk path reads ids so it can cover a fleet larger than one update
371
+ * batch, but it must still write through updateBy rather than rebinding
372
+ * each monitor the way the Network Device path does.
373
+ */
374
+ jest
375
+ .spyOn(MonitorService, "findBy")
376
+ .mockResolvedValue([
377
+ buildLinkedMonitor([DEVICE_ONE_ID]),
378
+ buildLinkedMonitor([DEVICE_ONE_ID]),
379
+ buildLinkedMonitor([DEVICE_TWO_ID]),
380
+ ]);
381
+ const perMonitorUpdateSpy: SpyInstance<
382
+ typeof MonitorService.updateOneById
383
+ > = jest.spyOn(MonitorService, "updateOneById");
371
384
 
372
385
  const result: SyncLinkedMonitorsResult =
373
386
  await MonitorTemplateService.syncLinkedMonitors({
@@ -378,6 +391,6 @@ describe("MonitorTemplateService Network Device synchronization", () => {
378
391
 
379
392
  expect(result).toEqual({ totalLinkedMonitors: 3, syncedMonitors: 3 });
380
393
  expect(bulkUpdateSpy).toHaveBeenCalledTimes(1);
381
- expect(findMonitorsSpy).not.toHaveBeenCalled();
394
+ expect(perMonitorUpdateSpy).not.toHaveBeenCalled();
382
395
  });
383
396
  });
@@ -785,6 +785,63 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
785
785
  ).toBe(TEMPLATE_DEVICE_ID);
786
786
  });
787
787
 
788
+ /*
789
+ * ISSUE #3486. "Default Monitor Name" is optional, and a template that
790
+ * leaves it blank names what it provisions after the DEVICE and nothing
791
+ * else - the whole complaint being that every auto-imported device came
792
+ * back carrying the same fixed suffix.
793
+ *
794
+ * Driven end to end through the engine rather than through
795
+ * NetworkDeviceMonitorTemplateUtil alone, because the name only survives
796
+ * to the create call if `monitorName` is still in this service's template
797
+ * select; a dropped select column looks exactly like a blank stored value
798
+ * and would silently un-suffix every project's monitors.
799
+ */
800
+ it("names the provisioned monitor after the device alone when the template has no default name", async () => {
801
+ scanFindOneByMock.mockResolvedValue(
802
+ makeScan({ discoveredDevices: [makeHost()] }),
803
+ );
804
+ ruleFindByMock.mockResolvedValue([
805
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
806
+ ]);
807
+ monitorTemplateFindByMock.mockResolvedValue([
808
+ makeTemplate({ monitorName: undefined }),
809
+ ]);
810
+
811
+ const result: AutoImportRuleRunResult | null = await processScan();
812
+
813
+ expect(result).toMatchObject({ monitorsCreated: 1, monitorsFailed: 0 });
814
+
815
+ const monitor: Monitor = provisionedMonitor(0);
816
+ expect(monitor.name).toBe("core-switch-01");
817
+ expect(monitor.name).not.toContain(" - ");
818
+
819
+ // The read that makes the suffix possible at all.
820
+ expect(monitorTemplateFindByMock.mock.calls[0]![0].select).toMatchObject({
821
+ monitorName: true,
822
+ });
823
+ });
824
+
825
+ it("names the provisioned monitor after the device alone when the stored default name is empty", async () => {
826
+ scanFindOneByMock.mockResolvedValue(
827
+ makeScan({ discoveredDevices: [makeHost()] }),
828
+ );
829
+ ruleFindByMock.mockResolvedValue([
830
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
831
+ ]);
832
+ /*
833
+ * Reachable without any migration backfill: the dashboard's edit form
834
+ * PUTs an empty string, not null, when the operator clears the box.
835
+ */
836
+ monitorTemplateFindByMock.mockResolvedValue([
837
+ makeTemplate({ monitorName: "" }),
838
+ ]);
839
+
840
+ await processScan();
841
+
842
+ expect(provisionedMonitor(0).name).toBe("core-switch-01");
843
+ });
844
+
788
845
  it("backfills a selected template monitor for an already-registered matching device", async () => {
789
846
  const existingDevice: NetworkDevice = makeExistingDevice();
790
847
  scanFindOneByMock.mockResolvedValue(