@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.
- package/Models/DatabaseModels/DashboardDomain.ts +43 -0
- package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
- package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
- package/Server/API/DashboardDomainAPI.ts +74 -0
- package/Server/API/MicrosoftTeamsAPI.ts +81 -180
- package/Server/API/StatusPageDomainAPI.ts +74 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/ProjectAuthorization.ts +6 -1
- package/Server/Services/DashboardDomainService.ts +131 -0
- package/Server/Services/LogPipelineProcessorService.ts +88 -0
- package/Server/Services/MonitorTemplateService.ts +70 -11
- package/Server/Services/StatusPageDomainService.ts +163 -3
- package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
- package/Server/Types/Domain.ts +17 -0
- package/Server/Utils/APIKey/AccessPermission.ts +24 -0
- package/Server/Utils/Greenlock/Greenlock.ts +156 -58
- package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
- package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
- package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
- package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
- package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
- package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
- package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
- package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
- package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
- package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
- package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
- package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
- package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
- package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
- package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
- package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
- package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
- package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
- package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
- package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
- package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
- package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
- package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
- package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
- package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
- package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
- package/Tests/Types/API/HTTPResponse.test.ts +175 -0
- package/Tests/Types/API/Route.test.ts +36 -4
- package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
- package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
- package/Tests/Types/DateLocalTimeString.test.ts +259 -0
- package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
- package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
- package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
- package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
- package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
- package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
- package/Tests/UI/Utils/Navigation.test.ts +82 -0
- package/Tests/Utils/Array.test.ts +100 -0
- package/Tests/Utils/CertificateReissue.test.ts +225 -0
- package/Tests/Utils/Grok/Grok.test.ts +492 -0
- package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
- package/Types/API/Route.ts +20 -5
- package/Types/Date.ts +143 -13
- package/Types/Rum/SessionReplay.ts +62 -1
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
- package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
- package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
- package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
- package/UI/Utils/Navigation.ts +42 -0
- package/Utils/Array.ts +37 -0
- package/Utils/CertificateReissue.ts +128 -0
- package/Utils/Grok/Grok.ts +461 -0
- package/Utils/Grok/GrokPatterns.ts +118 -0
- package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
- package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
- package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
- package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
- package/build/dist/Server/Services/DashboardDomainService.js +104 -0
- package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
- package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
- package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
- package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
- package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
- package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
- package/build/dist/Server/Types/Domain.js +16 -0
- package/build/dist/Server/Types/Domain.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
- package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
- package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
- package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/API/Route.js +16 -5
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/Date.js +115 -13
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplay.js +38 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
- package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +30 -0
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/Utils/Array.js +25 -0
- package/build/dist/Utils/Array.js.map +1 -1
- package/build/dist/Utils/CertificateReissue.js +97 -0
- package/build/dist/Utils/CertificateReissue.js.map +1 -0
- package/build/dist/Utils/Grok/Grok.js +324 -0
- package/build/dist/Utils/Grok/Grok.js.map +1 -0
- package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
- package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
- package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
- package/package.json +1 -1
- package/UI/Utils/JsonWebToken.ts +0 -14
- package/build/dist/UI/Utils/JsonWebToken.js +0 -10
- package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "crypto";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import logger from "../../../../Server/Utils/Logger";
|
|
4
|
+
import GoogleSecOpsClient, {
|
|
5
|
+
FetchAlertsResult,
|
|
6
|
+
FetchInitLike,
|
|
7
|
+
FetchLike,
|
|
8
|
+
FetchResponseLike,
|
|
9
|
+
} from "../../../../Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient";
|
|
10
|
+
import APIException from "../../../../Types/Exception/ApiException";
|
|
11
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
12
|
+
import { getJestSpyOn } from "../../../Spy";
|
|
13
|
+
import { afterEach, describe, expect, jest, test } from "@jest/globals";
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* The service-account half of the SecOps client: the RS256 assertion it
|
|
17
|
+
* signs, the token it caches, and what it does when Google refuses the
|
|
18
|
+
* token it is holding.
|
|
19
|
+
*
|
|
20
|
+
* The existing client suite only `jwt.decode`s the assertion and throws
|
|
21
|
+
* the public key from generateKeyPairSync away, so an assertion carrying
|
|
22
|
+
* `alg: "none"` and no signature at all satisfies every claim assertion it
|
|
23
|
+
* makes. Everything here that reads a claim reads it out of
|
|
24
|
+
* `jwt.verify(assertion, publicKey, { algorithms: ["RS256"] })`, so the
|
|
25
|
+
* claims are only ever asserted on a body whose signature was checked
|
|
26
|
+
* against the key the credential actually carries.
|
|
27
|
+
*
|
|
28
|
+
* Anything that turns on elapsed time drives a spied Date.now rather than
|
|
29
|
+
* the wall clock, because the cache boundaries under test are an hour
|
|
30
|
+
* apart and one of them is exactly on the slack edge.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Module scope on purpose: a real 2048-bit key, generated once. jwt.sign
|
|
35
|
+
* genuinely parses this, so a placeholder string fails inside the signer
|
|
36
|
+
* long before any assertion in this file runs.
|
|
37
|
+
*/
|
|
38
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
39
|
+
modulusLength: 2048,
|
|
40
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
41
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const CLIENT_EMAIL: string = "poller@example.iam.gserviceaccount.com";
|
|
45
|
+
|
|
46
|
+
/* Google's real OAuth host: token_uri is allowlisted, so a stand-in fails validation. */
|
|
47
|
+
const TOKEN_URI: string = "https://oauth2.googleapis.com/token";
|
|
48
|
+
|
|
49
|
+
const SERVICE_ACCOUNT_JSON: string = JSON.stringify({
|
|
50
|
+
client_email: CLIENT_EMAIL,
|
|
51
|
+
private_key: privateKey,
|
|
52
|
+
token_uri: TOKEN_URI,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const INSTANCE: string =
|
|
56
|
+
"projects/my-project/locations/us/instances/3f0a-instance";
|
|
57
|
+
|
|
58
|
+
const CHRONICLE_SCOPE: string =
|
|
59
|
+
"https://www.googleapis.com/auth/cloud-platform";
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* The three auth constants this file pins. They are duplicated rather than
|
|
63
|
+
* exported from the client because a test that imported them would move
|
|
64
|
+
* with any edit to them and stop being a check on anything.
|
|
65
|
+
*/
|
|
66
|
+
const TOKEN_LIFETIME_IN_SECONDS: number = 3600;
|
|
67
|
+
const TOKEN_EXPIRY_SLACK_IN_SECONDS: number = 60;
|
|
68
|
+
const TOKEN_CLOCK_SKEW_IN_SECONDS: number = 60;
|
|
69
|
+
|
|
70
|
+
const BASE_TIME_IN_MS: number = new Date("2026-08-21T09:00:00.000Z").getTime();
|
|
71
|
+
const WINDOW_START_ISO: string = "2026-08-21T09:00:00.000Z";
|
|
72
|
+
const WINDOW_END_ISO: string = "2026-08-21T10:00:00.000Z";
|
|
73
|
+
|
|
74
|
+
const UNAUTHENTICATED_BODY: string = JSON.stringify({
|
|
75
|
+
error: {
|
|
76
|
+
code: 401,
|
|
77
|
+
status: "UNAUTHENTICATED",
|
|
78
|
+
message: "Request had invalid authentication credentials.",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
interface StubbedResponse {
|
|
83
|
+
status: number;
|
|
84
|
+
body: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface RecordedRequest {
|
|
88
|
+
url: string;
|
|
89
|
+
method: string;
|
|
90
|
+
headers: Record<string, string>;
|
|
91
|
+
body?: string | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Transport {
|
|
95
|
+
fetchImplementation: FetchLike;
|
|
96
|
+
requests: Array<RecordedRequest>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface StubbedEndpoints {
|
|
100
|
+
token: Array<StubbedResponse>;
|
|
101
|
+
alerts: Array<StubbedResponse>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
* Routed by host and queued per endpoint rather than by call ordinal: the
|
|
106
|
+
* 401 path issues a second token exchange in the middle of the alerts
|
|
107
|
+
* sequence, so ordinal routing cannot express "the second token" and "the
|
|
108
|
+
* second alerts attempt" at the same time. Each queue repeats its last
|
|
109
|
+
* entry, so a queue of one is "answer this way every time".
|
|
110
|
+
*/
|
|
111
|
+
function makeFetch(responses: StubbedEndpoints): Transport {
|
|
112
|
+
const requests: Array<RecordedRequest> = [];
|
|
113
|
+
let tokenCallIndex: number = 0;
|
|
114
|
+
let alertsCallIndex: number = 0;
|
|
115
|
+
|
|
116
|
+
const fetchImplementation: FetchLike = (
|
|
117
|
+
url: string,
|
|
118
|
+
init: FetchInitLike,
|
|
119
|
+
): Promise<FetchResponseLike> => {
|
|
120
|
+
requests.push({
|
|
121
|
+
url: url,
|
|
122
|
+
method: init.method,
|
|
123
|
+
headers: init.headers,
|
|
124
|
+
body: init.body,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const isTokenCall: boolean = url === TOKEN_URI;
|
|
128
|
+
const queue: Array<StubbedResponse> = isTokenCall
|
|
129
|
+
? responses.token
|
|
130
|
+
: responses.alerts;
|
|
131
|
+
const index: number = isTokenCall ? tokenCallIndex++ : alertsCallIndex++;
|
|
132
|
+
const response: StubbedResponse = queue[
|
|
133
|
+
Math.min(index, queue.length - 1)
|
|
134
|
+
] as StubbedResponse;
|
|
135
|
+
|
|
136
|
+
return Promise.resolve({
|
|
137
|
+
ok: response.status >= 200 && response.status < 300,
|
|
138
|
+
status: response.status,
|
|
139
|
+
text: (): Promise<string> => {
|
|
140
|
+
return Promise.resolve(response.body);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { fetchImplementation: fetchImplementation, requests: requests };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeClient(responses: StubbedEndpoints): {
|
|
149
|
+
client: GoogleSecOpsClient;
|
|
150
|
+
requests: Array<RecordedRequest>;
|
|
151
|
+
} {
|
|
152
|
+
const { fetchImplementation, requests } = makeFetch(responses);
|
|
153
|
+
|
|
154
|
+
const client: GoogleSecOpsClient = new GoogleSecOpsClient({
|
|
155
|
+
region: "us",
|
|
156
|
+
instanceResourceName: INSTANCE,
|
|
157
|
+
serviceAccountJson: SERVICE_ACCOUNT_JSON,
|
|
158
|
+
fetchImplementation: fetchImplementation,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { client: client, requests: requests };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fetchWindow(client: GoogleSecOpsClient): Promise<FetchAlertsResult> {
|
|
165
|
+
return client.fetchDetectionAlerts({
|
|
166
|
+
startTime: new Date(WINDOW_START_ISO),
|
|
167
|
+
endTime: new Date(WINDOW_END_ISO),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function tokenResponse(fields: JSONObject): StubbedResponse {
|
|
172
|
+
return { status: 200, body: JSON.stringify(fields) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* The streaming envelope, so the auth paths end at a genuinely parseable body. */
|
|
176
|
+
function alertsResponse(alertIds: Array<string>): StubbedResponse {
|
|
177
|
+
return {
|
|
178
|
+
status: 200,
|
|
179
|
+
body: JSON.stringify([
|
|
180
|
+
{
|
|
181
|
+
alerts: {
|
|
182
|
+
alerts: alertIds.map((id: string): JSONObject => {
|
|
183
|
+
return { id: id };
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
complete: true,
|
|
187
|
+
progress: 1,
|
|
188
|
+
},
|
|
189
|
+
]),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function tokenRequests(
|
|
194
|
+
requests: Array<RecordedRequest>,
|
|
195
|
+
): Array<RecordedRequest> {
|
|
196
|
+
return requests.filter((request: RecordedRequest): boolean => {
|
|
197
|
+
return request.url === TOKEN_URI;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function alertsRequests(
|
|
202
|
+
requests: Array<RecordedRequest>,
|
|
203
|
+
): Array<RecordedRequest> {
|
|
204
|
+
return requests.filter((request: RecordedRequest): boolean => {
|
|
205
|
+
return request.url !== TOKEN_URI;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* Which minted token each Chronicle call actually carried. This is the
|
|
211
|
+
* only observable difference between "the cache was reused" and "a fresh
|
|
212
|
+
* token was fetched and then used", which several tests below turn on.
|
|
213
|
+
*/
|
|
214
|
+
function bearerTokens(requests: Array<RecordedRequest>): Array<string> {
|
|
215
|
+
return alertsRequests(requests).map((request: RecordedRequest): string => {
|
|
216
|
+
return request.headers["Authorization"] || "";
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function assertionFrom(request: RecordedRequest): string {
|
|
221
|
+
const params: URLSearchParams = new URLSearchParams(request.body || "");
|
|
222
|
+
|
|
223
|
+
return params.get("assertion") || "";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* Verify, never decode. Passing the public key and pinning the algorithm
|
|
228
|
+
* is what makes every claim assertion downstream of it meaningful: an
|
|
229
|
+
* assertion signed with alg "none", with the wrong key, or with a tampered
|
|
230
|
+
* payload fails here rather than sailing through.
|
|
231
|
+
*/
|
|
232
|
+
function verifiedAssertion(request: RecordedRequest): JSONObject {
|
|
233
|
+
return jwt.verify(assertionFrom(request), publicKey, {
|
|
234
|
+
algorithms: ["RS256"],
|
|
235
|
+
// Read through the same Date.now the client signed against.
|
|
236
|
+
clockTimestamp: Math.floor(Date.now() / 1000),
|
|
237
|
+
}) as unknown as JSONObject;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertionHeader(request: RecordedRequest): JSONObject {
|
|
241
|
+
const decoded: JSONObject = jwt.decode(assertionFrom(request), {
|
|
242
|
+
complete: true,
|
|
243
|
+
}) as unknown as JSONObject;
|
|
244
|
+
|
|
245
|
+
return decoded["header"] as JSONObject;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface FakeClock {
|
|
249
|
+
advanceBy: (milliseconds: number) => void;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
* Closure-scoped rather than a module-level `now`, so no test can inherit
|
|
254
|
+
* another's clock position.
|
|
255
|
+
*/
|
|
256
|
+
function installFakeClock(baseTimeInMs: number): FakeClock {
|
|
257
|
+
let nowInMs: number = baseTimeInMs;
|
|
258
|
+
|
|
259
|
+
getJestSpyOn(Date, "now").mockImplementation(((): number => {
|
|
260
|
+
return nowInMs;
|
|
261
|
+
}) as never);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
advanceBy: (milliseconds: number): void => {
|
|
265
|
+
nowInMs = nowInMs + milliseconds;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function captureRejection(promise: Promise<unknown>): Promise<Error> {
|
|
271
|
+
try {
|
|
272
|
+
await promise;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return error as Error;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw new Error("Expected GoogleSecOpsClient to reject, but it resolved.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Capture logger.warn without letting it reach the telemetry logger. */
|
|
281
|
+
function captureWarnings(): Array<string> {
|
|
282
|
+
const warnings: Array<string> = [];
|
|
283
|
+
|
|
284
|
+
getJestSpyOn(logger, "warn").mockImplementation(((message: string): void => {
|
|
285
|
+
warnings.push(String(message));
|
|
286
|
+
}) as never);
|
|
287
|
+
|
|
288
|
+
return warnings;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
describe("GoogleSecOpsClient JWT assertion", () => {
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
jest.restoreAllMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("signs an RS256 assertion that verifies against the service account's own public key", async () => {
|
|
297
|
+
const { client, requests } = makeClient({
|
|
298
|
+
token: [tokenResponse({ access_token: "token-1", expires_in: 3600 })],
|
|
299
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await fetchWindow(client);
|
|
303
|
+
|
|
304
|
+
const tokenCalls: Array<RecordedRequest> = tokenRequests(requests);
|
|
305
|
+
expect(tokenCalls).toHaveLength(1);
|
|
306
|
+
|
|
307
|
+
const payload: JSONObject = verifiedAssertion(
|
|
308
|
+
tokenCalls[0] as RecordedRequest,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(payload["iss"]).toBe(CLIENT_EMAIL);
|
|
312
|
+
expect(payload["aud"]).toBe(TOKEN_URI);
|
|
313
|
+
expect(payload["scope"]).toBe(CHRONICLE_SCOPE);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("declares RS256 in the JOSE header rather than relying on the verifier's default", async () => {
|
|
317
|
+
const { client, requests } = makeClient({
|
|
318
|
+
token: [tokenResponse({ access_token: "token-1", expires_in: 3600 })],
|
|
319
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await fetchWindow(client);
|
|
323
|
+
|
|
324
|
+
const header: JSONObject = assertionHeader(
|
|
325
|
+
tokenRequests(requests)[0] as RecordedRequest,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
expect(header["alg"]).toBe("RS256");
|
|
329
|
+
expect(header["typ"]).toBe("JWT");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("posts exactly the JWT-bearer grant form to token_uri", async () => {
|
|
333
|
+
const { client, requests } = makeClient({
|
|
334
|
+
token: [tokenResponse({ access_token: "token-1", expires_in: 3600 })],
|
|
335
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await fetchWindow(client);
|
|
339
|
+
|
|
340
|
+
const tokenRequest: RecordedRequest = tokenRequests(
|
|
341
|
+
requests,
|
|
342
|
+
)[0] as RecordedRequest;
|
|
343
|
+
|
|
344
|
+
expect(tokenRequest.url).toBe(TOKEN_URI);
|
|
345
|
+
expect(tokenRequest.method).toBe("POST");
|
|
346
|
+
expect(Object.keys(tokenRequest.headers).sort()).toEqual(["Content-Type"]);
|
|
347
|
+
expect(tokenRequest.headers["Content-Type"]).toBe(
|
|
348
|
+
"application/x-www-form-urlencoded",
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const params: URLSearchParams = new URLSearchParams(
|
|
352
|
+
tokenRequest.body || "",
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(Array.from(params.keys()).sort()).toEqual([
|
|
356
|
+
"assertion",
|
|
357
|
+
"grant_type",
|
|
358
|
+
]);
|
|
359
|
+
expect(params.get("grant_type")).toBe(
|
|
360
|
+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("backdates iat by the clock-skew allowance and keeps exp - iat at exactly 3600", async () => {
|
|
365
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
366
|
+
|
|
367
|
+
const { client, requests } = makeClient({
|
|
368
|
+
token: [tokenResponse({ access_token: "token-1", expires_in: 3600 })],
|
|
369
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await fetchWindow(client);
|
|
373
|
+
|
|
374
|
+
const nowInSeconds: number = Math.floor(BASE_TIME_IN_MS / 1000);
|
|
375
|
+
const payload: JSONObject = verifiedAssertion(
|
|
376
|
+
tokenRequests(requests)[0] as RecordedRequest,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const issuedAt: number = payload["iat"] as number;
|
|
380
|
+
const expiresAt: number = payload["exp"] as number;
|
|
381
|
+
|
|
382
|
+
expect(issuedAt).toBe(nowInSeconds - TOKEN_CLOCK_SKEW_IN_SECONDS);
|
|
383
|
+
expect(expiresAt - issuedAt).toBe(TOKEN_LIFETIME_IN_SECONDS);
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
* The skew is spent shortening the token, not extending it: 3600 is
|
|
387
|
+
* Google's hard maximum measured from the receiving clock, so exp must
|
|
388
|
+
* still land at or inside now + 3600.
|
|
389
|
+
*/
|
|
390
|
+
expect(expiresAt).toBeLessThan(nowInSeconds + TOKEN_LIFETIME_IN_SECONDS);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("GoogleSecOpsClient access token caching", () => {
|
|
395
|
+
afterEach(() => {
|
|
396
|
+
jest.restoreAllMocks();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("an expires_in inside the 60 second slack is already expired, one outside it is reused", async () => {
|
|
400
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
401
|
+
|
|
402
|
+
const insideSlack: {
|
|
403
|
+
client: GoogleSecOpsClient;
|
|
404
|
+
requests: Array<RecordedRequest>;
|
|
405
|
+
} = makeClient({
|
|
406
|
+
token: [
|
|
407
|
+
tokenResponse({
|
|
408
|
+
access_token: "token-1",
|
|
409
|
+
expires_in: TOKEN_EXPIRY_SLACK_IN_SECONDS - 30,
|
|
410
|
+
}),
|
|
411
|
+
tokenResponse({
|
|
412
|
+
access_token: "token-2",
|
|
413
|
+
expires_in: TOKEN_EXPIRY_SLACK_IN_SECONDS - 30,
|
|
414
|
+
}),
|
|
415
|
+
],
|
|
416
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await fetchWindow(insideSlack.client);
|
|
420
|
+
await fetchWindow(insideSlack.client);
|
|
421
|
+
|
|
422
|
+
expect(tokenRequests(insideSlack.requests)).toHaveLength(2);
|
|
423
|
+
expect(bearerTokens(insideSlack.requests)).toEqual([
|
|
424
|
+
"Bearer token-1",
|
|
425
|
+
"Bearer token-2",
|
|
426
|
+
]);
|
|
427
|
+
|
|
428
|
+
// Same clock, same two calls, lifetime moved just outside the slack.
|
|
429
|
+
const outsideSlack: {
|
|
430
|
+
client: GoogleSecOpsClient;
|
|
431
|
+
requests: Array<RecordedRequest>;
|
|
432
|
+
} = makeClient({
|
|
433
|
+
token: [
|
|
434
|
+
tokenResponse({
|
|
435
|
+
access_token: "token-3",
|
|
436
|
+
expires_in: TOKEN_EXPIRY_SLACK_IN_SECONDS + 30,
|
|
437
|
+
}),
|
|
438
|
+
tokenResponse({
|
|
439
|
+
access_token: "token-4",
|
|
440
|
+
expires_in: TOKEN_EXPIRY_SLACK_IN_SECONDS + 30,
|
|
441
|
+
}),
|
|
442
|
+
],
|
|
443
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await fetchWindow(outsideSlack.client);
|
|
447
|
+
await fetchWindow(outsideSlack.client);
|
|
448
|
+
|
|
449
|
+
expect(tokenRequests(outsideSlack.requests)).toHaveLength(1);
|
|
450
|
+
expect(bearerTokens(outsideSlack.requests)).toEqual([
|
|
451
|
+
"Bearer token-3",
|
|
452
|
+
"Bearer token-3",
|
|
453
|
+
]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("an absent expires_in caches for 3600 seconds, and re-mints once the slack window opens", async () => {
|
|
457
|
+
const clock: FakeClock = installFakeClock(BASE_TIME_IN_MS);
|
|
458
|
+
|
|
459
|
+
const { client, requests } = makeClient({
|
|
460
|
+
token: [
|
|
461
|
+
tokenResponse({ access_token: "token-1" }),
|
|
462
|
+
tokenResponse({ access_token: "token-2" }),
|
|
463
|
+
],
|
|
464
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await fetchWindow(client);
|
|
468
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
469
|
+
|
|
470
|
+
// One second short of the slack edge: still the first token.
|
|
471
|
+
clock.advanceBy(
|
|
472
|
+
(TOKEN_LIFETIME_IN_SECONDS - TOKEN_EXPIRY_SLACK_IN_SECONDS - 1) * 1000,
|
|
473
|
+
);
|
|
474
|
+
await fetchWindow(client);
|
|
475
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
476
|
+
|
|
477
|
+
// On the edge: a second exchange, and the new token is the one sent.
|
|
478
|
+
clock.advanceBy(1000);
|
|
479
|
+
await fetchWindow(client);
|
|
480
|
+
expect(tokenRequests(requests)).toHaveLength(2);
|
|
481
|
+
|
|
482
|
+
expect(bearerTokens(requests)).toEqual([
|
|
483
|
+
"Bearer token-1",
|
|
484
|
+
"Bearer token-1",
|
|
485
|
+
"Bearer token-2",
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("an unusable expires_in is never cached and never becomes NaN", async () => {
|
|
490
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
491
|
+
|
|
492
|
+
const warnings: Array<string> = captureWarnings();
|
|
493
|
+
|
|
494
|
+
/*
|
|
495
|
+
* Zero and negatives are nonsense lifetimes; the strings are what a
|
|
496
|
+
* proxy or a proto3 int64 rendering can put there. All five have to
|
|
497
|
+
* land in the same place — mint a fresh token next time — rather than
|
|
498
|
+
* an hour of caching a dead token or a NaN expiry.
|
|
499
|
+
*/
|
|
500
|
+
const unusableValues: Array<string | number> = [
|
|
501
|
+
0,
|
|
502
|
+
-30,
|
|
503
|
+
"0",
|
|
504
|
+
"not-a-number",
|
|
505
|
+
"NaN",
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
for (const unusable of unusableValues) {
|
|
509
|
+
const { client, requests } = makeClient({
|
|
510
|
+
token: [
|
|
511
|
+
tokenResponse({ access_token: "token-1", expires_in: unusable }),
|
|
512
|
+
tokenResponse({ access_token: "token-2", expires_in: unusable }),
|
|
513
|
+
],
|
|
514
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
warnings.length = 0;
|
|
518
|
+
|
|
519
|
+
await fetchWindow(client);
|
|
520
|
+
await fetchWindow(client);
|
|
521
|
+
|
|
522
|
+
expect(tokenRequests(requests)).toHaveLength(2);
|
|
523
|
+
expect(bearerTokens(requests)).toEqual([
|
|
524
|
+
"Bearer token-1",
|
|
525
|
+
"Bearer token-2",
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
// Refusing to cache is a decision worth logging, once per exchange.
|
|
529
|
+
expect(warnings).toHaveLength(2);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("a numeric-string expires_in is coerced and cached rather than treated as unusable", async () => {
|
|
534
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
535
|
+
|
|
536
|
+
const warnings: Array<string> = captureWarnings();
|
|
537
|
+
|
|
538
|
+
const { client, requests } = makeClient({
|
|
539
|
+
token: [
|
|
540
|
+
tokenResponse({ access_token: "token-1", expires_in: "1800" }),
|
|
541
|
+
tokenResponse({ access_token: "token-2", expires_in: "1800" }),
|
|
542
|
+
],
|
|
543
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await fetchWindow(client);
|
|
547
|
+
await fetchWindow(client);
|
|
548
|
+
|
|
549
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
550
|
+
expect(bearerTokens(requests)).toEqual([
|
|
551
|
+
"Bearer token-1",
|
|
552
|
+
"Bearer token-1",
|
|
553
|
+
]);
|
|
554
|
+
expect(warnings).toHaveLength(0);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("GoogleSecOpsClient token response failures", () => {
|
|
559
|
+
afterEach(() => {
|
|
560
|
+
jest.restoreAllMocks();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("a 200 with no usable access_token throws APIException before Chronicle is contacted", async () => {
|
|
564
|
+
const bodies: Array<string> = [
|
|
565
|
+
JSON.stringify({ expires_in: 3600 }),
|
|
566
|
+
JSON.stringify({ access_token: "", expires_in: 3600 }),
|
|
567
|
+
JSON.stringify({ access_token: null, expires_in: 3600 }),
|
|
568
|
+
JSON.stringify({}),
|
|
569
|
+
];
|
|
570
|
+
|
|
571
|
+
for (const body of bodies) {
|
|
572
|
+
const { client, requests } = makeClient({
|
|
573
|
+
token: [{ status: 200, body: body }],
|
|
574
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const error: Error = await captureRejection(fetchWindow(client));
|
|
578
|
+
|
|
579
|
+
expect(error).toBeInstanceOf(APIException);
|
|
580
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
581
|
+
// A credential we never resolved must not reach Chronicle at all.
|
|
582
|
+
expect(alertsRequests(requests)).toHaveLength(0);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("a 200 token body that is not a JSON object throws APIException, not a raw SyntaxError", async () => {
|
|
587
|
+
/*
|
|
588
|
+
* The first is a captive portal or SSO interstitial answering 200,
|
|
589
|
+
* which is what made this a JSON.parse crash outside the failure
|
|
590
|
+
* taxonomy rather than a reportable connector error.
|
|
591
|
+
*/
|
|
592
|
+
const bodies: Array<string> = [
|
|
593
|
+
"<!doctype html><html><body>Sign in to continue</body></html>",
|
|
594
|
+
"[]",
|
|
595
|
+
'"an-access-token"',
|
|
596
|
+
"null",
|
|
597
|
+
"",
|
|
598
|
+
];
|
|
599
|
+
|
|
600
|
+
for (const body of bodies) {
|
|
601
|
+
const { client, requests } = makeClient({
|
|
602
|
+
token: [{ status: 200, body: body }],
|
|
603
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const error: Error = await captureRejection(fetchWindow(client));
|
|
607
|
+
|
|
608
|
+
expect(error).toBeInstanceOf(APIException);
|
|
609
|
+
expect(error).not.toBeInstanceOf(SyntaxError);
|
|
610
|
+
expect(alertsRequests(requests)).toHaveLength(0);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a 401 from the token endpoint is not retried", async () => {
|
|
615
|
+
const { client, requests } = makeClient({
|
|
616
|
+
token: [{ status: 401, body: UNAUTHENTICATED_BODY }],
|
|
617
|
+
alerts: [alertsResponse(["alert-1"])],
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const error: Error = await captureRejection(fetchWindow(client));
|
|
621
|
+
|
|
622
|
+
expect(error).toBeInstanceOf(APIException);
|
|
623
|
+
/*
|
|
624
|
+
* The single-retry allowance belongs to the alerts call. A credential
|
|
625
|
+
* Google rejects at the OAuth endpoint is rejected, and re-signing the
|
|
626
|
+
* same key would only double the rate against a rate limiter.
|
|
627
|
+
*/
|
|
628
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
629
|
+
expect(alertsRequests(requests)).toHaveLength(0);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("GoogleSecOpsClient 401 on the alerts call", () => {
|
|
634
|
+
afterEach(() => {
|
|
635
|
+
jest.restoreAllMocks();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("clears the cached token, retries once with a freshly minted one, and caches the replacement", async () => {
|
|
639
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
640
|
+
|
|
641
|
+
const { client, requests } = makeClient({
|
|
642
|
+
token: [
|
|
643
|
+
tokenResponse({ access_token: "token-1", expires_in: 3600 }),
|
|
644
|
+
tokenResponse({ access_token: "token-2", expires_in: 3600 }),
|
|
645
|
+
],
|
|
646
|
+
alerts: [
|
|
647
|
+
{ status: 401, body: UNAUTHENTICATED_BODY },
|
|
648
|
+
alertsResponse(["alert-1", "alert-2"]),
|
|
649
|
+
alertsResponse(["alert-3"]),
|
|
650
|
+
],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const first: FetchAlertsResult = await fetchWindow(client);
|
|
654
|
+
|
|
655
|
+
expect(first.alerts).toEqual([{ id: "alert-1" }, { id: "alert-2" }]);
|
|
656
|
+
expect(first.complete).toBe(true);
|
|
657
|
+
|
|
658
|
+
expect(tokenRequests(requests)).toHaveLength(2);
|
|
659
|
+
expect(alertsRequests(requests)).toHaveLength(2);
|
|
660
|
+
|
|
661
|
+
/*
|
|
662
|
+
* token-1 was cached for an hour and would have been reused; the
|
|
663
|
+
* second Bearer is the whole point of clearing the cache first.
|
|
664
|
+
*/
|
|
665
|
+
expect(bearerTokens(requests)).toEqual([
|
|
666
|
+
"Bearer token-1",
|
|
667
|
+
"Bearer token-2",
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
// The retry re-sends the same request, not a re-derived window.
|
|
671
|
+
const attempts: Array<RecordedRequest> = alertsRequests(requests);
|
|
672
|
+
expect((attempts[1] as RecordedRequest).url).toBe(
|
|
673
|
+
(attempts[0] as RecordedRequest).url,
|
|
674
|
+
);
|
|
675
|
+
expect((attempts[1] as RecordedRequest).method).toBe("GET");
|
|
676
|
+
|
|
677
|
+
const second: FetchAlertsResult = await fetchWindow(client);
|
|
678
|
+
|
|
679
|
+
expect(second.alerts).toEqual([{ id: "alert-3" }]);
|
|
680
|
+
// The replacement token is cached like any other: still two exchanges.
|
|
681
|
+
expect(tokenRequests(requests)).toHaveLength(2);
|
|
682
|
+
expect(alertsRequests(requests)).toHaveLength(3);
|
|
683
|
+
expect(bearerTokens(requests)[2]).toBe("Bearer token-2");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("a 401 on the retry is surfaced, not retried again", async () => {
|
|
687
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
688
|
+
|
|
689
|
+
const { client, requests } = makeClient({
|
|
690
|
+
token: [
|
|
691
|
+
tokenResponse({ access_token: "token-1", expires_in: 3600 }),
|
|
692
|
+
tokenResponse({ access_token: "token-2", expires_in: 3600 }),
|
|
693
|
+
],
|
|
694
|
+
alerts: [{ status: 401, body: UNAUTHENTICATED_BODY }],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const error: Error = await captureRejection(fetchWindow(client));
|
|
698
|
+
|
|
699
|
+
expect(error).toBeInstanceOf(APIException);
|
|
700
|
+
expect(tokenRequests(requests)).toHaveLength(2);
|
|
701
|
+
expect(alertsRequests(requests)).toHaveLength(2);
|
|
702
|
+
expect(bearerTokens(requests)).toEqual([
|
|
703
|
+
"Bearer token-1",
|
|
704
|
+
"Bearer token-2",
|
|
705
|
+
]);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("a non-401 alerts failure does not touch the cached token", async () => {
|
|
709
|
+
installFakeClock(BASE_TIME_IN_MS);
|
|
710
|
+
|
|
711
|
+
const { client, requests } = makeClient({
|
|
712
|
+
token: [
|
|
713
|
+
tokenResponse({ access_token: "token-1", expires_in: 3600 }),
|
|
714
|
+
tokenResponse({ access_token: "token-2", expires_in: 3600 }),
|
|
715
|
+
],
|
|
716
|
+
alerts: [{ status: 403, body: "permission denied" }],
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const error: Error = await captureRejection(fetchWindow(client));
|
|
720
|
+
|
|
721
|
+
expect(error).toBeInstanceOf(APIException);
|
|
722
|
+
// One exchange, one attempt: 403 is a verdict on the caller, not the token.
|
|
723
|
+
expect(tokenRequests(requests)).toHaveLength(1);
|
|
724
|
+
expect(alertsRequests(requests)).toHaveLength(1);
|
|
725
|
+
});
|
|
726
|
+
});
|