@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.
Files changed (119) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/Index.ts +2 -0
  4. package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
  5. package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
  6. package/Server/EnvironmentConfig.ts +1 -8
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/ChangeEventService.ts +11 -0
  11. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  12. package/Server/Services/Index.ts +4 -0
  13. package/Server/Services/UserService.ts +42 -0
  14. package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
  15. package/Server/Utils/Attribution.ts +11 -11
  16. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  17. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  18. package/Server/Utils/TwoFactorBackupCode.ts +266 -0
  19. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  20. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  21. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  26. package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
  27. package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
  28. package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
  29. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  30. package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
  31. package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
  32. package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
  33. package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
  34. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  35. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  36. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  37. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  38. package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
  39. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  40. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  41. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  42. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  43. package/Types/Email/EmailTemplateType.ts +2 -0
  44. package/Types/Marketing/Attribution.ts +9 -15
  45. package/Types/Marketing/MarketingEvent.ts +10 -46
  46. package/Types/UserAuthenticationStatus.ts +16 -0
  47. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  48. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  49. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  50. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  51. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  52. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  53. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  54. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  55. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  56. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  57. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  58. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  59. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  60. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  61. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  62. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
  63. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
  64. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
  65. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
  66. package/build/dist/Server/EnvironmentConfig.js +1 -7
  67. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  68. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  74. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  75. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  76. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  77. package/build/dist/Server/Services/Index.js +4 -0
  78. package/build/dist/Server/Services/Index.js.map +1 -1
  79. package/build/dist/Server/Services/UserService.js +39 -0
  80. package/build/dist/Server/Services/UserService.js.map +1 -1
  81. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
  82. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
  83. package/build/dist/Server/Utils/Attribution.js +6 -6
  84. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  85. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  86. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  88. package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
  89. package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
  90. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  92. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  93. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  94. package/build/dist/Types/Marketing/Attribution.js +9 -15
  95. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  96. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  97. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  99. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  100. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  102. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  104. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  106. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  108. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  110. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  112. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  113. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  114. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  115. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  116. package/package.json +1 -1
  117. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  119. 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 four booleans directly. A field dropped or
205
- * renamed on the way out reads as `undefined`, which the UI shows as "no
206
- * password" for an account that has one.
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
  });