@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,653 @@
1
+ import { generateKeyPairSync } from "crypto";
2
+ import logger from "../../../../Server/Utils/Logger";
3
+ import GoogleSecOpsClient, {
4
+ FetchAlertsResult,
5
+ FetchInitLike,
6
+ FetchLike,
7
+ FetchResponseLike,
8
+ } from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient";
9
+ import APIException from "../../../../Types/Exception/ApiException";
10
+ import { JSONObject } from "../../../../Types/JSON";
11
+ import { getJestSpyOn } from "../../../Spy";
12
+ import {
13
+ afterEach,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ jest,
18
+ test,
19
+ } from "@jest/globals";
20
+
21
+ /*
22
+ * legacyFetchAlertsView is a server-STREAMING method: its 200 body is a
23
+ * JSON array of FetchAlertsViewResponse chunks and the alerts sit two
24
+ * levels down at chunk.alerts.alerts[]. Google's REST reference documents
25
+ * the streamed message and never the stream envelope, so a parser written
26
+ * from that page looks for a top-level `alerts` array — which against a
27
+ * real response is an object, not an array, and yields zero alerts for
28
+ * every well-formed body.
29
+ *
30
+ * That is why this file exists, and why the rule it pins is stated as a
31
+ * prohibition rather than a feature: an empty alert list may only ever be
32
+ * reported for a body the parser RECOGNIZED and that genuinely carried no
33
+ * alerts. Everything else throws. The poller reads an empty result as a
34
+ * healthy quiet window and writes its cursor forward past it, so a
35
+ * tolerant parse of an unrecognized shape is silent, permanent data loss.
36
+ */
37
+
38
+ const { privateKey } = generateKeyPairSync("rsa", {
39
+ modulusLength: 2048,
40
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
41
+ publicKeyEncoding: { type: "spki", format: "pem" },
42
+ });
43
+
44
+ /*
45
+ * The token_uri has to be a real Google host: the client allowlists it at
46
+ * construction time, so a made-up host fails in the constructor and never
47
+ * reaches the parsing under test here.
48
+ */
49
+ const GOOGLE_TOKEN_URI: string = "https://oauth2.googleapis.com/token";
50
+
51
+ const SERVICE_ACCOUNT_JSON: string = JSON.stringify({
52
+ client_email: "poller@example.iam.gserviceaccount.com",
53
+ private_key: privateKey,
54
+ token_uri: GOOGLE_TOKEN_URI,
55
+ });
56
+
57
+ const INSTANCE_RESOURCE_NAME: string =
58
+ "projects/my-project/locations/us/instances/3f0a-instance";
59
+
60
+ const CHRONICLE_ORIGIN: string = "https://us-chronicle.googleapis.com";
61
+
62
+ const WINDOW_START_ISO: string = "2026-08-21T09:00:00.000Z";
63
+ const WINDOW_END_ISO: string = "2026-08-21T10:00:00.000Z";
64
+
65
+ /*
66
+ * One canonical body, used both against parseAlertsBody directly and
67
+ * through a stubbed transport, so the two paths cannot drift apart.
68
+ */
69
+ const CANONICAL_BODY: string = JSON.stringify([
70
+ {
71
+ alerts: {
72
+ alerts: [
73
+ { id: "alert-1", ruleName: "Brute force" },
74
+ { id: "alert-2", ruleName: "Impossible travel" },
75
+ ],
76
+ },
77
+ complete: true,
78
+ progress: 1,
79
+ baselineAlertsCount: 2,
80
+ filteredAlertsCount: 2,
81
+ },
82
+ ]);
83
+
84
+ interface StubbedResponse {
85
+ status: number;
86
+ body: string;
87
+ }
88
+
89
+ interface RecordedRequest {
90
+ url: string;
91
+ method: string;
92
+ headers: Record<string, string>;
93
+ }
94
+
95
+ /*
96
+ * Routed by URL rather than by call order so a test can prove the body it
97
+ * asserts on came back from Chronicle and not from the OAuth endpoint.
98
+ */
99
+ function makeFetch(responses: {
100
+ token: StubbedResponse;
101
+ alerts: StubbedResponse;
102
+ }): {
103
+ fetchImplementation: FetchLike;
104
+ requests: Array<RecordedRequest>;
105
+ } {
106
+ const requests: Array<RecordedRequest> = [];
107
+
108
+ const fetchImplementation: FetchLike = (
109
+ url: string,
110
+ init: FetchInitLike,
111
+ ): Promise<FetchResponseLike> => {
112
+ requests.push({ url: url, method: init.method, headers: init.headers });
113
+
114
+ const response: StubbedResponse =
115
+ url === GOOGLE_TOKEN_URI ? responses.token : responses.alerts;
116
+
117
+ return Promise.resolve({
118
+ ok: response.status >= 200 && response.status < 300,
119
+ status: response.status,
120
+ text: (): Promise<string> => {
121
+ return Promise.resolve(response.body);
122
+ },
123
+ });
124
+ };
125
+
126
+ return { fetchImplementation: fetchImplementation, requests: requests };
127
+ }
128
+
129
+ function okTokenResponse(): StubbedResponse {
130
+ return {
131
+ status: 200,
132
+ body: JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
133
+ };
134
+ }
135
+
136
+ function makeClient(alertsBody: string): {
137
+ client: GoogleSecOpsClient;
138
+ requests: Array<RecordedRequest>;
139
+ } {
140
+ const { fetchImplementation, requests } = makeFetch({
141
+ token: okTokenResponse(),
142
+ alerts: { status: 200, body: alertsBody },
143
+ });
144
+
145
+ const client: GoogleSecOpsClient = new GoogleSecOpsClient({
146
+ region: "us",
147
+ instanceResourceName: INSTANCE_RESOURCE_NAME,
148
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
149
+ fetchImplementation: fetchImplementation,
150
+ });
151
+
152
+ return { client: client, requests: requests };
153
+ }
154
+
155
+ /*
156
+ * Refusals are asserted as "threw AND produced no result", never as
157
+ * "threw": the failure mode being guarded against is a parser that returns
158
+ * an empty list, so the absence of a return value is half the assertion.
159
+ */
160
+ interface ParseOutcome {
161
+ result: FetchAlertsResult | null;
162
+ error: Error | null;
163
+ }
164
+
165
+ function parseOutcome(bodyText: string): ParseOutcome {
166
+ try {
167
+ return {
168
+ result: GoogleSecOpsClient.parseAlertsBody(bodyText),
169
+ error: null,
170
+ };
171
+ } catch (error) {
172
+ return { result: null, error: error as Error };
173
+ }
174
+ }
175
+
176
+ async function fetchOutcome(client: GoogleSecOpsClient): Promise<ParseOutcome> {
177
+ try {
178
+ return {
179
+ result: await client.fetchDetectionAlerts({
180
+ startTime: new Date(WINDOW_START_ISO),
181
+ endTime: new Date(WINDOW_END_ISO),
182
+ }),
183
+ error: null,
184
+ };
185
+ } catch (error) {
186
+ return { result: null, error: error as Error };
187
+ }
188
+ }
189
+
190
+ function idsOf(alerts: Array<JSONObject>): Array<string> {
191
+ return alerts.map((alert: JSONObject): string => {
192
+ return String(alert["id"] || "");
193
+ });
194
+ }
195
+
196
+ function chronicleRequests(
197
+ requests: Array<RecordedRequest>,
198
+ ): Array<RecordedRequest> {
199
+ return requests.filter((request: RecordedRequest): boolean => {
200
+ return request.url.startsWith(CHRONICLE_ORIGIN);
201
+ });
202
+ }
203
+
204
+ beforeEach(() => {
205
+ /*
206
+ * Several fixtures deliberately end without complete=true, which the
207
+ * client warns about. Silenced so a real failure is the only thing in
208
+ * the output.
209
+ */
210
+ getJestSpyOn(logger, "warn").mockImplementation((() => {
211
+ return undefined;
212
+ }) as never);
213
+ getJestSpyOn(logger, "error").mockImplementation((() => {
214
+ return undefined;
215
+ }) as never);
216
+ });
217
+
218
+ afterEach(() => {
219
+ jest.restoreAllMocks();
220
+ });
221
+
222
+ describe("GoogleSecOpsClient.parseAlertsBody stream envelope", () => {
223
+ test("reads the canonical single chunk from alerts.alerts, not from a top-level array", () => {
224
+ const result: FetchAlertsResult =
225
+ GoogleSecOpsClient.parseAlertsBody(CANONICAL_BODY);
226
+
227
+ expect(result.alerts).toEqual([
228
+ { id: "alert-1", ruleName: "Brute force" },
229
+ { id: "alert-2", ruleName: "Impossible travel" },
230
+ ]);
231
+ expect(result.chunkCount).toBe(1);
232
+ expect(result.complete).toBe(true);
233
+ expect(result.progress).toBe(1);
234
+ expect(result.baselineAlertsCount).toBe(2);
235
+ expect(result.filteredAlertsCount).toBe(2);
236
+ expect(result.truncatedByCount).toBe(false);
237
+ expect(result.truncatedByBytes).toBe(false);
238
+ });
239
+
240
+ test("tolerates a bare unary object instead of the array envelope", () => {
241
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
242
+ JSON.stringify({
243
+ alerts: { alerts: [{ id: "alert-1" }] },
244
+ complete: true,
245
+ progress: 1,
246
+ }),
247
+ );
248
+
249
+ expect(idsOf(result.alerts)).toEqual(["alert-1"]);
250
+ expect(result.chunkCount).toBe(1);
251
+ expect(result.complete).toBe(true);
252
+ });
253
+
254
+ test("a chunk omitting complete and progress parses with proto3 scalar defaults", () => {
255
+ // Both are proto3 scalars: false / 0 are serialized by omitting the key.
256
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
257
+ JSON.stringify([{ alerts: { alerts: [{ id: "alert-1" }] } }]),
258
+ );
259
+
260
+ expect(idsOf(result.alerts)).toEqual(["alert-1"]);
261
+ expect(result.complete).toBe(false);
262
+ expect(result.progress).toBe(0);
263
+ expect(result.baselineAlertsCount).toBe(0);
264
+ expect(result.filteredAlertsCount).toBe(0);
265
+ });
266
+
267
+ test("an absent scalar in a later chunk does not overwrite one an earlier chunk carried", () => {
268
+ /*
269
+ * "Last chunk that CARRIES the key", not "last chunk". Reading absence
270
+ * as present-and-false would turn a completed stream into a partial
271
+ * one the moment a trailing chunk omitted the flag.
272
+ */
273
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
274
+ JSON.stringify([
275
+ { complete: true, progress: 1, baselineAlertsCount: 7 },
276
+ { alerts: { alerts: [{ id: "alert-1" }] } },
277
+ ]),
278
+ );
279
+
280
+ expect(result.complete).toBe(true);
281
+ expect(result.progress).toBe(1);
282
+ expect(result.baselineAlertsCount).toBe(7);
283
+ expect(result.chunkCount).toBe(2);
284
+ });
285
+
286
+ test("counts arriving as proto3 int64 strings are read as numbers", () => {
287
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
288
+ JSON.stringify([
289
+ {
290
+ alerts: { alerts: [{ id: "alert-1" }] },
291
+ complete: true,
292
+ baselineAlertsCount: "1200",
293
+ filteredAlertsCount: "34",
294
+ },
295
+ ]),
296
+ );
297
+
298
+ expect(result.baselineAlertsCount).toBe(1200);
299
+ expect(result.filteredAlertsCount).toBe(34);
300
+ });
301
+
302
+ test("repairs concatenated objects that were never joined into an array", () => {
303
+ const body: string =
304
+ JSON.stringify({
305
+ alerts: { alerts: [{ id: "alert-1" }] },
306
+ progress: 0.5,
307
+ }) +
308
+ "\n" +
309
+ JSON.stringify({
310
+ alerts: { alerts: [{ id: "alert-2" }] },
311
+ complete: true,
312
+ progress: 1,
313
+ });
314
+
315
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(body);
316
+
317
+ expect(idsOf(result.alerts)).toEqual(["alert-1", "alert-2"]);
318
+ expect(result.chunkCount).toBe(2);
319
+ expect(result.complete).toBe(true);
320
+ expect(result.progress).toBe(1);
321
+ });
322
+
323
+ test("repairs a trailing comma before a closing bracket", () => {
324
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
325
+ '[{"alerts":{"alerts":[{"id":"alert-1"},]},"complete":true},]',
326
+ );
327
+
328
+ expect(idsOf(result.alerts)).toEqual(["alert-1"]);
329
+ expect(result.chunkCount).toBe(1);
330
+ expect(result.complete).toBe(true);
331
+ });
332
+ });
333
+
334
+ describe("GoogleSecOpsClient.parseAlertsBody accumulation across chunks", () => {
335
+ test("unions disjoint ids across chunks", () => {
336
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
337
+ JSON.stringify([
338
+ { alerts: { alerts: [{ id: "a" }, { id: "b" }] }, progress: 0.5 },
339
+ { alerts: { alerts: [{ id: "c" }] }, complete: true, progress: 1 },
340
+ ]),
341
+ );
342
+
343
+ expect(idsOf(result.alerts)).toEqual(["a", "b", "c"]);
344
+ expect(result.chunkCount).toBe(2);
345
+ });
346
+
347
+ test("dedupes ids repeated across chunks instead of counting them twice", () => {
348
+ /*
349
+ * The C3 pin. No Google page states whether chunk.alerts is cumulative
350
+ * or incremental, and the cumulative reading is the more likely one:
351
+ * each chunk restates the whole top-N. Under a blind push the window
352
+ * below would ingest five Detection Finding rows for three real
353
+ * alerts, and every duplicate would be a separate, alertable finding.
354
+ * The dedupe-by-id union is correct under either reading.
355
+ */
356
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
357
+ JSON.stringify([
358
+ { alerts: { alerts: [{ id: "a" }, { id: "b" }] }, progress: 0.5 },
359
+ {
360
+ alerts: { alerts: [{ id: "a" }, { id: "b" }, { id: "c" }] },
361
+ complete: true,
362
+ progress: 1,
363
+ },
364
+ ]),
365
+ );
366
+
367
+ expect(result.alerts).toHaveLength(3);
368
+ expect(idsOf(result.alerts)).toEqual(["a", "b", "c"]);
369
+ });
370
+
371
+ test("a mutated copy of an id in a later chunk wins", () => {
372
+ // Cumulative chunks refresh what they restate; the freshest state wins.
373
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
374
+ JSON.stringify([
375
+ {
376
+ alerts: {
377
+ alerts: [{ id: "a", alertState: "OPEN", severity: "LOW" }],
378
+ },
379
+ },
380
+ {
381
+ alerts: {
382
+ alerts: [{ id: "a", alertState: "CLOSED", severity: "HIGH" }],
383
+ },
384
+ complete: true,
385
+ },
386
+ ]),
387
+ );
388
+
389
+ expect(result.alerts).toEqual([
390
+ { id: "a", alertState: "CLOSED", severity: "HIGH" },
391
+ ]);
392
+ });
393
+
394
+ test("alerts with no usable id are never collapsed into one another", () => {
395
+ /*
396
+ * A missing id and an empty-string id both fall back to a positional
397
+ * key. Keying them all on "" would silently discard every id-less
398
+ * alert but the last one in the window.
399
+ */
400
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
401
+ JSON.stringify([
402
+ {
403
+ alerts: {
404
+ alerts: [
405
+ { ruleName: "r1" },
406
+ { ruleName: "r2" },
407
+ { id: "", ruleName: "r3" },
408
+ ],
409
+ },
410
+ },
411
+ {
412
+ alerts: {
413
+ alerts: [{ ruleName: "r1" }, { id: "", ruleName: "r4" }],
414
+ },
415
+ complete: true,
416
+ },
417
+ ]),
418
+ );
419
+
420
+ expect(result.alerts).toEqual([
421
+ { ruleName: "r1" },
422
+ { ruleName: "r2" },
423
+ { id: "", ruleName: "r3" },
424
+ { ruleName: "r1" },
425
+ { id: "", ruleName: "r4" },
426
+ ]);
427
+ });
428
+ });
429
+
430
+ describe("GoogleSecOpsClient.parseAlertsBody recognized shapes carrying no alerts", () => {
431
+ test("a recognized chunk with no alerts returns an empty list without throwing", () => {
432
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
433
+ JSON.stringify([{ complete: true }]),
434
+ );
435
+
436
+ expect(result.alerts).toEqual([]);
437
+ expect(result.complete).toBe(true);
438
+ expect(result.chunkCount).toBe(1);
439
+ });
440
+
441
+ test("alerts present but alerts.alerts absent is a recognized empty window", () => {
442
+ // Both levels of the AlertList nesting are omitted when empty.
443
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
444
+ JSON.stringify([{ alerts: {}, complete: true }]),
445
+ );
446
+
447
+ expect(result.alerts).toEqual([]);
448
+ expect(result.complete).toBe(true);
449
+ });
450
+
451
+ test("non-object entries inside alerts.alerts are filtered rather than fatal", () => {
452
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
453
+ JSON.stringify([
454
+ {
455
+ alerts: {
456
+ alerts: [{ id: "alert-1" }, "junk", 42, null, [{ id: "nested" }]],
457
+ },
458
+ complete: true,
459
+ },
460
+ ]),
461
+ );
462
+
463
+ expect(result.alerts).toEqual([{ id: "alert-1" }]);
464
+ });
465
+ });
466
+
467
+ describe("GoogleSecOpsClient.parseAlertsBody legacy top-level shapes", () => {
468
+ test("a top-level alerts array still works", () => {
469
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
470
+ JSON.stringify({ alerts: [{ id: "legacy-1" }, { id: "legacy-2" }] }),
471
+ );
472
+
473
+ expect(idsOf(result.alerts)).toEqual(["legacy-1", "legacy-2"]);
474
+ });
475
+
476
+ test("a top-level detections array still works", () => {
477
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
478
+ JSON.stringify({ detections: [{ id: "det-1" }] }),
479
+ );
480
+
481
+ expect(idsOf(result.alerts)).toEqual(["det-1"]);
482
+ });
483
+
484
+ test("an empty alerts array does not shadow a populated detections array", () => {
485
+ /*
486
+ * The first-array-wins bug: the old loop returned on the first
487
+ * array-valued key rather than the first non-empty one, so this body
488
+ * reported zero alerts and dropped the detections.
489
+ */
490
+ const result: FetchAlertsResult = GoogleSecOpsClient.parseAlertsBody(
491
+ JSON.stringify({ alerts: [], detections: [{ id: "det-1" }] }),
492
+ );
493
+
494
+ expect(idsOf(result.alerts)).toEqual(["det-1"]);
495
+ });
496
+ });
497
+
498
+ describe("GoogleSecOpsClient.parseAlertsBody bodies it must refuse", () => {
499
+ test("an unrecognized shape throws and never reports zero alerts", () => {
500
+ /*
501
+ * The most important assertion in this change. A [] here is
502
+ * indistinguishable from a genuinely quiet window: the poller records
503
+ * a healthy poll, advances lastPolledAt past the window, and every
504
+ * alert in it is lost with nothing written to lastError. The parser
505
+ * must refuse a body it did not recognize, not summarize it as empty.
506
+ */
507
+ const unary: ParseOutcome = parseOutcome(JSON.stringify({ nope: true }));
508
+
509
+ expect(unary.error).toBeInstanceOf(APIException);
510
+ expect(unary.result).toBeNull();
511
+
512
+ const wrapped: ParseOutcome = parseOutcome(
513
+ JSON.stringify([{ nope: true }, { alsoNope: 1 }]),
514
+ );
515
+
516
+ expect(wrapped.error).toBeInstanceOf(APIException);
517
+ expect(wrapped.result).toBeNull();
518
+
519
+ /*
520
+ * And the contrast that makes the refusal meaningful: a body that IS
521
+ * recognized and carries nothing returns, rather than throwing.
522
+ */
523
+ const recognized: ParseOutcome = parseOutcome(
524
+ JSON.stringify([{ complete: true }]),
525
+ );
526
+
527
+ expect(recognized.error).toBeNull();
528
+ expect(recognized.result?.alerts).toEqual([]);
529
+ });
530
+
531
+ test("an empty array has no recognized chunk and throws", () => {
532
+ const outcome: ParseOutcome = parseOutcome("[]");
533
+
534
+ expect(outcome.error).toBeInstanceOf(APIException);
535
+ expect(outcome.result).toBeNull();
536
+ });
537
+
538
+ test("a JSON root that is not an object or array throws", () => {
539
+ for (const root of ['"junk"', "null", "42", "true"]) {
540
+ const outcome: ParseOutcome = parseOutcome(root);
541
+
542
+ expect(outcome.error).toBeInstanceOf(APIException);
543
+ expect(outcome.result).toBeNull();
544
+ }
545
+ });
546
+
547
+ test("text that is not JSON at all throws the non-JSON body exception", () => {
548
+ const outcome: ParseOutcome = parseOutcome("not json at all");
549
+
550
+ expect(outcome.error).toBeInstanceOf(APIException);
551
+ expect(outcome.result).toBeNull();
552
+ });
553
+
554
+ test("an HTML body from a mistyped region throws APIException, not a raw SyntaxError", () => {
555
+ /*
556
+ * googleapis.com is a DNS wildcard, so a bad region prefix resolves to
557
+ * a Google frontend that answers with an HTML 404. An unwrapped
558
+ * SyntaxError escapes the failure taxonomy the operator guidance is
559
+ * written against.
560
+ */
561
+ const outcome: ParseOutcome = parseOutcome(
562
+ '<!DOCTYPE html>\n<html lang="en">\n<head><title>Error 404 (Not Found)</title></head>\n<body><p>The requested URL was not found on this server.</p></body>\n</html>\n',
563
+ );
564
+
565
+ expect(outcome.error).toBeInstanceOf(APIException);
566
+ expect(outcome.error).not.toBeInstanceOf(SyntaxError);
567
+ expect(outcome.result).toBeNull();
568
+ });
569
+
570
+ test("an empty or whitespace-only body throws", () => {
571
+ for (const body of ["", " ", "\n\t "]) {
572
+ const outcome: ParseOutcome = parseOutcome(body);
573
+
574
+ expect(outcome.error).toBeInstanceOf(APIException);
575
+ expect(outcome.result).toBeNull();
576
+ }
577
+ });
578
+
579
+ test("the three refusal kinds are distinguishable from one another", () => {
580
+ /*
581
+ * Derived rather than pasted: whatever the templates say, an operator
582
+ * reading lastError has to be able to tell "Chronicle sent nothing"
583
+ * from "Chronicle sent a web page" from "Chronicle sent JSON we do not
584
+ * understand", because the three have different fixes.
585
+ */
586
+ const outcomes: Array<ParseOutcome> = [
587
+ parseOutcome(""),
588
+ parseOutcome("<html><body>404</body></html>"),
589
+ parseOutcome(JSON.stringify({ nope: true })),
590
+ ];
591
+
592
+ const messages: Array<string> = outcomes.map(
593
+ (outcome: ParseOutcome): string => {
594
+ expect(outcome.error).toBeInstanceOf(APIException);
595
+
596
+ return String(outcome.error?.message || "");
597
+ },
598
+ );
599
+
600
+ for (const message of messages) {
601
+ expect(message.length).toBeGreaterThan(0);
602
+ }
603
+
604
+ expect(new Set(messages).size).toBe(3);
605
+ });
606
+ });
607
+
608
+ describe("GoogleSecOpsClient.fetchDetectionAlerts response parsing", () => {
609
+ test("returns the parsed stream envelope from a live-shaped 200", async () => {
610
+ const { client, requests } = makeClient(CANONICAL_BODY);
611
+
612
+ const result: FetchAlertsResult = await client.fetchDetectionAlerts({
613
+ startTime: new Date(WINDOW_START_ISO),
614
+ endTime: new Date(WINDOW_END_ISO),
615
+ });
616
+
617
+ expect(idsOf(result.alerts)).toEqual(["alert-1", "alert-2"]);
618
+ expect(result.complete).toBe(true);
619
+ expect(result.progress).toBe(1);
620
+ expect(result.chunkCount).toBe(1);
621
+ expect(result.truncatedByCount).toBe(false);
622
+ expect(result.truncatedByBytes).toBe(false);
623
+
624
+ // The parsed body came back from Chronicle, not from the token endpoint.
625
+ const alertsCalls: Array<RecordedRequest> = chronicleRequests(requests);
626
+ expect(alertsCalls).toHaveLength(1);
627
+ expect(alertsCalls[0]!.method).toBe("GET");
628
+ expect(alertsCalls[0]!.headers["Authorization"]).toBe("Bearer test-token");
629
+ });
630
+
631
+ test("a 200 carrying an unrecognized shape rejects instead of resolving empty", async () => {
632
+ /*
633
+ * The same refusal as the unit case, asserted at the boundary the
634
+ * poller actually calls: a resolved empty result here is what advances
635
+ * the cursor past unread alerts.
636
+ */
637
+ const { client } = makeClient(JSON.stringify({ nope: true }));
638
+
639
+ const outcome: ParseOutcome = await fetchOutcome(client);
640
+
641
+ expect(outcome.error).toBeInstanceOf(APIException);
642
+ expect(outcome.result).toBeNull();
643
+ });
644
+
645
+ test("a 200 with an empty body rejects instead of resolving empty", async () => {
646
+ const { client } = makeClient("");
647
+
648
+ const outcome: ParseOutcome = await fetchOutcome(client);
649
+
650
+ expect(outcome.error).toBeInstanceOf(APIException);
651
+ expect(outcome.result).toBeNull();
652
+ });
653
+ });