@oneuptime/common 12.0.27 → 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 (204) hide show
  1. package/Models/AnalyticsModels/RumSessionChunk.ts +52 -0
  2. package/Models/DatabaseModels/CloudResource.ts +17 -1
  3. package/Models/DatabaseModels/DashboardDomain.ts +43 -0
  4. package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
  5. package/Models/DatabaseModels/RumApplication.ts +35 -2
  6. package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
  7. package/Server/API/DashboardDomainAPI.ts +74 -0
  8. package/Server/API/MicrosoftTeamsAPI.ts +81 -180
  9. package/Server/API/StatusPageDomainAPI.ts +74 -0
  10. package/Server/API/TelemetryAPI.ts +104 -15
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  14. package/Server/Middleware/ProjectAuthorization.ts +6 -1
  15. package/Server/Services/DashboardDomainService.ts +131 -0
  16. package/Server/Services/LogPipelineProcessorService.ts +88 -0
  17. package/Server/Services/MonitorTemplateService.ts +70 -11
  18. package/Server/Services/StatusPageDomainService.ts +163 -3
  19. package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
  20. package/Server/Types/Domain.ts +17 -0
  21. package/Server/Utils/APIKey/AccessPermission.ts +24 -0
  22. package/Server/Utils/Greenlock/Greenlock.ts +156 -58
  23. package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
  24. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
  25. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
  26. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
  27. package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
  28. package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
  29. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
  30. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
  31. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
  32. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
  33. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
  34. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
  35. package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
  36. package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
  37. package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
  38. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
  39. package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
  40. package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
  41. package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
  42. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
  43. package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
  44. package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
  45. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
  46. package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
  47. package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
  48. package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
  49. package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
  50. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
  51. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
  52. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
  53. package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
  54. package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
  55. package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
  56. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
  57. package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
  58. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
  59. package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
  60. package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
  61. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
  62. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
  63. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
  64. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
  65. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
  66. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
  67. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
  68. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
  69. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
  70. package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
  71. package/Tests/Types/API/HTTPResponse.test.ts +175 -0
  72. package/Tests/Types/API/Route.test.ts +36 -4
  73. package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
  74. package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
  75. package/Tests/Types/DateLocalTimeString.test.ts +259 -0
  76. package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
  77. package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
  78. package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
  79. package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
  80. package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
  81. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
  82. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
  83. package/Tests/UI/Utils/Navigation.test.ts +82 -0
  84. package/Tests/Utils/Array.test.ts +100 -0
  85. package/Tests/Utils/CertificateReissue.test.ts +225 -0
  86. package/Tests/Utils/Grok/Grok.test.ts +492 -0
  87. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
  88. package/Types/API/Route.ts +20 -5
  89. package/Types/Date.ts +143 -13
  90. package/Types/Rum/SessionReplay.ts +76 -1
  91. package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
  92. package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
  93. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
  94. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
  95. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
  96. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
  97. package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
  98. package/UI/Utils/Navigation.ts +42 -0
  99. package/Utils/Array.ts +37 -0
  100. package/Utils/CertificateReissue.ts +128 -0
  101. package/Utils/Grok/Grok.ts +461 -0
  102. package/Utils/Grok/GrokPatterns.ts +118 -0
  103. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
  104. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
  105. package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
  107. package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
  109. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
  111. package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
  113. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  114. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
  115. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  116. package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
  117. package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
  118. package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
  119. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  120. package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
  121. package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
  122. package/build/dist/Server/API/TelemetryAPI.js +67 -14
  123. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  130. package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
  131. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  132. package/build/dist/Server/Services/DashboardDomainService.js +104 -0
  133. package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
  134. package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
  135. package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
  136. package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
  137. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  138. package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
  139. package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
  140. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
  141. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
  142. package/build/dist/Server/Types/Domain.js +16 -0
  143. package/build/dist/Server/Types/Domain.js.map +1 -1
  144. package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
  145. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  146. package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
  147. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  148. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
  149. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
  150. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
  151. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
  152. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
  153. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  154. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  155. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
  156. package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
  157. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
  158. package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
  159. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
  160. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  161. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
  162. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  163. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
  164. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
  165. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
  166. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  167. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
  168. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  169. package/build/dist/Types/API/Route.js +16 -5
  170. package/build/dist/Types/API/Route.js.map +1 -1
  171. package/build/dist/Types/Date.js +115 -13
  172. package/build/dist/Types/Date.js.map +1 -1
  173. package/build/dist/Types/Rum/SessionReplay.js +38 -0
  174. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  175. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
  176. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
  177. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
  178. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  179. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
  180. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
  181. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
  182. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  183. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
  184. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  185. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
  186. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  187. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
  188. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
  189. package/build/dist/UI/Utils/Navigation.js +30 -0
  190. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  191. package/build/dist/Utils/Array.js +25 -0
  192. package/build/dist/Utils/Array.js.map +1 -1
  193. package/build/dist/Utils/CertificateReissue.js +97 -0
  194. package/build/dist/Utils/CertificateReissue.js.map +1 -0
  195. package/build/dist/Utils/Grok/Grok.js +324 -0
  196. package/build/dist/Utils/Grok/Grok.js.map +1 -0
  197. package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
  198. package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
  199. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
  200. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  201. package/package.json +1 -1
  202. package/UI/Utils/JsonWebToken.ts +0 -14
  203. package/build/dist/UI/Utils/JsonWebToken.js +0 -10
  204. package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
