@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,862 @@
1
+ import UserTwoFactorBackupCodeService, {
2
+ TwoFactorBackupCodeStatus,
3
+ } from "../../../Server/Services/UserTwoFactorBackupCodeService";
4
+ import TwoFactorBackupCode, {
5
+ BackupCodeSetSize,
6
+ } from "../../../Server/Utils/TwoFactorBackupCode";
7
+ import UserTwoFactorBackupCode from "../../../Models/DatabaseModels/UserTwoFactorBackupCode";
8
+ import LIMIT_MAX from "../../../Types/Database/LimitMax";
9
+ import ObjectID from "../../../Types/ObjectID";
10
+ import PositiveNumber from "../../../Types/PositiveNumber";
11
+ import BadDataException from "../../../Types/Exception/BadDataException";
12
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
13
+ import { FindOperator } from "typeorm";
14
+ import { getJestSpyOn } from "../../Spy";
15
+ import {
16
+ afterEach,
17
+ beforeEach,
18
+ describe,
19
+ expect,
20
+ jest,
21
+ test,
22
+ } from "@jest/globals";
23
+
24
+ /*
25
+ * ---------------------------------------------------------------------------
26
+ * UserTwoFactorBackupCodeService -- minting, spending and counting a user's
27
+ * recovery codes.
28
+ *
29
+ * WHAT THIS FILE IS GUARDING
30
+ *
31
+ * "Single use" is the ONLY promise a backup code makes beyond being a secret.
32
+ * A code that can be spent twice is a code that keeps working after the user
33
+ * has watched somebody use it, and the obvious implementation -- find the row,
34
+ * check `usedAt`, then update it -- gets that wrong under exactly the
35
+ * conditions an attacker would arrange. `consumeCode` therefore does the whole
36
+ * thing in ONE conditional UPDATE, and the assertions below are about the
37
+ * shape of that statement rather than about a value it returns, because the
38
+ * atomicity lives in the SQL and nowhere else.
39
+ *
40
+ * The second thing guarded here is regeneration. It must REPLACE, never add:
41
+ * a user who regenerates after losing a printed list has to know the lost list
42
+ * is dead, and "generate ten more" leaves it alive.
43
+ *
44
+ * WHAT IS MOCKED, AND WHAT IS NOT
45
+ *
46
+ * The database is. `getRepository` is stubbed down to a `manager.query` spy so
47
+ * the exact SQL and its bound parameters can be inspected -- there is no
48
+ * Postgres in this suite, and the statement is the thing under test.
49
+ *
50
+ * TwoFactorBackupCode is NOT mocked. The digests written by regeneration are
51
+ * checked against the real hashing function, because a service that stored
52
+ * something other than the digest the login path recomputes would produce a
53
+ * set of codes that has never worked and cannot be told apart from a set that
54
+ * has all been used.
55
+ *
56
+ * The crypto itself is covered by
57
+ * Common/Tests/Server/Utils/TwoFactorBackupCode.test.ts; the admin surfaces
58
+ * that call into this service are covered by
59
+ * Common/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts.
60
+ * ---------------------------------------------------------------------------
61
+ */
62
+
63
+ const USER_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
64
+
65
+ /*
66
+ * The postgres driver hands back `[rows, rowCount]` for an UPDATE rather than
67
+ * a bare row array. Every stub here reproduces that shape, because getting it
68
+ * wrong is precisely how a consumed code could read as "not consumed" (or, far
69
+ * worse, the other way round).
70
+ */
71
+ type UpdateResultFunction = (rowCount: number) => Array<unknown>;
72
+
73
+ const updateResult: UpdateResultFunction = (
74
+ rowCount: number,
75
+ ): Array<unknown> => {
76
+ const rows: Array<{ _id: string }> = [];
77
+
78
+ for (let index: number = 0; index < rowCount; index++) {
79
+ rows.push({ _id: ObjectID.generate().toString() });
80
+ }
81
+
82
+ return [rows, rowCount];
83
+ };
84
+
85
+ /*
86
+ * Deliberately `any`. The Common suite type-checks its tests (unlike the App
87
+ * suite, which transpiles only), and jest's own `Mock` generic does not line
88
+ * up with the loosely typed `mock.calls` reads below.
89
+ */
90
+ let queryMock: any;
91
+ let createSpy: any;
92
+ let countBySpy: any;
93
+ let findOneBySpy: any;
94
+ let deleteBySpy: any;
95
+
96
+ /* Every row `create` was asked to write, in order. */
97
+ let createdRows: Array<UserTwoFactorBackupCode> = [];
98
+
99
+ type StubQueryFunction = (result: unknown) => void;
100
+
101
+ const stubQuery: StubQueryFunction = (result: unknown): void => {
102
+ queryMock.mockResolvedValue(result as never);
103
+ };
104
+
105
+ beforeEach(() => {
106
+ jest.restoreAllMocks();
107
+
108
+ createdRows = [];
109
+
110
+ queryMock = jest.fn();
111
+ stubQuery(updateResult(0));
112
+
113
+ getJestSpyOn(UserTwoFactorBackupCodeService, "getRepository").mockReturnValue(
114
+ {
115
+ manager: {
116
+ query: (...args: Array<unknown>): unknown => {
117
+ return queryMock(...args);
118
+ },
119
+ },
120
+ } as never,
121
+ );
122
+
123
+ createSpy = getJestSpyOn(
124
+ UserTwoFactorBackupCodeService,
125
+ "create",
126
+ ).mockImplementation(async (input: any): Promise<UserTwoFactorBackupCode> => {
127
+ createdRows.push(input.data as UserTwoFactorBackupCode);
128
+ return input.data as UserTwoFactorBackupCode;
129
+ });
130
+
131
+ countBySpy = getJestSpyOn(
132
+ UserTwoFactorBackupCodeService,
133
+ "countBy",
134
+ ).mockImplementation(async (): Promise<PositiveNumber> => {
135
+ return new PositiveNumber(0);
136
+ });
137
+
138
+ findOneBySpy = getJestSpyOn(
139
+ UserTwoFactorBackupCodeService,
140
+ "findOneBy",
141
+ ).mockImplementation(async (): Promise<UserTwoFactorBackupCode | null> => {
142
+ return null;
143
+ });
144
+
145
+ deleteBySpy = getJestSpyOn(
146
+ UserTwoFactorBackupCodeService,
147
+ "deleteBy",
148
+ ).mockImplementation(async (): Promise<number> => {
149
+ return 0;
150
+ });
151
+ });
152
+
153
+ afterEach(() => {
154
+ jest.restoreAllMocks();
155
+ });
156
+
157
+ describe("regenerateForUser -- minting a set", () => {
158
+ test("returns a full set of plaintext codes", async () => {
159
+ const codes: Array<string> =
160
+ await UserTwoFactorBackupCodeService.regenerateForUser({
161
+ userId: USER_ID,
162
+ });
163
+
164
+ expect(codes).toHaveLength(BackupCodeSetSize);
165
+ expect(new Set(codes).size).toBe(BackupCodeSetSize);
166
+ });
167
+
168
+ test("honours an explicit count", async () => {
169
+ const codes: Array<string> =
170
+ await UserTwoFactorBackupCodeService.regenerateForUser({
171
+ userId: USER_ID,
172
+ count: 4,
173
+ });
174
+
175
+ expect(codes).toHaveLength(4);
176
+ expect(createSpy).toHaveBeenCalledTimes(4);
177
+ });
178
+
179
+ /*
180
+ * THE ONE THAT MATTERS MOST. If the row held anything other than the digest
181
+ * the login path recomputes -- the plaintext, a differently keyed hash, a
182
+ * digest computed for the wrong user -- then every code in the set would be
183
+ * rejected at sign-in, and the user would find out at the worst possible
184
+ * moment: locked out, holding a list they were told to trust.
185
+ */
186
+ test("stores the digest the login path will recompute, never the code", async () => {
187
+ const codes: Array<string> =
188
+ await UserTwoFactorBackupCodeService.regenerateForUser({
189
+ userId: USER_ID,
190
+ });
191
+
192
+ const storedHashes: Array<string> = createdRows.map(
193
+ (row: UserTwoFactorBackupCode) => {
194
+ return row.codeHash || "";
195
+ },
196
+ );
197
+
198
+ const expectedHashes: Array<string> = codes.map((code: string) => {
199
+ return TwoFactorBackupCode.hashCode({ code: code, userId: USER_ID });
200
+ });
201
+
202
+ expect(storedHashes).toEqual(expectedHashes);
203
+
204
+ const leaked: Array<string> = codes.filter((code: string) => {
205
+ return storedHashes.includes(code);
206
+ });
207
+
208
+ expect(leaked).toEqual([]);
209
+ });
210
+
211
+ test("owns every row to the user it was asked for", async () => {
212
+ await UserTwoFactorBackupCodeService.regenerateForUser({
213
+ userId: USER_ID,
214
+ });
215
+
216
+ const violations: Array<string> = [];
217
+
218
+ for (const row of createdRows) {
219
+ if (row.userId?.toString() !== USER_ID.toString()) {
220
+ violations.push(String(row.userId));
221
+ }
222
+ }
223
+
224
+ expect(violations).toEqual([]);
225
+ });
226
+
227
+ test("mints every row unused", async () => {
228
+ await UserTwoFactorBackupCodeService.regenerateForUser({
229
+ userId: USER_ID,
230
+ });
231
+
232
+ const spent: Array<unknown> = createdRows
233
+ .map((row: UserTwoFactorBackupCode) => {
234
+ return row.usedAt;
235
+ })
236
+ .filter((usedAt: Date | undefined) => {
237
+ return Boolean(usedAt);
238
+ });
239
+
240
+ expect(spent).toEqual([]);
241
+ });
242
+
243
+ /*
244
+ * REPLACE, not add. A user regenerating after losing a printed list is
245
+ * telling us that list may be in somebody else's hands; leaving those codes
246
+ * alive would make the button they pressed a no-op against the exact threat
247
+ * they pressed it for.
248
+ */
249
+ test("deletes the previous set before writing the new one", async () => {
250
+ await UserTwoFactorBackupCodeService.regenerateForUser({
251
+ userId: USER_ID,
252
+ });
253
+
254
+ expect(deleteBySpy).toHaveBeenCalledTimes(1);
255
+
256
+ const deleteOrder: number = deleteBySpy.mock.invocationCallOrder[0]!;
257
+ const firstCreateOrder: number = createSpy.mock.invocationCallOrder[0]!;
258
+
259
+ expect(deleteOrder).toBeLessThan(firstCreateOrder);
260
+ });
261
+
262
+ test("deletes only this user's codes, and does not cap below the set size", async () => {
263
+ await UserTwoFactorBackupCodeService.regenerateForUser({
264
+ userId: USER_ID,
265
+ });
266
+
267
+ expect(deleteBySpy).toHaveBeenCalledWith(
268
+ expect.objectContaining({
269
+ query: { userId: USER_ID },
270
+ limit: LIMIT_MAX,
271
+ skip: 0,
272
+ props: { isRoot: true },
273
+ }),
274
+ );
275
+ });
276
+
277
+ /*
278
+ * Every write goes through `isRoot`. The model denies create to everybody --
279
+ * including the code's own owner -- because a caller who could supply a
280
+ * `codeHash` would be choosing their own recovery credential.
281
+ */
282
+ test("writes as root, because nothing else is permitted to", async () => {
283
+ await UserTwoFactorBackupCodeService.regenerateForUser({
284
+ userId: USER_ID,
285
+ });
286
+
287
+ const violations: Array<unknown> = [];
288
+
289
+ for (const call of createSpy.mock.calls) {
290
+ const input: any = (call as Array<unknown>)[0];
291
+
292
+ if (input?.props?.isRoot !== true) {
293
+ violations.push(input?.props);
294
+ }
295
+ }
296
+
297
+ expect(violations).toEqual([]);
298
+ });
299
+ });
300
+
301
+ describe("regenerateForUser -- when a write fails partway through", () => {
302
+ /*
303
+ * THE STATE THAT LOOKS LIKE THE GOOD ONE.
304
+ *
305
+ * The old set is deleted first, then the new rows go in one at a time. A
306
+ * failure on, say, the fifth insert would otherwise leave four rows behind
307
+ * -- rows the caller never returned to anybody, because it threw. The
308
+ * profile page would then read "4 backup codes" off the database and tell a
309
+ * user they have a recovery route, when in fact they hold none of those four
310
+ * codes and never saw them.
311
+ *
312
+ * That is worse than having no codes at all, because "you have no backup
313
+ * codes" is a warning the page already shows and the user can act on.
314
+ */
315
+ test("leaves no codes behind rather than codes nobody has seen", async () => {
316
+ let created: number = 0;
317
+
318
+ createSpy.mockImplementation(
319
+ async (input: any): Promise<UserTwoFactorBackupCode> => {
320
+ created++;
321
+
322
+ if (created === 5) {
323
+ throw new Error("Database not connected");
324
+ }
325
+
326
+ createdRows.push(input.data as UserTwoFactorBackupCode);
327
+ return input.data as UserTwoFactorBackupCode;
328
+ },
329
+ );
330
+
331
+ await expect(
332
+ UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
333
+ ).rejects.toThrow("Database not connected");
334
+
335
+ const violations: Array<string> = [];
336
+
337
+ /*
338
+ * Twice: once to clear the previous set, once to compensate for the
339
+ * partial write. Both scoped to this user.
340
+ */
341
+ if (deleteBySpy.mock.calls.length !== 2) {
342
+ violations.push(
343
+ `expected two deletes, saw ${String(deleteBySpy.mock.calls.length)}`,
344
+ );
345
+ }
346
+
347
+ const lastDeleteOrder: number =
348
+ deleteBySpy.mock.invocationCallOrder[
349
+ deleteBySpy.mock.invocationCallOrder.length - 1
350
+ ]!;
351
+ const lastCreateOrder: number =
352
+ createSpy.mock.invocationCallOrder[
353
+ createSpy.mock.invocationCallOrder.length - 1
354
+ ]!;
355
+
356
+ if (lastDeleteOrder < lastCreateOrder) {
357
+ violations.push(
358
+ "the compensating delete ran before the failing write, so it cleaned up nothing",
359
+ );
360
+ }
361
+
362
+ expect(violations).toEqual([]);
363
+ });
364
+
365
+ /*
366
+ * The ORIGINAL failure is what the caller has to see. If the compensating
367
+ * delete also fails, reporting that one instead would tell an operator why
368
+ * the rollback did not happen while hiding why the write did not -- and the
369
+ * second is the one they can act on.
370
+ */
371
+ test("reports the write failure even when the cleanup also fails", async () => {
372
+ createSpy.mockImplementation(async (): Promise<UserTwoFactorBackupCode> => {
373
+ throw new Error("the write failed");
374
+ });
375
+
376
+ let cleanupAttempted: boolean = false;
377
+
378
+ deleteBySpy.mockImplementation(async (): Promise<number> => {
379
+ if (cleanupAttempted) {
380
+ throw new Error("the cleanup failed");
381
+ }
382
+
383
+ cleanupAttempted = true;
384
+ return 0;
385
+ });
386
+
387
+ await expect(
388
+ UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
389
+ ).rejects.toThrow("the write failed");
390
+ });
391
+
392
+ /*
393
+ * A failure in the FIRST delete must not be compensated, because nothing has
394
+ * been written yet -- and, more to the point, the old set is still intact.
395
+ * Re-running a delete that has just failed buys nothing and could mask the
396
+ * fact that the user's existing codes are untouched.
397
+ */
398
+ test("does not write or compensate when the initial delete fails", async () => {
399
+ deleteBySpy.mockImplementation(async (): Promise<number> => {
400
+ throw new Error("Database not connected");
401
+ });
402
+
403
+ await expect(
404
+ UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
405
+ ).rejects.toThrow("Database not connected");
406
+
407
+ expect(deleteBySpy).toHaveBeenCalledTimes(1);
408
+ expect(createSpy).not.toHaveBeenCalled();
409
+ });
410
+ });
411
+
412
+ describe("onBeforeCreate -- the second lock on the create path", () => {
413
+ /*
414
+ * The table permission is the first lock, and `isRoot` walks straight past
415
+ * it -- which every internal caller uses. So the hook has to refuse the two
416
+ * things that would silently produce an unusable or unowned credential.
417
+ */
418
+ type InvokeHookFunction = (
419
+ data: Partial<UserTwoFactorBackupCode>,
420
+ ) => Promise<CreateBy<UserTwoFactorBackupCode>>;
421
+
422
+ const invokeHook: InvokeHookFunction = async (
423
+ data: Partial<UserTwoFactorBackupCode>,
424
+ ): Promise<CreateBy<UserTwoFactorBackupCode>> => {
425
+ const row: UserTwoFactorBackupCode = new UserTwoFactorBackupCode();
426
+ Object.assign(row, data);
427
+
428
+ const createBy: CreateBy<UserTwoFactorBackupCode> = {
429
+ data: row,
430
+ props: { isRoot: true },
431
+ } as CreateBy<UserTwoFactorBackupCode>;
432
+
433
+ const result: { createBy: CreateBy<UserTwoFactorBackupCode> } = await (
434
+ UserTwoFactorBackupCodeService as any
435
+ ).onBeforeCreate(createBy);
436
+
437
+ return result.createBy;
438
+ };
439
+
440
+ test("refuses a row with no owner", async () => {
441
+ await expect(invokeHook({ codeHash: "a".repeat(64) })).rejects.toThrow(
442
+ BadDataException,
443
+ );
444
+ });
445
+
446
+ test("refuses a row with no digest", async () => {
447
+ await expect(invokeHook({ userId: USER_ID })).rejects.toThrow(
448
+ BadDataException,
449
+ );
450
+ });
451
+
452
+ /*
453
+ * A code that arrives already spent is a caller confused about which end of
454
+ * the lifecycle it is at -- and would be a row the user can never use,
455
+ * counted against their remaining total.
456
+ */
457
+ test("strips a usedAt that a caller tried to set", async () => {
458
+ const createBy: CreateBy<UserTwoFactorBackupCode> = await invokeHook({
459
+ userId: USER_ID,
460
+ codeHash: "a".repeat(64),
461
+ usedAt: new Date(),
462
+ });
463
+
464
+ expect(createBy.data.usedAt).toBeUndefined();
465
+ });
466
+
467
+ test("lets a well formed row through", async () => {
468
+ const createBy: CreateBy<UserTwoFactorBackupCode> = await invokeHook({
469
+ userId: USER_ID,
470
+ codeHash: "a".repeat(64),
471
+ });
472
+
473
+ expect(createBy.data.userId).toEqual(USER_ID);
474
+ expect(createBy.data.codeHash).toBe("a".repeat(64));
475
+ });
476
+ });
477
+
478
+ describe("consumeCode -- spending one, exactly once", () => {
479
+ test("reports success when the statement matched a row", async () => {
480
+ stubQuery(updateResult(1));
481
+
482
+ await expect(
483
+ UserTwoFactorBackupCodeService.consumeCode({
484
+ userId: USER_ID,
485
+ code: "ABCDE-12345",
486
+ }),
487
+ ).resolves.toBe(true);
488
+ });
489
+
490
+ test("reports failure when the statement matched nothing", async () => {
491
+ stubQuery(updateResult(0));
492
+
493
+ await expect(
494
+ UserTwoFactorBackupCodeService.consumeCode({
495
+ userId: USER_ID,
496
+ code: "ABCDE-12345",
497
+ }),
498
+ ).resolves.toBe(false);
499
+ });
500
+
501
+ /*
502
+ * THE ATOMICITY. `usedAt IS NULL` has to be in the WHERE clause of the same
503
+ * statement that sets it, or two sign-ins carrying the same code both read a
504
+ * null and both get let in. There is no assertion available for "this is
505
+ * atomic" other than looking at the SQL, so that is what this does.
506
+ */
507
+ test("decides single-use inside the statement, not around it", async () => {
508
+ stubQuery(updateResult(1));
509
+
510
+ await UserTwoFactorBackupCodeService.consumeCode({
511
+ userId: USER_ID,
512
+ code: "ABCDE-12345",
513
+ });
514
+
515
+ const sql: string = String(
516
+ (queryMock.mock.calls[0] as Array<unknown>)[0],
517
+ ).replace(/\s+/g, " ");
518
+
519
+ expect(sql).toContain('UPDATE "UserTwoFactorBackupCode"');
520
+ expect(sql).toContain('"usedAt" IS NULL');
521
+ expect(sql).toContain('RETURNING "_id"');
522
+ });
523
+
524
+ /*
525
+ * Soft-deleted rows are still physically present. Without this predicate, a
526
+ * code from a set that regeneration replaced would keep signing people in --
527
+ * which is the one thing regeneration exists to stop.
528
+ */
529
+ test("ignores soft-deleted rows", async () => {
530
+ stubQuery(updateResult(1));
531
+
532
+ await UserTwoFactorBackupCodeService.consumeCode({
533
+ userId: USER_ID,
534
+ code: "ABCDE-12345",
535
+ });
536
+
537
+ const sql: string = String(
538
+ (queryMock.mock.calls[0] as Array<unknown>)[0],
539
+ ).replace(/\s+/g, " ");
540
+
541
+ expect(sql).toContain('"deletedAt" IS NULL');
542
+ });
543
+
544
+ test("scopes the statement to the owning user", async () => {
545
+ stubQuery(updateResult(1));
546
+
547
+ await UserTwoFactorBackupCodeService.consumeCode({
548
+ userId: USER_ID,
549
+ code: "ABCDE-12345",
550
+ });
551
+
552
+ const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
553
+
554
+ expect(String(sql).replace(/\s+/g, " ")).toContain('"userId" = $2');
555
+ expect(params[1]).toBe(USER_ID.toString());
556
+ });
557
+
558
+ /*
559
+ * Every value is bound, never interpolated. The code is caller-supplied, so
560
+ * an interpolated statement here would be a SQL injection on an
561
+ * unauthenticated route.
562
+ */
563
+ test("binds every value as a parameter", async () => {
564
+ stubQuery(updateResult(1));
565
+
566
+ await UserTwoFactorBackupCodeService.consumeCode({
567
+ userId: USER_ID,
568
+ code: '\'; DROP TABLE "User"; --',
569
+ });
570
+
571
+ const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
572
+
573
+ expect(sql).not.toContain("DROP TABLE");
574
+ expect(params).toHaveLength(3);
575
+ expect(params[0]).toBeInstanceOf(Date);
576
+ });
577
+
578
+ test("looks the code up by its digest, never by the code itself", async () => {
579
+ stubQuery(updateResult(1));
580
+
581
+ const code: string = "ABCDE-12345";
582
+
583
+ await UserTwoFactorBackupCodeService.consumeCode({
584
+ userId: USER_ID,
585
+ code: code,
586
+ });
587
+
588
+ const params: Array<unknown> = (
589
+ queryMock.mock.calls[0] as [string, Array<unknown>]
590
+ )[1];
591
+
592
+ expect(params[2]).toBe(
593
+ TwoFactorBackupCode.hashCode({ code: code, userId: USER_ID }),
594
+ );
595
+
596
+ expect(params).not.toContain(code);
597
+ expect(params).not.toContain("ABCDE12345");
598
+ });
599
+
600
+ /*
601
+ * The user is typing off a printed list, months later, on whatever keyboard
602
+ * they have. Each of these is a way to reject somebody who supplied exactly
603
+ * the right secret.
604
+ */
605
+ test.each([
606
+ "ABCDE-12345",
607
+ "ABCDE12345",
608
+ "abcde-12345",
609
+ " ABCDE 12345 ",
610
+ "abcde\n12345",
611
+ ])("accepts the code typed as %p", async (typed: string) => {
612
+ stubQuery(updateResult(1));
613
+
614
+ await UserTwoFactorBackupCodeService.consumeCode({
615
+ userId: USER_ID,
616
+ code: typed,
617
+ });
618
+
619
+ const params: Array<unknown> = (
620
+ queryMock.mock.calls[0] as [string, Array<unknown>]
621
+ )[1];
622
+
623
+ expect(params[2]).toBe(
624
+ TwoFactorBackupCode.hashCode({
625
+ code: "ABCDE12345",
626
+ userId: USER_ID,
627
+ }),
628
+ );
629
+ });
630
+
631
+ /*
632
+ * Refused before the round trip. An empty submission cannot be anybody's
633
+ * code, and this route is one an attacker can call.
634
+ */
635
+ test.each([
636
+ ["", "empty"],
637
+ [" ", "whitespace"],
638
+ ["---", "punctuation only"],
639
+ ])(
640
+ "refuses %p (%s) without touching the database",
641
+ async (code: string, _label: string) => {
642
+ await expect(
643
+ UserTwoFactorBackupCodeService.consumeCode({
644
+ userId: USER_ID,
645
+ code: code,
646
+ }),
647
+ ).resolves.toBe(false);
648
+
649
+ expect(queryMock).not.toHaveBeenCalled();
650
+ },
651
+ );
652
+
653
+ test.each([[undefined], [null], [12345], [{}]])(
654
+ "refuses the non-string submission %p without throwing",
655
+ async (code: unknown) => {
656
+ await expect(
657
+ UserTwoFactorBackupCodeService.consumeCode({
658
+ userId: USER_ID,
659
+ code: code as string,
660
+ }),
661
+ ).resolves.toBe(false);
662
+
663
+ expect(queryMock).not.toHaveBeenCalled();
664
+ },
665
+ );
666
+
667
+ /*
668
+ * The driver's return shape is not something this code controls. Anything
669
+ * it does not recognise must read as "no code was spent" -- the direction
670
+ * that refuses a login, never the direction that grants one.
671
+ */
672
+ test.each([
673
+ [null, "null"],
674
+ [undefined, "undefined"],
675
+ [[], "an empty array"],
676
+ [[[], 0], "zero rows"],
677
+ [{}, "an object"],
678
+ [["not an array", 1], "a non-array rows slot"],
679
+ ])(
680
+ "treats %p (%s) as nothing consumed",
681
+ async (result: unknown, _label: string) => {
682
+ stubQuery(result);
683
+
684
+ await expect(
685
+ UserTwoFactorBackupCodeService.consumeCode({
686
+ userId: USER_ID,
687
+ code: "ABCDE-12345",
688
+ }),
689
+ ).resolves.toBe(false);
690
+ },
691
+ );
692
+
693
+ test("stamps usedAt rather than leaving the row looking unspent", async () => {
694
+ stubQuery(updateResult(1));
695
+
696
+ await UserTwoFactorBackupCodeService.consumeCode({
697
+ userId: USER_ID,
698
+ code: "ABCDE-12345",
699
+ });
700
+
701
+ const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
702
+
703
+ expect(String(sql).replace(/\s+/g, " ")).toContain('SET "usedAt" = $1');
704
+ expect(params[0]).toBeInstanceOf(Date);
705
+ });
706
+ });
707
+
708
+ describe("countUnusedForUser", () => {
709
+ /*
710
+ * `usedAt: null` on its own is DROPPED by TypeORM rather than compiled to
711
+ * `IS NULL`, so the count would silently include spent codes -- and the
712
+ * login page would offer a recovery route to somebody with nothing left to
713
+ * recover with.
714
+ */
715
+ test("counts only codes that have not been spent", async () => {
716
+ countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
717
+ return new PositiveNumber(7);
718
+ });
719
+
720
+ const unused: number =
721
+ await UserTwoFactorBackupCodeService.countUnusedForUser({
722
+ userId: USER_ID,
723
+ });
724
+
725
+ expect(unused).toBe(7);
726
+
727
+ const call: { query: Record<string, any>; props: Record<string, unknown> } =
728
+ (
729
+ countBySpy.mock.calls[0] as Array<{
730
+ query: Record<string, any>;
731
+ props: Record<string, unknown>;
732
+ }>
733
+ )[0]!;
734
+
735
+ expect(call.query["userId"]).toEqual(USER_ID);
736
+ expect(call.props).toEqual({ isRoot: true });
737
+
738
+ /*
739
+ * Compared through the SQL the operator emits rather than by deep equality
740
+ * against `QueryHelper.isNull()`. Two calls to that helper build two
741
+ * different closures, so `toEqual` compares function identity and fails on
742
+ * a query that is in fact correct -- and the thing worth pinning is the
743
+ * SQL anyway: a bare `usedAt: null` is DROPPED by TypeORM rather than
744
+ * compiled to `IS NULL`, so the count would silently include spent codes
745
+ * and the login page would offer a recovery route to somebody with nothing
746
+ * left to recover with.
747
+ */
748
+ const usedAtOperator: any = call.query["usedAt"];
749
+
750
+ expect(usedAtOperator).toBeInstanceOf(FindOperator);
751
+ expect(usedAtOperator.type).toBe("raw");
752
+ expect(usedAtOperator.getSql('"usedAt"')).toContain("IS NULL");
753
+ });
754
+
755
+ test("reports zero for a user with no codes", async () => {
756
+ countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
757
+ return new PositiveNumber(0);
758
+ });
759
+
760
+ await expect(
761
+ UserTwoFactorBackupCodeService.countUnusedForUser({ userId: USER_ID }),
762
+ ).resolves.toBe(0);
763
+ });
764
+ });
765
+
766
+ describe("getStatusForUser", () => {
767
+ test("short-circuits for a user who has never generated any", async () => {
768
+ countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
769
+ return new PositiveNumber(0);
770
+ });
771
+
772
+ const status: TwoFactorBackupCodeStatus =
773
+ await UserTwoFactorBackupCodeService.getStatusForUser({
774
+ userId: USER_ID,
775
+ });
776
+
777
+ expect(status).toEqual({ total: 0, unused: 0, generatedAt: null });
778
+
779
+ /*
780
+ * One count, then nothing. Reading the newest row for a `generatedAt` that
781
+ * cannot exist is a query per page load for no answer.
782
+ */
783
+ expect(countBySpy).toHaveBeenCalledTimes(1);
784
+ expect(findOneBySpy).not.toHaveBeenCalled();
785
+ });
786
+
787
+ test("reports the total, the unused count and when the set was minted", async () => {
788
+ const generatedAt: Date = new Date("2026-01-02T03:04:05.000Z");
789
+
790
+ let call: number = 0;
791
+
792
+ countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
793
+ call++;
794
+ // First call is the total, second is the unused count.
795
+ return new PositiveNumber(call === 1 ? 10 : 4);
796
+ });
797
+
798
+ findOneBySpy.mockImplementation(
799
+ async (): Promise<UserTwoFactorBackupCode> => {
800
+ const row: UserTwoFactorBackupCode = new UserTwoFactorBackupCode();
801
+ row.createdAt = generatedAt;
802
+ return row;
803
+ },
804
+ );
805
+
806
+ const status: TwoFactorBackupCodeStatus =
807
+ await UserTwoFactorBackupCodeService.getStatusForUser({
808
+ userId: USER_ID,
809
+ });
810
+
811
+ expect(status).toEqual({
812
+ total: 10,
813
+ unused: 4,
814
+ generatedAt: generatedAt,
815
+ });
816
+ });
817
+
818
+ /*
819
+ * The rows carry a credential digest and no caller needs it, so nothing that
820
+ * a stray log line could print is ever loaded.
821
+ */
822
+ test("never selects the digest", async () => {
823
+ let call: number = 0;
824
+
825
+ countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
826
+ call++;
827
+ return new PositiveNumber(call === 1 ? 10 : 4);
828
+ });
829
+
830
+ findOneBySpy.mockImplementation(
831
+ async (): Promise<UserTwoFactorBackupCode> => {
832
+ return new UserTwoFactorBackupCode();
833
+ },
834
+ );
835
+
836
+ await UserTwoFactorBackupCodeService.getStatusForUser({
837
+ userId: USER_ID,
838
+ });
839
+
840
+ const select: Record<string, unknown> = (
841
+ findOneBySpy.mock.calls[0] as Array<{ select: Record<string, unknown> }>
842
+ )[0]!.select;
843
+
844
+ expect(select["codeHash"]).toBeUndefined();
845
+ expect(select).toEqual({ createdAt: true });
846
+ });
847
+ });
848
+
849
+ describe("deleteAllForUser", () => {
850
+ test("removes every code the user has, scoped to that user", async () => {
851
+ await UserTwoFactorBackupCodeService.deleteAllForUser({ userId: USER_ID });
852
+
853
+ expect(deleteBySpy).toHaveBeenCalledWith(
854
+ expect.objectContaining({
855
+ query: { userId: USER_ID },
856
+ limit: LIMIT_MAX,
857
+ skip: 0,
858
+ props: { isRoot: true },
859
+ }),
860
+ );
861
+ });
862
+ });