@oneuptime/common 12.0.19 → 12.0.20
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/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +17 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Views/Partials/AnalyticsConsent.ejs +533 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +302 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +155 -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/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/Marketing/MarketingEvent.ts +49 -6
- package/UI/Components/Charts/Area/AreaChart.tsx +8 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +20 -2
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +105 -0
- package/UI/Components/Charts/Line/LineChart.tsx +8 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- 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 +15 -0
- package/build/dist/Server/EnvironmentConfig.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/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/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +40 -1
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +9 -1
- 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/ChartLibrary/AreaChart/AreaChart.js +22 -13
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +27 -15
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +27 -18
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +71 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +9 -1
- 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/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
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 {
|
|
11
|
+
RenderResult,
|
|
12
|
+
act,
|
|
13
|
+
cleanup,
|
|
14
|
+
fireEvent,
|
|
15
|
+
render,
|
|
16
|
+
screen,
|
|
17
|
+
waitFor,
|
|
18
|
+
} from "@testing-library/react";
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Drag-to-zoom on a dashboard chart widget is the "investigate this
|
|
24
|
+
* spike" entry point: selecting a window must narrow THIS widget only
|
|
25
|
+
* (never the dashboard-wide range), refetch for the narrowed window, and
|
|
26
|
+
* offer a way back (Reset) and a way deeper (Open in Explorer, carrying
|
|
27
|
+
* the zoomed window). Edit mode must not zoom — the drag gesture belongs
|
|
28
|
+
* to widget move/resize there, and series pivots would navigate away from
|
|
29
|
+
* unsaved dashboard changes.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fetchResultsMock: MockFunction = getJestMockFunction();
|
|
33
|
+
const metricChartsRenderMock: MockFunction = getJestMockFunction();
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the
|
|
37
|
+
* compiled requires, so the mock variables above are still unassigned when
|
|
38
|
+
* the factory runs. Dereferencing them lazily, at call time, is what makes
|
|
39
|
+
* this work.
|
|
40
|
+
*/
|
|
41
|
+
jest.mock(
|
|
42
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
|
|
43
|
+
() => {
|
|
44
|
+
return {
|
|
45
|
+
__esModule: true,
|
|
46
|
+
default: {
|
|
47
|
+
fetchResults: (...args: Array<any>) => {
|
|
48
|
+
return fetchResultsMock(...args);
|
|
49
|
+
},
|
|
50
|
+
clearQueryTopNOverridesForScope: () => {
|
|
51
|
+
return undefined;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* The widget's job under test is which window it ASKS for and which
|
|
60
|
+
* affordances it hands down; a real recharts surface in jsdom would test
|
|
61
|
+
* neither.
|
|
62
|
+
*/
|
|
63
|
+
jest.mock(
|
|
64
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts",
|
|
65
|
+
() => {
|
|
66
|
+
return {
|
|
67
|
+
__esModule: true,
|
|
68
|
+
default: (props: Record<string, unknown>): React.ReactElement => {
|
|
69
|
+
metricChartsRenderMock(props);
|
|
70
|
+
return React.createElement(
|
|
71
|
+
"div",
|
|
72
|
+
{ "data-testid": "metric-charts" },
|
|
73
|
+
"chart",
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
import DashboardChartComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent";
|
|
81
|
+
import { DashboardBaseComponentProps } from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent";
|
|
82
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
83
|
+
import DashboardChartType from "../../../Types/Dashboard/Chart/ChartType";
|
|
84
|
+
import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
|
|
85
|
+
import DashboardChartComponent from "../../../Types/Dashboard/DashboardComponents/DashboardChartComponent";
|
|
86
|
+
import DashboardViewConfig from "../../../Types/Dashboard/DashboardViewConfig";
|
|
87
|
+
import { ObjectType } from "../../../Types/JSON";
|
|
88
|
+
import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
|
|
89
|
+
import MetricViewData from "../../../Types/Metrics/MetricViewData";
|
|
90
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
91
|
+
import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
|
|
92
|
+
import TimeRange from "../../../Types/Time/TimeRange";
|
|
93
|
+
|
|
94
|
+
const COMPONENT_ID: ObjectID = new ObjectID(
|
|
95
|
+
"22222222-1111-4111-8111-222222222222",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const DASHBOARD_START: Date = new Date("2026-08-20T09:00:00.000Z");
|
|
99
|
+
const DASHBOARD_END: Date = new Date("2026-08-20T10:00:00.000Z");
|
|
100
|
+
const ZOOM_START: Date = new Date("2026-08-20T09:20:00.000Z");
|
|
101
|
+
const ZOOM_END: Date = new Date("2026-08-20T09:35:00.000Z");
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* A CUSTOM range pins the window to fixed instants — a relative range
|
|
105
|
+
* would resolve against the wall clock and make the window assertions
|
|
106
|
+
* untestable.
|
|
107
|
+
*/
|
|
108
|
+
const DASHBOARD_RANGE: RangeStartAndEndDateTime = {
|
|
109
|
+
range: TimeRange.CUSTOM,
|
|
110
|
+
startAndEndDate: new InBetween<Date>(DASHBOARD_START, DASHBOARD_END),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const DASHBOARD_VIEW_CONFIG: DashboardViewConfig = {
|
|
114
|
+
_type: ObjectType.DashboardViewConfig,
|
|
115
|
+
components: [],
|
|
116
|
+
heightInDashboardUnits: 60,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function buildBaseProps(
|
|
120
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
121
|
+
): DashboardBaseComponentProps {
|
|
122
|
+
return {
|
|
123
|
+
componentId: COMPONENT_ID,
|
|
124
|
+
isEditMode: false,
|
|
125
|
+
isSelected: false,
|
|
126
|
+
key: "chart-widget",
|
|
127
|
+
onComponentUpdate: (): void => {
|
|
128
|
+
// The widget under test never writes back through this.
|
|
129
|
+
},
|
|
130
|
+
totalCurrentDashboardWidthInPx: 1200,
|
|
131
|
+
dashboardCanvasTopInPx: 0,
|
|
132
|
+
dashboardCanvasLeftInPx: 0,
|
|
133
|
+
dashboardCanvasWidthInPx: 1200,
|
|
134
|
+
dashboardCanvasHeightInPx: 800,
|
|
135
|
+
dashboardComponentHeightInPx: 320,
|
|
136
|
+
dashboardComponentWidthInPx: 480,
|
|
137
|
+
dashboardViewConfig: DASHBOARD_VIEW_CONFIG,
|
|
138
|
+
dashboardStartAndEndDate: DASHBOARD_RANGE,
|
|
139
|
+
metricTypes: [],
|
|
140
|
+
refreshTick: 0,
|
|
141
|
+
variables: undefined,
|
|
142
|
+
...overrides,
|
|
143
|
+
} as DashboardBaseComponentProps;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildChartComponent(): DashboardChartComponent {
|
|
147
|
+
return {
|
|
148
|
+
_type: ObjectType.DashboardComponent,
|
|
149
|
+
componentId: COMPONENT_ID,
|
|
150
|
+
componentType: DashboardComponentType.Chart,
|
|
151
|
+
topInDashboardUnits: 0,
|
|
152
|
+
leftInDashboardUnits: 0,
|
|
153
|
+
widthInDashboardUnits: 6,
|
|
154
|
+
heightInDashboardUnits: 3,
|
|
155
|
+
minWidthInDashboardUnits: 6,
|
|
156
|
+
minHeightInDashboardUnits: 3,
|
|
157
|
+
arguments: {
|
|
158
|
+
chartTitle: "CPU by host",
|
|
159
|
+
chartType: DashboardChartType.Line,
|
|
160
|
+
metricQueryConfig: {
|
|
161
|
+
metricAliasData: { metricVariable: "a" },
|
|
162
|
+
metricQueryData: {
|
|
163
|
+
filterData: {
|
|
164
|
+
metricName: "cpu.usage",
|
|
165
|
+
aggegationType: MetricsAggregationType.Avg,
|
|
166
|
+
},
|
|
167
|
+
groupByAttributeKeys: ["host.name"],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
} as unknown as DashboardChartComponent;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderWidget(
|
|
175
|
+
overrides: Partial<DashboardBaseComponentProps> = {},
|
|
176
|
+
): RenderResult {
|
|
177
|
+
return render(
|
|
178
|
+
<DashboardChartComponentElement
|
|
179
|
+
{...buildBaseProps(overrides)}
|
|
180
|
+
component={buildChartComponent()}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface CapturedChartProps {
|
|
186
|
+
metricViewData: MetricViewData;
|
|
187
|
+
onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
|
|
188
|
+
enableSeriesActions?: boolean | undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function lastChartProps(): CapturedChartProps {
|
|
192
|
+
const calls: Array<Array<unknown>> = metricChartsRenderMock.mock.calls;
|
|
193
|
+
return calls[calls.length - 1]?.[0] as CapturedChartProps;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface CapturedFetchArgs {
|
|
197
|
+
metricViewData: MetricViewData;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function lastFetchArgs(): CapturedFetchArgs {
|
|
201
|
+
const calls: Array<Array<unknown>> = fetchResultsMock.mock.calls;
|
|
202
|
+
return calls[calls.length - 1]?.[0] as CapturedFetchArgs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
fetchResultsMock.mockReset();
|
|
207
|
+
metricChartsRenderMock.mockReset();
|
|
208
|
+
fetchResultsMock.mockReturnValue(
|
|
209
|
+
Promise.resolve([{ data: [], truncated: false }]),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
cleanup();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("dashboard chart widget drag-to-zoom", () => {
|
|
218
|
+
test("drag-selecting a window refetches THIS widget for that window and shows the zoom bar", async () => {
|
|
219
|
+
renderWidget();
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Initial fetch asks for the dashboard's window.
|
|
226
|
+
expect(lastFetchArgs().metricViewData.startAndEndDate?.startValue).toEqual(
|
|
227
|
+
DASHBOARD_START,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const initialProps: CapturedChartProps = lastChartProps();
|
|
231
|
+
expect(initialProps.onTimeRangeSelect).toBeDefined();
|
|
232
|
+
expect(initialProps.enableSeriesActions).toBe(true);
|
|
233
|
+
// A pinned Custom dashboard window is not a rolling token.
|
|
234
|
+
expect(initialProps.metricViewData.rangeToken).toBe(TimeRange.CUSTOM);
|
|
235
|
+
|
|
236
|
+
const fetchCountBeforeZoom: number = fetchResultsMock.mock.calls.length;
|
|
237
|
+
|
|
238
|
+
act(() => {
|
|
239
|
+
initialProps.onTimeRangeSelect?.(ZOOM_START, ZOOM_END);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The zoom bar names the window and offers the escape hatches.
|
|
243
|
+
expect(screen.getByText(/Zoomed:/)).toBeInTheDocument();
|
|
244
|
+
expect(screen.getByText("Reset")).toBeInTheDocument();
|
|
245
|
+
expect(screen.getByText("Open in Explorer")).toBeInTheDocument();
|
|
246
|
+
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(fetchResultsMock.mock.calls.length).toBeGreaterThan(
|
|
249
|
+
fetchCountBeforeZoom,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// The refetch and the chart both use the ZOOMED window…
|
|
254
|
+
expect(lastFetchArgs().metricViewData.startAndEndDate?.startValue).toEqual(
|
|
255
|
+
ZOOM_START,
|
|
256
|
+
);
|
|
257
|
+
expect(lastFetchArgs().metricViewData.startAndEndDate?.endValue).toEqual(
|
|
258
|
+
ZOOM_END,
|
|
259
|
+
);
|
|
260
|
+
// …and a zoomed window is a deliberate pin: no rolling range token.
|
|
261
|
+
await waitFor(() => {
|
|
262
|
+
expect(lastChartProps().metricViewData.rangeToken).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("Reset returns to the dashboard's window", async () => {
|
|
267
|
+
renderWidget();
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
act(() => {
|
|
274
|
+
lastChartProps().onTimeRangeSelect?.(ZOOM_START, ZOOM_END);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(screen.getByText(/Zoomed:/)).toBeInTheDocument();
|
|
278
|
+
|
|
279
|
+
fireEvent.click(screen.getByText("Reset"));
|
|
280
|
+
|
|
281
|
+
expect(screen.queryByText(/Zoomed:/)).toBeNull();
|
|
282
|
+
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(
|
|
285
|
+
lastFetchArgs().metricViewData.startAndEndDate?.startValue,
|
|
286
|
+
).toEqual(DASHBOARD_START);
|
|
287
|
+
});
|
|
288
|
+
expect(lastChartProps().metricViewData.rangeToken).toBe(TimeRange.CUSTOM);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("edit mode disables zoom and series navigation", async () => {
|
|
292
|
+
renderWidget({ isEditMode: true });
|
|
293
|
+
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const captured: CapturedChartProps = lastChartProps();
|
|
299
|
+
expect(captured.onTimeRangeSelect).toBeUndefined();
|
|
300
|
+
expect(captured.enableSeriesActions).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -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
|
+
});
|