@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,919 @@
1
+ import UserService from "../../../Server/Services/UserService";
2
+ import UserSessionService from "../../../Server/Services/UserSessionService";
3
+ import UserTotpAuthService from "../../../Server/Services/UserTotpAuthService";
4
+ import UserTwoFactorBackupCodeService from "../../../Server/Services/UserTwoFactorBackupCodeService";
5
+ import UserWebAuthnService from "../../../Server/Services/UserWebAuthnService";
6
+ import logger from "../../../Server/Utils/Logger";
7
+ import User from "../../../Models/DatabaseModels/User";
8
+ import NotFoundException from "../../../Types/Exception/NotFoundException";
9
+ import ObjectID from "../../../Types/ObjectID";
10
+ import PositiveNumber from "../../../Types/PositiveNumber";
11
+ import TwoFactorAuthStatus from "../../../Types/TwoFactorAuthStatus";
12
+ import UserAuthenticationStatus from "../../../Types/UserAuthenticationStatus";
13
+ import { getJestSpyOn } from "../../Spy";
14
+ import {
15
+ afterEach,
16
+ beforeEach,
17
+ describe,
18
+ expect,
19
+ jest,
20
+ test,
21
+ } from "@jest/globals";
22
+
23
+ /*
24
+ * ---------------------------------------------------------------------------
25
+ * The two UserService methods that backup codes had to be threaded into:
26
+ * resetTwoFactorAuth and getAuthenticationStatus.
27
+ *
28
+ * Backup codes are password-equivalent credentials that live in a table
29
+ * neither of these methods knew about until now, and the two ways of getting
30
+ * the wiring wrong fail in opposite directions -- one leaves a credential
31
+ * alive that everybody believes is gone, the other kills an account by
32
+ * counting a credential twice:
33
+ *
34
+ * - resetTwoFactorAuth is the lost-device fix, and an operator presses it
35
+ * believing it revokes the account's second-factor material. A reset that
36
+ * cleared the TOTP secret and the security keys but left a printed list of
37
+ * recovery codes behind would have revoked NOTHING from a thief who took
38
+ * the phone and the paper beside it -- while reporting success, logging
39
+ * success, and leaving the Authentication page looking exactly as it does
40
+ * after a reset that worked. Nothing in the product surfaces the leftover
41
+ * rows; only this test does.
42
+ * - getAuthenticationStatus has to report the codes WITHOUT letting them
43
+ * count as a configured factor. Folding them into
44
+ * verifiedTwoFactorAuthMethodCount is the single most natural-looking edit
45
+ * anybody will ever make to that method, it makes the page read more
46
+ * "complete", and it locks people out permanently. The long comment on
47
+ * "backup codes do not make an account look configured" below is the whole
48
+ * reason this file exists.
49
+ *
50
+ * SIBLING FILES, so nothing here is duplicated:
51
+ *
52
+ * - UserTwoFactorAuthAdmin.test.ts owns resetTwoFactorAuth and
53
+ * setTwoFactorAuthRequired as they were BEFORE backup codes: the TOTP and
54
+ * WebAuthn delete shapes (LIMIT_MAX, root, no isVerified predicate), the
55
+ * revocation reason's ShortText bound, and the three deleted hooks. This
56
+ * file adds only the backup-code step and the properties that step can
57
+ * break.
58
+ * - UserAuthenticationService.test.ts owns getAuthenticationStatus's
59
+ * password, email-verification and reset-link fields, and the tri-state as
60
+ * derived from TOTP and WebAuthn alone. This file adds only
61
+ * unusedTwoFactorBackupCodeCount and the separation between it and the
62
+ * method count.
63
+ * - The code generation, hashing and single-use consumption of the codes
64
+ * themselves are not here at all; they belong to
65
+ * Common/Server/Utils/TwoFactorBackupCode.ts and its service.
66
+ *
67
+ * Everything stubs at each service's own public boundary -- findOneBy /
68
+ * updateOneById / updateBy on the UserService singleton, deleteBy and countBy
69
+ * on the two authenticator services, deleteAllForUser and countUnusedForUser
70
+ * on UserTwoFactorBackupCodeService, plus UserSessionService -- so every
71
+ * assertion is about what these two methods ASK for. Deliberately NOT mocked:
72
+ * UserService.deriveTwoFactorAuthStatus, which is a pure static and is the
73
+ * thing under test in the tri-state cases -- stubbing it would leave this file
74
+ * asserting that a mock returns what the mock was told to return. No database
75
+ * is involved.
76
+ * ---------------------------------------------------------------------------
77
+ */
78
+
79
+ /*
80
+ * A spy plus the name to blame in a violation message, so a failure says WHICH
81
+ * write entry point was used rather than only that one of them was.
82
+ */
83
+ type NamedSpy = {
84
+ name: string;
85
+ spy: any;
86
+ };
87
+
88
+ /*
89
+ * One row of the tri-state matrix: verified authenticator apps, verified
90
+ * security keys, unused backup codes, and the status the trio must produce.
91
+ * Declared as a tuple type rather than inferred so the callback's parameters
92
+ * can carry real types instead of `number | TwoFactorAuthStatus`.
93
+ */
94
+ type TwoFactorStatusCase = [number, number, number, TwoFactorAuthStatus];
95
+
96
+ type BuildUserFunction = (data: {
97
+ id: ObjectID;
98
+ enableTwoFactorAuth?: boolean | undefined;
99
+ }) => User;
100
+
101
+ const buildUser: BuildUserFunction = (data: {
102
+ id: ObjectID;
103
+ enableTwoFactorAuth?: boolean | undefined;
104
+ }): User => {
105
+ const user: User = new User();
106
+ user.id = data.id;
107
+
108
+ /*
109
+ * Assigned only when supplied, so an omitted column is genuinely absent --
110
+ * the shape a `select` that did not ask for it produces -- rather than
111
+ * explicitly undefined.
112
+ */
113
+ if (data.enableTwoFactorAuth !== undefined) {
114
+ user.enableTwoFactorAuth = data.enableTwoFactorAuth;
115
+ }
116
+
117
+ return user;
118
+ };
119
+
120
+ describe("UserService -- the admin surfaces backup codes plug into", () => {
121
+ /*
122
+ * TWO DISTINCT IDS ON PURPOSE, exactly as in UserTwoFactorAuthAdmin.test.ts,
123
+ * and for this file the distinction earns its keep twice over.
124
+ *
125
+ * `userId` is what the CALLER asked about -- the id in the admin route's
126
+ * URL. `foundUserId` is the id on the row `findOneBy` actually resolves.
127
+ * `resetTwoFactorAuth` looks the row up and then keys every delete and the
128
+ * revocation on `user.id!`, never on `data.userId`, and the newest of those
129
+ * deletes destroys credentials. If the fixture made the two ids the same
130
+ * value -- the obvious way to write it -- then `deleteAllForUser({ userId:
131
+ * data.userId })` and `deleteAllForUser({ userId: user.id! })` would produce
132
+ * byte-identical calls, and a backup-code delete aimed at an id nobody
133
+ * confirmed exists would be indistinguishable from a correct one.
134
+ */
135
+ let userId: ObjectID;
136
+ let foundUserId: ObjectID;
137
+ let findOneBySpy: any;
138
+ let updateOneByIdSpy: any;
139
+ let updateBySpy: any;
140
+ let revokeSessionsSpy: any;
141
+ let totpDeleteBySpy: any;
142
+ let webAuthnDeleteBySpy: any;
143
+ let totpCountBySpy: any;
144
+ let webAuthnCountBySpy: any;
145
+ let backupCodeDeleteAllSpy: any;
146
+ let backupCodeCountUnusedSpy: any;
147
+ let loggerInfoSpy: any;
148
+
149
+ beforeEach(() => {
150
+ jest.restoreAllMocks();
151
+
152
+ userId = ObjectID.generate();
153
+ foundUserId = ObjectID.generate();
154
+
155
+ findOneBySpy = getJestSpyOn(UserService, "findOneBy").mockImplementation(
156
+ async (): Promise<User | null> => {
157
+ return null;
158
+ },
159
+ );
160
+
161
+ updateOneByIdSpy = getJestSpyOn(
162
+ UserService,
163
+ "updateOneById",
164
+ ).mockImplementation(async (): Promise<void> => {
165
+ return undefined;
166
+ });
167
+
168
+ /*
169
+ * The OTHER write entry point on this service. `updateOneById` is the one
170
+ * these methods use today, but `updateBy` is what it funnels into, so
171
+ * spying on both is what lets "this method writes nothing to the User row"
172
+ * be asserted as a fact about the service rather than about one method
173
+ * name.
174
+ */
175
+ updateBySpy = getJestSpyOn(UserService, "updateBy").mockImplementation(
176
+ async (): Promise<number> => {
177
+ return 0;
178
+ },
179
+ );
180
+
181
+ revokeSessionsSpy = getJestSpyOn(
182
+ UserSessionService,
183
+ "revokeAllSessionsByUserId",
184
+ ).mockImplementation(async (): Promise<void> => {
185
+ return undefined;
186
+ });
187
+
188
+ totpDeleteBySpy = getJestSpyOn(
189
+ UserTotpAuthService,
190
+ "deleteBy",
191
+ ).mockImplementation(async (): Promise<number> => {
192
+ return 0;
193
+ });
194
+
195
+ webAuthnDeleteBySpy = getJestSpyOn(
196
+ UserWebAuthnService,
197
+ "deleteBy",
198
+ ).mockImplementation(async (): Promise<number> => {
199
+ return 0;
200
+ });
201
+
202
+ /*
203
+ * getAuthenticationStatus counts this user's verified authenticators to
204
+ * derive the tri-state. Stubbed to zero by default so every test that is
205
+ * not ABOUT the authenticator side keeps describing a user with nothing
206
+ * set up; the cases that care override them.
207
+ */
208
+ totpCountBySpy = getJestSpyOn(
209
+ UserTotpAuthService,
210
+ "countBy",
211
+ ).mockImplementation(async (): Promise<PositiveNumber> => {
212
+ return new PositiveNumber(0);
213
+ });
214
+
215
+ webAuthnCountBySpy = getJestSpyOn(
216
+ UserWebAuthnService,
217
+ "countBy",
218
+ ).mockImplementation(async (): Promise<PositiveNumber> => {
219
+ return new PositiveNumber(0);
220
+ });
221
+
222
+ /*
223
+ * The two new dependencies, stubbed at the backup-code service's own
224
+ * public surface rather than at its `deleteBy` / `countBy` underneath.
225
+ * That boundary is the contract UserService depends on: how
226
+ * `deleteAllForUser` pages the delete and how `countUnusedForUser` spells
227
+ * "usedAt IS NULL" are that service's business, and they have their own
228
+ * tests. What this file is entitled to assert is that UserService calls
229
+ * them, for the right user, at the right moment.
230
+ */
231
+ backupCodeDeleteAllSpy = getJestSpyOn(
232
+ UserTwoFactorBackupCodeService,
233
+ "deleteAllForUser",
234
+ ).mockImplementation(async (): Promise<void> => {
235
+ return undefined;
236
+ });
237
+
238
+ backupCodeCountUnusedSpy = getJestSpyOn(
239
+ UserTwoFactorBackupCodeService,
240
+ "countUnusedForUser",
241
+ ).mockImplementation(async (): Promise<number> => {
242
+ return 0;
243
+ });
244
+
245
+ loggerInfoSpy = getJestSpyOn(logger, "info").mockImplementation(
246
+ (): void => {
247
+ return undefined;
248
+ },
249
+ );
250
+
251
+ /*
252
+ * The deliberate-failure cases below hand a rejection to the CaptureSpan
253
+ * decorator, which records it. That is correct behaviour, and it is also a
254
+ * stack trace on stderr for a test that passed -- silenced so a genuine
255
+ * error in this suite's output is still worth reading.
256
+ */
257
+ getJestSpyOn(logger, "error").mockImplementation((): void => {
258
+ return undefined;
259
+ });
260
+ });
261
+
262
+ afterEach(() => {
263
+ jest.restoreAllMocks();
264
+ });
265
+
266
+ type ResolveUserFunction = (user: User | null) => void;
267
+
268
+ // Makes the next (and every subsequent) findOneBy return this row.
269
+ const resolveUser: ResolveUserFunction = (user: User | null): void => {
270
+ findOneBySpy.mockImplementation(async (): Promise<User | null> => {
271
+ return user;
272
+ });
273
+ };
274
+
275
+ type FlushMicrotasksFunction = () => Promise<void>;
276
+
277
+ /*
278
+ * Lets every already-scheduled continuation run without advancing anything
279
+ * that is genuinely blocked. Used by the "is it awaited?" case: after this,
280
+ * a method that did NOT await its dependency has definitely finished.
281
+ */
282
+ const flushMicrotasks: FlushMicrotasksFunction = async (): Promise<void> => {
283
+ await new Promise<void>((resolve: () => void) => {
284
+ setTimeout(resolve, 0);
285
+ });
286
+ };
287
+
288
+ describe("resetTwoFactorAuth -- the backup codes go with the authenticators", () => {
289
+ test("deletes the user's backup codes, keyed on the SAME id the authenticator deletes use", async () => {
290
+ /*
291
+ * Two facts in one place, and the second is the one a same-id fixture
292
+ * would hide.
293
+ *
294
+ * That the codes are deleted at all is the point of the step: a code
295
+ * signs somebody in on its own, so a reset that skipped them would have
296
+ * revoked nothing from whoever is holding the stolen device and the
297
+ * printed list beside it.
298
+ *
299
+ * And it must be the RESOLVED user's id, not the requested one. Every
300
+ * other delete in this method is keyed on `user.id!` -- the id of a row
301
+ * that was actually confirmed to exist -- so a backup-code delete keyed
302
+ * on `data.userId` would silently disagree with its two neighbours the
303
+ * moment the lookup ever resolves something else (a soft-delete filter,
304
+ * a tenancy scope, a future findOneBy overload). The failure mode is not
305
+ * an exception: it is a reset that clears two of the three tables for
306
+ * one user and none of the third, reported as a success.
307
+ *
308
+ * Compared against the TOTP delete's own target rather than only against
309
+ * the fixture, so the assertion states the invariant that matters --
310
+ * these deletes all describe one account -- instead of restating a
311
+ * constant.
312
+ */
313
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
314
+
315
+ await UserService.resetTwoFactorAuth({ userId: userId });
316
+
317
+ expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
318
+
319
+ const call: any = backupCodeDeleteAllSpy.mock.calls[0][0];
320
+ const totpTargetId: string =
321
+ totpDeleteBySpy.mock.calls[0][0].query.userId.toString();
322
+ const webAuthnTargetId: string =
323
+ webAuthnDeleteBySpy.mock.calls[0][0].query.userId.toString();
324
+
325
+ const violations: Array<string> = [];
326
+
327
+ if (call.userId.toString() !== foundUserId.toString()) {
328
+ violations.push(
329
+ `the backup code delete targeted ${call.userId.toString()}, not the found user ${foundUserId.toString()}`,
330
+ );
331
+ }
332
+
333
+ if (call.userId.toString() !== totpTargetId) {
334
+ violations.push(
335
+ "the backup code delete and the authenticator app delete targeted different users",
336
+ );
337
+ }
338
+
339
+ if (call.userId.toString() !== webAuthnTargetId) {
340
+ violations.push(
341
+ "the backup code delete and the security key delete targeted different users",
342
+ );
343
+ }
344
+
345
+ expect(violations).toEqual([]);
346
+ });
347
+
348
+ test("the backup code delete carries nothing but the owner", async () => {
349
+ /*
350
+ * The tempting extra predicate is `usedAt`: "only delete the codes that
351
+ * are still usable" reads as tidy and is wrong in both directions. Spent
352
+ * rows are what the profile page counts to say "3 of 10 remaining" and
353
+ * what a user asking "did somebody else get in?" reads a timestamp off,
354
+ * so a filtered delete would leave the account claiming a set it no
355
+ * longer has -- and would leave the reset visibly half-done to anybody
356
+ * who looked at the table.
357
+ *
358
+ * Asserted as the exact argument object rather than as "no usedAt key",
359
+ * so a filter in the other direction (deleting only the spent rows and
360
+ * keeping every live code) is caught by the same line.
361
+ */
362
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
363
+
364
+ await UserService.resetTwoFactorAuth({ userId: userId });
365
+
366
+ expect(backupCodeDeleteAllSpy.mock.calls[0][0]).toEqual({
367
+ userId: foundUserId,
368
+ });
369
+ });
370
+
371
+ test("the backup codes are gone BEFORE the sessions are revoked", async () => {
372
+ /*
373
+ * Ordering, not just presence, and for exactly the reason the two
374
+ * existing deletes are ordered ahead of the revocation.
375
+ *
376
+ * Revoking first opens a window between the revocation and the delete in
377
+ * which a still-live backup code is a WORKING second factor: whoever
378
+ * holds the password and the list signs in through
379
+ * POST /verify-backup-code, spends one code, and comes out with a fresh
380
+ * session on the far side of a reset that was supposed to have locked
381
+ * them out. The window is small and entirely real -- each delete is a
382
+ * round trip to Postgres -- and it is worse for codes than for the
383
+ * authenticators, because a code needs no device at all: a photograph of
384
+ * the printed list is enough.
385
+ *
386
+ * The revocation is also the LAST thing that can fail. Ordered as it is,
387
+ * a crash mid-reset leaves the credentials destroyed and some sessions
388
+ * alive, which the next attempt fixes. Ordered the other way it leaves
389
+ * the sessions killed and the credentials intact, which nothing fixes
390
+ * and nobody notices.
391
+ */
392
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
393
+
394
+ await UserService.resetTwoFactorAuth({ userId: userId });
395
+
396
+ const backupCodeOrder: number = backupCodeDeleteAllSpy.mock
397
+ .invocationCallOrder[0] as number;
398
+ const revokeOrder: number = revokeSessionsSpy.mock
399
+ .invocationCallOrder[0] as number;
400
+ const lookupOrder: number = findOneBySpy.mock
401
+ .invocationCallOrder[0] as number;
402
+
403
+ const violations: Array<string> = [];
404
+
405
+ if (!(backupCodeOrder < revokeOrder)) {
406
+ violations.push(
407
+ "sessions were revoked before the backup codes were deleted, leaving a live code as a working second factor",
408
+ );
409
+ }
410
+
411
+ if (!(lookupOrder < backupCodeOrder)) {
412
+ violations.push(
413
+ "backup codes were deleted before the user row was confirmed to exist",
414
+ );
415
+ }
416
+
417
+ expect(violations).toEqual([]);
418
+ });
419
+
420
+ test("does not resolve until the backup code delete has actually settled", async () => {
421
+ /*
422
+ * A fire-and-forget delete would still call the spy, so every assertion
423
+ * above would keep passing while the guarantee evaporated. The reset
424
+ * would answer 200, the operator would tell the user "you are safe now",
425
+ * and the delete could fail afterwards with nobody listening -- an
426
+ * unhandled rejection in a log nobody reads, and ten working recovery
427
+ * codes still in the table.
428
+ *
429
+ * It also matters for the ORDERING above, which is only meaningful if
430
+ * each step completes before the next begins: an unawaited delete
431
+ * running concurrently with the revocation reopens the same window this
432
+ * file just closed.
433
+ *
434
+ * So the await is pinned directly, by blocking the delete on a gate this
435
+ * test holds and checking the method has not finished.
436
+ */
437
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
438
+
439
+ let releaseDelete: () => void = (): void => {
440
+ return undefined;
441
+ };
442
+
443
+ const deleteGate: Promise<void> = new Promise<void>(
444
+ (resolve: () => void) => {
445
+ releaseDelete = resolve;
446
+ },
447
+ );
448
+
449
+ backupCodeDeleteAllSpy.mockImplementation(async (): Promise<void> => {
450
+ await deleteGate;
451
+ });
452
+
453
+ let settled: boolean = false;
454
+
455
+ const pending: Promise<void> = UserService.resetTwoFactorAuth({
456
+ userId: userId,
457
+ }).then((): void => {
458
+ settled = true;
459
+ });
460
+
461
+ await flushMicrotasks();
462
+
463
+ const violations: Array<string> = [];
464
+
465
+ if (backupCodeDeleteAllSpy.mock.calls.length !== 1) {
466
+ violations.push(
467
+ `the backup code delete was called ${backupCodeDeleteAllSpy.mock.calls.length} time(s), expected once`,
468
+ );
469
+ }
470
+
471
+ if (settled) {
472
+ violations.push(
473
+ "resetTwoFactorAuth resolved while the backup code delete was still in flight",
474
+ );
475
+ }
476
+
477
+ if (revokeSessionsSpy.mock.calls.length !== 0) {
478
+ violations.push(
479
+ "sessions were revoked while the backup code delete was still in flight",
480
+ );
481
+ }
482
+
483
+ expect(violations).toEqual([]);
484
+
485
+ releaseDelete();
486
+ await pending;
487
+
488
+ expect(settled).toBe(true);
489
+ });
490
+
491
+ test("throws NotFoundException for a user that does not exist, deleting no backup codes", async () => {
492
+ /*
493
+ * `deleteAllForUser` is keyed on `userId`, so a missing row would make it
494
+ * a harmless no-op -- which is precisely why the check has to stay
495
+ * explicit rather than being left to the delete. An operator acting on a
496
+ * mistyped or stale id would otherwise be told the reset worked, walk
497
+ * away, and the person who actually needs it is still locked out holding
498
+ * codes they were told were void.
499
+ *
500
+ * The revocation is checked alongside because it is the one step that is
501
+ * NOT harmless against a wrong id: it signs out whoever that id really
502
+ * belongs to.
503
+ */
504
+ resolveUser(null);
505
+
506
+ await expect(
507
+ UserService.resetTwoFactorAuth({ userId: userId }),
508
+ ).rejects.toThrow(NotFoundException);
509
+
510
+ const violations: Array<string> = [];
511
+
512
+ const untouched: Array<NamedSpy> = [
513
+ { name: "deleteAllForUser", spy: backupCodeDeleteAllSpy },
514
+ { name: "UserTotpAuthService.deleteBy", spy: totpDeleteBySpy },
515
+ { name: "UserWebAuthnService.deleteBy", spy: webAuthnDeleteBySpy },
516
+ { name: "revokeAllSessionsByUserId", spy: revokeSessionsSpy },
517
+ ];
518
+
519
+ for (const entry of untouched) {
520
+ if (entry.spy.mock.calls.length > 0) {
521
+ violations.push(`${entry.name} ran for a user that does not exist`);
522
+ }
523
+ }
524
+
525
+ expect(violations).toEqual([]);
526
+ });
527
+
528
+ test("still does NOT touch enableTwoFactorAuth now that the codes go too", async () => {
529
+ /*
530
+ * UserTwoFactorAuthAdmin.test.ts asserts this for the method as it was
531
+ * before backup codes existed. It is re-asserted here, with the new
532
+ * delete in place, because the new delete is what makes clearing the
533
+ * flag look reasonable: after this method runs, the account has NO
534
+ * second-factor material of any kind left -- no authenticator, no
535
+ * security key, and now no recovery codes either -- and "there is
536
+ * nothing to satisfy the requirement with, so lift the requirement" is a
537
+ * short, sympathetic-looking edit to write directly underneath the line
538
+ * that deleted the codes.
539
+ *
540
+ * It is also the one edit that turns a lost phone into a silent
541
+ * downgrade. "Reset" means clear the configuration and KEEP the
542
+ * requirement: the account lands on a new QR code at its next sign-in,
543
+ * which is the whole point. An operator helping with a lost phone would
544
+ * have no reason to notice that the mandate quietly went away.
545
+ *
546
+ * Asserted as "no user update at all" through BOTH write entry points,
547
+ * which is the stronger and simpler fact: this method has no business
548
+ * writing to the User row through any of them.
549
+ */
550
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
551
+
552
+ await UserService.resetTwoFactorAuth({ userId: userId });
553
+
554
+ const writeEntryPoints: Array<NamedSpy> = [
555
+ { name: "updateOneById", spy: updateOneByIdSpy },
556
+ { name: "updateBy", spy: updateBySpy },
557
+ ];
558
+
559
+ const violations: Array<string> = [];
560
+
561
+ for (const entryPoint of writeEntryPoints) {
562
+ for (const call of entryPoint.spy.mock.calls) {
563
+ const keys: Array<string> = Object.keys(call[0]?.data || {});
564
+
565
+ violations.push(
566
+ `resetTwoFactorAuth wrote to the User row via ${entryPoint.name} with data keys [${keys.join(", ")}]`,
567
+ );
568
+ }
569
+ }
570
+
571
+ expect(violations).toEqual([]);
572
+
573
+ /*
574
+ * ...and the backup codes really were part of this run, so the above is
575
+ * a statement about the new method rather than about the old one.
576
+ */
577
+ expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
578
+ });
579
+
580
+ test("is a successful no-op for a user who never generated any codes", async () => {
581
+ /*
582
+ * The operator pressing this button does not know what the user has set
583
+ * up -- that is why they are on the page -- and most accounts have no
584
+ * backup codes at all. Refusing, or short-circuiting the rest of the
585
+ * reset, because there was nothing to delete would turn the ordinary
586
+ * case into an error the operator has to interpret, on the one page
587
+ * where interpreting an error wrongly means leaving somebody locked out.
588
+ */
589
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
590
+
591
+ await expect(
592
+ UserService.resetTwoFactorAuth({ userId: userId }),
593
+ ).resolves.toBeUndefined();
594
+
595
+ expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
596
+ expect(revokeSessionsSpy).toHaveBeenCalledTimes(1);
597
+ expect(loggerInfoSpy.mock.calls.length).toBeGreaterThan(0);
598
+ });
599
+ });
600
+
601
+ describe("getAuthenticationStatus -- reporting the codes without counting them", () => {
602
+ type StatusForFunction = () => Promise<UserAuthenticationStatus>;
603
+
604
+ const statusFor: StatusForFunction =
605
+ async (): Promise<UserAuthenticationStatus> => {
606
+ return await UserService.getAuthenticationStatus(userId);
607
+ };
608
+
609
+ test("reports unusedTwoFactorBackupCodeCount straight from countUnusedForUser", async () => {
610
+ /*
611
+ * The number an operator reads off a lost-phone ticket to decide whether
612
+ * they are needed at all. A user with codes left should be told to use
613
+ * one; only a user with none needs the reset button, which signs them
614
+ * out everywhere and marches them through enrolment. Reporting a
615
+ * hard-coded zero, or the TOTAL rather than the UNUSED count, points the
616
+ * operator at the wrong half of that decision -- and the second mistake
617
+ * is invisible, because a user who has spent all ten codes and a user
618
+ * holding ten both have a total of ten.
619
+ */
620
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
621
+
622
+ backupCodeCountUnusedSpy.mockResolvedValue(7 as never);
623
+
624
+ const status: UserAuthenticationStatus = await statusFor();
625
+
626
+ expect(backupCodeCountUnusedSpy).toHaveBeenCalledTimes(1);
627
+ expect(status.unusedTwoFactorBackupCodeCount).toBe(7);
628
+ });
629
+
630
+ test("the backup code count describes the same user as the method counts", async () => {
631
+ /*
632
+ * Three probes go out for one page -- verified TOTP rows, verified
633
+ * security keys, unused backup codes -- and they must all be about the
634
+ * same account. A page that mixed one user's authenticators with
635
+ * another's recovery codes would be a coherent-looking screen that is
636
+ * true of nobody, and the operator's next click on it revokes or resets
637
+ * somebody's access.
638
+ *
639
+ * Written as a comparison between the three calls rather than against
640
+ * the fixture, because that is the invariant: whichever id this method
641
+ * decides to key its reads on, it has to use one id.
642
+ */
643
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
644
+
645
+ await statusFor();
646
+
647
+ const totpProbeId: string =
648
+ totpCountBySpy.mock.calls[0][0].query.userId.toString();
649
+ const webAuthnProbeId: string =
650
+ webAuthnCountBySpy.mock.calls[0][0].query.userId.toString();
651
+ const backupProbeId: string =
652
+ backupCodeCountUnusedSpy.mock.calls[0][0].userId.toString();
653
+
654
+ const violations: Array<string> = [];
655
+
656
+ if (backupProbeId !== totpProbeId) {
657
+ violations.push(
658
+ `the backup code count is about ${backupProbeId} but the authenticator app count is about ${totpProbeId}`,
659
+ );
660
+ }
661
+
662
+ if (backupProbeId !== webAuthnProbeId) {
663
+ violations.push(
664
+ `the backup code count is about ${backupProbeId} but the security key count is about ${webAuthnProbeId}`,
665
+ );
666
+ }
667
+
668
+ expect(violations).toEqual([]);
669
+ });
670
+
671
+ test("backup codes do NOT make an account look configured", async () => {
672
+ /*
673
+ * -------------------------------------------------------------------
674
+ * THE ONE THAT MATTERS, and the reason this whole file exists.
675
+ *
676
+ * The account here is the exact shape a reset leaves behind, or that a
677
+ * user creates by generating codes before enrolling: two factor auth
678
+ * REQUIRED, zero verified authenticators, ten unused backup codes.
679
+ *
680
+ * The edit this test forbids is one line long and looks like a bug fix.
681
+ * `verifiedTwoFactorAuthMethodCount` is described as "how many ways can
682
+ * this person satisfy the second factor", and a backup code manifestly
683
+ * IS one of those ways -- so adding the code count to it makes the
684
+ * Authentication page stop saying "Enabled - Pending Setup" about
685
+ * somebody who is visibly holding ten working credentials. The page
686
+ * reads better afterwards. Everything still renders. No test that only
687
+ * checks fields in isolation would notice.
688
+ *
689
+ * What it actually does is lock the account out for good.
690
+ *
691
+ * That count feeds `deriveTwoFactorAuthStatus`, and the status decides
692
+ * which door login opens. Non-zero means EnabledConfigured, and
693
+ * EnabledConfigured means /login sends the user to the two factor
694
+ * CHALLENGE screen instead of through enrolment. The challenge screen
695
+ * asks for a code from an authenticator that does not exist. The only
696
+ * thing the user can answer it with is a backup code -- so they spend
697
+ * one, get in, and land in a product that offers them no enrolment step
698
+ * because the server believes they are already configured. Next
699
+ * sign-in: another code. Ten sign-ins later there are none left, the
700
+ * challenge screen has nothing behind it at all, and the account needs
701
+ * an administrator -- who is the person this page was built for, now
702
+ * looking at a page that told them everything was fine.
703
+ *
704
+ * Backup codes are the way BACK IN to an account that has a factor it
705
+ * cannot currently reach. They are not the factor. The two numbers stay
706
+ * separate so that the page can say both things at once: "cannot sign in
707
+ * unaided" AND "has ten codes to sign in with", which is precisely the
708
+ * pair an operator needs and precisely the pair that a single summed
709
+ * count destroys.
710
+ *
711
+ * All three fields are pinned together, because the bug shows up as a
712
+ * DISAGREEMENT between them rather than in any one of them: ten codes
713
+ * reported correctly, and a method count that quietly includes them.
714
+ * -------------------------------------------------------------------
715
+ */
716
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
717
+
718
+ backupCodeCountUnusedSpy.mockResolvedValue(10 as never);
719
+
720
+ const status: UserAuthenticationStatus = await statusFor();
721
+
722
+ const violations: Array<string> = [];
723
+
724
+ if (status.verifiedTwoFactorAuthMethodCount !== 0) {
725
+ violations.push(
726
+ `ten backup codes were counted as ${status.verifiedTwoFactorAuthMethodCount} verified two factor method(s); login would send this account to a challenge screen it cannot answer`,
727
+ );
728
+ }
729
+
730
+ if (
731
+ status.twoFactorAuthStatus !== TwoFactorAuthStatus.EnabledPendingSetup
732
+ ) {
733
+ violations.push(
734
+ `an account with no verified factor and ten backup codes reported ${status.twoFactorAuthStatus} instead of ${TwoFactorAuthStatus.EnabledPendingSetup}; it would never be offered enrolment again`,
735
+ );
736
+ }
737
+
738
+ if (status.unusedTwoFactorBackupCodeCount !== 10) {
739
+ violations.push(
740
+ `the ten backup codes were reported as ${status.unusedTwoFactorBackupCodeCount}; the operator cannot tell whether this user can recover unaided`,
741
+ );
742
+ }
743
+
744
+ // The requirement itself is untouched by any of this.
745
+ if (status.isTwoFactorAuthEnabled !== true) {
746
+ violations.push(
747
+ "the two factor auth requirement was reported as off for an account that has it on",
748
+ );
749
+ }
750
+
751
+ expect(violations).toEqual([]);
752
+ });
753
+
754
+ /*
755
+ * The same invariant swept across the range, so it cannot be satisfied by
756
+ * a special case at ten, and in BOTH directions: codes must not promote
757
+ * EnabledPendingSetup to EnabledConfigured, and must not be needed to
758
+ * reach EnabledConfigured either. The rows with a verified factor AND
759
+ * codes are the ordinary healthy account -- if a summed count were ever
760
+ * introduced they would still pass, which is why the zero-factor rows
761
+ * above them are the load-bearing ones.
762
+ */
763
+ const twoFactorStatusCases: Array<TwoFactorStatusCase> = [
764
+ [0, 0, 0, TwoFactorAuthStatus.EnabledPendingSetup],
765
+ [0, 0, 1, TwoFactorAuthStatus.EnabledPendingSetup],
766
+ [0, 0, 3, TwoFactorAuthStatus.EnabledPendingSetup],
767
+ [0, 0, 10, TwoFactorAuthStatus.EnabledPendingSetup],
768
+ [1, 0, 0, TwoFactorAuthStatus.EnabledConfigured],
769
+ [1, 0, 10, TwoFactorAuthStatus.EnabledConfigured],
770
+ [0, 1, 10, TwoFactorAuthStatus.EnabledConfigured],
771
+ [2, 1, 10, TwoFactorAuthStatus.EnabledConfigured],
772
+ ];
773
+
774
+ test.each(twoFactorStatusCases)(
775
+ "%p verified apps + %p verified keys + %p unused backup codes reads as %s",
776
+ async (
777
+ verifiedTotpCount: number,
778
+ verifiedWebAuthnCount: number,
779
+ unusedBackupCodeCount: number,
780
+ expectedStatus: TwoFactorAuthStatus,
781
+ ): Promise<void> => {
782
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
783
+
784
+ totpCountBySpy.mockResolvedValue(
785
+ new PositiveNumber(verifiedTotpCount) as never,
786
+ );
787
+ webAuthnCountBySpy.mockResolvedValue(
788
+ new PositiveNumber(verifiedWebAuthnCount) as never,
789
+ );
790
+ backupCodeCountUnusedSpy.mockResolvedValue(
791
+ unusedBackupCodeCount as never,
792
+ );
793
+
794
+ const status: UserAuthenticationStatus = await statusFor();
795
+
796
+ const violations: Array<string> = [];
797
+
798
+ if (status.twoFactorAuthStatus !== expectedStatus) {
799
+ violations.push(
800
+ `status was ${status.twoFactorAuthStatus}, expected ${expectedStatus}`,
801
+ );
802
+ }
803
+
804
+ if (
805
+ status.verifiedTwoFactorAuthMethodCount !==
806
+ verifiedTotpCount + verifiedWebAuthnCount
807
+ ) {
808
+ violations.push(
809
+ `the verified method count was ${status.verifiedTwoFactorAuthMethodCount}, expected ${verifiedTotpCount + verifiedWebAuthnCount} -- the backup codes leaked into it`,
810
+ );
811
+ }
812
+
813
+ if (status.unusedTwoFactorBackupCodeCount !== unusedBackupCodeCount) {
814
+ violations.push(
815
+ `the unused backup code count was ${status.unusedTwoFactorBackupCodeCount}, expected ${unusedBackupCodeCount}`,
816
+ );
817
+ }
818
+
819
+ expect(violations).toEqual([]);
820
+ },
821
+ );
822
+
823
+ test("codes on an account with the requirement OFF still read NotEnabled", async () => {
824
+ /*
825
+ * Generating backup codes is deliberately NOT gated on
826
+ * `enableTwoFactorAuth` -- doing it before turning two factor auth on is
827
+ * the sensible order -- so "has codes, has no requirement" is a state
828
+ * the product creates on purpose, not a corruption.
829
+ *
830
+ * It must not read as two factor auth being on. An operator told
831
+ * "Enabled" about an account that login waves straight through on a
832
+ * password alone has been told the opposite of the truth about the only
833
+ * thing this page exists to report, and would have no reason to ask why
834
+ * a compromise happened without a second factor being involved.
835
+ */
836
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: false }));
837
+
838
+ backupCodeCountUnusedSpy.mockResolvedValue(10 as never);
839
+
840
+ const status: UserAuthenticationStatus = await statusFor();
841
+
842
+ const violations: Array<string> = [];
843
+
844
+ if (status.twoFactorAuthStatus !== TwoFactorAuthStatus.NotEnabled) {
845
+ violations.push(
846
+ `an account with ten backup codes and no requirement reported ${status.twoFactorAuthStatus}`,
847
+ );
848
+ }
849
+
850
+ if (status.isTwoFactorAuthEnabled !== false) {
851
+ violations.push(
852
+ "backup codes made isTwoFactorAuthEnabled read true for an account with the requirement off",
853
+ );
854
+ }
855
+
856
+ if (status.unusedTwoFactorBackupCodeCount !== 10) {
857
+ violations.push(
858
+ "the codes an unrequired account holds were not reported at all",
859
+ );
860
+ }
861
+
862
+ expect(violations).toEqual([]);
863
+ });
864
+
865
+ test("throws NotFoundException for a user that does not exist, counting nothing", async () => {
866
+ /*
867
+ * Returning a status object full of zeroes for an id that does not exist
868
+ * would render an ordinary-looking page describing nobody, and
869
+ * "unusedTwoFactorBackupCodeCount: 0" is the reading that sends an
870
+ * operator straight to the reset button. They have to be told, not shown
871
+ * defaults.
872
+ *
873
+ * The count is asserted as not-called rather than merely not-reported,
874
+ * because a probe issued before the row is confirmed is a query per
875
+ * mistyped id on an endpoint an operator can hold down.
876
+ */
877
+ resolveUser(null);
878
+
879
+ await expect(UserService.getAuthenticationStatus(userId)).rejects.toThrow(
880
+ NotFoundException,
881
+ );
882
+
883
+ const violations: Array<string> = [];
884
+
885
+ const probes: Array<NamedSpy> = [
886
+ { name: "countUnusedForUser", spy: backupCodeCountUnusedSpy },
887
+ { name: "UserTotpAuthService.countBy", spy: totpCountBySpy },
888
+ { name: "UserWebAuthnService.countBy", spy: webAuthnCountBySpy },
889
+ ];
890
+
891
+ for (const probe of probes) {
892
+ if (probe.spy.mock.calls.length > 0) {
893
+ violations.push(
894
+ `${probe.name} was queried for a user that does not exist`,
895
+ );
896
+ }
897
+ }
898
+
899
+ expect(violations).toEqual([]);
900
+ });
901
+
902
+ test("reading the status deletes nothing", async () => {
903
+ /*
904
+ * A read path, asserted as one. `getAuthenticationStatus` and
905
+ * `resetTwoFactorAuth` are the two halves of the same admin page and
906
+ * they now share a dependency, so this pins the direction of that
907
+ * dependency: the page an operator opens to LOOK at an account must
908
+ * never be the thing that voids its recovery codes.
909
+ */
910
+ resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
911
+
912
+ await statusFor();
913
+
914
+ expect(backupCodeDeleteAllSpy).not.toHaveBeenCalled();
915
+ expect(updateOneByIdSpy).not.toHaveBeenCalled();
916
+ expect(revokeSessionsSpy).not.toHaveBeenCalled();
917
+ });
918
+ });
919
+ });