@oneuptime/common 12.0.21 → 12.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
- package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/package.json +1 -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
|
+
});
|