@@ -0,0 +1,961 @@
1
+ import { generateKeyPairSync } from "crypto";
2
+ import GoogleSecOpsClient, {
3
+ FetchAlertsResult,
4
+ FetchInitLike,
5
+ FetchLike,
6
+ FetchResponseLike,
7
+ GoogleServiceAccountCredentials,
8
+ } from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient";
9
+ import BadDataException from "../../../../Types/Exception/BadDataException";
10
+ import { describe, expect, test } from "@jest/globals";
11
+
12
+ /*
13
+ * The wire contract of the alerts request, asserted by parsing.
14
+ *
15
+ * This connector shipped broken for one reason: the only request coverage
16
+ * that existed was `toContain` on the path prefix and on the encoded ISO
17
+ * timestamp, and both stay green under ANY parameter rename. A query
18
+ * string carrying `pageSize` — a parameter this endpoint does not have —
19
+ * passed every test run and 400'd at Google's transcoder in production.
20
+ * So nothing here matches a substring of a URL: every parameter assertion
21
+ * goes through `new URL(...).searchParams` and pins the SORTED KEY LIST,
22
+ * which fails on an extra, a renamed, or a missing key alike.
23
+ *
24
+ * The second half pins the validation standing between a customer-supplied
25
+ * connection row and an outbound request — the region allowlist, the
26
+ * region/location cross-check, the token_uri host pin, the service-account
27
+ * parse and the instance-name shape. Each of those used to admit something
28
+ * that either could not work or should never have been reachable.
29
+ */
30
+
31
+ /*
32
+ * Google's real OAuth endpoint. It is also the routing key for the
33
+ * injected transport below: the token exchange and the Chronicle call are
34
+ * told apart by host, never by call order, so a test asserting on "the
35
+ * alerts request" cannot accidentally be asserting on the token POST.
36
+ */
37
+ const GOOGLE_TOKEN_URI: string = "https://oauth2.googleapis.com/token";
38
+
39
+ /*
40
+ * A real RS256 key, because the client genuinely signs a JWT assertion
41
+ * before it ever reaches Chronicle; a fake key fails inside jwt.sign and
42
+ * the request under test is never built.
43
+ */
44
+ const { privateKey } = generateKeyPairSync("rsa", {
45
+ modulusLength: 2048,
46
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
47
+ publicKeyEncoding: { type: "spki", format: "pem" },
48
+ });
49
+
50
+ const SERVICE_ACCOUNT_JSON: string = JSON.stringify({
51
+ client_email: "poller@example.iam.gserviceaccount.com",
52
+ private_key: privateKey,
53
+ token_uri: GOOGLE_TOKEN_URI,
54
+ });
55
+
56
+ const INSTANCE: string =
57
+ "projects/my-project/locations/us/instances/3f0a-instance";
58
+
59
+ const ACCESS_TOKEN: string = "ya29.test-access-token";
60
+
61
+ const WINDOW_START: Date = new Date("2026-08-21T09:00:00.000Z");
62
+ const WINDOW_END: Date = new Date("2026-08-21T10:00:00.000Z");
63
+
64
+ /*
65
+ * The whole parameter set, sorted. Kept as one constant so every request
66
+ * test asserts the same closed list rather than each one checking the
67
+ * subset it happens to care about.
68
+ */
69
+ const EXPECTED_QUERY_KEYS: Array<string> = [
70
+ "alertListOptions.maxReturnedAlerts",
71
+ "timeRange.endTime",
72
+ "timeRange.startTime",
73
+ ];
74
+
75
+ /*
76
+ * The 22 documented {region}-chronicle.googleapis.com prefixes. Written
77
+ * out rather than imported because the point is to pin the set against the
78
+ * documentation, not against whatever the module currently holds.
79
+ */
80
+ const DOCUMENTED_REGION_PREFIXES: Array<string> = [
81
+ "us",
82
+ "eu",
83
+ "europe",
84
+ "africa-south1",
85
+ "asia-east1",
86
+ "asia-northeast1",
87
+ "asia-northeast3",
88
+ "asia-south1",
89
+ "asia-southeast1",
90
+ "asia-southeast2",
91
+ "australia-southeast1",
92
+ "europe-central2",
93
+ "europe-west12",
94
+ "europe-west2",
95
+ "europe-west3",
96
+ "europe-west6",
97
+ "europe-west9",
98
+ "me-central1",
99
+ "me-central2",
100
+ "me-west1",
101
+ "northamerica-northeast2",
102
+ "southamerica-east1",
103
+ ];
104
+
105
+ interface StubbedResponse {
106
+ status: number;
107
+ body: string;
108
+ }
109
+
110
+ interface RecordedRequest {
111
+ url: string;
112
+ method: string;
113
+ headers: Record<string, string>;
114
+ body: string | undefined;
115
+ }
116
+
117
+ function okTokenResponse(): StubbedResponse {
118
+ return {
119
+ status: 200,
120
+ body: JSON.stringify({ access_token: ACCESS_TOKEN, expires_in: 3600 }),
121
+ };
122
+ }
123
+
124
+ /*
125
+ * The canonical streaming envelope: an array of one chunk with the alerts
126
+ * two levels down at alerts.alerts[]. complete is set so no test on this
127
+ * path trips the partial-window warning and turns into log noise.
128
+ */
129
+ function okAlertsResponse(): StubbedResponse {
130
+ return {
131
+ status: 200,
132
+ body: JSON.stringify([
133
+ {
134
+ alerts: { alerts: [{ id: "alert-1" }] },
135
+ complete: true,
136
+ progress: 1,
137
+ },
138
+ ]),
139
+ };
140
+ }
141
+
142
+ function makeFetch(responses: {
143
+ token: StubbedResponse;
144
+ alerts: StubbedResponse;
145
+ }): {
146
+ fetchImplementation: FetchLike;
147
+ requests: Array<RecordedRequest>;
148
+ } {
149
+ const requests: Array<RecordedRequest> = [];
150
+
151
+ const fetchImplementation: FetchLike = (
152
+ url: string,
153
+ init: FetchInitLike,
154
+ ): Promise<FetchResponseLike> => {
155
+ requests.push({
156
+ url: url,
157
+ method: init.method,
158
+ headers: init.headers,
159
+ body: init.body,
160
+ });
161
+
162
+ const response: StubbedResponse =
163
+ url === GOOGLE_TOKEN_URI ? responses.token : responses.alerts;
164
+
165
+ return Promise.resolve({
166
+ ok: response.status >= 200 && response.status < 300,
167
+ status: response.status,
168
+ text: (): Promise<string> => {
169
+ return Promise.resolve(response.body);
170
+ },
171
+ });
172
+ };
173
+
174
+ return { fetchImplementation: fetchImplementation, requests: requests };
175
+ }
176
+
177
+ function makeClient(overrides?: {
178
+ region?: string | undefined;
179
+ instanceResourceName?: string | undefined;
180
+ }): { client: GoogleSecOpsClient; requests: Array<RecordedRequest> } {
181
+ const { fetchImplementation, requests } = makeFetch({
182
+ token: okTokenResponse(),
183
+ alerts: okAlertsResponse(),
184
+ });
185
+
186
+ const client: GoogleSecOpsClient = new GoogleSecOpsClient({
187
+ region: overrides?.region || "us",
188
+ instanceResourceName: overrides?.instanceResourceName || INSTANCE,
189
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
190
+ fetchImplementation: fetchImplementation,
191
+ });
192
+
193
+ return { client: client, requests: requests };
194
+ }
195
+
196
+ /*
197
+ * For the tests that only construct a client. A transport that throws on
198
+ * use proves construction really is offline, so a validation assertion can
199
+ * never be satisfied by a round trip nobody noticed.
200
+ */
201
+ function neverCalledFetch(): FetchLike {
202
+ return (): Promise<FetchResponseLike> => {
203
+ throw new Error(
204
+ "This test constructs a client only; no HTTP request was expected.",
205
+ );
206
+ };
207
+ }
208
+
209
+ /*
210
+ * The Chronicle request, isolated from the token exchange by host. Throws
211
+ * rather than returning the wrong one, so a routing mistake in the harness
212
+ * can never be read as a passing request-shape assertion.
213
+ */
214
+ function alertsRequestIn(requests: Array<RecordedRequest>): RecordedRequest {
215
+ const chronicleRequests: Array<RecordedRequest> = requests.filter(
216
+ (request: RecordedRequest): boolean => {
217
+ return request.url !== GOOGLE_TOKEN_URI;
218
+ },
219
+ );
220
+
221
+ if (chronicleRequests.length !== 1) {
222
+ throw new Error(
223
+ `Expected exactly one Chronicle request, recorded ${chronicleRequests.length}.`,
224
+ );
225
+ }
226
+
227
+ return chronicleRequests[0]!;
228
+ }
229
+
230
+ function queryParamsOf(request: RecordedRequest): URLSearchParams {
231
+ return new URL(request.url).searchParams;
232
+ }
233
+
234
+ function sortedQueryKeysOf(request: RecordedRequest): Array<string> {
235
+ const keys: Array<string> = [];
236
+
237
+ queryParamsOf(request).forEach((_value: string, key: string): void => {
238
+ keys.push(key);
239
+ });
240
+
241
+ return keys.sort();
242
+ }
243
+
244
+ /*
245
+ * The query string as it actually goes on the wire, before URLSearchParams
246
+ * decodes it. Encoding is load-bearing in both directions: the transcoder
247
+ * binds on the literal field path, so the `.` must survive as a `.`, while
248
+ * the `:` in an RFC 3339 timestamp must not survive as a `:`.
249
+ */
250
+ function rawQueryPairsOf(request: RecordedRequest): Map<string, string> {
251
+ const search: string = new URL(request.url).search;
252
+ const query: string = search.startsWith("?") ? search.slice(1) : search;
253
+ const pairs: Map<string, string> = new Map<string, string>();
254
+
255
+ if (!query) {
256
+ return pairs;
257
+ }
258
+
259
+ for (const pair of query.split("&")) {
260
+ const separatorIndex: number = pair.indexOf("=");
261
+
262
+ pairs.set(
263
+ separatorIndex === -1 ? pair : pair.slice(0, separatorIndex),
264
+ separatorIndex === -1 ? "" : pair.slice(separatorIndex + 1),
265
+ );
266
+ }
267
+
268
+ return pairs;
269
+ }
270
+
271
+ function sortedKeysOf(pairs: Map<string, string>): Array<string> {
272
+ const keys: Array<string> = [];
273
+
274
+ pairs.forEach((_value: string, key: string): void => {
275
+ keys.push(key);
276
+ });
277
+
278
+ return keys.sort();
279
+ }
280
+
281
+ function errorThrownBy(run: () => unknown): unknown {
282
+ try {
283
+ run();
284
+ } catch (error) {
285
+ return error;
286
+ }
287
+
288
+ throw new Error("Expected this call to throw, and it returned instead.");
289
+ }
290
+
291
+ function messageThrownBy(run: () => unknown): string {
292
+ return (errorThrownBy(run) as Error).message;
293
+ }
294
+
295
+ function serviceAccountJsonWith(fields: Record<string, unknown>): string {
296
+ return JSON.stringify({
297
+ client_email: "poller@example.iam.gserviceaccount.com",
298
+ private_key: privateKey,
299
+ ...fields,
300
+ });
301
+ }
302
+
303
+ describe("GoogleSecOpsClient alerts request parameters", () => {
304
+ test("sends exactly the three documented field paths and nothing else", async () => {
305
+ const { client, requests } = makeClient();
306
+
307
+ const result: FetchAlertsResult = await client.fetchDetectionAlerts({
308
+ startTime: WINDOW_START,
309
+ endTime: WINDOW_END,
310
+ });
311
+
312
+ /*
313
+ * The call genuinely completed against the canonical envelope, so what
314
+ * follows is the shape of a request Chronicle answered, not the shape
315
+ * of one that fell over half-built.
316
+ */
317
+ expect(result.alerts).toEqual([{ id: "alert-1" }]);
318
+ expect(result.complete).toBe(true);
319
+
320
+ // One token exchange, one Chronicle call, nothing else on the wire.
321
+ expect(requests).toHaveLength(2);
322
+ expect(
323
+ requests.filter((request: RecordedRequest): boolean => {
324
+ return request.url === GOOGLE_TOKEN_URI;
325
+ }),
326
+ ).toHaveLength(1);
327
+
328
+ expect(sortedQueryKeysOf(alertsRequestIn(requests))).toEqual(
329
+ EXPECTED_QUERY_KEYS,
330
+ );
331
+ });
332
+
333
+ test("pins both ends of the window as RFC 3339 instants, read back by name", async () => {
334
+ const { client, requests } = makeClient();
335
+
336
+ await client.fetchDetectionAlerts({
337
+ startTime: WINDOW_START,
338
+ endTime: WINDOW_END,
339
+ });
340
+
341
+ const params: URLSearchParams = queryParamsOf(alertsRequestIn(requests));
342
+
343
+ expect(params.get("timeRange.startTime")).toBe("2026-08-21T09:00:00.000Z");
344
+ expect(params.get("timeRange.endTime")).toBe("2026-08-21T10:00:00.000Z");
345
+ });
346
+
347
+ test("defaults maxReturnedAlerts to 1000 and honours an explicit ceiling without adding a parameter", async () => {
348
+ const defaultRun: {
349
+ client: GoogleSecOpsClient;
350
+ requests: Array<RecordedRequest>;
351
+ } = makeClient();
352
+
353
+ await defaultRun.client.fetchDetectionAlerts({
354
+ startTime: WINDOW_START,
355
+ endTime: WINDOW_END,
356
+ });
357
+
358
+ expect(
359
+ queryParamsOf(alertsRequestIn(defaultRun.requests)).get(
360
+ "alertListOptions.maxReturnedAlerts",
361
+ ),
362
+ ).toBe("1000");
363
+
364
+ const cappedRun: {
365
+ client: GoogleSecOpsClient;
366
+ requests: Array<RecordedRequest>;
367
+ } = makeClient();
368
+
369
+ await cappedRun.client.fetchDetectionAlerts({
370
+ startTime: WINDOW_START,
371
+ endTime: WINDOW_END,
372
+ maxAlerts: 50,
373
+ });
374
+
375
+ const cappedRequest: RecordedRequest = alertsRequestIn(cappedRun.requests);
376
+
377
+ expect(
378
+ queryParamsOf(cappedRequest).get("alertListOptions.maxReturnedAlerts"),
379
+ ).toBe("50");
380
+
381
+ /*
382
+ * A ceiling is a value, not an extra knob. This endpoint has no
383
+ * pagination of any kind, so nothing about asking for fewer alerts may
384
+ * introduce a second parameter.
385
+ */
386
+ expect(sortedQueryKeysOf(cappedRequest)).toEqual(EXPECTED_QUERY_KEYS);
387
+ });
388
+
389
+ test("never sends pageSize, snapshotQuery or baselineQuery", async () => {
390
+ const { client, requests } = makeClient();
391
+
392
+ await client.fetchDetectionAlerts({
393
+ startTime: WINDOW_START,
394
+ endTime: WINDOW_END,
395
+ maxAlerts: 25,
396
+ });
397
+
398
+ const request: RecordedRequest = alertsRequestIn(requests);
399
+ const params: URLSearchParams = queryParamsOf(request);
400
+
401
+ /*
402
+ * First, that there is a query string at all. Every assertion below is
403
+ * an absence check, and absence checks pass just as happily against a
404
+ * URL that lost its parameters entirely.
405
+ */
406
+ expect(sortedQueryKeysOf(request)).toEqual(EXPECTED_QUERY_KEYS);
407
+
408
+ /*
409
+ * The regression tripwire. `pageSize` has no rename target — this
410
+ * endpoint has no pageSize, no pageToken and no nextPageToken — so its
411
+ * presence in any form is the exact production 400 coming back.
412
+ */
413
+ expect(params.has("pageSize")).toBe(false);
414
+ expect(params.has("pageToken")).toBe(false);
415
+
416
+ /*
417
+ * Spec conflict C2, open and deliberately unsettled here. The reference
418
+ * doc prefixes snapshotQuery with `Required.`, but that is a
419
+ * field_behavior annotation the HTTP transcoder does not enforce, and
420
+ * this service validates queries in-band (validSnapshotQuery /
421
+ * queryValidationErrors) instead of rejecting them. We omit it, because
422
+ * the only concrete catch-all candidate is Google's own SDK default
423
+ * `feedback_summary.status != "CLOSED"`, which silently drops every
424
+ * CLOSED alert — trading a loud, testable 400 for permanent invisible
425
+ * data loss. Omission fails loudly if it turns out to be enforced.
426
+ *
427
+ * Settling this needs one live call against a real tenant, with and
428
+ * without the parameter, comparing HTTP status and baselineAlertsCount.
429
+ * Until that runs, this assertion is the record of the decision and the
430
+ * first line to change if the tenant disagrees.
431
+ */
432
+ expect(params.has("snapshotQuery")).toBe(false);
433
+
434
+ /*
435
+ * baselineQuery exists only to reuse a cached baseline across repeated
436
+ * requests, and a single-pass poller has nothing to reuse.
437
+ */
438
+ expect(params.has("baselineQuery")).toBe(false);
439
+
440
+ /*
441
+ * The two enum-valued preferences, which take AlertsFeaturePreference
442
+ * strings rather than booleans: sending either as true/false produces
443
+ * the same class of 400 being fixed here.
444
+ */
445
+ expect(params.has("enableCache")).toBe(false);
446
+ expect(params.has("includeNonAlertingDetections")).toBe(false);
447
+ });
448
+ });
449
+
450
+ describe("GoogleSecOpsClient alerts request transport", () => {
451
+ test("GETs with no body, and carries only Accept and Bearer authorization", async () => {
452
+ const { client, requests } = makeClient();
453
+
454
+ await client.fetchDetectionAlerts({
455
+ startTime: WINDOW_START,
456
+ endTime: WINDOW_END,
457
+ });
458
+
459
+ const request: RecordedRequest = alertsRequestIn(requests);
460
+
461
+ expect(request.method).toBe("GET");
462
+ expect(request.body).toBeUndefined();
463
+
464
+ /*
465
+ * The closed header set, which is also how "no Content-Type on a
466
+ * bodyless GET" is asserted without naming the header it must not send.
467
+ */
468
+ expect(Object.keys(request.headers).sort()).toEqual([
469
+ "Accept",
470
+ "Authorization",
471
+ ]);
472
+ expect(request.headers["Accept"]).toBe("application/json");
473
+ expect(request.headers["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`);
474
+ });
475
+
476
+ test("addresses the region's Chronicle host at the legacy alerts view, query after the path", async () => {
477
+ const { client, requests } = makeClient();
478
+
479
+ await client.fetchDetectionAlerts({
480
+ startTime: WINDOW_START,
481
+ endTime: WINDOW_END,
482
+ });
483
+
484
+ const url: URL = new URL(alertsRequestIn(requests).url);
485
+
486
+ expect(url.origin).toBe("https://us-chronicle.googleapis.com");
487
+ expect(url.pathname).toBe(
488
+ `/v1alpha/${INSTANCE}/legacy:legacyFetchAlertsView`,
489
+ );
490
+ expect(url.search.startsWith("?")).toBe(true);
491
+
492
+ // Nothing may fall off the end of the path into a fragment.
493
+ expect(url.hash).toBe("");
494
+ });
495
+
496
+ test("leaves the dot in a field path literal and percent-encodes the colon in a timestamp", async () => {
497
+ const { client, requests } = makeClient();
498
+
499
+ await client.fetchDetectionAlerts({
500
+ startTime: WINDOW_START,
501
+ endTime: WINDOW_END,
502
+ });
503
+
504
+ const raw: Map<string, string> = rawQueryPairsOf(alertsRequestIn(requests));
505
+
506
+ /*
507
+ * Undecoded keys, compared against the same list the decoded keys are
508
+ * compared against: identical means the `.` went out as a `.` rather
509
+ * than as %2E, which is what the transcoder binds on.
510
+ */
511
+ expect(sortedKeysOf(raw)).toEqual(EXPECTED_QUERY_KEYS);
512
+
513
+ expect(raw.get("timeRange.startTime")).toBe("2026-08-21T09%3A00%3A00.000Z");
514
+ expect(raw.get("timeRange.endTime")).toBe("2026-08-21T10%3A00%3A00.000Z");
515
+ expect(raw.get("alertListOptions.maxReturnedAlerts")).toBe("1000");
516
+ });
517
+ });
518
+
519
+ describe("GoogleSecOpsClient.getApiBaseUrl", () => {
520
+ test("builds the regional host and the v1alpha instance path for us and europe", () => {
521
+ const usClient: GoogleSecOpsClient = new GoogleSecOpsClient({
522
+ region: "us",
523
+ instanceResourceName: INSTANCE,
524
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
525
+ fetchImplementation: neverCalledFetch(),
526
+ });
527
+
528
+ expect(usClient.getApiBaseUrl()).toBe(
529
+ `https://us-chronicle.googleapis.com/v1alpha/${INSTANCE}`,
530
+ );
531
+
532
+ const europeInstance: string =
533
+ "projects/my-project/locations/europe/instances/3f0a-instance";
534
+
535
+ const europeClient: GoogleSecOpsClient = new GoogleSecOpsClient({
536
+ region: "europe",
537
+ instanceResourceName: europeInstance,
538
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
539
+ fetchImplementation: neverCalledFetch(),
540
+ });
541
+
542
+ expect(europeClient.getApiBaseUrl()).toBe(
543
+ `https://europe-chronicle.googleapis.com/v1alpha/${europeInstance}`,
544
+ );
545
+ });
546
+ });
547
+
548
+ describe("GoogleSecOpsClient region allowlist", () => {
549
+ test("accepts every documented prefix and builds that region's host from it", () => {
550
+ expect(DOCUMENTED_REGION_PREFIXES).toHaveLength(22);
551
+
552
+ for (const region of DOCUMENTED_REGION_PREFIXES) {
553
+ expect(() => {
554
+ return GoogleSecOpsClient.validateRegion(region);
555
+ }).not.toThrow();
556
+
557
+ const instanceResourceName: string = `projects/my-project/locations/${region}/instances/3f0a-instance`;
558
+
559
+ const client: GoogleSecOpsClient = new GoogleSecOpsClient({
560
+ region: region,
561
+ instanceResourceName: instanceResourceName,
562
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
563
+ fetchImplementation: neverCalledFetch(),
564
+ });
565
+
566
+ expect(client.getApiBaseUrl()).toBe(
567
+ `https://${region}-chronicle.googleapis.com/v1alpha/${instanceResourceName}`,
568
+ );
569
+ }
570
+ });
571
+
572
+ test("rejects regions that merely look plausible", () => {
573
+ /*
574
+ * *.googleapis.com is a DNS wildcard, so every one of these resolves to
575
+ * a Google frontend and answers with an HTML 404 rather than an API
576
+ * error. A shape regex would admit them all and turn a typo into a
577
+ * parse failure; the allowlist is what makes it read as a bad region.
578
+ */
579
+ const rejected: Array<string> = [
580
+ "",
581
+ "US",
582
+ "EU",
583
+ "Europe",
584
+ "us-central1",
585
+ "use1",
586
+ "us-",
587
+ "us----",
588
+ "us east",
589
+ "us_",
590
+ "us/../etc",
591
+ "us.chronicle",
592
+ // Real Google Cloud regions that Chronicle does not serve.
593
+ "europe-west1",
594
+ "asia-east2",
595
+ "me-west2",
596
+ "northamerica-northeast1",
597
+ ];
598
+
599
+ for (const region of rejected) {
600
+ expect(() => {
601
+ return GoogleSecOpsClient.validateRegion(region);
602
+ }).toThrow(BadDataException);
603
+ }
604
+ });
605
+ });
606
+
607
+ describe("GoogleSecOpsClient region and instance location cross-check", () => {
608
+ test("rejects a region that disagrees with the instance's locations segment", () => {
609
+ const mismatched: Array<{ region: string; location: string }> = [
610
+ { region: "us", location: "europe" },
611
+ { region: "us", location: "eu" },
612
+ { region: "europe", location: "us" },
613
+ { region: "asia-south1", location: "asia-southeast1" },
614
+ { region: "me-central1", location: "me-central2" },
615
+ ];
616
+
617
+ for (const pair of mismatched) {
618
+ expect(() => {
619
+ return new GoogleSecOpsClient({
620
+ region: pair.region,
621
+ instanceResourceName: `projects/my-project/locations/${pair.location}/instances/3f0a-instance`,
622
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
623
+ fetchImplementation: neverCalledFetch(),
624
+ });
625
+ }).toThrow(BadDataException);
626
+ }
627
+ });
628
+
629
+ test("distinguishes an unsupported region from a region that disagrees with the instance", () => {
630
+ /*
631
+ * Both are BadDataException, and an operator only ever sees the text.
632
+ * Two different misconfigurations must not read as the same one.
633
+ */
634
+ const unsupportedRegionMessage: string = messageThrownBy(
635
+ (): GoogleSecOpsClient => {
636
+ return new GoogleSecOpsClient({
637
+ region: "us-central1",
638
+ instanceResourceName: INSTANCE,
639
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
640
+ fetchImplementation: neverCalledFetch(),
641
+ });
642
+ },
643
+ );
644
+
645
+ const mismatchMessage: string = messageThrownBy((): GoogleSecOpsClient => {
646
+ return new GoogleSecOpsClient({
647
+ region: "us",
648
+ instanceResourceName:
649
+ "projects/my-project/locations/europe/instances/3f0a-instance",
650
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
651
+ fetchImplementation: neverCalledFetch(),
652
+ });
653
+ });
654
+
655
+ expect(unsupportedRegionMessage.length).toBeGreaterThan(0);
656
+ expect(mismatchMessage.length).toBeGreaterThan(0);
657
+ expect(unsupportedRegionMessage).not.toBe(mismatchMessage);
658
+ });
659
+
660
+ test("treats eu and europe as one place, in both directions", () => {
661
+ /*
662
+ * europe-chronicle.googleapis.com is a documented live host while the
663
+ * migration guide names the same multi-region's location code `eu`. A
664
+ * strict identity check here rejects every valid EU tenant, so this is
665
+ * a regression guard on real customers rather than an edge case.
666
+ */
667
+ const euRegionEuropeInstance: string =
668
+ "projects/my-project/locations/europe/instances/3f0a-instance";
669
+
670
+ const euClient: GoogleSecOpsClient = new GoogleSecOpsClient({
671
+ region: "eu",
672
+ instanceResourceName: euRegionEuropeInstance,
673
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
674
+ fetchImplementation: neverCalledFetch(),
675
+ });
676
+
677
+ // The host follows the region; the path keeps the instance verbatim.
678
+ expect(euClient.getApiBaseUrl()).toBe(
679
+ `https://eu-chronicle.googleapis.com/v1alpha/${euRegionEuropeInstance}`,
680
+ );
681
+
682
+ const europeRegionEuInstance: string =
683
+ "projects/my-project/locations/eu/instances/3f0a-instance";
684
+
685
+ const europeClient: GoogleSecOpsClient = new GoogleSecOpsClient({
686
+ region: "europe",
687
+ instanceResourceName: europeRegionEuInstance,
688
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
689
+ fetchImplementation: neverCalledFetch(),
690
+ });
691
+
692
+ expect(europeClient.getApiBaseUrl()).toBe(
693
+ `https://europe-chronicle.googleapis.com/v1alpha/${europeRegionEuInstance}`,
694
+ );
695
+ });
696
+ });
697
+
698
+ describe("GoogleSecOpsClient service account token_uri", () => {
699
+ test("accepts Google token endpoints and returns them unchanged", () => {
700
+ const accepted: Array<string> = [
701
+ "https://oauth2.googleapis.com/token",
702
+ "https://accounts.google.com/o/oauth2/token",
703
+ "https://us-chronicle.googleapis.com/token",
704
+ ];
705
+
706
+ for (const tokenUri of accepted) {
707
+ const credentials: GoogleServiceAccountCredentials =
708
+ GoogleSecOpsClient.parseServiceAccountJson(
709
+ serviceAccountJsonWith({ token_uri: tokenUri }),
710
+ );
711
+
712
+ expect(credentials.tokenUri).toBe(tokenUri);
713
+ }
714
+ });
715
+
716
+ test("defaults to Google's token endpoint when the field is absent or empty, and the default passes its own allowlist", () => {
717
+ const absent: GoogleServiceAccountCredentials =
718
+ GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJsonWith({}));
719
+
720
+ expect(absent.tokenUri).toBe("https://oauth2.googleapis.com/token");
721
+
722
+ const empty: GoogleServiceAccountCredentials =
723
+ GoogleSecOpsClient.parseServiceAccountJson(
724
+ serviceAccountJsonWith({ token_uri: "" }),
725
+ );
726
+
727
+ expect(empty.tokenUri).toBe("https://oauth2.googleapis.com/token");
728
+ });
729
+
730
+ test("rejects a token_uri that is not an https URL on a Google host", () => {
731
+ /*
732
+ * The token endpoint is customer-supplied and the first 500 characters
733
+ * of whatever answers it are echoed into lastError and rendered in the
734
+ * dashboard. Unpinned, that is an exfiltration primitive rather than a
735
+ * blind SSRF — hence the metadata address and the lookalike hosts.
736
+ */
737
+ const rejected: Array<string> = [
738
+ "http://oauth2.googleapis.com/token",
739
+ "http://169.254.169.254/latest/meta-data/",
740
+ "ftp://oauth2.googleapis.com/token",
741
+ "file:///etc/passwd",
742
+ "https://attacker@oauth2.googleapis.com/token",
743
+ "https://evil.example.com/token",
744
+ // Suffix lookalikes: neither is a subdomain of googleapis.com.
745
+ "https://evil-googleapis.com/token",
746
+ "https://oauth2.googleapis.com.evil.example/token",
747
+ // Not absolute at all.
748
+ "oauth2.googleapis.com/token",
749
+ "//oauth2.googleapis.com/token",
750
+ "/token",
751
+ ];
752
+
753
+ for (const tokenUri of rejected) {
754
+ expect(() => {
755
+ return GoogleSecOpsClient.parseServiceAccountJson(
756
+ serviceAccountJsonWith({ token_uri: tokenUri }),
757
+ );
758
+ }).toThrow(BadDataException);
759
+ }
760
+ });
761
+ });
762
+
763
+ describe("GoogleSecOpsClient service account JSON", () => {
764
+ test("throws BadDataException, never a raw TypeError, for a root that is not an object", () => {
765
+ /*
766
+ * JSON.parse("null") succeeds, and the property reads that follow used
767
+ * to throw a bare TypeError out of the public create/update API — a 500
768
+ * where the caller's own input deserves a 400.
769
+ */
770
+ const nonObjectRoots: Array<string> = [
771
+ "null",
772
+ "[]",
773
+ "[{}]",
774
+ "42",
775
+ '"a string"',
776
+ "true",
777
+ ];
778
+
779
+ for (const serviceAccountJson of nonObjectRoots) {
780
+ const thrown: unknown = errorThrownBy(
781
+ (): GoogleServiceAccountCredentials => {
782
+ return GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJson);
783
+ },
784
+ );
785
+
786
+ expect(thrown).toBeInstanceOf(BadDataException);
787
+ expect(thrown).not.toBeInstanceOf(TypeError);
788
+ }
789
+ });
790
+
791
+ test("surfaces the same BadDataException through the constructor", () => {
792
+ const thrown: unknown = errorThrownBy((): GoogleSecOpsClient => {
793
+ return new GoogleSecOpsClient({
794
+ region: "us",
795
+ instanceResourceName: INSTANCE,
796
+ serviceAccountJson: "null",
797
+ fetchImplementation: neverCalledFetch(),
798
+ });
799
+ });
800
+
801
+ expect(thrown).toBeInstanceOf(BadDataException);
802
+ expect(thrown).not.toBeInstanceOf(TypeError);
803
+ });
804
+
805
+ test("rejects non-string client_email and private_key instead of coercing them", () => {
806
+ /*
807
+ * String({}) is "[object Object]": non-empty, past every emptiness
808
+ * check, and fatal an hour later inside jwt.sign on the cron.
809
+ */
810
+ const rejected: Array<string> = [
811
+ JSON.stringify({ client_email: "a@b.c", private_key: {} }),
812
+ JSON.stringify({ client_email: "a@b.c", private_key: 12345 }),
813
+ JSON.stringify({ client_email: "a@b.c", private_key: null }),
814
+ JSON.stringify({ client_email: {}, private_key: privateKey }),
815
+ JSON.stringify({ client_email: 42, private_key: privateKey }),
816
+ JSON.stringify({ private_key: privateKey }),
817
+ JSON.stringify({ client_email: "a@b.c" }),
818
+ "not json at all",
819
+ ];
820
+
821
+ for (const serviceAccountJson of rejected) {
822
+ expect(() => {
823
+ return GoogleSecOpsClient.parseServiceAccountJson(serviceAccountJson);
824
+ }).toThrow(BadDataException);
825
+ }
826
+ });
827
+
828
+ test("rejects a private_key that no PEM decoder can read", () => {
829
+ /*
830
+ * A double-escaped or truncated key saves cleanly today and fails an
831
+ * hour later inside jwt.sign as an unwrapped OpenSSL decoder error, on
832
+ * the cron — exactly the class of failure save-time validation exists
833
+ * to prevent.
834
+ */
835
+ const unreadableKeys: Array<string> = [
836
+ privateKey.replace(/\n/g, "\\n"),
837
+ privateKey.slice(0, 120),
838
+ "-----BEGIN PRIVATE KEY-----\nnot-base64\n-----END PRIVATE KEY-----\n",
839
+ "[object Object]",
840
+ "not a key at all",
841
+ " ",
842
+ ];
843
+
844
+ for (const key of unreadableKeys) {
845
+ expect(() => {
846
+ return GoogleSecOpsClient.parseServiceAccountJson(
847
+ serviceAccountJsonWith({ private_key: key }),
848
+ );
849
+ }).toThrow(BadDataException);
850
+ }
851
+
852
+ // The control: a real key still parses, and comes back byte-identical.
853
+ const credentials: GoogleServiceAccountCredentials =
854
+ GoogleSecOpsClient.parseServiceAccountJson(SERVICE_ACCOUNT_JSON);
855
+
856
+ expect(credentials.privateKey).toBe(privateKey);
857
+ expect(credentials.clientEmail).toBe(
858
+ "poller@example.iam.gserviceaccount.com",
859
+ );
860
+ });
861
+ });
862
+
863
+ describe("GoogleSecOpsClient instance resource name", () => {
864
+ test("accepts the documented four-segment shape only", () => {
865
+ expect(() => {
866
+ return GoogleSecOpsClient.validateInstanceResourceName(INSTANCE);
867
+ }).not.toThrow();
868
+
869
+ const rejected: Array<string> = [
870
+ "",
871
+ "instances/abc",
872
+ "projects/p/instances/i",
873
+ "projects/p/locations/l/instances/i/extra",
874
+ "projects/p/locations/l/instances/",
875
+ "projects//locations/l/instances/i",
876
+ ];
877
+
878
+ for (const name of rejected) {
879
+ expect(() => {
880
+ return GoogleSecOpsClient.validateInstanceResourceName(name);
881
+ }).toThrow(BadDataException);
882
+ }
883
+ });
884
+
885
+ test("rejects a name carrying URL syntax that would reshape the request", () => {
886
+ /*
887
+ * The host is fixed by getApiBaseUrl, so none of these is a URL
888
+ * takeover — but `#` truncates the path and drops the entire query
889
+ * string, `?` injects parameters ahead of the real ones, and `%` lets a
890
+ * segment smuggle an encoded separator past this check. All three are
891
+ * config-controlled request shaping.
892
+ */
893
+ const rejected: Array<string> = [
894
+ "projects/my-project/locations/us/instances/3f0a#fragment",
895
+ "projects/my-project/locations/us/instances/3f0a?alt=json",
896
+ "projects/my-project/locations/us/instances/3f0a&alt=json",
897
+ "projects/my-project/locations/us/instances/%23",
898
+ "projects/my-project#/locations/us/instances/3f0a",
899
+ "projects/my-project/locations/us/instances/3f0a instance",
900
+ ];
901
+
902
+ for (const name of rejected) {
903
+ expect(() => {
904
+ return GoogleSecOpsClient.validateInstanceResourceName(name);
905
+ }).toThrow(BadDataException);
906
+
907
+ expect(() => {
908
+ return new GoogleSecOpsClient({
909
+ region: "us",
910
+ instanceResourceName: name,
911
+ serviceAccountJson: SERVICE_ACCOUNT_JSON,
912
+ fetchImplementation: neverCalledFetch(),
913
+ });
914
+ }).toThrow(BadDataException);
915
+ }
916
+ });
917
+
918
+ test("percent-encodes each surviving segment losslessly into the request path", async () => {
919
+ /*
920
+ * The other half of the same defect: what the shape check does admit
921
+ * still has to reach the wire as path segments rather than as syntax.
922
+ */
923
+ const oddInstance: string =
924
+ "projects/my+project/locations/us/instances/3f0a+instance";
925
+
926
+ const { client, requests } = makeClient({
927
+ instanceResourceName: oddInstance,
928
+ });
929
+
930
+ await client.fetchDetectionAlerts({
931
+ startTime: WINDOW_START,
932
+ endTime: WINDOW_END,
933
+ });
934
+
935
+ const url: URL = new URL(alertsRequestIn(requests).url);
936
+
937
+ expect(url.origin).toBe("https://us-chronicle.googleapis.com");
938
+ expect(url.pathname).toBe(
939
+ "/v1alpha/projects/my%2Bproject/locations/us/instances/3f0a%2Binstance/legacy:legacyFetchAlertsView",
940
+ );
941
+
942
+ const decodedPath: string = url.pathname
943
+ .split("/")
944
+ .map((segment: string): string => {
945
+ return decodeURIComponent(segment);
946
+ })
947
+ .join("/");
948
+
949
+ // Encoding happened, and it round-trips back to the configured name.
950
+ expect(url.pathname).not.toBe(decodedPath);
951
+ expect(decodedPath).toBe(
952
+ `/v1alpha/${oddInstance}/legacy:legacyFetchAlertsView`,
953
+ );
954
+
955
+ // And none of it leaked into the query or off the end of the path.
956
+ expect(sortedQueryKeysOf(alertsRequestIn(requests))).toEqual(
957
+ EXPECTED_QUERY_KEYS,
958
+ );
959
+ expect(url.hash).toBe("");
960
+ });
961
+ });