@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,1390 @@
1
+ import UserTwoFactorBackupCodeAPI from "../../../Server/API/UserTwoFactorBackupCodeAPI";
2
+ import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
3
+ import UserTwoFactorBackupCodeService, {
4
+ TwoFactorBackupCodeStatus,
5
+ } from "../../../Server/Services/UserTwoFactorBackupCodeService";
6
+ import TwoFactorBackupCode from "../../../Server/Utils/TwoFactorBackupCode";
7
+ import {
8
+ NextFunction,
9
+ OneUptimeRequest,
10
+ OneUptimeResponse,
11
+ } from "../../../Server/Utils/Express";
12
+ import logger from "../../../Server/Utils/Logger";
13
+ import MailService from "../../../Server/Services/MailService";
14
+ import UserService from "../../../Server/Services/UserService";
15
+ import DatabaseConfig from "../../../Server/DatabaseConfig";
16
+ import User from "../../../Models/DatabaseModels/User";
17
+ import Email from "../../../Types/Email";
18
+ import EmailTemplateType from "../../../Types/Email/EmailTemplateType";
19
+ import Hostname from "../../../Types/API/Hostname";
20
+ import Protocol from "../../../Types/API/Protocol";
21
+ import Response from "../../../Server/Utils/Response";
22
+ import { mockRouter } from "./Helpers";
23
+ import { getJestSpyOn } from "../../Spy";
24
+ import Dictionary from "../../../Types/Dictionary";
25
+ import NotAuthenticatedException from "../../../Types/Exception/NotAuthenticatedException";
26
+ import { JSONObject } from "../../../Types/JSON";
27
+ import ObjectID from "../../../Types/ObjectID";
28
+ import {
29
+ afterEach,
30
+ beforeAll,
31
+ beforeEach,
32
+ describe,
33
+ expect,
34
+ test,
35
+ } from "@jest/globals";
36
+
37
+ /*
38
+ * The two self-service routes a signed-in user drives from their profile page:
39
+ * POST /user-two-factor-backup-code/generate and
40
+ * GET /user-two-factor-backup-code/status.
41
+ *
42
+ * WHAT THIS FILE GUARDS
43
+ *
44
+ * Three failure modes, all of which leave the suite green if nobody asserts
45
+ * them here.
46
+ *
47
+ * 1. THE OWNER IS TAKEN FROM THE TOKEN, NOT FROM THE REQUEST. There is no
48
+ * `:userId` in either path today, which is what makes both routes safe --
49
+ * but "safe because there is no parameter to abuse" is a property that
50
+ * evaporates the moment somebody adds one, or reaches for
51
+ * `req.body["userId"]` to make the admin page reuse the endpoint. Two
52
+ * matrices below therefore send a DIFFERENT user id in the params, the
53
+ * query and the body at once and assert the service was still called with
54
+ * the id off the access token. Generate is destructive -- it throws away
55
+ * every code the caller was holding -- so a route steered by its own body
56
+ * would let any signed-in user strip a stranger's recovery codes with one
57
+ * request, and the victim would find out at the next sign-in they could
58
+ * not complete.
59
+ *
60
+ * 2. THE PLAINTEXT CODES NEVER REACH THE LOGGER. `regenerateForUser` returns
61
+ * the only copy of the codes that will ever exist, and the handler logs a
62
+ * line right next to it. Widening that line to "regenerated codes: ..."
63
+ * while debugging is a one-word edit that puts sign-in credentials into
64
+ * stdout, the recent-log buffer and telemetry at once, and no other test
65
+ * in the repository would notice.
66
+ *
67
+ * 3. `replacedCodeCount` IS READ BEFORE THE OLD SET IS DESTROYED. Read
68
+ * afterwards it reports the size of the set that was just MINTED, so the
69
+ * page tells a user "10 codes replaced" whether or not they had any --
70
+ * and the first-time user who had none is told they have just invalidated
71
+ * a list they should go and find.
72
+ *
73
+ * WHAT IS MOCKED, AND WHAT IS DELIBERATELY NOT
74
+ *
75
+ * `UserTwoFactorBackupCodeService` is spied on at the singleton, so nothing
76
+ * touches Postgres and the plaintext codes are fixed strings this file can
77
+ * search the logger output for.
78
+ *
79
+ * `TwoFactorBackupCode` is NOT mocked. `formatForDisplay` is the thing the
80
+ * generate route's contract with the page is made of -- the hyphen is what
81
+ * makes a ten character run transcribable off a screen -- and a stubbed
82
+ * formatter would make the assertion about it circular.
83
+ *
84
+ * `Response` is mocked so payloads can be read off the call, and `logger` is
85
+ * spied on rather than silenced so its arguments can be inspected.
86
+ *
87
+ * SIBLING FILES, SO NOTHING HERE IS DUPLICATED
88
+ *
89
+ * - Common/Tests/Server/Utils/TwoFactorBackupCode.test.ts owns the alphabet,
90
+ * the code space, the HMAC construction and normalization. The ONLY thing
91
+ * this file borrows from that module is `formatForDisplay`, and only as the
92
+ * route's output contract.
93
+ * - Common/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts owns
94
+ * minting, the single conditional UPDATE that makes a code single-use, and
95
+ * the counting queries. Both service methods are stubbed here.
96
+ * - Common/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts
97
+ * owns the UserService side: the reset that must take the codes with it,
98
+ * and `unusedTwoFactorBackupCodeCount` on the authentication status.
99
+ * - App/Tests/FeatureSet/Identity/BackupCodeLoginVerification.test.ts owns
100
+ * POST /verify-backup-code -- the SPENDING of a code. Nothing about login
101
+ * is exercised from here.
102
+ * - Common/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts owns the
103
+ * master-admin routes that drive somebody ELSE's two factor auth, and
104
+ * Common/Tests/Server/API/UserTotpAuthAPI.test.ts owns the other
105
+ * self-service two factor route -- including the ownership check it needs
106
+ * BECAUSE it takes a record id from the body, which is the design the two
107
+ * routes here avoid having to get right.
108
+ *
109
+ * What is left, and what this file is entirely about, is the two HTTP handlers
110
+ * around all of that.
111
+ */
112
+
113
+ jest.mock("../../../Server/Utils/Express", () => {
114
+ return {
115
+ getRouter: () => {
116
+ return mockRouter;
117
+ },
118
+ };
119
+ });
120
+
121
+ jest.mock("../../../Server/Utils/Response", () => {
122
+ return {
123
+ sendEntityArrayResponse: jest.fn(),
124
+ sendJsonArrayResponse: jest.fn(),
125
+ sendJsonObjectResponse: jest.fn(),
126
+ sendEmptySuccessResponse: jest.fn(),
127
+ sendEntityResponse: jest.fn(),
128
+ sendErrorResponse: jest.fn(),
129
+ sendFileResponse: jest.fn(),
130
+ sendFileByPath: jest.fn(),
131
+ setNoCacheHeaders: jest.fn(),
132
+ };
133
+ });
134
+
135
+ const GENERATE_ROUTE: string = "/user-two-factor-backup-code/generate";
136
+ const STATUS_ROUTE: string = "/user-two-factor-backup-code/status";
137
+
138
+ const CALLER_ID: ObjectID = new ObjectID(
139
+ "11111111-1111-4111-8111-111111111111",
140
+ );
141
+
142
+ /*
143
+ * The id an attacker puts in the body, the params and the query hoping the
144
+ * handler reads one of them instead of the token.
145
+ */
146
+ const SOMEBODY_ELSE_ID: string = "22222222-2222-4222-8222-222222222222";
147
+
148
+ /*
149
+ * What the service hands back. Fixed rather than generated so the logger
150
+ * assertion has an exact needle to search for -- a random code would make
151
+ * "the codes were not logged" depend on a string this file cannot pin down.
152
+ *
153
+ * Ten of them because that is the real set size, but the count is the
154
+ * service's business: the route maps over whatever it is given.
155
+ */
156
+ const PLAINTEXT_CODES: Array<string> = [
157
+ "2W9XKQ4M7B",
158
+ "H3TRZ5D8NC",
159
+ "P6JVG2YK4S",
160
+ "B8NQ7MXW3T",
161
+ "F5RD9CZH2K",
162
+ "T4KY6BVN8P",
163
+ "M2XS5GQJ7W",
164
+ "C9HB3NPR6D",
165
+ "Z7VM4TKG5X",
166
+ "K3PC8WYS9N",
167
+ ];
168
+
169
+ /*
170
+ * The shape the page renders as-is: two groups of five over the backup code
171
+ * alphabet, joined by the hyphen that makes a ten character run transcribable
172
+ * off a screen. Written out as a literal here rather than derived from
173
+ * `formatForDisplay`, so that a formatter reduced to the identity function
174
+ * fails the assertion instead of agreeing with itself.
175
+ */
176
+ const DISPLAY_FORM: RegExp = /^[0-9A-Z]{5}-[0-9A-Z]{5}$/;
177
+
178
+ const PREVIOUS_SET_GENERATED_AT: Date = new Date("2026-01-02T03:04:05.678Z");
179
+ const NEW_SET_GENERATED_AT: Date = new Date("2026-08-25T09:10:11.222Z");
180
+
181
+ /*
182
+ * What the user was holding before they pressed the button: a partly spent set
183
+ * of four. Both numbers are deliberately NOT ten, so a handler that reported
184
+ * the size of the new set instead of the old one is visible in the assertion
185
+ * rather than coincidentally equal to it.
186
+ */
187
+ const PREVIOUS_STATUS: TwoFactorBackupCodeStatus = {
188
+ total: 4,
189
+ unused: 1,
190
+ generatedAt: PREVIOUS_SET_GENERATED_AT,
191
+ };
192
+
193
+ const FRESH_STATUS: TwoFactorBackupCodeStatus = {
194
+ total: PLAINTEXT_CODES.length,
195
+ unused: PLAINTEXT_CODES.length,
196
+ generatedAt: NEW_SET_GENERATED_AT,
197
+ };
198
+
199
+ type RouteCallResult = {
200
+ thrownToNext: unknown;
201
+ nextCallCount: number;
202
+ };
203
+
204
+ type CallRouteOptions = {
205
+ method: string;
206
+ route: string;
207
+ params?: Dictionary<string> | undefined;
208
+ query?: Dictionary<string> | undefined;
209
+ body?: Dictionary<unknown> | undefined;
210
+
211
+ /*
212
+ * Spliced onto the request in place of the default signed-in caller. An
213
+ * EMPTY object is a request with no `userAuthorization` property at all,
214
+ * which is what reaches the handler if the middleware in front of it is ever
215
+ * removed.
216
+ */
217
+ authorization?: Dictionary<unknown> | undefined;
218
+ };
219
+
220
+ type CallRouteFunction = (data: CallRouteOptions) => Promise<RouteCallResult>;
221
+
222
+ const callRoute: CallRouteFunction = async (
223
+ data: CallRouteOptions,
224
+ ): Promise<RouteCallResult> => {
225
+ const req: OneUptimeRequest = {
226
+ params: data.params || {},
227
+ query: data.query || {},
228
+ body: data.body || {},
229
+ headers: {},
230
+ ...(data.authorization || { userAuthorization: { userId: CALLER_ID } }),
231
+ } as unknown as OneUptimeRequest;
232
+
233
+ const res: OneUptimeResponse = {
234
+ send: jest.fn(),
235
+ json: jest.fn(),
236
+ setHeader: jest.fn(),
237
+ status: jest.fn().mockReturnThis(),
238
+ } as unknown as OneUptimeResponse;
239
+
240
+ const next: jest.Mock = jest.fn();
241
+
242
+ await mockRouter
243
+ .match(data.method, data.route)
244
+ .handlerFunction(req, res, next as unknown as NextFunction);
245
+
246
+ return {
247
+ thrownToNext: next.mock.calls[0] ? next.mock.calls[0][0] : undefined,
248
+ nextCallCount: next.mock.calls.length,
249
+ };
250
+ };
251
+
252
+ type AsMockFunction = (fn: unknown) => jest.Mock;
253
+
254
+ const asMock: AsMockFunction = (fn: unknown): jest.Mock => {
255
+ return fn as unknown as jest.Mock;
256
+ };
257
+
258
+ type SentJsonObjectFunction = () => JSONObject;
259
+
260
+ const sentJsonObject: SentJsonObjectFunction = (): JSONObject => {
261
+ const call: Array<unknown> | undefined = asMock(
262
+ Response.sendJsonObjectResponse,
263
+ ).mock.calls[0];
264
+
265
+ return (call?.[2] as JSONObject) || {};
266
+ };
267
+
268
+ type ServiceCallArgument = { userId: ObjectID };
269
+
270
+ type FirstServiceArgumentFunction = (
271
+ spy: jest.SpyInstance,
272
+ ) => ServiceCallArgument;
273
+
274
+ const firstServiceArgument: FirstServiceArgumentFunction = (
275
+ spy: jest.SpyInstance,
276
+ ): ServiceCallArgument => {
277
+ return spy.mock.calls[0]![0] as ServiceCallArgument;
278
+ };
279
+
280
+ /*
281
+ * Every level, not just `info`. The handler logs through `info` today, but a
282
+ * future `logger.debug("codes", codes)` added while chasing a support ticket
283
+ * is exactly the edit this guard exists to catch, and it would not go through
284
+ * `info`.
285
+ */
286
+ type LoggerLevel = "info" | "warn" | "error" | "debug" | "trace";
287
+
288
+ const LOGGER_LEVELS: Array<LoggerLevel> = [
289
+ "info",
290
+ "warn",
291
+ "error",
292
+ "debug",
293
+ "trace",
294
+ ];
295
+
296
+ let loggerSpies: Array<jest.SpyInstance> = [];
297
+
298
+ type StringifyLogArgumentFunction = (value: unknown) => string;
299
+
300
+ const stringifyLogArgument: StringifyLogArgumentFunction = (
301
+ value: unknown,
302
+ ): string => {
303
+ if (typeof value === "string") {
304
+ return value;
305
+ }
306
+
307
+ try {
308
+ return JSON.stringify(value) || String(value);
309
+ } catch {
310
+ return String(value);
311
+ }
312
+ };
313
+
314
+ /*
315
+ * Everything the handler passed to the logger, flattened into one string so a
316
+ * code can be searched for wherever it might have been smuggled -- a template
317
+ * literal, a second attributes argument, or an object nested inside one.
318
+ */
319
+ type LoggerHaystackFunction = () => string;
320
+
321
+ const loggerHaystack: LoggerHaystackFunction = (): string => {
322
+ const lines: Array<string> = [];
323
+
324
+ for (const spy of loggerSpies) {
325
+ for (const call of spy.mock.calls) {
326
+ lines.push(
327
+ (call as Array<unknown>)
328
+ .map((argument: unknown): string => {
329
+ return stringifyLogArgument(argument);
330
+ })
331
+ .join(" "),
332
+ );
333
+ }
334
+ }
335
+
336
+ return lines.join("\n");
337
+ };
338
+
339
+ type LoggerCallCountFunction = () => number;
340
+
341
+ const loggerCallCount: LoggerCallCountFunction = (): number => {
342
+ return loggerSpies.reduce((total: number, spy: jest.SpyInstance): number => {
343
+ return total + spy.mock.calls.length;
344
+ }, 0);
345
+ };
346
+
347
+ /*
348
+ * A signed-in caller trying to aim either route at somebody else, through
349
+ * every channel a handler could plausibly read an id from. The assertion is
350
+ * always the same: the service saw the TOKEN's id.
351
+ */
352
+ type ImpersonationAttempt = {
353
+ params: Dictionary<string>;
354
+ query: Dictionary<string>;
355
+ body: Dictionary<unknown>;
356
+ };
357
+
358
+ const IMPERSONATION_ATTEMPTS: Array<[string, ImpersonationAttempt]> = [
359
+ [
360
+ "a userId in the body",
361
+ { params: {}, query: {}, body: { userId: SOMEBODY_ELSE_ID } },
362
+ ],
363
+ [
364
+ "a userId in the route params",
365
+ { params: { userId: SOMEBODY_ELSE_ID }, query: {}, body: {} },
366
+ ],
367
+ [
368
+ "a userId in the query string",
369
+ { params: {}, query: { userId: SOMEBODY_ELSE_ID }, body: {} },
370
+ ],
371
+ [
372
+ "an id and a _id as well as a userId",
373
+ {
374
+ params: { id: SOMEBODY_ELSE_ID, userId: SOMEBODY_ELSE_ID },
375
+ query: { _id: SOMEBODY_ELSE_ID },
376
+ body: {
377
+ id: SOMEBODY_ELSE_ID,
378
+ _id: SOMEBODY_ELSE_ID,
379
+ userId: SOMEBODY_ELSE_ID,
380
+ },
381
+ },
382
+ ],
383
+ [
384
+ "a props.isRoot smuggled alongside the userId",
385
+ {
386
+ params: {},
387
+ query: {},
388
+ body: { userId: SOMEBODY_ELSE_ID, props: { isRoot: true } },
389
+ },
390
+ ],
391
+ ];
392
+
393
+ /*
394
+ * Requests that must not reach the service at all. `userAuthorization` is put
395
+ * there by UserMiddleware; every shape below is what the handler sees if that
396
+ * middleware is removed, short-circuited, or fails open -- and the handler is
397
+ * the second lock, so it has to refuse rather than build an ObjectID out of
398
+ * nothing.
399
+ */
400
+ const UNAUTHENTICATED_REQUESTS: Array<[string, Dictionary<unknown>]> = [
401
+ ["the userAuthorization property is absent entirely", {}],
402
+ ["userAuthorization is undefined", { userAuthorization: undefined }],
403
+ ["userAuthorization is null", { userAuthorization: null }],
404
+ ["userAuthorization carries no userId", { userAuthorization: {} }],
405
+ [
406
+ "userAuthorization.userId is undefined",
407
+ { userAuthorization: { userId: undefined } },
408
+ ],
409
+ ["userAuthorization.userId is null", { userAuthorization: { userId: null } }],
410
+ [
411
+ "userAuthorization.userId is an empty string",
412
+ { userAuthorization: { userId: "" } },
413
+ ],
414
+ ];
415
+
416
+ let getStatusSpy: jest.SpyInstance;
417
+ let regenerateSpy: jest.SpyInstance;
418
+ let sendMailSpy: jest.SpyInstance;
419
+ let findUserSpy: jest.SpyInstance;
420
+
421
+ /* The address the notification is expected to reach. */
422
+ const OWNER_EMAIL: string = "owner@example.com";
423
+
424
+ /*
425
+ * Let the DETACHED notification run.
426
+ *
427
+ * `notifyCodesRegenerated` is fired without being awaited, so that an
428
+ * unreachable mail server cannot fail a regeneration whose response carries
429
+ * the only copy of the new codes. That also puts it out of reach of a plain
430
+ * `await` on the handler, so the queue has to be drained explicitly rather
431
+ * than relied on to have drained by luck.
432
+ */
433
+ type FlushDetachedWorkFunction = () => Promise<void>;
434
+
435
+ const flushDetachedWork: FlushDetachedWorkFunction =
436
+ async (): Promise<void> => {
437
+ for (let tick: number = 0; tick < 10; tick++) {
438
+ await Promise.resolve();
439
+ }
440
+ };
441
+
442
+ beforeAll(() => {
443
+ mockRouter.routes.length = 0;
444
+ new UserTwoFactorBackupCodeAPI();
445
+ });
446
+
447
+ beforeEach(() => {
448
+ jest.clearAllMocks();
449
+
450
+ getStatusSpy = getJestSpyOn(
451
+ UserTwoFactorBackupCodeService,
452
+ "getStatusForUser",
453
+ );
454
+ getStatusSpy.mockResolvedValue(PREVIOUS_STATUS as never);
455
+
456
+ regenerateSpy = getJestSpyOn(
457
+ UserTwoFactorBackupCodeService,
458
+ "regenerateForUser",
459
+ );
460
+ regenerateSpy.mockResolvedValue(PLAINTEXT_CODES as never);
461
+
462
+ /*
463
+ * The regeneration notification reads the owner's address and mails it.
464
+ * Stubbed rather than left real because there is no database and no SMTP
465
+ * here -- and because an un-stubbed detached failure would surface as an
466
+ * unhandled rejection in an unrelated test.
467
+ */
468
+ findUserSpy = getJestSpyOn(UserService, "findOneById");
469
+ findUserSpy.mockImplementation(async (): Promise<User> => {
470
+ const user: User = new User();
471
+ user.email = new Email(OWNER_EMAIL);
472
+ return user;
473
+ });
474
+
475
+ sendMailSpy = getJestSpyOn(MailService, "sendMail");
476
+ sendMailSpy.mockResolvedValue(undefined as never);
477
+
478
+ getJestSpyOn(DatabaseConfig, "getHost").mockImplementation(
479
+ async (): Promise<Hostname> => {
480
+ return new Hostname("localhost");
481
+ },
482
+ );
483
+
484
+ getJestSpyOn(DatabaseConfig, "getHttpProtocol").mockImplementation(
485
+ async (): Promise<Protocol> => {
486
+ return Protocol.HTTP;
487
+ },
488
+ );
489
+
490
+ loggerSpies = LOGGER_LEVELS.map((level: LoggerLevel): jest.SpyInstance => {
491
+ return getJestSpyOn(logger, level).mockImplementation((): void => {
492
+ return undefined;
493
+ });
494
+ });
495
+ });
496
+
497
+ afterEach(() => {
498
+ jest.restoreAllMocks();
499
+ });
500
+
501
+ describe("UserTwoFactorBackupCodeAPI route registration", () => {
502
+ test("registers both routes", () => {
503
+ /*
504
+ * A typo in either path is not a compile error. It is a 404 the profile
505
+ * page surfaces as an unexplained failure the first time somebody tries to
506
+ * find out whether they have any recovery codes.
507
+ */
508
+ const violations: Array<string> = [];
509
+
510
+ if (
511
+ !mockRouter.routes.some((route: { method: string; uri: string }) => {
512
+ return route.method === "POST" && route.uri === GENERATE_ROUTE;
513
+ })
514
+ ) {
515
+ violations.push(`POST ${GENERATE_ROUTE} is not registered`);
516
+ }
517
+
518
+ if (
519
+ !mockRouter.routes.some((route: { method: string; uri: string }) => {
520
+ return route.method === "GET" && route.uri === STATUS_ROUTE;
521
+ })
522
+ ) {
523
+ violations.push(`GET ${STATUS_ROUTE} is not registered`);
524
+ }
525
+
526
+ expect(violations).toEqual([]);
527
+ });
528
+
529
+ test("generate is a POST and not a GET", () => {
530
+ /*
531
+ * Generating throws away every code the user is currently holding. As a
532
+ * GET, a browser prefetch or a link crawler would silently void a list
533
+ * somebody printed -- a lockout with no user action behind it.
534
+ */
535
+ expect(() => {
536
+ return mockRouter.match("GET", GENERATE_ROUTE);
537
+ }).toThrow();
538
+ });
539
+
540
+ test("both routes put UserMiddleware.getUserMiddleware in front of the handler", () => {
541
+ /*
542
+ * This is the middleware that puts `userAuthorization` on the request.
543
+ * Without it the handler's own check would be the only lock, and while
544
+ * that check does refuse an anonymous caller, the codes route would then
545
+ * be answering unauthenticated traffic all the way down to the point of
546
+ * refusal. Asserted by IDENTITY rather than by counting: swapping it for a
547
+ * different middleware keeps the count at one.
548
+ */
549
+ const violations: Array<string> = [];
550
+
551
+ const routes: Array<[string, string]> = [
552
+ ["POST", GENERATE_ROUTE],
553
+ ["GET", STATUS_ROUTE],
554
+ ];
555
+
556
+ for (const [method, uri] of routes) {
557
+ const route: {
558
+ middlewares: Array<unknown>;
559
+ middleware: unknown;
560
+ handlerFunction: unknown;
561
+ } = mockRouter.match(method, uri);
562
+
563
+ if (route.middlewares.length !== 1) {
564
+ violations.push(
565
+ `${method} ${uri} has ${route.middlewares.length} middlewares, expected exactly 1`,
566
+ );
567
+ }
568
+
569
+ if (route.middleware !== UserMiddleware.getUserMiddleware) {
570
+ violations.push(
571
+ `${method} ${uri} is not gated by UserMiddleware.getUserMiddleware`,
572
+ );
573
+ }
574
+
575
+ if (route.handlerFunction === UserMiddleware.getUserMiddleware) {
576
+ violations.push(
577
+ `${method} ${uri} registered the middleware as its handler -- the handler is missing`,
578
+ );
579
+ }
580
+
581
+ if (typeof route.handlerFunction !== "function") {
582
+ violations.push(`${method} ${uri} has no handler function`);
583
+ }
584
+ }
585
+
586
+ expect(violations).toEqual([]);
587
+ });
588
+ });
589
+
590
+ describe("POST /user-two-factor-backup-code/generate", () => {
591
+ test("returns the plaintext codes the service minted", async () => {
592
+ const result: RouteCallResult = await callRoute({
593
+ method: "POST",
594
+ route: GENERATE_ROUTE,
595
+ });
596
+
597
+ const payload: JSONObject = sentJsonObject();
598
+
599
+ expect(result.nextCallCount).toBe(0);
600
+ expect(payload["codes"]).toEqual(
601
+ PLAINTEXT_CODES.map((code: string): string => {
602
+ return TwoFactorBackupCode.formatForDisplay(code);
603
+ }),
604
+ );
605
+ });
606
+
607
+ test("hands back every code, in order, with none dropped", async () => {
608
+ /*
609
+ * The response is the ONLY copy. A map that quietly lost one -- a filter
610
+ * that crept in, a slice for a "preview" -- means the user prints nine
611
+ * codes and the tenth exists only as a digest nobody can ever satisfy.
612
+ */
613
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
614
+
615
+ const codes: Array<string> = sentJsonObject()["codes"] as Array<string>;
616
+
617
+ expect(codes).toHaveLength(PLAINTEXT_CODES.length);
618
+ expect(
619
+ codes.map((code: string): string => {
620
+ return code.replace("-", "");
621
+ }),
622
+ ).toEqual(PLAINTEXT_CODES);
623
+ });
624
+
625
+ test("formats every code for display rather than shipping the raw run", async () => {
626
+ /*
627
+ * The hyphen is the whole reason `formatForDisplay` exists: a ten
628
+ * character run is transcribed wrongly off a screen, and these get typed
629
+ * back in months later off a piece of paper. Asserted against a literal
630
+ * pattern rather than by calling the formatter again, so that a formatter
631
+ * reduced to the identity function fails here instead of agreeing with
632
+ * itself.
633
+ */
634
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
635
+
636
+ const codes: Array<string> = sentJsonObject()["codes"] as Array<string>;
637
+
638
+ const violations: Array<string> = [];
639
+
640
+ codes.forEach((code: string, index: number): void => {
641
+ if (!DISPLAY_FORM.test(code)) {
642
+ violations.push(`code ${index} is not in the display form: ${code}`);
643
+ }
644
+
645
+ if (code === PLAINTEXT_CODES[index]) {
646
+ violations.push(
647
+ `code ${index} was sent unformatted -- formatForDisplay was not applied`,
648
+ );
649
+ }
650
+ });
651
+
652
+ expect(violations).toEqual([]);
653
+ expect(codes.length).toBeGreaterThan(0);
654
+ });
655
+
656
+ test("regenerates for the caller on the access token", async () => {
657
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
658
+
659
+ expect(regenerateSpy).toHaveBeenCalledTimes(1);
660
+ expect(firstServiceArgument(regenerateSpy).userId.toString()).toBe(
661
+ CALLER_ID.toString(),
662
+ );
663
+ });
664
+
665
+ test.each(IMPERSONATION_ATTEMPTS)(
666
+ "regenerates for the token's user even when the request carries %s",
667
+ async (_label: string, attempt: ImpersonationAttempt): Promise<void> => {
668
+ /*
669
+ * The destructive direction of the ownership rule. A handler that read
670
+ * an id off the request would let any signed-in user throw away a
671
+ * stranger's recovery codes, and the victim only finds out at the sign-in
672
+ * they can no longer complete.
673
+ */
674
+ await callRoute({
675
+ method: "POST",
676
+ route: GENERATE_ROUTE,
677
+ params: attempt.params,
678
+ query: attempt.query,
679
+ body: attempt.body,
680
+ });
681
+
682
+ const violations: Array<string> = [];
683
+
684
+ if (regenerateSpy.mock.calls.length !== 1) {
685
+ violations.push(
686
+ `regenerateForUser was called ${regenerateSpy.mock.calls.length} times, expected exactly 1`,
687
+ );
688
+ }
689
+
690
+ const suppliedUserId: string =
691
+ firstServiceArgument(regenerateSpy).userId.toString();
692
+
693
+ if (suppliedUserId === SOMEBODY_ELSE_ID) {
694
+ violations.push(
695
+ "regenerateForUser was aimed at the id supplied by the request",
696
+ );
697
+ }
698
+
699
+ if (suppliedUserId !== CALLER_ID.toString()) {
700
+ violations.push(
701
+ `regenerateForUser was called with ${suppliedUserId}, expected the token's ${CALLER_ID.toString()}`,
702
+ );
703
+ }
704
+
705
+ const statusUserId: string =
706
+ firstServiceArgument(getStatusSpy).userId.toString();
707
+
708
+ if (statusUserId !== CALLER_ID.toString()) {
709
+ violations.push(
710
+ `getStatusForUser was called with ${statusUserId}, expected the token's ${CALLER_ID.toString()}`,
711
+ );
712
+ }
713
+
714
+ expect(violations).toEqual([]);
715
+ },
716
+ );
717
+
718
+ test("passes exactly a userId to the service and nothing else", async () => {
719
+ /*
720
+ * `regenerateForUser` also takes a `count`. Forwarded from the body, a
721
+ * caller could ask for one code -- an account with a single recovery code
722
+ * is one bad sign-in away from needing an administrator -- or for a number
723
+ * large enough to make the request a write amplification lever.
724
+ */
725
+ await callRoute({
726
+ method: "POST",
727
+ route: GENERATE_ROUTE,
728
+ body: { count: 1, userId: SOMEBODY_ELSE_ID, props: { isRoot: true } },
729
+ });
730
+
731
+ expect(Object.keys(firstServiceArgument(regenerateSpy))).toEqual([
732
+ "userId",
733
+ ]);
734
+ });
735
+
736
+ test("regenerates exactly once per request", async () => {
737
+ /*
738
+ * Two calls would mean the codes returned to the user are from the FIRST
739
+ * set and the codes in the database are from the second -- every code the
740
+ * page just displayed already invalid.
741
+ */
742
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
743
+
744
+ expect(regenerateSpy).toHaveBeenCalledTimes(1);
745
+ expect(asMock(Response.sendJsonObjectResponse)).toHaveBeenCalledTimes(1);
746
+ });
747
+
748
+ test("reports replacedCodeCount from the status read BEFORE regenerating", async () => {
749
+ /*
750
+ * The status here is LIVE: regenerating swaps what `getStatusForUser`
751
+ * would answer. A handler that read the count after minting the new set
752
+ * would report ten and the page would tell a first-time user they had just
753
+ * invalidated a list they never had.
754
+ */
755
+ const store: { current: TwoFactorBackupCodeStatus } = {
756
+ current: PREVIOUS_STATUS,
757
+ };
758
+
759
+ getStatusSpy.mockImplementation(
760
+ async (): Promise<TwoFactorBackupCodeStatus> => {
761
+ return store.current;
762
+ },
763
+ );
764
+
765
+ regenerateSpy.mockImplementation(async (): Promise<Array<string>> => {
766
+ store.current = FRESH_STATUS;
767
+ return PLAINTEXT_CODES;
768
+ });
769
+
770
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
771
+
772
+ const payload: JSONObject = sentJsonObject();
773
+
774
+ const violations: Array<string> = [];
775
+
776
+ if (payload["replacedCodeCount"] !== PREVIOUS_STATUS.total) {
777
+ violations.push(
778
+ `replacedCodeCount was ${String(payload["replacedCodeCount"])}, expected the previous set's ${PREVIOUS_STATUS.total}`,
779
+ );
780
+ }
781
+
782
+ if (payload["replacedCodeCount"] === FRESH_STATUS.total) {
783
+ violations.push(
784
+ "replacedCodeCount reports the size of the set that was just minted",
785
+ );
786
+ }
787
+
788
+ const statusOrder: number = getStatusSpy.mock.invocationCallOrder[0]!;
789
+ const regenerateOrder: number = regenerateSpy.mock.invocationCallOrder[0]!;
790
+
791
+ if (statusOrder > regenerateOrder) {
792
+ violations.push("the status was read after the old set was destroyed");
793
+ }
794
+
795
+ expect(violations).toEqual([]);
796
+ });
797
+
798
+ test("reports zero replaced codes for a user who had none", async () => {
799
+ /*
800
+ * The first-time case, and the one the number is most useful in: the page
801
+ * must not warn somebody that they have invalidated codes they never
802
+ * generated. Zero is also the value most easily lost on the way out, by a
803
+ * serializer that drops falsy fields.
804
+ */
805
+ getStatusSpy.mockResolvedValue({
806
+ total: 0,
807
+ unused: 0,
808
+ generatedAt: null,
809
+ } as never);
810
+
811
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
812
+
813
+ const payload: JSONObject = sentJsonObject();
814
+
815
+ expect(Object.keys(payload)).toContain("replacedCodeCount");
816
+ expect(payload["replacedCodeCount"]).toBe(0);
817
+ });
818
+
819
+ test("sends only the codes and the replaced count", async () => {
820
+ /*
821
+ * Nothing else belongs in this payload. The status object it is built from
822
+ * is the service's, and forwarding it wholesale is how a field nobody
823
+ * reviewed ends up on the wire next to the credentials.
824
+ */
825
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
826
+
827
+ expect(Object.keys(sentJsonObject()).sort()).toEqual([
828
+ "codes",
829
+ "replacedCodeCount",
830
+ ]);
831
+ });
832
+
833
+ test("never passes the plaintext codes to the logger", async () => {
834
+ /*
835
+ * THE reason this file exists. Logger output reaches stdout, the
836
+ * recent-log buffer and telemetry at once, and a backup code is a
837
+ * password-equivalent credential -- one in a log line is a sign-in
838
+ * somebody else can complete. The fact that a set was minted IS worth
839
+ * recording, so the assertion is about the codes, not about silence.
840
+ */
841
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
842
+
843
+ const haystack: string = loggerHaystack();
844
+
845
+ const violations: Array<string> = [];
846
+
847
+ for (const code of PLAINTEXT_CODES) {
848
+ if (haystack.includes(code)) {
849
+ violations.push(`a plaintext backup code reached the logger: ${code}`);
850
+ }
851
+
852
+ const displayForm: string = TwoFactorBackupCode.formatForDisplay(code);
853
+
854
+ if (haystack.includes(displayForm)) {
855
+ violations.push(
856
+ `a backup code reached the logger in its display form: ${displayForm}`,
857
+ );
858
+ }
859
+ }
860
+
861
+ expect(violations).toEqual([]);
862
+
863
+ /*
864
+ * The guards that stop the assertion above from passing vacuously. If the
865
+ * spies stopped capturing -- a renamed logger, a handler that stopped
866
+ * logging, a `restoreAllMocks` in the wrong place -- the haystack would be
867
+ * empty and `includes` would find nothing no matter what was logged. The
868
+ * user id is in the real log line, so finding it proves the haystack is
869
+ * the handler's actual output.
870
+ */
871
+ expect(loggerCallCount()).toBeGreaterThan(0);
872
+ expect(haystack).toContain(CALLER_ID.toString());
873
+ });
874
+
875
+ test.each(UNAUTHENTICATED_REQUESTS)(
876
+ "throws NotAuthenticatedException when %s",
877
+ async (
878
+ _label: string,
879
+ authorization: Dictionary<unknown>,
880
+ ): Promise<void> => {
881
+ /*
882
+ * "No caller" cannot be a request this route proceeds with: there is no
883
+ * user whose codes it would be replacing. It has to refuse rather than
884
+ * build an ObjectID out of undefined and hand it to a delete.
885
+ */
886
+ const result: RouteCallResult = await callRoute({
887
+ method: "POST",
888
+ route: GENERATE_ROUTE,
889
+ authorization: authorization,
890
+ });
891
+
892
+ const violations: Array<string> = [];
893
+
894
+ if (!(result.thrownToNext instanceof NotAuthenticatedException)) {
895
+ violations.push(
896
+ `expected a NotAuthenticatedException, got ${String(result.thrownToNext)}`,
897
+ );
898
+ }
899
+
900
+ if (result.nextCallCount !== 1) {
901
+ violations.push(
902
+ `next() was called ${result.nextCallCount} times, expected exactly 1`,
903
+ );
904
+ }
905
+
906
+ if (regenerateSpy.mock.calls.length > 0) {
907
+ violations.push(
908
+ "an unauthenticated request reached regenerateForUser -- codes were destroyed",
909
+ );
910
+ }
911
+
912
+ if (getStatusSpy.mock.calls.length > 0) {
913
+ violations.push("an unauthenticated request reached getStatusForUser");
914
+ }
915
+
916
+ if (asMock(Response.sendJsonObjectResponse).mock.calls.length > 0) {
917
+ violations.push("an unauthenticated request was answered with codes");
918
+ }
919
+
920
+ expect(violations).toEqual([]);
921
+ },
922
+ );
923
+
924
+ test("passes a regeneration failure to next() rather than reporting success", async () => {
925
+ /*
926
+ * `regenerateForUser` deletes the old set before it writes the new one. A
927
+ * failure in the middle leaves the account with NO codes, so reporting
928
+ * success would tell a user they now hold ten codes that do not exist --
929
+ * and they would stop looking for the problem.
930
+ */
931
+ const failure: Error = new Error("deadlock detected");
932
+
933
+ regenerateSpy.mockRejectedValue(failure as never);
934
+
935
+ const result: RouteCallResult = await callRoute({
936
+ method: "POST",
937
+ route: GENERATE_ROUTE,
938
+ });
939
+
940
+ expect(result.thrownToNext).toBe(failure);
941
+ expect(result.nextCallCount).toBe(1);
942
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
943
+ });
944
+
945
+ test("passes a status-read failure to next() without destroying anything", async () => {
946
+ /*
947
+ * The status read happens first. If it fails, the old codes must still be
948
+ * there afterwards -- a route that pressed on would have thrown away a
949
+ * working set in service of a number it only needed for a label.
950
+ */
951
+ const failure: Error = new Error("connection terminated unexpectedly");
952
+
953
+ getStatusSpy.mockRejectedValue(failure as never);
954
+
955
+ const result: RouteCallResult = await callRoute({
956
+ method: "POST",
957
+ route: GENERATE_ROUTE,
958
+ });
959
+
960
+ expect(result.thrownToNext).toBe(failure);
961
+ expect(result.nextCallCount).toBe(1);
962
+ expect(regenerateSpy).not.toHaveBeenCalled();
963
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
964
+ });
965
+ });
966
+
967
+ describe("POST /user-two-factor-backup-code/generate -- telling the owner", () => {
968
+ /*
969
+ * WHY THIS ROUTE NEEDS A NOTIFICATION AT ALL.
970
+ *
971
+ * It is the destructive one, and it is the one with the least standing in
972
+ * front of it: a session alone -- no password, no second factor -- voids
973
+ * every recovery code the user is holding. Somebody who has stolen a session
974
+ * can therefore remove the owner's way back in, silently, and the owner
975
+ * finds out at the next sign-in they cannot complete.
976
+ *
977
+ * The mail does not prevent that. It makes it visible, in a channel the
978
+ * person driving the browser does not control -- the same bargain the
979
+ * "a backup code was used" mail strikes on the login path.
980
+ */
981
+ test("mails the owner that their previous codes have stopped working", async () => {
982
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
983
+ await flushDetachedWork();
984
+
985
+ const violations: Array<string> = [];
986
+
987
+ if (sendMailSpy.mock.calls.length !== 1) {
988
+ violations.push(
989
+ `expected exactly one mail, saw ${String(sendMailSpy.mock.calls.length)}`,
990
+ );
991
+ }
992
+
993
+ const sent: {
994
+ toEmail: Email;
995
+ templateType: EmailTemplateType;
996
+ vars: Dictionary<string>;
997
+ } = (sendMailSpy.mock.calls[0] as Array<unknown>)[0] as {
998
+ toEmail: Email;
999
+ templateType: EmailTemplateType;
1000
+ vars: Dictionary<string>;
1001
+ };
1002
+
1003
+ if (
1004
+ sent?.templateType !== EmailTemplateType.TwoFactorBackupCodesRegenerated
1005
+ ) {
1006
+ violations.push(`wrong template: ${String(sent?.templateType)}`);
1007
+ }
1008
+
1009
+ if (sent?.vars?.["newCodeCount"] !== String(PLAINTEXT_CODES.length)) {
1010
+ violations.push(
1011
+ `the mail reported ${String(sent?.vars?.["newCodeCount"])} new codes rather than ${String(PLAINTEXT_CODES.length)}`,
1012
+ );
1013
+ }
1014
+
1015
+ expect(violations).toEqual([]);
1016
+ });
1017
+
1018
+ /*
1019
+ * The address comes off the ACCOUNT, never off the request. The only thing
1020
+ * the caller proved is which user they are; a notification steered by their
1021
+ * own body would go to the attacker rather than to the person being robbed
1022
+ * of their recovery codes -- which is worse than not sending one, because it
1023
+ * looks from the logs as though the owner was told.
1024
+ */
1025
+ test("sends to the address on the account, not to anything in the request", async () => {
1026
+ await callRoute({
1027
+ method: "POST",
1028
+ route: GENERATE_ROUTE,
1029
+ body: { email: "attacker@example.com" },
1030
+ params: { email: "attacker@example.com" },
1031
+ query: { email: "attacker@example.com" },
1032
+ });
1033
+ await flushDetachedWork();
1034
+
1035
+ const sent: { toEmail: Email } = (
1036
+ sendMailSpy.mock.calls[0] as Array<unknown>
1037
+ )[0] as { toEmail: Email };
1038
+
1039
+ expect(sent.toEmail.toString()).toBe(OWNER_EMAIL);
1040
+
1041
+ expect(findUserSpy).toHaveBeenCalledWith(
1042
+ expect.objectContaining({
1043
+ id: CALLER_ID,
1044
+ }),
1045
+ );
1046
+ });
1047
+
1048
+ /*
1049
+ * The codes are in the RESPONSE and nowhere else -- not in the database, not
1050
+ * recoverable by an operator. A mail that carried them would put the entire
1051
+ * recovery set into an inbox and into every hop between here and it, which
1052
+ * is precisely the exposure hashing them was for.
1053
+ */
1054
+ test("never puts a code in the mail", async () => {
1055
+ await callRoute({ method: "POST", route: GENERATE_ROUTE });
1056
+ await flushDetachedWork();
1057
+
1058
+ const haystack: string = JSON.stringify(sendMailSpy.mock.calls);
1059
+
1060
+ // Guard: an empty haystack would make every assertion below vacuous.
1061
+ expect(haystack.length).toBeGreaterThan(20);
1062
+
1063
+ const leaked: Array<string> = PLAINTEXT_CODES.filter((code: string) => {
1064
+ return haystack.includes(code);
1065
+ });
1066
+
1067
+ expect(leaked).toEqual([]);
1068
+ });
1069
+
1070
+ /*
1071
+ * The response carries the ONLY copy of the new codes. Failing the request
1072
+ * because SMTP is unreachable would throw them away -- while the old set has
1073
+ * already been destroyed, so the user would be left with no working codes at
1074
+ * all and no way to see the ones that replaced them.
1075
+ */
1076
+ test("still returns the codes when the mail cannot be sent", async () => {
1077
+ sendMailSpy.mockRejectedValue(new Error("SMTP is unreachable") as never);
1078
+
1079
+ const result: RouteCallResult = await callRoute({
1080
+ method: "POST",
1081
+ route: GENERATE_ROUTE,
1082
+ });
1083
+ await flushDetachedWork();
1084
+
1085
+ const violations: Array<string> = [];
1086
+
1087
+ if (result.nextCallCount !== 0) {
1088
+ violations.push(
1089
+ `the mail failure escaped to next(): ${String(result.thrownToNext)}`,
1090
+ );
1091
+ }
1092
+
1093
+ const payload: JSONObject = sentJsonObject();
1094
+
1095
+ if (!Array.isArray(payload["codes"])) {
1096
+ violations.push("no codes were returned to the caller");
1097
+ }
1098
+
1099
+ expect(violations).toEqual([]);
1100
+ });
1101
+
1102
+ /*
1103
+ * Same hazard one step earlier: the address lookup is a database round trip,
1104
+ * and it happens after the old codes are already gone.
1105
+ */
1106
+ test("still returns the codes when the owner cannot be looked up", async () => {
1107
+ findUserSpy.mockRejectedValue(new Error("Database not connected") as never);
1108
+
1109
+ const result: RouteCallResult = await callRoute({
1110
+ method: "POST",
1111
+ route: GENERATE_ROUTE,
1112
+ });
1113
+ await flushDetachedWork();
1114
+
1115
+ expect(result.nextCallCount).toBe(0);
1116
+ expect(Array.isArray(sentJsonObject()["codes"])).toBe(true);
1117
+ });
1118
+ });
1119
+
1120
+ describe("GET /user-two-factor-backup-code/status", () => {
1121
+ test("reports the total, the unused count and when the set was minted", async () => {
1122
+ getStatusSpy.mockResolvedValue({
1123
+ total: 10,
1124
+ unused: 7,
1125
+ generatedAt: PREVIOUS_SET_GENERATED_AT,
1126
+ } as never);
1127
+
1128
+ const result: RouteCallResult = await callRoute({
1129
+ method: "GET",
1130
+ route: STATUS_ROUTE,
1131
+ });
1132
+
1133
+ expect(result.nextCallCount).toBe(0);
1134
+ expect(sentJsonObject()).toEqual({
1135
+ total: 10,
1136
+ unused: 7,
1137
+ generatedAt: "2026-01-02T03:04:05.678Z",
1138
+ });
1139
+ });
1140
+
1141
+ test("sends generatedAt as an ISO string rather than a Date", async () => {
1142
+ /*
1143
+ * A Date survives `JSON.stringify` as an ISO string by accident, so this
1144
+ * looks the same from a browser today -- until something in the response
1145
+ * path starts formatting dates in the server's locale, at which point the
1146
+ * page's parse silently starts producing Invalid Date. Serializing here is
1147
+ * what makes the wire format the route's decision rather than the
1148
+ * serializer's.
1149
+ */
1150
+ getStatusSpy.mockResolvedValue({
1151
+ total: 10,
1152
+ unused: 10,
1153
+ generatedAt: NEW_SET_GENERATED_AT,
1154
+ } as never);
1155
+
1156
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1157
+
1158
+ const generatedAt: unknown = sentJsonObject()["generatedAt"];
1159
+
1160
+ expect(typeof generatedAt).toBe("string");
1161
+ expect(generatedAt).toBe(NEW_SET_GENERATED_AT.toISOString());
1162
+ });
1163
+
1164
+ test("sends a null generatedAt when the user has no codes", async () => {
1165
+ /*
1166
+ * The account that has never generated any. `null` is a fact the page
1167
+ * renders as "no backup codes yet"; an absent key is indistinguishable
1168
+ * from a field the page failed to load, and a `new Date(undefined)` on the
1169
+ * way out would be an Invalid Date rendered as a real one.
1170
+ */
1171
+ getStatusSpy.mockResolvedValue({
1172
+ total: 0,
1173
+ unused: 0,
1174
+ generatedAt: null,
1175
+ } as never);
1176
+
1177
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1178
+
1179
+ const payload: JSONObject = sentJsonObject();
1180
+
1181
+ expect(Object.keys(payload).sort()).toEqual([
1182
+ "generatedAt",
1183
+ "total",
1184
+ "unused",
1185
+ ]);
1186
+ expect(payload["generatedAt"]).toBeNull();
1187
+ });
1188
+
1189
+ test("keeps zero counts rather than dropping them", async () => {
1190
+ /*
1191
+ * A user who has spent all ten codes reads `total: 10, unused: 0`, and
1192
+ * that zero is the whole reason they are being told to regenerate. Lost on
1193
+ * the way out, the page cannot tell them apart from a user with codes to
1194
+ * spare.
1195
+ */
1196
+ getStatusSpy.mockResolvedValue({
1197
+ total: 10,
1198
+ unused: 0,
1199
+ generatedAt: PREVIOUS_SET_GENERATED_AT,
1200
+ } as never);
1201
+
1202
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1203
+
1204
+ const payload: JSONObject = sentJsonObject();
1205
+
1206
+ expect(Object.keys(payload)).toContain("unused");
1207
+ expect(payload["unused"]).toBe(0);
1208
+ expect(payload["total"]).toBe(10);
1209
+ });
1210
+
1211
+ test("sets the no-cache headers before the payload goes out", async () => {
1212
+ /*
1213
+ * Headers written after the body has been sent are ignored, so the ORDER
1214
+ * is the protection rather than merely that both calls happened. A stale
1215
+ * "you have 8 codes left" served out of a browser cache to somebody who
1216
+ * has just regenerated is a user who concludes the button did nothing --
1217
+ * and presses it again, invalidating the set they were just shown.
1218
+ */
1219
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1220
+
1221
+ const noCacheOrder: number = asMock(Response.setNoCacheHeaders).mock
1222
+ .invocationCallOrder[0]!;
1223
+ const sendOrder: number = asMock(Response.sendJsonObjectResponse).mock
1224
+ .invocationCallOrder[0]!;
1225
+
1226
+ expect(Response.setNoCacheHeaders).toHaveBeenCalledTimes(1);
1227
+ expect(noCacheOrder).toBeLessThan(sendOrder);
1228
+ });
1229
+
1230
+ test("reads the status for the caller on the access token", async () => {
1231
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1232
+
1233
+ expect(getStatusSpy).toHaveBeenCalledTimes(1);
1234
+ expect(firstServiceArgument(getStatusSpy).userId.toString()).toBe(
1235
+ CALLER_ID.toString(),
1236
+ );
1237
+ expect(Object.keys(firstServiceArgument(getStatusSpy))).toEqual(["userId"]);
1238
+ });
1239
+
1240
+ test.each(IMPERSONATION_ATTEMPTS)(
1241
+ "reads only the token's user even when the request carries %s",
1242
+ async (_label: string, attempt: ImpersonationAttempt): Promise<void> => {
1243
+ /*
1244
+ * Read-only, but not harmless: this payload says whether a named account
1245
+ * has recovery codes and how nearly exhausted they are, which is exactly
1246
+ * the reconnaissance somebody picks a target with. It is safe to serve
1247
+ * only because it can only ever be about the caller.
1248
+ */
1249
+ await callRoute({
1250
+ method: "GET",
1251
+ route: STATUS_ROUTE,
1252
+ params: attempt.params,
1253
+ query: attempt.query,
1254
+ body: attempt.body,
1255
+ });
1256
+
1257
+ const violations: Array<string> = [];
1258
+
1259
+ if (getStatusSpy.mock.calls.length !== 1) {
1260
+ violations.push(
1261
+ `getStatusForUser was called ${getStatusSpy.mock.calls.length} times, expected exactly 1`,
1262
+ );
1263
+ }
1264
+
1265
+ const suppliedUserId: string =
1266
+ firstServiceArgument(getStatusSpy).userId.toString();
1267
+
1268
+ if (suppliedUserId === SOMEBODY_ELSE_ID) {
1269
+ violations.push(
1270
+ "getStatusForUser was aimed at the id supplied by the request",
1271
+ );
1272
+ }
1273
+
1274
+ if (suppliedUserId !== CALLER_ID.toString()) {
1275
+ violations.push(
1276
+ `getStatusForUser was called with ${suppliedUserId}, expected the token's ${CALLER_ID.toString()}`,
1277
+ );
1278
+ }
1279
+
1280
+ expect(violations).toEqual([]);
1281
+ },
1282
+ );
1283
+
1284
+ test.each(UNAUTHENTICATED_REQUESTS)(
1285
+ "throws NotAuthenticatedException when %s",
1286
+ async (
1287
+ _label: string,
1288
+ authorization: Dictionary<unknown>,
1289
+ ): Promise<void> => {
1290
+ const result: RouteCallResult = await callRoute({
1291
+ method: "GET",
1292
+ route: STATUS_ROUTE,
1293
+ authorization: authorization,
1294
+ });
1295
+
1296
+ const violations: Array<string> = [];
1297
+
1298
+ if (!(result.thrownToNext instanceof NotAuthenticatedException)) {
1299
+ violations.push(
1300
+ `expected a NotAuthenticatedException, got ${String(result.thrownToNext)}`,
1301
+ );
1302
+ }
1303
+
1304
+ if (result.nextCallCount !== 1) {
1305
+ violations.push(
1306
+ `next() was called ${result.nextCallCount} times, expected exactly 1`,
1307
+ );
1308
+ }
1309
+
1310
+ if (getStatusSpy.mock.calls.length > 0) {
1311
+ violations.push("an unauthenticated request reached getStatusForUser");
1312
+ }
1313
+
1314
+ if (asMock(Response.sendJsonObjectResponse).mock.calls.length > 0) {
1315
+ violations.push(
1316
+ "an unauthenticated request was answered with a status",
1317
+ );
1318
+ }
1319
+
1320
+ expect(violations).toEqual([]);
1321
+ },
1322
+ );
1323
+
1324
+ test("never regenerates as a side effect of being asked for the status", async () => {
1325
+ /*
1326
+ * The read path and the destructive path share a service. A status route
1327
+ * that reached for `regenerateForUser` -- to "refresh" an empty set, say --
1328
+ * would void a printed list on a page load.
1329
+ */
1330
+ await callRoute({ method: "GET", route: STATUS_ROUTE });
1331
+
1332
+ expect(regenerateSpy).not.toHaveBeenCalled();
1333
+ });
1334
+
1335
+ test("passes a service failure to next() rather than reporting a count", async () => {
1336
+ /*
1337
+ * A failed count reported as `total: 0` would tell a user with ten codes
1338
+ * that they have none, and send them to regenerate -- invalidating the
1339
+ * list they were holding for no reason at all.
1340
+ */
1341
+ const failure: Error = new Error("connection terminated unexpectedly");
1342
+
1343
+ getStatusSpy.mockRejectedValue(failure as never);
1344
+
1345
+ const result: RouteCallResult = await callRoute({
1346
+ method: "GET",
1347
+ route: STATUS_ROUTE,
1348
+ });
1349
+
1350
+ expect(result.thrownToNext).toBe(failure);
1351
+ expect(result.nextCallCount).toBe(1);
1352
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
1353
+ });
1354
+ });
1355
+
1356
+ describe("the input matrices themselves", () => {
1357
+ /*
1358
+ * A `test.each` over an emptied table registers no tests and the suite still
1359
+ * goes green. Both matrices above are the only place their protections are
1360
+ * asserted, so a bad merge that empties one has to fail loudly rather than
1361
+ * silently removing the coverage.
1362
+ */
1363
+ test("the impersonation table is not empty", () => {
1364
+ expect(IMPERSONATION_ATTEMPTS.length).toBeGreaterThan(3);
1365
+ });
1366
+
1367
+ test("the unauthenticated table is not empty", () => {
1368
+ expect(UNAUTHENTICATED_REQUESTS.length).toBeGreaterThan(3);
1369
+ });
1370
+
1371
+ test("every impersonation attempt actually supplies a foreign user id", () => {
1372
+ /*
1373
+ * A row whose params, query and body carry nothing would pass the
1374
+ * ownership assertion trivially. This is what keeps the matrix honest.
1375
+ */
1376
+ const violations: Array<string> = [];
1377
+
1378
+ for (const [label, attempt] of IMPERSONATION_ATTEMPTS) {
1379
+ const serialized: string = JSON.stringify(attempt);
1380
+
1381
+ if (!serialized.includes(SOMEBODY_ELSE_ID)) {
1382
+ violations.push(
1383
+ `the "${label}" attempt does not carry a foreign user id`,
1384
+ );
1385
+ }
1386
+ }
1387
+
1388
+ expect(violations).toEqual([]);
1389
+ });
1390
+ });