@oneuptime/common 12.0.20 → 12.0.22
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/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/EnvironmentConfig.ts +1 -8
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -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/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -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/TwoFactorBackupCode.test.ts +475 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +10 -46
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
- package/UI/Components/Charts/Line/LineChart.tsx +15 -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/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +1 -7
- 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/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -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 +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- 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/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.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 +2 -33
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.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 +92 -38
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- 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,258 @@
|
|
|
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
|
+
cleanup,
|
|
12
|
+
fireEvent,
|
|
13
|
+
render,
|
|
14
|
+
screen,
|
|
15
|
+
waitFor,
|
|
16
|
+
} from "@testing-library/react";
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* The investigation drawer is the "what happened in this window?" panel:
|
|
22
|
+
* it must summarize the log signal for the window+scope, hand every
|
|
23
|
+
* companion signal the SAME pinned window, and route escape hatches
|
|
24
|
+
* (patterns, explorer) through onClose so the page underneath doesn't
|
|
25
|
+
* change behind an open drawer.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const histogramMock: MockFunction = getJestMockFunction();
|
|
29
|
+
const patternsMock: MockFunction = getJestMockFunction();
|
|
30
|
+
const companionTabsMock: MockFunction = getJestMockFunction();
|
|
31
|
+
const embeddedCardMock: MockFunction = getJestMockFunction();
|
|
32
|
+
|
|
33
|
+
jest.mock(
|
|
34
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Logs/LogsInsightsApi",
|
|
35
|
+
() => {
|
|
36
|
+
return {
|
|
37
|
+
__esModule: true,
|
|
38
|
+
fetchLogsHistogramRaw: (...args: Array<any>) => {
|
|
39
|
+
return histogramMock(...args);
|
|
40
|
+
},
|
|
41
|
+
fetchTopErrorPatterns: (...args: Array<any>) => {
|
|
42
|
+
return patternsMock(...args);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* The companion tabs and the embedded metric card are heavy, separately
|
|
50
|
+
* tested surfaces — here they only need to prove WHAT they were handed.
|
|
51
|
+
*/
|
|
52
|
+
jest.mock(
|
|
53
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/TelemetryCompanionSignalTabs",
|
|
54
|
+
() => {
|
|
55
|
+
return {
|
|
56
|
+
__esModule: true,
|
|
57
|
+
default: (props: Record<string, unknown>): React.ReactElement => {
|
|
58
|
+
companionTabsMock(props);
|
|
59
|
+
return React.createElement(
|
|
60
|
+
"div",
|
|
61
|
+
{ "data-testid": "companion-tabs" },
|
|
62
|
+
props["primarySignalElement"] as React.ReactElement,
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
jest.mock(
|
|
70
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/EmbeddedMetricCard",
|
|
71
|
+
() => {
|
|
72
|
+
return {
|
|
73
|
+
__esModule: true,
|
|
74
|
+
default: (props: Record<string, unknown>): React.ReactElement => {
|
|
75
|
+
embeddedCardMock(props);
|
|
76
|
+
return React.createElement("div", {
|
|
77
|
+
"data-testid": "embedded-metric-card",
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
import InvestigationDrawer from "../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/InvestigationDrawer";
|
|
85
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
86
|
+
import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
|
|
87
|
+
import MetricViewData from "../../../Types/Metrics/MetricViewData";
|
|
88
|
+
import TelemetryType from "../../../Types/Telemetry/TelemetryType";
|
|
89
|
+
import Navigation from "../../../UI/Utils/Navigation";
|
|
90
|
+
|
|
91
|
+
const WINDOW: InBetween<Date> = new InBetween<Date>(
|
|
92
|
+
new Date("2026-08-20T10:00:00.000Z"),
|
|
93
|
+
new Date("2026-08-20T10:15:00.000Z"),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
function buildViewData(): MetricViewData {
|
|
97
|
+
return {
|
|
98
|
+
queryConfigs: [
|
|
99
|
+
{
|
|
100
|
+
metricAliasData: { metricVariable: "a" },
|
|
101
|
+
metricQueryData: {
|
|
102
|
+
filterData: {
|
|
103
|
+
metricName: "cpu.usage",
|
|
104
|
+
attributes: { "host.name": "web-01" },
|
|
105
|
+
aggegationType: MetricsAggregationType.Avg,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
formulaConfigs: [],
|
|
111
|
+
startAndEndDate: null,
|
|
112
|
+
} as unknown as MetricViewData;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let navigateSpy: ReturnType<typeof jest.spyOn>;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
histogramMock.mockReset();
|
|
119
|
+
patternsMock.mockReset();
|
|
120
|
+
companionTabsMock.mockReset();
|
|
121
|
+
embeddedCardMock.mockReset();
|
|
122
|
+
histogramMock.mockReturnValue(
|
|
123
|
+
Promise.resolve([
|
|
124
|
+
{ time: "2026-08-20T10:00:00.000Z", severity: "Information", count: 90 },
|
|
125
|
+
{ time: "2026-08-20T10:05:00.000Z", severity: "Error", count: 10 },
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
patternsMock.mockReturnValue(
|
|
129
|
+
Promise.resolve([
|
|
130
|
+
{
|
|
131
|
+
pattern: "connection refused to <IP>",
|
|
132
|
+
sampleBody: "connection refused to 10.0.0.5",
|
|
133
|
+
count: 42,
|
|
134
|
+
resourceIds: [],
|
|
135
|
+
severities: ["Error"],
|
|
136
|
+
sampleTraceIds: [],
|
|
137
|
+
},
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
navigateSpy = jest.spyOn(Navigation, "navigate").mockImplementation(() => {
|
|
141
|
+
return undefined;
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
jest.restoreAllMocks();
|
|
147
|
+
cleanup();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("InvestigationDrawer", () => {
|
|
151
|
+
test("summarizes the window's log signal and lists top error patterns", async () => {
|
|
152
|
+
render(
|
|
153
|
+
<InvestigationDrawer
|
|
154
|
+
title="host.name=web-01"
|
|
155
|
+
window={WINDOW}
|
|
156
|
+
metricViewData={buildViewData()}
|
|
157
|
+
onClose={() => {
|
|
158
|
+
// not exercised here
|
|
159
|
+
}}
|
|
160
|
+
/>,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Scope chips reflect the metric view's filters.
|
|
164
|
+
expect(screen.getByText("host.name = web-01")).toBeInTheDocument();
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(screen.getByText("100")).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
expect(screen.getByText("10")).toBeInTheDocument();
|
|
170
|
+
expect(screen.getByText("10.0%")).toBeInTheDocument();
|
|
171
|
+
expect(
|
|
172
|
+
screen.getByText("connection refused to 10.0.0.5"),
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
|
|
175
|
+
// The histogram request carried the attribute scope.
|
|
176
|
+
const histogramBody: Record<string, unknown> = histogramMock.mock
|
|
177
|
+
.calls[0]?.[0] as Record<string, unknown>;
|
|
178
|
+
expect(
|
|
179
|
+
(histogramBody["attributes"] as Record<string, unknown>)["host.name"],
|
|
180
|
+
).toBe("web-01");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("hands the companion tabs the SAME pinned window and metric queries", async () => {
|
|
184
|
+
render(
|
|
185
|
+
<InvestigationDrawer
|
|
186
|
+
window={WINDOW}
|
|
187
|
+
metricViewData={buildViewData()}
|
|
188
|
+
onClose={() => {
|
|
189
|
+
// not exercised here
|
|
190
|
+
}}
|
|
191
|
+
/>,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
expect(companionTabsMock).toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const tabsProps: Record<string, unknown> = companionTabsMock.mock
|
|
199
|
+
.calls[0]?.[0] as Record<string, unknown>;
|
|
200
|
+
const telemetryQuery: Record<string, unknown> = tabsProps[
|
|
201
|
+
"telemetryQuery"
|
|
202
|
+
] as Record<string, unknown>;
|
|
203
|
+
|
|
204
|
+
expect(telemetryQuery["telemetryType"]).toBe(TelemetryType.Metric);
|
|
205
|
+
const viewData: MetricViewData = telemetryQuery[
|
|
206
|
+
"metricViewData"
|
|
207
|
+
] as MetricViewData;
|
|
208
|
+
expect(viewData.startAndEndDate?.startValue.getTime()).toBe(
|
|
209
|
+
WINDOW.startValue.getTime(),
|
|
210
|
+
);
|
|
211
|
+
// Pinned — never a rolling token.
|
|
212
|
+
expect(viewData.rangeToken).toBeUndefined();
|
|
213
|
+
expect(tabsProps["eventNoun"]).toBe("view");
|
|
214
|
+
|
|
215
|
+
const snapshotWindow: InBetween<Date> = tabsProps[
|
|
216
|
+
"snapshotWindow"
|
|
217
|
+
] as InBetween<Date>;
|
|
218
|
+
expect(snapshotWindow.endValue.getTime()).toBe(WINDOW.endValue.getTime());
|
|
219
|
+
|
|
220
|
+
// The primary element is the interactive metric card, same queries.
|
|
221
|
+
expect(screen.getByTestId("embedded-metric-card")).toBeInTheDocument();
|
|
222
|
+
const cardProps: Record<string, unknown> = embeddedCardMock.mock
|
|
223
|
+
.calls[0]?.[0] as Record<string, unknown>;
|
|
224
|
+
const cardConfigs: Array<Record<string, any>> = cardProps[
|
|
225
|
+
"queryConfigs"
|
|
226
|
+
] as Array<Record<string, any>>;
|
|
227
|
+
expect(
|
|
228
|
+
cardConfigs[0]?.["metricQueryData"]["filterData"]["metricName"],
|
|
229
|
+
).toBe("cpu.usage");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("escape hatches close the drawer before navigating", async () => {
|
|
233
|
+
const onClose: MockFunction = getJestMockFunction();
|
|
234
|
+
|
|
235
|
+
render(
|
|
236
|
+
<InvestigationDrawer
|
|
237
|
+
window={WINDOW}
|
|
238
|
+
metricViewData={buildViewData()}
|
|
239
|
+
onClose={(...args: Array<any>) => {
|
|
240
|
+
onClose(...args);
|
|
241
|
+
}}
|
|
242
|
+
/>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
fireEvent.click(screen.getByText("Open in Metric Explorer"));
|
|
246
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
|
248
|
+
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(
|
|
251
|
+
screen.getByText("connection refused to 10.0.0.5"),
|
|
252
|
+
).toBeInTheDocument();
|
|
253
|
+
});
|
|
254
|
+
fireEvent.click(screen.getByText("connection refused to 10.0.0.5"));
|
|
255
|
+
expect(onClose).toHaveBeenCalledTimes(2);
|
|
256
|
+
expect(navigateSpy).toHaveBeenCalledTimes(2);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
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 } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Ghost overlays end-to-end through the real MetricCharts: previous-
|
|
16
|
+
* period results must render as dashed twins of DISPLAYED series only —
|
|
17
|
+
* never as legend chips, never consuming Top-N slots, and always
|
|
18
|
+
* time-shifted onto the current window's bucket grid (unshifted points
|
|
19
|
+
* would be silently dropped by the bucketer).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
jest.mock("recharts", () => {
|
|
23
|
+
const actual: Record<string, any> = jest.requireActual("recharts") as Record<
|
|
24
|
+
string,
|
|
25
|
+
any
|
|
26
|
+
>;
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
|
|
30
|
+
return React.cloneElement(children, {
|
|
31
|
+
width: 600,
|
|
32
|
+
height: 300,
|
|
33
|
+
} as Record<string, unknown>);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const fetchExemplarsMock: MockFunction = getJestMockFunction();
|
|
39
|
+
|
|
40
|
+
jest.mock(
|
|
41
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
|
|
42
|
+
() => {
|
|
43
|
+
return {
|
|
44
|
+
__esModule: true,
|
|
45
|
+
default: {
|
|
46
|
+
fetchExemplars: (...args: Array<any>) => {
|
|
47
|
+
fetchExemplarsMock(...args);
|
|
48
|
+
return new Promise(() => {
|
|
49
|
+
// Intentionally never settles.
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
setQueryTopNOverride: () => {
|
|
53
|
+
return undefined;
|
|
54
|
+
},
|
|
55
|
+
getQueryConfigTopNKey: (
|
|
56
|
+
_queryConfig: unknown,
|
|
57
|
+
index: number,
|
|
58
|
+
scope?: string,
|
|
59
|
+
) => {
|
|
60
|
+
return `${scope || ""}:${index}`;
|
|
61
|
+
},
|
|
62
|
+
clearQueryTopNOverridesForScope: () => {
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
serializeAttributeFiltersForKey: (attributes: unknown) => {
|
|
66
|
+
return JSON.stringify(attributes || {});
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
DEFAULT_TOP_N_SERIES: 10,
|
|
70
|
+
SHOW_ALL_SERIES_TOP_N: 10_000,
|
|
71
|
+
sanitizeAttributeFilters: (attributes: unknown) => {
|
|
72
|
+
return attributes;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
import MetricCharts from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts";
|
|
79
|
+
import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
|
|
80
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
81
|
+
import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
|
|
82
|
+
import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
|
|
83
|
+
import MetricViewData from "../../../Types/Metrics/MetricViewData";
|
|
84
|
+
|
|
85
|
+
const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
|
|
86
|
+
const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
|
|
87
|
+
const WINDOW_MS: number = WINDOW_END.getTime() - WINDOW_START.getTime();
|
|
88
|
+
|
|
89
|
+
function buildViewData(): MetricViewData {
|
|
90
|
+
return {
|
|
91
|
+
queryConfigs: [
|
|
92
|
+
{
|
|
93
|
+
metricAliasData: { metricVariable: "a", legend: "CPU" },
|
|
94
|
+
metricQueryData: {
|
|
95
|
+
filterData: {
|
|
96
|
+
metricName: "cpu.usage",
|
|
97
|
+
attributes: {},
|
|
98
|
+
aggegationType: MetricsAggregationType.Avg,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
} as unknown as MetricQueryConfigData,
|
|
102
|
+
],
|
|
103
|
+
formulaConfigs: [],
|
|
104
|
+
startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
|
|
105
|
+
} as MetricViewData;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildResult(offsetMs: number): AggregatedResult {
|
|
109
|
+
return {
|
|
110
|
+
data: [
|
|
111
|
+
{
|
|
112
|
+
timestamp: new Date(
|
|
113
|
+
new Date("2026-08-20T10:10:00.000Z").getTime() - offsetMs,
|
|
114
|
+
),
|
|
115
|
+
value: 40,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
timestamp: new Date(
|
|
119
|
+
new Date("2026-08-20T10:30:00.000Z").getTime() - offsetMs,
|
|
120
|
+
),
|
|
121
|
+
value: 70,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
truncated: false,
|
|
125
|
+
} as unknown as AggregatedResult;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
fetchExemplarsMock.mockReset();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
cleanup();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("MetricCharts compare overlays", () => {
|
|
137
|
+
test("renders a dashed ghost twin without adding legend chips", () => {
|
|
138
|
+
const { container } = render(
|
|
139
|
+
<MetricCharts
|
|
140
|
+
metricViewData={buildViewData()}
|
|
141
|
+
metricResults={[buildResult(0)]}
|
|
142
|
+
metricResultsPrevious={[buildResult(WINDOW_MS)]}
|
|
143
|
+
compareOffsetMs={WINDOW_MS}
|
|
144
|
+
metricTypes={[]}
|
|
145
|
+
/>,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// The ghost line is on the chart, dashed…
|
|
149
|
+
expect(
|
|
150
|
+
container.querySelector('path[stroke-dasharray="6 4"]'),
|
|
151
|
+
).toBeInTheDocument();
|
|
152
|
+
|
|
153
|
+
// …but the chip legend shows only the live series.
|
|
154
|
+
expect(screen.getAllByText("CPU").length).toBeGreaterThan(0);
|
|
155
|
+
expect(screen.queryByText("CPU (previous)")).toBeNull();
|
|
156
|
+
// And the series count still reads 1.
|
|
157
|
+
expect(screen.getByText("1 series")).toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("no previous results means no ghosts", () => {
|
|
161
|
+
const { container } = render(
|
|
162
|
+
<MetricCharts
|
|
163
|
+
metricViewData={buildViewData()}
|
|
164
|
+
metricResults={[buildResult(0)]}
|
|
165
|
+
metricTypes={[]}
|
|
166
|
+
/>,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(container.querySelector('path[stroke-dasharray="6 4"]')).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
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 getJestMockFunction, { MockFunction } from "../../MockType";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Compare-to-previous-period: MetricView must fetch the SAME queries a
|
|
16
|
+
* second time over the window shifted back by exactly one window width,
|
|
17
|
+
* hand both result sets to MetricCharts, and degrade to live-only when
|
|
18
|
+
* the shifted fetch fails.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fetchResultsMock: MockFunction = getJestMockFunction();
|
|
22
|
+
const metricChartsMock: MockFunction = getJestMockFunction();
|
|
23
|
+
|
|
24
|
+
jest.mock(
|
|
25
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
|
|
26
|
+
() => {
|
|
27
|
+
return {
|
|
28
|
+
__esModule: true,
|
|
29
|
+
default: {
|
|
30
|
+
fetchResults: (...args: Array<any>) => {
|
|
31
|
+
return fetchResultsMock(...args);
|
|
32
|
+
},
|
|
33
|
+
getMetricTypes: () => {
|
|
34
|
+
return Promise.resolve([]);
|
|
35
|
+
},
|
|
36
|
+
loadAllMetricsTypes: () => {
|
|
37
|
+
return Promise.resolve({
|
|
38
|
+
metricTypes: [],
|
|
39
|
+
telemetryServices: [],
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
jest.mock(
|
|
48
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts",
|
|
49
|
+
() => {
|
|
50
|
+
return {
|
|
51
|
+
__esModule: true,
|
|
52
|
+
default: (props: Record<string, unknown>): React.ReactElement => {
|
|
53
|
+
metricChartsMock(props);
|
|
54
|
+
return React.createElement("div", {
|
|
55
|
+
"data-testid": "metric-charts",
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
import MetricView from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricView";
|
|
63
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
64
|
+
import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
|
|
65
|
+
import MetricViewData from "../../../Types/Metrics/MetricViewData";
|
|
66
|
+
|
|
67
|
+
const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
|
|
68
|
+
const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
|
|
69
|
+
const WINDOW_MS: number = WINDOW_END.getTime() - WINDOW_START.getTime();
|
|
70
|
+
|
|
71
|
+
function buildData(): MetricViewData {
|
|
72
|
+
return {
|
|
73
|
+
queryConfigs: [
|
|
74
|
+
{
|
|
75
|
+
metricAliasData: { metricVariable: "a" },
|
|
76
|
+
metricQueryData: {
|
|
77
|
+
filterData: {
|
|
78
|
+
metricName: "cpu.usage",
|
|
79
|
+
attributes: {},
|
|
80
|
+
aggegationType: MetricsAggregationType.Avg,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
formulaConfigs: [],
|
|
86
|
+
startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
|
|
87
|
+
} as unknown as MetricViewData;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface CapturedFetch {
|
|
91
|
+
metricViewData: MetricViewData;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
fetchResultsMock.mockReset();
|
|
96
|
+
metricChartsMock.mockReset();
|
|
97
|
+
fetchResultsMock.mockReturnValue(
|
|
98
|
+
Promise.resolve([{ data: [], truncated: false }]),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("MetricView compare with previous period", () => {
|
|
107
|
+
test("fetches the shifted window alongside the live one and hands both down", async () => {
|
|
108
|
+
render(
|
|
109
|
+
<MetricView
|
|
110
|
+
data={buildData()}
|
|
111
|
+
hideQueryElements={true}
|
|
112
|
+
hideStartAndEndDate={true}
|
|
113
|
+
compareWithPreviousPeriod={true}
|
|
114
|
+
onChange={() => {
|
|
115
|
+
// not exercised
|
|
116
|
+
}}
|
|
117
|
+
/>,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(fetchResultsMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const windows: Array<[number, number]> = fetchResultsMock.mock.calls.map(
|
|
125
|
+
(call: Array<unknown>): [number, number] => {
|
|
126
|
+
const args: CapturedFetch = call[0] as CapturedFetch;
|
|
127
|
+
return [
|
|
128
|
+
args.metricViewData.startAndEndDate!.startValue.getTime(),
|
|
129
|
+
args.metricViewData.startAndEndDate!.endValue.getTime(),
|
|
130
|
+
];
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* One live window and one shifted back by EXACTLY the window width
|
|
136
|
+
* (bucket-congruent modulo the shift). Alignment may floor the live
|
|
137
|
+
* start onto the bucket grid, so assert the relationship between the
|
|
138
|
+
* two fetches rather than raw instants.
|
|
139
|
+
*/
|
|
140
|
+
const [liveWindow, previousWindow] = windows as [
|
|
141
|
+
[number, number],
|
|
142
|
+
[number, number],
|
|
143
|
+
];
|
|
144
|
+
const liveWidth: number = liveWindow[1] - liveWindow[0];
|
|
145
|
+
expect(previousWindow[0]).toBe(liveWindow[0] - liveWidth);
|
|
146
|
+
expect(previousWindow[1]).toBe(liveWindow[1] - liveWidth);
|
|
147
|
+
expect(liveWidth).toBeGreaterThanOrEqual(WINDOW_MS);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
|
|
151
|
+
metricChartsMock.mock.calls.length - 1
|
|
152
|
+
]?.[0] as Record<string, unknown>;
|
|
153
|
+
expect(lastProps["metricResultsPrevious"]).toBeTruthy();
|
|
154
|
+
expect(lastProps["compareOffsetMs"]).toBe(liveWidth);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("compare off means exactly one fetch and no ghost props", async () => {
|
|
159
|
+
render(
|
|
160
|
+
<MetricView
|
|
161
|
+
data={buildData()}
|
|
162
|
+
hideQueryElements={true}
|
|
163
|
+
hideStartAndEndDate={true}
|
|
164
|
+
onChange={() => {
|
|
165
|
+
// not exercised
|
|
166
|
+
}}
|
|
167
|
+
/>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
expect(fetchResultsMock).toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
// Let any (wrong) shifted fetch get scheduled before asserting.
|
|
174
|
+
await new Promise((resolve: (value: unknown) => void) => {
|
|
175
|
+
setTimeout(resolve, 30);
|
|
176
|
+
});
|
|
177
|
+
/*
|
|
178
|
+
* MetricView may legitimately refetch the LIVE window more than once
|
|
179
|
+
* on mount — what compare-off forbids is any fetch of a SHIFTED
|
|
180
|
+
* (earlier) window.
|
|
181
|
+
*/
|
|
182
|
+
const anyShiftedFetch: boolean = fetchResultsMock.mock.calls.some(
|
|
183
|
+
(call: Array<unknown>): boolean => {
|
|
184
|
+
const args: CapturedFetch = call[0] as CapturedFetch;
|
|
185
|
+
return (
|
|
186
|
+
args.metricViewData.startAndEndDate!.endValue.getTime() <=
|
|
187
|
+
WINDOW_START.getTime()
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
expect(anyShiftedFetch).toBe(false);
|
|
192
|
+
const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
|
|
193
|
+
metricChartsMock.mock.calls.length - 1
|
|
194
|
+
]?.[0] as Record<string, unknown>;
|
|
195
|
+
expect(lastProps["metricResultsPrevious"]).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a failed shifted fetch degrades to live-only, never an error", async () => {
|
|
199
|
+
fetchResultsMock.mockImplementation(
|
|
200
|
+
(args: { metricViewData: MetricViewData }) => {
|
|
201
|
+
const start: number =
|
|
202
|
+
args.metricViewData.startAndEndDate!.startValue.getTime();
|
|
203
|
+
// The shifted (earlier) fetch fails; the live one succeeds.
|
|
204
|
+
if (start < WINDOW_START.getTime()) {
|
|
205
|
+
return Promise.reject(new Error("clickhouse hiccup"));
|
|
206
|
+
}
|
|
207
|
+
return Promise.resolve([{ data: [], truncated: false }]);
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
render(
|
|
212
|
+
<MetricView
|
|
213
|
+
data={buildData()}
|
|
214
|
+
hideQueryElements={true}
|
|
215
|
+
hideStartAndEndDate={true}
|
|
216
|
+
compareWithPreviousPeriod={true}
|
|
217
|
+
onChange={() => {
|
|
218
|
+
// not exercised
|
|
219
|
+
}}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(metricChartsMock).toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
|
|
228
|
+
metricChartsMock.mock.calls.length - 1
|
|
229
|
+
]?.[0] as Record<string, unknown>;
|
|
230
|
+
expect(lastProps["metricResults"]).toBeTruthy();
|
|
231
|
+
expect(lastProps["metricResultsPrevious"]).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -96,6 +96,14 @@ const AUTHENTICATION_STATUS: UserAuthenticationStatus = {
|
|
|
96
96
|
*/
|
|
97
97
|
twoFactorAuthStatus: TwoFactorAuthStatus.EnabledPendingSetup,
|
|
98
98
|
verifiedTwoFactorAuthMethodCount: 0,
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Reported alongside the method count, never folded into it -- backup codes
|
|
102
|
+
* are the way back in to an account whose factor is unreachable, not a
|
|
103
|
+
* factor. Zero here keeps this fixture the pure "mandated, nothing set up"
|
|
104
|
+
* case it exists to be.
|
|
105
|
+
*/
|
|
106
|
+
unusedTwoFactorBackupCodeCount: 0,
|
|
99
107
|
hasPendingPasswordResetLink: false,
|
|
100
108
|
};
|
|
101
109
|
|
|
@@ -201,9 +209,13 @@ describe("GET /user/:userId/authentication-status", () => {
|
|
|
201
209
|
|
|
202
210
|
test("sends exactly what the service reported", async () => {
|
|
203
211
|
/*
|
|
204
|
-
* The page renders these
|
|
205
|
-
*
|
|
206
|
-
*
|
|
212
|
+
* The page renders these fields directly. One dropped or renamed on the
|
|
213
|
+
* way out reads as `undefined`, which the UI shows as "no password" for an
|
|
214
|
+
* account that has one.
|
|
215
|
+
*
|
|
216
|
+
* Asserted as an EXACT object rather than field by field, so a new field
|
|
217
|
+
* added to the DTO has to be added here too -- which is how this test has
|
|
218
|
+
* done its job twice now, most recently for the backup code count.
|
|
207
219
|
*/
|
|
208
220
|
await callRoute({
|
|
209
221
|
method: "GET",
|
|
@@ -217,6 +229,7 @@ describe("GET /user/:userId/authentication-status", () => {
|
|
|
217
229
|
isTwoFactorAuthEnabled: true,
|
|
218
230
|
twoFactorAuthStatus: TwoFactorAuthStatus.EnabledPendingSetup,
|
|
219
231
|
verifiedTwoFactorAuthMethodCount: 0,
|
|
232
|
+
unusedTwoFactorBackupCodeCount: 0,
|
|
220
233
|
hasPendingPasswordResetLink: false,
|
|
221
234
|
});
|
|
222
235
|
});
|