@oneuptime/common 12.0.19 → 12.0.21
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/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +15 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +19 -12
- package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
- package/UI/Components/Charts/Line/LineChart.tsx +23 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +14 -5
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import { cleanup, render, waitFor } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { MemoryRouter } from "react-router-dom";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The wiring half of issue #3377.
|
|
16
|
+
*
|
|
17
|
+
* DiscoveryScanFormValidation.test.ts (in App/Tests) proves the validators
|
|
18
|
+
* themselves are right, and BasicFormStepValidation.test.tsx proves a wizard
|
|
19
|
+
* step refuses to turn while its own field has an error. Neither proves the
|
|
20
|
+
* Discovery page actually HANGS those validators off the right fields, on the
|
|
21
|
+
* right steps — and that is the part a refactor drops silently: removing
|
|
22
|
+
* `customValidation` from a field object is type-safe, render-safe, and puts
|
|
23
|
+
* the form straight back to accepting a phone number as a scan target.
|
|
24
|
+
*
|
|
25
|
+
* The page is one big ModelTable call and its fields are configuration passed
|
|
26
|
+
* as props, so the table is mocked to capture them and the captured functions
|
|
27
|
+
* are then exercised directly — the same approach
|
|
28
|
+
* SecurityEventsDetectionRulesPage.test.tsx uses.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
type CapturedFormField = {
|
|
32
|
+
field: Record<string, boolean>;
|
|
33
|
+
title: string;
|
|
34
|
+
stepId?: string | undefined;
|
|
35
|
+
required?: boolean | undefined;
|
|
36
|
+
placeholder?: string | undefined;
|
|
37
|
+
validation?:
|
|
38
|
+
| {
|
|
39
|
+
maxLength?: number | undefined;
|
|
40
|
+
minValue?: number | undefined;
|
|
41
|
+
maxValue?: number | undefined;
|
|
42
|
+
}
|
|
43
|
+
| undefined;
|
|
44
|
+
customValidation?:
|
|
45
|
+
| ((values: Record<string, unknown>) => string | null)
|
|
46
|
+
| undefined;
|
|
47
|
+
showIf?: ((values: Record<string, unknown>) => boolean) | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type CapturedFormStep = {
|
|
51
|
+
id: string;
|
|
52
|
+
title: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type CapturedTableProps = {
|
|
56
|
+
formFields?: Array<CapturedFormField>;
|
|
57
|
+
formSteps?: Array<CapturedFormStep>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let capturedTableProps: CapturedTableProps | null = null;
|
|
61
|
+
|
|
62
|
+
jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
|
|
63
|
+
return {
|
|
64
|
+
__esModule: true,
|
|
65
|
+
default: (props: CapturedTableProps) => {
|
|
66
|
+
capturedTableProps = props;
|
|
67
|
+
return null;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
import DiscoveryPage from "../../../../App/FeatureSet/Dashboard/src/Pages/NetworkDevice/Discovery";
|
|
73
|
+
import ProbeUtil from "../../../../App/FeatureSet/Dashboard/src/Utils/Probe";
|
|
74
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
75
|
+
import Probe from "../../../Models/DatabaseModels/Probe";
|
|
76
|
+
import ProjectUtil from "../../../UI/Utils/Project";
|
|
77
|
+
import ScanTargetUtil from "../../../Utils/NetworkDiscovery/ScanTargetUtil";
|
|
78
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
79
|
+
import Route from "../../../Types/API/Route";
|
|
80
|
+
|
|
81
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
82
|
+
"11111111-1111-4111-8111-111111111111",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const STEP_SCAN_TARGET: string = "scan-target";
|
|
86
|
+
const STEP_SNMP: string = "snmp";
|
|
87
|
+
const STEP_SCHEDULE: string = "schedule";
|
|
88
|
+
|
|
89
|
+
function fieldKeyOf(field: CapturedFormField): string {
|
|
90
|
+
return Object.keys(field.field || {})[0] as string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fieldNamed(key: string): CapturedFormField {
|
|
94
|
+
const field: CapturedFormField | undefined =
|
|
95
|
+
capturedTableProps?.formFields?.find(
|
|
96
|
+
(formField: CapturedFormField): boolean => {
|
|
97
|
+
return fieldKeyOf(formField) === key;
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (!field) {
|
|
102
|
+
throw new Error(`Discovery scan form field "${key}" not found`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return field;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validate(key: string, values: Record<string, unknown>): string | null {
|
|
109
|
+
const field: CapturedFormField = fieldNamed(key);
|
|
110
|
+
|
|
111
|
+
if (!field.customValidation) {
|
|
112
|
+
throw new Error(`Field "${key}" has no customValidation`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return field.customValidation(values);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function renderPage(): Promise<void> {
|
|
119
|
+
const project: Project = new Project();
|
|
120
|
+
project.id = PROJECT_ID;
|
|
121
|
+
|
|
122
|
+
render(
|
|
123
|
+
<MemoryRouter>
|
|
124
|
+
<DiscoveryPage
|
|
125
|
+
pageRoute={new Route("/dashboard/network-devices/discovery")}
|
|
126
|
+
currentProject={project}
|
|
127
|
+
hasPaymentMethod={true}
|
|
128
|
+
/>
|
|
129
|
+
</MemoryRouter>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The page renders a loader until its probe fetch settles, so the table (and
|
|
134
|
+
* with it the form config) does not exist on the first paint.
|
|
135
|
+
*/
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(capturedTableProps).not.toBeNull();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("Create Network Device Discovery Scan wizard", () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
capturedTableProps = null;
|
|
144
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
145
|
+
|
|
146
|
+
const probe: Probe = new Probe();
|
|
147
|
+
probe.id = new ObjectID("22222222-2222-4222-8222-222222222222");
|
|
148
|
+
probe.name = "Datacenter Probe";
|
|
149
|
+
|
|
150
|
+
jest.spyOn(ProbeUtil, "getAllProbes").mockResolvedValue([probe] as never);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
cleanup();
|
|
155
|
+
jest.restoreAllMocks();
|
|
156
|
+
capturedTableProps = null;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("is still a three-step wizard", async () => {
|
|
160
|
+
await renderPage();
|
|
161
|
+
|
|
162
|
+
expect(
|
|
163
|
+
capturedTableProps?.formSteps?.map((step: CapturedFormStep): string => {
|
|
164
|
+
return step.id;
|
|
165
|
+
}),
|
|
166
|
+
).toEqual([STEP_SCAN_TARGET, STEP_SNMP, STEP_SCHEDULE]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("every field names a step that the wizard actually declares", async () => {
|
|
170
|
+
await renderPage();
|
|
171
|
+
|
|
172
|
+
const declared: Array<string> =
|
|
173
|
+
capturedTableProps?.formSteps?.map((step: CapturedFormStep): string => {
|
|
174
|
+
return step.id;
|
|
175
|
+
}) || [];
|
|
176
|
+
|
|
177
|
+
const fields: Array<CapturedFormField> =
|
|
178
|
+
capturedTableProps?.formFields || [];
|
|
179
|
+
|
|
180
|
+
expect(fields.length).toBeGreaterThan(0);
|
|
181
|
+
|
|
182
|
+
for (const field of fields) {
|
|
183
|
+
/*
|
|
184
|
+
* A field with no stepId is validated on no step and rendered on no
|
|
185
|
+
* step — the exact failure this wizard's validators are meant to close.
|
|
186
|
+
*/
|
|
187
|
+
expect(declared).toContain(field.stepId);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Scan Target is validated on the Scan Target step", () => {
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
capturedTableProps = null;
|
|
195
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
196
|
+
jest.spyOn(ProbeUtil, "getAllProbes").mockResolvedValue([] as never);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
cleanup();
|
|
201
|
+
jest.restoreAllMocks();
|
|
202
|
+
capturedTableProps = null;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("the field carries a validator, on the first step, and is required", async () => {
|
|
206
|
+
await renderPage();
|
|
207
|
+
|
|
208
|
+
const field: CapturedFormField = fieldNamed("cidr");
|
|
209
|
+
|
|
210
|
+
expect(field.stepId).toBe(STEP_SCAN_TARGET);
|
|
211
|
+
expect(field.required).toBe(true);
|
|
212
|
+
expect(typeof field.customValidation).toBe("function");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("the reported input is rejected right there", async () => {
|
|
216
|
+
await renderPage();
|
|
217
|
+
|
|
218
|
+
expect(validate("cidr", { cidr: "9876543210" })).toContain(
|
|
219
|
+
"is not a valid scan target",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test.each([
|
|
224
|
+
["free text", "not-a-target"],
|
|
225
|
+
["a bad prefix", "10.0.0.0/33"],
|
|
226
|
+
["an out-of-range octet", "10.0.0.256"],
|
|
227
|
+
["a reversed range", "10.22-16.0.1"],
|
|
228
|
+
["a target over the address ceiling", "10.0.0.0/8"],
|
|
229
|
+
])("%s is rejected", async (_label: string, target: string) => {
|
|
230
|
+
await renderPage();
|
|
231
|
+
|
|
232
|
+
expect(validate("cidr", { cidr: target })).toBe(
|
|
233
|
+
ScanTargetUtil.getValidationError(target),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test.each([
|
|
238
|
+
["CIDR", "192.168.1.0/24"],
|
|
239
|
+
["an octet range", "10.16-22.0-255.51-66"],
|
|
240
|
+
["a single address", "10.0.0.5"],
|
|
241
|
+
])("%s is accepted", async (_label: string, target: string) => {
|
|
242
|
+
await renderPage();
|
|
243
|
+
|
|
244
|
+
expect(validate("cidr", { cidr: target })).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an empty target is left to the field's own required rule", async () => {
|
|
248
|
+
await renderPage();
|
|
249
|
+
|
|
250
|
+
for (const value of [undefined, null, ""]) {
|
|
251
|
+
expect(validate("cidr", { cidr: value })).toBeNull();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a BLANK target is not left to anything — nothing else would catch it", async () => {
|
|
256
|
+
await renderPage();
|
|
257
|
+
|
|
258
|
+
/*
|
|
259
|
+
* Validation.validateRequired measures the untrimmed string, so a lone
|
|
260
|
+
* space satisfies it. If the validator read a blank box as an empty one,
|
|
261
|
+
* " " would clear step 1 and fail on the server two steps later — the
|
|
262
|
+
* reported bug, reproduced through the fix meant to close it.
|
|
263
|
+
*/
|
|
264
|
+
for (const value of [" ", " ", "\t"]) {
|
|
265
|
+
expect(validate("cidr", { cidr: value })).not.toBeNull();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("a target past the parser's length cap is refused on this step", async () => {
|
|
270
|
+
await renderPage();
|
|
271
|
+
|
|
272
|
+
const tooLong: string = "1".repeat(ScanTargetUtil.MAX_TARGET_LENGTH + 1);
|
|
273
|
+
|
|
274
|
+
expect(validate("cidr", { cidr: tooLong })).toContain(
|
|
275
|
+
`longer than ${ScanTargetUtil.MAX_TARGET_LENGTH} characters`,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("the field declares no maxLength of its own", async () => {
|
|
280
|
+
/*
|
|
281
|
+
* ModelForm infers one from the ShortText column (100) and validateLength
|
|
282
|
+
* runs before customValidation, which then overwrites its message with the
|
|
283
|
+
* parser's — so a declared cap would be inert. The rule that actually
|
|
284
|
+
* bites is the parser's own 64, asserted above.
|
|
285
|
+
*/
|
|
286
|
+
await renderPage();
|
|
287
|
+
|
|
288
|
+
expect(fieldNamed("cidr").validation?.maxLength).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("Rescan Interval is validated on the Schedule step", () => {
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
capturedTableProps = null;
|
|
295
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
296
|
+
jest.spyOn(ProbeUtil, "getAllProbes").mockResolvedValue([] as never);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
cleanup();
|
|
301
|
+
jest.restoreAllMocks();
|
|
302
|
+
capturedTableProps = null;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("the field carries a validator and lives on the last step", async () => {
|
|
306
|
+
await renderPage();
|
|
307
|
+
|
|
308
|
+
const field: CapturedFormField = fieldNamed("rescanIntervalInMinutes");
|
|
309
|
+
|
|
310
|
+
expect(field.stepId).toBe(STEP_SCHEDULE);
|
|
311
|
+
expect(typeof field.customValidation).toBe("function");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("it only reveals itself once the scan is set to repeat", async () => {
|
|
315
|
+
await renderPage();
|
|
316
|
+
|
|
317
|
+
const field: CapturedFormField = fieldNamed("rescanIntervalInMinutes");
|
|
318
|
+
|
|
319
|
+
expect(field.showIf?.({ isRecurring: true })).toBe(true);
|
|
320
|
+
expect(field.showIf?.({ isRecurring: false })).toBe(false);
|
|
321
|
+
expect(field.showIf?.({})).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("an interval under the floor is rejected", async () => {
|
|
325
|
+
await renderPage();
|
|
326
|
+
|
|
327
|
+
expect(
|
|
328
|
+
validate("rescanIntervalInMinutes", {
|
|
329
|
+
isRecurring: true,
|
|
330
|
+
rescanIntervalInMinutes: 5,
|
|
331
|
+
}),
|
|
332
|
+
).toContain("at least 15 minutes");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a fractional interval is rejected even though it clears the floor", async () => {
|
|
336
|
+
await renderPage();
|
|
337
|
+
|
|
338
|
+
expect(
|
|
339
|
+
validate("rescanIntervalInMinutes", {
|
|
340
|
+
isRecurring: true,
|
|
341
|
+
rescanIntervalInMinutes: 20.5,
|
|
342
|
+
}),
|
|
343
|
+
).toContain("whole number");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a sensible interval is accepted", async () => {
|
|
347
|
+
await renderPage();
|
|
348
|
+
|
|
349
|
+
expect(
|
|
350
|
+
validate("rescanIntervalInMinutes", {
|
|
351
|
+
isRecurring: true,
|
|
352
|
+
rescanIntervalInMinutes: 60,
|
|
353
|
+
}),
|
|
354
|
+
).toBeNull();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("nothing is demanded of a scan that does not repeat", async () => {
|
|
358
|
+
await renderPage();
|
|
359
|
+
|
|
360
|
+
expect(
|
|
361
|
+
validate("rescanIntervalInMinutes", {
|
|
362
|
+
isRecurring: false,
|
|
363
|
+
rescanIntervalInMinutes: 1,
|
|
364
|
+
}),
|
|
365
|
+
).toBeNull();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe("SNMP Port is bounded on the SNMP Credentials step", () => {
|
|
370
|
+
beforeEach(() => {
|
|
371
|
+
capturedTableProps = null;
|
|
372
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
373
|
+
jest.spyOn(ProbeUtil, "getAllProbes").mockResolvedValue([] as never);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
afterEach(() => {
|
|
377
|
+
cleanup();
|
|
378
|
+
jest.restoreAllMocks();
|
|
379
|
+
capturedTableProps = null;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("carries a validator on the SNMP step", async () => {
|
|
383
|
+
await renderPage();
|
|
384
|
+
|
|
385
|
+
const field: CapturedFormField = fieldNamed("snmpPort");
|
|
386
|
+
|
|
387
|
+
expect(field.stepId).toBe(STEP_SNMP);
|
|
388
|
+
expect(typeof field.customValidation).toBe("function");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test.each([
|
|
392
|
+
["zero", 0],
|
|
393
|
+
["a port past the top of the range", 65536],
|
|
394
|
+
["a fractional port", 161.5],
|
|
395
|
+
])("%s is rejected right there", async (_label: string, value: unknown) => {
|
|
396
|
+
await renderPage();
|
|
397
|
+
|
|
398
|
+
expect(validate("snmpPort", { snmpPort: value })).not.toBeNull();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("the SNMP default is accepted, and an empty box says nothing", async () => {
|
|
402
|
+
await renderPage();
|
|
403
|
+
|
|
404
|
+
expect(validate("snmpPort", { snmpPort: 161 })).toBeNull();
|
|
405
|
+
expect(validate("snmpPort", { snmpPort: "" })).toBeNull();
|
|
406
|
+
expect(validate("snmpPort", {})).toBeNull();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The shared event-overlay hook is what puts "what happened here" on every
|
|
16
|
+
* metric chart: incidents and alerts as solid severity-colored markers
|
|
17
|
+
* with click-through, change events (deploys) as dashed indigo markers.
|
|
18
|
+
* It must fetch nothing on public dashboards and survive any single
|
|
19
|
+
* source failing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const getListMock: MockFunction = getJestMockFunction();
|
|
23
|
+
const analyticsGetListMock: MockFunction = getJestMockFunction();
|
|
24
|
+
const isPublicDashboardMock: MockFunction = getJestMockFunction();
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the
|
|
28
|
+
* compiled requires, so the mock variables above are still unassigned when
|
|
29
|
+
* the factory runs.
|
|
30
|
+
*/
|
|
31
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
32
|
+
return {
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: {
|
|
35
|
+
getList: (...args: Array<any>) => {
|
|
36
|
+
return getListMock(...args);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
|
|
43
|
+
return {
|
|
44
|
+
__esModule: true,
|
|
45
|
+
default: {
|
|
46
|
+
getList: (...args: Array<any>) => {
|
|
47
|
+
return analyticsGetListMock(...args);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
jest.mock(
|
|
54
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Utils/PublicDashboardContext",
|
|
55
|
+
() => {
|
|
56
|
+
return {
|
|
57
|
+
__esModule: true,
|
|
58
|
+
isPublicDashboard: (...args: Array<any>) => {
|
|
59
|
+
return isPublicDashboardMock(...args);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
import useEventTimeReferenceLines, {
|
|
66
|
+
EventTimeReferenceLines,
|
|
67
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/UseEventTimeReferenceLines";
|
|
68
|
+
import ChartTimeReferenceLineProps from "../../../UI/Components/Charts/Types/TimeReferenceLineProps";
|
|
69
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
70
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
71
|
+
import ProjectUtil from "../../../UI/Utils/Project";
|
|
72
|
+
|
|
73
|
+
const WINDOW: InBetween<Date> = new InBetween<Date>(
|
|
74
|
+
new Date("2026-08-20T10:00:00.000Z"),
|
|
75
|
+
new Date("2026-08-20T11:00:00.000Z"),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
79
|
+
"9e1b6b0e-0000-4000-8000-000000000011",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
function Probe(props: {
|
|
83
|
+
enabled: boolean;
|
|
84
|
+
window: InBetween<Date> | null;
|
|
85
|
+
}): React.ReactElement {
|
|
86
|
+
const { lines, markerCount }: EventTimeReferenceLines =
|
|
87
|
+
useEventTimeReferenceLines({
|
|
88
|
+
enabled: props.enabled,
|
|
89
|
+
window: props.window,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div>
|
|
94
|
+
<span data-testid="marker-count">{markerCount}</span>
|
|
95
|
+
{lines.map((line: ChartTimeReferenceLineProps, index: number) => {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
key={index}
|
|
99
|
+
data-testid="marker"
|
|
100
|
+
data-color={line.color}
|
|
101
|
+
data-dash={line.strokeDasharray || "solid"}
|
|
102
|
+
data-clickable={line.onClick ? "yes" : "no"}
|
|
103
|
+
>
|
|
104
|
+
{line.label}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
getListMock.mockReset();
|
|
114
|
+
analyticsGetListMock.mockReset();
|
|
115
|
+
isPublicDashboardMock.mockReset();
|
|
116
|
+
isPublicDashboardMock.mockReturnValue(false);
|
|
117
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
jest.restoreAllMocks();
|
|
122
|
+
cleanup();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("useEventTimeReferenceLines", () => {
|
|
126
|
+
test("maps incidents, alerts, and change events onto distinct marker styles", async () => {
|
|
127
|
+
getListMock.mockImplementation((args: { modelType: { name: string } }) => {
|
|
128
|
+
if (args.modelType.name === "Incident") {
|
|
129
|
+
return Promise.resolve({
|
|
130
|
+
data: [
|
|
131
|
+
{
|
|
132
|
+
id: new ObjectID("11111111-0000-4000-8000-000000000001"),
|
|
133
|
+
createdAt: "2026-08-20T10:10:00.000Z",
|
|
134
|
+
title: "API is down",
|
|
135
|
+
incidentSeverity: { color: "#123456" },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
data: [
|
|
142
|
+
{
|
|
143
|
+
id: new ObjectID("22222222-0000-4000-8000-000000000002"),
|
|
144
|
+
createdAt: "2026-08-20T10:20:00.000Z",
|
|
145
|
+
title: "High latency",
|
|
146
|
+
alertSeverity: undefined,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
analyticsGetListMock.mockResolvedValue({
|
|
152
|
+
data: [
|
|
153
|
+
{
|
|
154
|
+
time: "2026-08-20T10:03:00.000Z",
|
|
155
|
+
title: "v2.31.0",
|
|
156
|
+
eventType: "deployment",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("3");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const markers: Array<HTMLElement> = screen.getAllByTestId("marker");
|
|
168
|
+
const byLabel: (needle: string) => HTMLElement = (
|
|
169
|
+
needle: string,
|
|
170
|
+
): HTMLElement => {
|
|
171
|
+
const found: HTMLElement | undefined = markers.find(
|
|
172
|
+
(marker: HTMLElement) => {
|
|
173
|
+
return (marker.textContent || "").includes(needle);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
if (!found) {
|
|
177
|
+
throw new Error(`no marker containing ${needle}`);
|
|
178
|
+
}
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const incident: HTMLElement = byLabel("Incident: API is down");
|
|
183
|
+
expect(incident.dataset["color"]).toBe("#123456");
|
|
184
|
+
expect(incident.dataset["dash"]).toBe("solid");
|
|
185
|
+
expect(incident.dataset["clickable"]).toBe("yes");
|
|
186
|
+
|
|
187
|
+
// Alert without a severity color falls back to amber.
|
|
188
|
+
expect(byLabel("Alert: High latency").dataset["color"]).toBe("#fbbf24");
|
|
189
|
+
|
|
190
|
+
const deploy: HTMLElement = byLabel("Deploy: v2.31.0");
|
|
191
|
+
expect(deploy.dataset["color"]).toBe("#6366f1");
|
|
192
|
+
expect(deploy.dataset["dash"]).toBe("4 4");
|
|
193
|
+
// Change events have no detail page — no dead-end click handler.
|
|
194
|
+
expect(deploy.dataset["clickable"]).toBe("no");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("one source failing never takes the others down", async () => {
|
|
198
|
+
getListMock.mockImplementation((args: { modelType: { name: string } }) => {
|
|
199
|
+
if (args.modelType.name === "Incident") {
|
|
200
|
+
return Promise.resolve({
|
|
201
|
+
data: [
|
|
202
|
+
{
|
|
203
|
+
id: new ObjectID("11111111-0000-4000-8000-000000000001"),
|
|
204
|
+
createdAt: "2026-08-20T10:10:00.000Z",
|
|
205
|
+
title: "Still here",
|
|
206
|
+
incidentSeverity: undefined,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return Promise.resolve({ data: [] });
|
|
212
|
+
});
|
|
213
|
+
analyticsGetListMock.mockRejectedValue(new Error("clickhouse hiccup"));
|
|
214
|
+
|
|
215
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("1");
|
|
219
|
+
});
|
|
220
|
+
expect(screen.getByText(/Incident: Still here/)).toBeInTheDocument();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("public dashboards fetch nothing at all", async () => {
|
|
224
|
+
isPublicDashboardMock.mockReturnValue(true);
|
|
225
|
+
|
|
226
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
227
|
+
|
|
228
|
+
// Give any (wrong) fetch a chance to fire.
|
|
229
|
+
await new Promise((resolve: (value: unknown) => void) => {
|
|
230
|
+
setTimeout(resolve, 20);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(getListMock).not.toHaveBeenCalled();
|
|
234
|
+
expect(analyticsGetListMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("0");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("disabled and windowless states fetch nothing and yield no lines", async () => {
|
|
239
|
+
render(<Probe enabled={false} window={WINDOW} />);
|
|
240
|
+
render(<Probe enabled={true} window={null} />);
|
|
241
|
+
|
|
242
|
+
await new Promise((resolve: (value: unknown) => void) => {
|
|
243
|
+
setTimeout(resolve, 20);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(getListMock).not.toHaveBeenCalled();
|
|
247
|
+
expect(analyticsGetListMock).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|