@proteinjs/user-server 1.21.1 → 1.22.1
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/CHANGELOG.md +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +5 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordResetToken.d.ts +43 -12
- package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
- package/dist/src/authentication/PasswordResetToken.js +86 -21
- package/dist/src/authentication/PasswordResetToken.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts +1 -1
- package/dist/src/routes/devLogin.js +1 -1
- package/dist/src/routes/devMail.d.ts +4 -0
- package/dist/src/routes/devMail.d.ts.map +1 -0
- package/dist/src/routes/devMail.js +119 -0
- package/dist/src/routes/devMail.js.map +1 -0
- package/dist/src/routes/executePasswordReset.d.ts +2 -1
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +9 -7
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +3 -2
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +30 -27
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/test/DbTraffic.d.ts +29 -0
- package/dist/test/DbTraffic.d.ts.map +1 -0
- package/dist/test/DbTraffic.js +106 -0
- package/dist/test/DbTraffic.js.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
- package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
- package/dist/test/DevMail.test.d.ts +2 -0
- package/dist/test/DevMail.test.d.ts.map +1 -0
- package/dist/test/DevMail.test.js +250 -0
- package/dist/test/DevMail.test.js.map +1 -0
- package/dist/test/ExecutePasswordReset.test.js +513 -124
- package/dist/test/ExecutePasswordReset.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
- package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
- package/dist/test/InitiatePasswordReset.test.js +415 -0
- package/dist/test/InitiatePasswordReset.test.js.map +1 -0
- package/dist/test/LogCapture.d.ts +22 -0
- package/dist/test/LogCapture.d.ts.map +1 -0
- package/dist/test/LogCapture.js +127 -0
- package/dist/test/LogCapture.js.map +1 -0
- package/dist/test/MalformedResetTokens.d.ts +11 -0
- package/dist/test/MalformedResetTokens.d.ts.map +1 -0
- package/dist/test/MalformedResetTokens.js +35 -0
- package/dist/test/MalformedResetTokens.js.map +1 -0
- package/dist/test/ValidateResetToken.test.js +156 -33
- package/dist/test/ValidateResetToken.test.js.map +1 -1
- package/dist/test/emulatorLock.test.d.ts +2 -0
- package/dist/test/emulatorLock.test.d.ts.map +1 -0
- package/dist/test/emulatorLock.test.js +32 -0
- package/dist/test/emulatorLock.test.js.map +1 -0
- package/dist/test/emulatorLockName.test.d.ts +2 -0
- package/dist/test/emulatorLockName.test.d.ts.map +1 -0
- package/dist/test/emulatorLockName.test.js +36 -0
- package/dist/test/emulatorLockName.test.js.map +1 -0
- package/generated/index.ts +5 -1
- package/index.ts +1 -1
- package/jest.config.js +2 -2
- package/package.json +4 -4
- package/src/authentication/PasswordResetToken.ts +74 -20
- package/src/routes/devLogin.ts +1 -1
- package/src/routes/devMail.ts +72 -0
- package/src/routes/executePasswordReset.ts +3 -2
- package/src/routes/initiatePasswordReset.ts +20 -17
- package/test/DbTraffic.ts +43 -0
- package/test/DevLoginBootstrapAdmin.test.ts +3 -4
- package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
- package/test/DevMail.test.ts +151 -0
- package/test/ExecutePasswordReset.test.ts +308 -70
- package/test/InitiatePasswordReset.test.ts +250 -0
- package/test/LogCapture.ts +54 -0
- package/test/MalformedResetTokens.ts +27 -0
- package/test/ValidateResetToken.test.ts +108 -24
- package/test/emulatorLock.js +5 -6
- package/test/emulatorLock.test.ts +32 -0
- package/test/emulatorLockName.test.ts +33 -0
|
@@ -50,16 +50,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
50
50
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
51
51
|
};
|
|
52
52
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
var crypto_1 = require("crypto");
|
|
53
54
|
var moment_1 = __importDefault(require("moment"));
|
|
54
55
|
var db_1 = require("@proteinjs/db");
|
|
55
|
-
var logger_1 = require("@proteinjs/logger");
|
|
56
56
|
var user_1 = require("@proteinjs/user");
|
|
57
57
|
var executePasswordReset_1 = require("../src/routes/executePasswordReset");
|
|
58
58
|
var PasswordHasher_1 = require("../src/authentication/PasswordHasher");
|
|
59
59
|
var PasswordResetToken_1 = require("../src/authentication/PasswordResetToken");
|
|
60
60
|
var UserServerTestEnvironment_1 = require("./UserServerTestEnvironment");
|
|
61
|
+
var DbTraffic_1 = require("./DbTraffic");
|
|
62
|
+
var LogCapture_1 = require("./LogCapture");
|
|
63
|
+
var MalformedResetTokens_1 = require("./MalformedResetTokens");
|
|
61
64
|
var testEnv = new UserServerTestEnvironment_1.UserServerTestEnvironment();
|
|
62
|
-
var
|
|
65
|
+
var invokeExecuteRequest = function (request) { return __awaiter(void 0, void 0, void 0, function () {
|
|
63
66
|
var outcome, response;
|
|
64
67
|
return __generator(this, function (_a) {
|
|
65
68
|
switch (_a.label) {
|
|
@@ -74,15 +77,45 @@ var invokeExecute = function (body) { return __awaiter(void 0, void 0, void 0, f
|
|
|
74
77
|
outcome.body = body;
|
|
75
78
|
},
|
|
76
79
|
};
|
|
77
|
-
return [4 /*yield*/, executePasswordReset_1.executePasswordReset.onRequest(
|
|
80
|
+
return [4 /*yield*/, executePasswordReset_1.executePasswordReset.onRequest(request, response)];
|
|
78
81
|
case 1:
|
|
79
82
|
_a.sent();
|
|
80
83
|
return [2 /*return*/, outcome];
|
|
81
84
|
}
|
|
82
85
|
});
|
|
83
86
|
}); };
|
|
84
|
-
var
|
|
85
|
-
|
|
87
|
+
var invokeExecute = function (body) { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
88
|
+
switch (_a.label) {
|
|
89
|
+
case 0: return [4 /*yield*/, invokeExecuteRequest({ body: body })];
|
|
90
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
91
|
+
}
|
|
92
|
+
}); }); };
|
|
93
|
+
/** The digest a row must hold for `token`, computed here independently of the owner. */
|
|
94
|
+
var sha256Hex = function (value) { return (0, crypto_1.createHash)('sha256').update(value).digest('hex'); };
|
|
95
|
+
/** A well-formed token that was never issued to anyone. */
|
|
96
|
+
var unissuedToken = function () { return sha256Hex("never issued ".concat(Math.random())); };
|
|
97
|
+
/** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
|
|
98
|
+
var armResetToken = function (email, expiration) { return __awaiter(void 0, void 0, void 0, function () {
|
|
99
|
+
var user, token;
|
|
100
|
+
return __generator(this, function (_a) {
|
|
101
|
+
switch (_a.label) {
|
|
102
|
+
case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: email })];
|
|
103
|
+
case 1:
|
|
104
|
+
user = _a.sent();
|
|
105
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
|
|
106
|
+
case 2:
|
|
107
|
+
token = _a.sent();
|
|
108
|
+
if (!(expiration !== undefined)) return [3 /*break*/, 4];
|
|
109
|
+
return [4 /*yield*/, (0, db_1.getDbAsSystem)().update(user_1.tables.User, { id: user.id, passwordResetTokenExpiration: expiration })];
|
|
110
|
+
case 3:
|
|
111
|
+
_a.sent();
|
|
112
|
+
_a.label = 4;
|
|
113
|
+
case 4: return [2 /*return*/, { user: user, token: token }];
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}); };
|
|
117
|
+
/** A user whose token column is written raw — only for the column states the owner never writes. */
|
|
118
|
+
var armRawTokenColumn = function (email, stored) { return __awaiter(void 0, void 0, void 0, function () {
|
|
86
119
|
var user;
|
|
87
120
|
return __generator(this, function (_a) {
|
|
88
121
|
switch (_a.label) {
|
|
@@ -91,8 +124,8 @@ var armResetToken = function (email, token, expiration) { return __awaiter(void
|
|
|
91
124
|
user = _a.sent();
|
|
92
125
|
return [4 /*yield*/, (0, db_1.getDbAsSystem)().update(user_1.tables.User, {
|
|
93
126
|
id: user.id,
|
|
94
|
-
passwordResetToken:
|
|
95
|
-
passwordResetTokenExpiration:
|
|
127
|
+
passwordResetToken: stored,
|
|
128
|
+
passwordResetTokenExpiration: (0, moment_1.default)().add(1, 'hour'),
|
|
96
129
|
})];
|
|
97
130
|
case 2:
|
|
98
131
|
_a.sent();
|
|
@@ -106,23 +139,28 @@ var userRow = function (id) { return __awaiter(void 0, void 0, void 0, function
|
|
|
106
139
|
case 1: return [2 /*return*/, _a.sent()];
|
|
107
140
|
}
|
|
108
141
|
}); }); };
|
|
109
|
-
/** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
110
|
-
var
|
|
142
|
+
/** Every password, stored token and expiry in the table, by email — the whole-table outcome a refused request must leave untouched. */
|
|
143
|
+
var resetStateByEmail = function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
111
144
|
var byEmail, _i, _a, user;
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
var _b;
|
|
146
|
+
return __generator(this, function (_c) {
|
|
147
|
+
switch (_c.label) {
|
|
114
148
|
case 0:
|
|
115
149
|
byEmail = {};
|
|
116
150
|
_i = 0;
|
|
117
151
|
return [4 /*yield*/, (0, db_1.getDbAsSystem)().query(user_1.tables.User, {})];
|
|
118
152
|
case 1:
|
|
119
|
-
_a =
|
|
120
|
-
|
|
153
|
+
_a = _c.sent();
|
|
154
|
+
_c.label = 2;
|
|
121
155
|
case 2:
|
|
122
156
|
if (!(_i < _a.length)) return [3 /*break*/, 4];
|
|
123
157
|
user = _a[_i];
|
|
124
|
-
byEmail[user.email] =
|
|
125
|
-
|
|
158
|
+
byEmail[user.email] = [
|
|
159
|
+
user.password,
|
|
160
|
+
(_b = user.passwordResetToken) !== null && _b !== void 0 ? _b : null,
|
|
161
|
+
user.passwordResetTokenExpiration ? (0, moment_1.default)(user.passwordResetTokenExpiration).toISOString() : null,
|
|
162
|
+
];
|
|
163
|
+
_c.label = 3;
|
|
126
164
|
case 3:
|
|
127
165
|
_i++;
|
|
128
166
|
return [3 /*break*/, 2];
|
|
@@ -130,6 +168,31 @@ var passwordsByEmail = function () { return __awaiter(void 0, void 0, void 0, fu
|
|
|
130
168
|
}
|
|
131
169
|
});
|
|
132
170
|
}); };
|
|
171
|
+
/** Which of `candidates` the stored password hash verifies. */
|
|
172
|
+
var verifiedPasswords = function (storedHash, candidates) { return __awaiter(void 0, void 0, void 0, function () {
|
|
173
|
+
var verified, _i, candidates_1, candidate;
|
|
174
|
+
return __generator(this, function (_a) {
|
|
175
|
+
switch (_a.label) {
|
|
176
|
+
case 0:
|
|
177
|
+
verified = [];
|
|
178
|
+
_i = 0, candidates_1 = candidates;
|
|
179
|
+
_a.label = 1;
|
|
180
|
+
case 1:
|
|
181
|
+
if (!(_i < candidates_1.length)) return [3 /*break*/, 4];
|
|
182
|
+
candidate = candidates_1[_i];
|
|
183
|
+
return [4 /*yield*/, new PasswordHasher_1.PasswordHasher().verify(storedHash, candidate)];
|
|
184
|
+
case 2:
|
|
185
|
+
if (_a.sent()) {
|
|
186
|
+
verified.push(candidate);
|
|
187
|
+
}
|
|
188
|
+
_a.label = 3;
|
|
189
|
+
case 3:
|
|
190
|
+
_i++;
|
|
191
|
+
return [3 /*break*/, 1];
|
|
192
|
+
case 4: return [2 /*return*/, verified];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}); };
|
|
133
196
|
describe('executePasswordReset route', function () {
|
|
134
197
|
beforeAll(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
135
198
|
return __generator(this, function (_a) {
|
|
@@ -143,7 +206,7 @@ describe('executePasswordReset route', function () {
|
|
|
143
206
|
// No pending reset — the account a `null` token's `IS NULL` filter would match.
|
|
144
207
|
_a.sent();
|
|
145
208
|
// An emptied token column — the account an empty token would match.
|
|
146
|
-
return [4 /*yield*/,
|
|
209
|
+
return [4 /*yield*/, armRawTokenColumn('reset-emptied@test.local', '')];
|
|
147
210
|
case 3:
|
|
148
211
|
// An emptied token column — the account an empty token would match.
|
|
149
212
|
_a.sent();
|
|
@@ -162,111 +225,233 @@ describe('executePasswordReset route', function () {
|
|
|
162
225
|
});
|
|
163
226
|
}); });
|
|
164
227
|
describe('a request without a well-formed token is refused before any lookup', function () {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
['not a token string', { token: 'tok-valid-1' }],
|
|
170
|
-
['a number', { token: 123 }],
|
|
171
|
-
['an array', { token: ['a', 'b'] }],
|
|
172
|
-
['an object', { token: { passwordResetToken: null } }],
|
|
173
|
-
])('%s: 400, and no password in the table changes', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
|
|
174
|
-
var before, outcome, _a;
|
|
228
|
+
var liveUser;
|
|
229
|
+
var liveToken;
|
|
230
|
+
beforeAll(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
231
|
+
var _a;
|
|
175
232
|
return __generator(this, function (_b) {
|
|
176
233
|
switch (_b.label) {
|
|
177
|
-
case 0: return [4 /*yield*/,
|
|
234
|
+
case 0: return [4 /*yield*/, armResetToken('reset-shapes@test.local')];
|
|
178
235
|
case 1:
|
|
179
|
-
|
|
180
|
-
return [
|
|
236
|
+
(_a = _b.sent(), liveUser = _a.user, liveToken = _a.token);
|
|
237
|
+
return [2 /*return*/];
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}); });
|
|
241
|
+
it.each(MalformedResetTokens_1.MalformedResetTokens.CASES)('%s: 400, nothing in the table changes, and nothing reaches the database', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
|
|
242
|
+
var before, _a, outcome, traffic, _b;
|
|
243
|
+
return __generator(this, function (_c) {
|
|
244
|
+
switch (_c.label) {
|
|
245
|
+
case 0: return [4 /*yield*/, resetStateByEmail()];
|
|
246
|
+
case 1:
|
|
247
|
+
before = _c.sent();
|
|
248
|
+
return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
249
|
+
switch (_a.label) {
|
|
250
|
+
case 0: return [4 /*yield*/, invokeExecute(__assign(__assign({}, tokenField(liveToken)), { newPassword: 'hijacked' }))];
|
|
251
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
252
|
+
}
|
|
253
|
+
}); }); })];
|
|
181
254
|
case 2:
|
|
182
|
-
|
|
255
|
+
_a = _c.sent(), outcome = _a.result, traffic = _a.traffic;
|
|
183
256
|
expect(outcome.status).toBe(400);
|
|
184
257
|
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
185
|
-
|
|
186
|
-
|
|
258
|
+
expect(traffic).toEqual(DbTraffic_1.DbTraffic.NONE);
|
|
259
|
+
_b = expect;
|
|
260
|
+
return [4 /*yield*/, resetStateByEmail()];
|
|
261
|
+
case 3:
|
|
262
|
+
_b.apply(void 0, [_c.sent()]).toEqual(before);
|
|
263
|
+
return [2 /*return*/];
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}); });
|
|
267
|
+
it('after every refusal the live token still resets the password', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
268
|
+
var outcome, row;
|
|
269
|
+
return __generator(this, function (_a) {
|
|
270
|
+
switch (_a.label) {
|
|
271
|
+
case 0: return [4 /*yield*/, invokeExecute({ token: liveToken, newPassword: 'still live' })];
|
|
272
|
+
case 1:
|
|
273
|
+
outcome = _a.sent();
|
|
274
|
+
expect(outcome.status).toBe(200);
|
|
275
|
+
return [4 /*yield*/, userRow(liveUser.id)];
|
|
276
|
+
case 2:
|
|
277
|
+
row = _a.sent();
|
|
278
|
+
return [4 /*yield*/, expect(new PasswordHasher_1.PasswordHasher().verify(row.password, 'still live')).resolves.toBe(true)];
|
|
187
279
|
case 3:
|
|
188
|
-
_a.
|
|
280
|
+
_a.sent();
|
|
189
281
|
return [2 /*return*/];
|
|
190
282
|
}
|
|
191
283
|
});
|
|
192
284
|
}); });
|
|
193
285
|
});
|
|
286
|
+
it.each([
|
|
287
|
+
['no body', 'reset-no-body@test.local', {}],
|
|
288
|
+
['a null body', 'reset-null-body@test.local', { body: null }],
|
|
289
|
+
])('a request with %s: 400, and nothing changes', function (_label, email, request) { return __awaiter(void 0, void 0, void 0, function () {
|
|
290
|
+
var _a, user, token, before, outcome, _b, _c;
|
|
291
|
+
return __generator(this, function (_d) {
|
|
292
|
+
switch (_d.label) {
|
|
293
|
+
case 0: return [4 /*yield*/, armResetToken(email)];
|
|
294
|
+
case 1:
|
|
295
|
+
_a = _d.sent(), user = _a.user, token = _a.token;
|
|
296
|
+
return [4 /*yield*/, resetStateByEmail()];
|
|
297
|
+
case 2:
|
|
298
|
+
before = _d.sent();
|
|
299
|
+
return [4 /*yield*/, invokeExecuteRequest(request)];
|
|
300
|
+
case 3:
|
|
301
|
+
outcome = _d.sent();
|
|
302
|
+
expect(outcome.status).toBe(400);
|
|
303
|
+
_b = expect;
|
|
304
|
+
return [4 /*yield*/, resetStateByEmail()];
|
|
305
|
+
case 4:
|
|
306
|
+
_b.apply(void 0, [_d.sent()]).toEqual(before);
|
|
307
|
+
_c = expect;
|
|
308
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
309
|
+
case 5:
|
|
310
|
+
_c.apply(void 0, [(_d.sent()).passwordResetToken]).toBe(sha256Hex(token));
|
|
311
|
+
return [2 /*return*/];
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}); });
|
|
194
315
|
it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
195
|
-
var
|
|
196
|
-
return __generator(this, function (
|
|
197
|
-
switch (
|
|
198
|
-
case 0:
|
|
199
|
-
token = mintToken();
|
|
200
|
-
return [4 /*yield*/, armResetToken('reset-live@test.local', token, (0, moment_1.default)().add(1, 'hour'))];
|
|
316
|
+
var _a, user, token, first, afterFirst, second, _b;
|
|
317
|
+
return __generator(this, function (_c) {
|
|
318
|
+
switch (_c.label) {
|
|
319
|
+
case 0: return [4 /*yield*/, armResetToken('reset-live@test.local')];
|
|
201
320
|
case 1:
|
|
202
|
-
|
|
321
|
+
_a = _c.sent(), user = _a.user, token = _a.token;
|
|
203
322
|
return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'first new password' })];
|
|
204
323
|
case 2:
|
|
205
|
-
first =
|
|
324
|
+
first = _c.sent();
|
|
206
325
|
expect(first.status).toBe(200);
|
|
207
326
|
expect(first.body).toEqual({ message: 'Password has been successfully reset' });
|
|
208
327
|
return [4 /*yield*/, userRow(user.id)];
|
|
209
328
|
case 3:
|
|
210
|
-
afterFirst =
|
|
329
|
+
afterFirst = _c.sent();
|
|
211
330
|
return [4 /*yield*/, expect(new PasswordHasher_1.PasswordHasher().verify(afterFirst.password, 'first new password')).resolves.toBe(true)];
|
|
212
331
|
case 4:
|
|
213
|
-
|
|
332
|
+
_c.sent();
|
|
214
333
|
expect(afterFirst.passwordResetToken).toBeNull();
|
|
215
334
|
expect(afterFirst.passwordResetTokenExpiration).toBeNull();
|
|
216
335
|
return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'second new password' })];
|
|
217
336
|
case 5:
|
|
218
|
-
second =
|
|
337
|
+
second = _c.sent();
|
|
219
338
|
expect(second.status).toBe(400);
|
|
220
|
-
|
|
339
|
+
_b = expect;
|
|
221
340
|
return [4 /*yield*/, userRow(user.id)];
|
|
222
341
|
case 6:
|
|
223
|
-
|
|
342
|
+
_b.apply(void 0, [(_c.sent()).password]).toBe(afterFirst.password);
|
|
224
343
|
return [2 /*return*/];
|
|
225
344
|
}
|
|
226
345
|
});
|
|
227
346
|
}); });
|
|
228
|
-
it(
|
|
229
|
-
var
|
|
230
|
-
return __generator(this, function (
|
|
231
|
-
switch (
|
|
232
|
-
case 0:
|
|
233
|
-
|
|
234
|
-
|
|
347
|
+
it("two simultaneous presentations of one token: exactly one 200, and the row verifies only the winner's password", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
348
|
+
var _a, user, token, candidates, hashPassword, release, bothHashed, hashed, hash, outcomes, winner, row, _b;
|
|
349
|
+
return __generator(this, function (_c) {
|
|
350
|
+
switch (_c.label) {
|
|
351
|
+
case 0: return [4 /*yield*/, armResetToken('reset-race@test.local')];
|
|
352
|
+
case 1:
|
|
353
|
+
_a = _c.sent(), user = _a.user, token = _a.token;
|
|
354
|
+
candidates = ['racer one password', 'racer two password'];
|
|
355
|
+
hashPassword = PasswordHasher_1.PasswordHasher.prototype.hash;
|
|
356
|
+
release = function () { return undefined; };
|
|
357
|
+
bothHashed = new Promise(function (resolve) { return (release = resolve); });
|
|
358
|
+
hashed = 0;
|
|
359
|
+
hash = jest.spyOn(PasswordHasher_1.PasswordHasher.prototype, 'hash').mockImplementation(function (password) {
|
|
360
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
361
|
+
var hashedPassword;
|
|
362
|
+
return __generator(this, function (_a) {
|
|
363
|
+
switch (_a.label) {
|
|
364
|
+
case 0: return [4 /*yield*/, hashPassword.call(this, password)];
|
|
365
|
+
case 1:
|
|
366
|
+
hashedPassword = _a.sent();
|
|
367
|
+
if (++hashed === candidates.length) {
|
|
368
|
+
release();
|
|
369
|
+
}
|
|
370
|
+
return [4 /*yield*/, bothHashed];
|
|
371
|
+
case 2:
|
|
372
|
+
_a.sent();
|
|
373
|
+
return [2 /*return*/, hashedPassword];
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
_c.label = 2;
|
|
379
|
+
case 2:
|
|
380
|
+
_c.trys.push([2, , 4, 5]);
|
|
381
|
+
return [4 /*yield*/, Promise.all(candidates.map(function (newPassword) { return invokeExecute({ token: token, newPassword: newPassword }); }))];
|
|
382
|
+
case 3:
|
|
383
|
+
outcomes = _c.sent();
|
|
384
|
+
return [3 /*break*/, 5];
|
|
385
|
+
case 4:
|
|
386
|
+
hash.mockRestore();
|
|
387
|
+
return [7 /*endfinally*/];
|
|
388
|
+
case 5:
|
|
389
|
+
expect(outcomes.map(function (outcome) { return outcome.status; }).sort()).toEqual([200, 400]);
|
|
390
|
+
winner = candidates[outcomes.findIndex(function (outcome) { return outcome.status === 200; })];
|
|
391
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
392
|
+
case 6:
|
|
393
|
+
row = _c.sent();
|
|
394
|
+
_b = expect;
|
|
395
|
+
return [4 /*yield*/, verifiedPasswords(row.password, candidates)];
|
|
396
|
+
case 7:
|
|
397
|
+
_b.apply(void 0, [_c.sent()]).toEqual([winner]);
|
|
398
|
+
expect(row.passwordResetToken).toBeNull();
|
|
399
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
400
|
+
return [2 /*return*/];
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}); });
|
|
404
|
+
it('the row stores the SHA-256 digest of the token, never the token — and the digest presented as a token is refused', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
405
|
+
var _a, user, token, armed, _b, outcome, traffic, row;
|
|
406
|
+
return __generator(this, function (_c) {
|
|
407
|
+
switch (_c.label) {
|
|
408
|
+
case 0: return [4 /*yield*/, armResetToken('reset-digest@test.local')];
|
|
235
409
|
case 1:
|
|
236
|
-
|
|
410
|
+
_a = _c.sent(), user = _a.user, token = _a.token;
|
|
237
411
|
return [4 /*yield*/, userRow(user.id)];
|
|
238
412
|
case 2:
|
|
239
|
-
|
|
240
|
-
|
|
413
|
+
armed = _c.sent();
|
|
414
|
+
expect(armed.passwordResetToken).toBe(sha256Hex(token));
|
|
415
|
+
expect(JSON.stringify(armed)).not.toContain(token);
|
|
416
|
+
return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
417
|
+
switch (_a.label) {
|
|
418
|
+
case 0: return [4 /*yield*/, invokeExecute({ token: armed.passwordResetToken, newPassword: 'hijacked' })];
|
|
419
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
420
|
+
}
|
|
421
|
+
}); }); })];
|
|
241
422
|
case 3:
|
|
242
|
-
|
|
423
|
+
_b = _c.sent(), outcome = _b.result, traffic = _b.traffic;
|
|
243
424
|
expect(outcome.status).toBe(400);
|
|
244
|
-
expect(outcome.body).toEqual({ error: '
|
|
425
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
426
|
+
expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
|
|
427
|
+
expect(traffic.statements).toBe(1);
|
|
428
|
+
expect(traffic.writes).toBe(0);
|
|
245
429
|
return [4 /*yield*/, userRow(user.id)];
|
|
246
430
|
case 4:
|
|
247
|
-
row =
|
|
248
|
-
expect(row.password).toBe(
|
|
249
|
-
expect(row.passwordResetToken).toBe(token);
|
|
431
|
+
row = _c.sent();
|
|
432
|
+
expect(row.password).toBe(armed.password);
|
|
433
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
250
434
|
return [2 /*return*/];
|
|
251
435
|
}
|
|
252
436
|
});
|
|
253
437
|
}); });
|
|
254
|
-
it('a token
|
|
255
|
-
var
|
|
438
|
+
it('a token an earlier release stored in clear is refused — it fails for the rest of its hour, no migration', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
439
|
+
var clearToken, user, before, outcome, _a;
|
|
256
440
|
return __generator(this, function (_b) {
|
|
257
441
|
switch (_b.label) {
|
|
258
442
|
case 0:
|
|
259
|
-
|
|
260
|
-
return [4 /*yield*/,
|
|
443
|
+
clearToken = unissuedToken();
|
|
444
|
+
return [4 /*yield*/, armRawTokenColumn('reset-stored-in-clear@test.local', clearToken)];
|
|
261
445
|
case 1:
|
|
262
446
|
user = _b.sent();
|
|
263
447
|
return [4 /*yield*/, userRow(user.id)];
|
|
264
448
|
case 2:
|
|
265
449
|
before = (_b.sent()).password;
|
|
266
|
-
return [4 /*yield*/, invokeExecute({ token:
|
|
450
|
+
return [4 /*yield*/, invokeExecute({ token: clearToken, newPassword: 'hijacked' })];
|
|
267
451
|
case 3:
|
|
268
452
|
outcome = _b.sent();
|
|
269
453
|
expect(outcome.status).toBe(400);
|
|
454
|
+
expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
|
|
270
455
|
_a = expect;
|
|
271
456
|
return [4 /*yield*/, userRow(user.id)];
|
|
272
457
|
case 4:
|
|
@@ -275,80 +460,211 @@ describe('executePasswordReset route', function () {
|
|
|
275
460
|
}
|
|
276
461
|
});
|
|
277
462
|
}); });
|
|
278
|
-
it('
|
|
279
|
-
var
|
|
463
|
+
it('an expired token is refused and changes nothing', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
464
|
+
var _a, user, token, before, outcome, row;
|
|
280
465
|
return __generator(this, function (_b) {
|
|
281
466
|
switch (_b.label) {
|
|
282
|
-
case 0:
|
|
283
|
-
token = mintToken();
|
|
284
|
-
return [4 /*yield*/, armResetToken('reset-blank@test.local', token, (0, moment_1.default)().add(1, 'hour'))];
|
|
467
|
+
case 0: return [4 /*yield*/, armResetToken('reset-expired@test.local', (0, moment_1.default)().subtract(1, 'minute'))];
|
|
285
468
|
case 1:
|
|
286
|
-
|
|
469
|
+
_a = _b.sent(), user = _a.user, token = _a.token;
|
|
287
470
|
return [4 /*yield*/, userRow(user.id)];
|
|
288
471
|
case 2:
|
|
289
472
|
before = (_b.sent()).password;
|
|
290
|
-
|
|
291
|
-
|
|
473
|
+
return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
|
|
474
|
+
case 3:
|
|
475
|
+
outcome = _b.sent();
|
|
476
|
+
expect(outcome.status).toBe(400);
|
|
477
|
+
expect(outcome.body).toEqual({ error: 'Reset token has expired' });
|
|
478
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
479
|
+
case 4:
|
|
480
|
+
row = _b.sent();
|
|
481
|
+
expect(row.password).toBe(before);
|
|
482
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
483
|
+
return [2 /*return*/];
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}); });
|
|
487
|
+
it('a token whose row carries no expiry is refused — never live by omission', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
488
|
+
var _a, user, token, before, outcome, _b;
|
|
489
|
+
return __generator(this, function (_c) {
|
|
490
|
+
switch (_c.label) {
|
|
491
|
+
case 0: return [4 /*yield*/, armResetToken('reset-no-expiry@test.local', null)];
|
|
492
|
+
case 1:
|
|
493
|
+
_a = _c.sent(), user = _a.user, token = _a.token;
|
|
494
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
495
|
+
case 2:
|
|
496
|
+
before = (_c.sent()).password;
|
|
497
|
+
return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
|
|
498
|
+
case 3:
|
|
499
|
+
outcome = _c.sent();
|
|
500
|
+
expect(outcome.status).toBe(400);
|
|
501
|
+
_b = expect;
|
|
502
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
503
|
+
case 4:
|
|
504
|
+
_b.apply(void 0, [(_c.sent()).password]).toBe(before);
|
|
505
|
+
return [2 /*return*/];
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}); });
|
|
509
|
+
it('a blank new password is refused without consuming the token', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
510
|
+
var _a, user, token, before, _i, _b, newPassword, outcome, row;
|
|
511
|
+
return __generator(this, function (_c) {
|
|
512
|
+
switch (_c.label) {
|
|
513
|
+
case 0: return [4 /*yield*/, armResetToken('reset-blank@test.local')];
|
|
514
|
+
case 1:
|
|
515
|
+
_a = _c.sent(), user = _a.user, token = _a.token;
|
|
516
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
517
|
+
case 2:
|
|
518
|
+
before = (_c.sent()).password;
|
|
519
|
+
_i = 0, _b = ['', undefined, 42];
|
|
520
|
+
_c.label = 3;
|
|
292
521
|
case 3:
|
|
293
|
-
if (!(_i <
|
|
294
|
-
newPassword =
|
|
522
|
+
if (!(_i < _b.length)) return [3 /*break*/, 6];
|
|
523
|
+
newPassword = _b[_i];
|
|
295
524
|
return [4 /*yield*/, invokeExecute({ token: token, newPassword: newPassword })];
|
|
296
525
|
case 4:
|
|
297
|
-
outcome =
|
|
526
|
+
outcome = _c.sent();
|
|
298
527
|
expect(outcome.status).toBe(400);
|
|
299
|
-
|
|
528
|
+
_c.label = 5;
|
|
300
529
|
case 5:
|
|
301
530
|
_i++;
|
|
302
531
|
return [3 /*break*/, 3];
|
|
303
532
|
case 6: return [4 /*yield*/, userRow(user.id)];
|
|
304
533
|
case 7:
|
|
305
|
-
row =
|
|
534
|
+
row = _c.sent();
|
|
306
535
|
expect(row.password).toBe(before);
|
|
307
|
-
expect(row.passwordResetToken).toBe(token);
|
|
536
|
+
expect(row.passwordResetToken).toBe(sha256Hex(token));
|
|
308
537
|
return [2 /*return*/];
|
|
309
538
|
}
|
|
310
539
|
});
|
|
311
540
|
}); });
|
|
312
|
-
it('the
|
|
313
|
-
var
|
|
541
|
+
it('no token, no leading characters of one and no full digest reach the log — every path through the route', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
542
|
+
var live, expired, unissued, log, _i, _a, token;
|
|
314
543
|
return __generator(this, function (_b) {
|
|
315
544
|
switch (_b.label) {
|
|
316
|
-
case 0:
|
|
317
|
-
token = mintToken();
|
|
318
|
-
info = jest.spyOn(logger_1.Logger.prototype, 'info');
|
|
319
|
-
_b.label = 1;
|
|
545
|
+
case 0: return [4 /*yield*/, armResetToken('reset-log-live@test.local')];
|
|
320
546
|
case 1:
|
|
321
|
-
_b.
|
|
322
|
-
return [4 /*yield*/,
|
|
547
|
+
live = (_b.sent()).token;
|
|
548
|
+
return [4 /*yield*/, armResetToken('reset-log-expired@test.local', (0, moment_1.default)().subtract(1, 'minute'))];
|
|
323
549
|
case 2:
|
|
324
|
-
_b.sent();
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
550
|
+
expired = (_b.sent()).token;
|
|
551
|
+
unissued = unissuedToken();
|
|
552
|
+
return [4 /*yield*/, LogCapture_1.LogCapture.during(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
553
|
+
return __generator(this, function (_a) {
|
|
554
|
+
switch (_a.label) {
|
|
555
|
+
case 0: return [4 /*yield*/, invokeExecute({ token: unissued, newPassword: 'hijacked' })];
|
|
556
|
+
case 1:
|
|
557
|
+
_a.sent();
|
|
558
|
+
return [4 /*yield*/, invokeExecute({ token: live.toUpperCase(), newPassword: 'hijacked' })];
|
|
559
|
+
case 2:
|
|
560
|
+
_a.sent();
|
|
561
|
+
return [4 /*yield*/, invokeExecute({ token: sha256Hex(live), newPassword: 'hijacked' })];
|
|
562
|
+
case 3:
|
|
563
|
+
_a.sent();
|
|
564
|
+
return [4 /*yield*/, invokeExecute({ token: expired, newPassword: 'hijacked' })];
|
|
565
|
+
case 4:
|
|
566
|
+
_a.sent();
|
|
567
|
+
return [4 /*yield*/, invokeExecute({ token: live, newPassword: 'a logged password' })];
|
|
568
|
+
case 5:
|
|
569
|
+
_a.sent();
|
|
570
|
+
return [4 /*yield*/, invokeExecute({ token: live, newPassword: 'a second logged password' })];
|
|
571
|
+
case 6:
|
|
572
|
+
_a.sent();
|
|
573
|
+
return [2 /*return*/];
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}); })];
|
|
331
577
|
case 3:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
578
|
+
log = _b.sent();
|
|
579
|
+
// The capture saw the route's lines: each path above logged.
|
|
580
|
+
expect(log.text).toContain('Invalid reset token used');
|
|
581
|
+
expect(log.text).toContain('Expired reset token used');
|
|
582
|
+
expect(log.text).toContain('Password successfully reset');
|
|
583
|
+
expect(log.lines.length).toBeGreaterThanOrEqual(6);
|
|
584
|
+
// The digest presented as a token is covered by `live`: its full digest is what was presented.
|
|
585
|
+
for (_i = 0, _a = [unissued, live, expired]; _i < _a.length; _i++) {
|
|
586
|
+
token = _a[_i];
|
|
587
|
+
log.expectFreeOf(token);
|
|
588
|
+
}
|
|
589
|
+
return [2 /*return*/];
|
|
335
590
|
}
|
|
336
591
|
});
|
|
337
592
|
}); });
|
|
338
593
|
describe('PasswordResetToken', function () {
|
|
339
594
|
var internals = new PasswordResetToken_1.PasswordResetToken();
|
|
340
|
-
it('mints 64 lowercase hex characters, fresh each time', function () {
|
|
341
|
-
var
|
|
342
|
-
|
|
343
|
-
|
|
595
|
+
it('mints 64 lowercase hex characters, fresh each time, and stores the digest with an expiry an hour out', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
596
|
+
var user, first, afterFirst, second, afterSecond, minutesOut;
|
|
597
|
+
return __generator(this, function (_a) {
|
|
598
|
+
switch (_a.label) {
|
|
599
|
+
case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: 'reset-mint@test.local' })];
|
|
600
|
+
case 1:
|
|
601
|
+
user = _a.sent();
|
|
602
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
|
|
603
|
+
case 2:
|
|
604
|
+
first = _a.sent();
|
|
605
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
606
|
+
case 3:
|
|
607
|
+
afterFirst = _a.sent();
|
|
608
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
|
|
609
|
+
case 4:
|
|
610
|
+
second = _a.sent();
|
|
611
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
612
|
+
case 5:
|
|
613
|
+
afterSecond = _a.sent();
|
|
614
|
+
expect(first).toMatch(/^[0-9a-f]{64}$/);
|
|
615
|
+
expect(second).toMatch(/^[0-9a-f]{64}$/);
|
|
616
|
+
expect(second).not.toBe(first);
|
|
617
|
+
expect(afterFirst.passwordResetToken).toBe(sha256Hex(first));
|
|
618
|
+
expect(afterSecond.passwordResetToken).toBe(sha256Hex(second));
|
|
619
|
+
minutesOut = (0, moment_1.default)(afterSecond.passwordResetTokenExpiration).diff((0, moment_1.default)(), 'minutes', true);
|
|
620
|
+
expect(minutesOut).toBeGreaterThan(59);
|
|
621
|
+
expect(minutesOut).toBeLessThanOrEqual(60);
|
|
622
|
+
return [2 /*return*/];
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}); });
|
|
626
|
+
it.each(MalformedResetTokens_1.MalformedResetTokens.CASES)('resolve refuses %s as malformed without touching the database, and gives it no log reference', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
|
|
627
|
+
var presented, _a, resolution, traffic;
|
|
628
|
+
return __generator(this, function (_b) {
|
|
629
|
+
switch (_b.label) {
|
|
630
|
+
case 0:
|
|
631
|
+
presented = tokenField(unissuedToken()).token;
|
|
632
|
+
return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
633
|
+
switch (_a.label) {
|
|
634
|
+
case 0: return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().resolve(presented)];
|
|
635
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
636
|
+
}
|
|
637
|
+
}); }); })];
|
|
638
|
+
case 1:
|
|
639
|
+
_a = _b.sent(), resolution = _a.result, traffic = _a.traffic;
|
|
640
|
+
expect(resolution).toEqual({ status: 'malformed' });
|
|
641
|
+
expect(traffic).toEqual(DbTraffic_1.DbTraffic.NONE);
|
|
642
|
+
expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(presented)).toBeUndefined();
|
|
643
|
+
return [2 /*return*/];
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}); });
|
|
647
|
+
it('the log reference to a token is cut from its digest, never from the token: the same token reads the same, another reads differently', function () {
|
|
648
|
+
var token = unissuedToken();
|
|
649
|
+
var another = unissuedToken();
|
|
650
|
+
var reference = new PasswordResetToken_1.PasswordResetToken().fingerprint(token);
|
|
651
|
+
expect(reference).toBe(sha256Hex(token).slice(0, 12));
|
|
652
|
+
expect(reference).not.toBe(token.slice(0, 12));
|
|
653
|
+
expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(token)).toBe(reference);
|
|
654
|
+
expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(another)).not.toBe(reference);
|
|
655
|
+
});
|
|
656
|
+
it('the digest is the SHA-256 of the token, hex-encoded', function () {
|
|
657
|
+
var token = unissuedToken();
|
|
658
|
+
expect(internals.digest(token)).toBe(sha256Hex(token));
|
|
659
|
+
expect(internals.digest(token)).not.toBe(token);
|
|
344
660
|
});
|
|
345
|
-
it('a stored
|
|
346
|
-
var
|
|
347
|
-
expect(internals.matches(
|
|
348
|
-
expect(internals.matches(
|
|
349
|
-
expect(internals.matches(null,
|
|
350
|
-
expect(internals.matches(undefined,
|
|
351
|
-
expect(internals.matches('',
|
|
661
|
+
it('a stored digest matches only the same digest — never an absent or emptied one', function () {
|
|
662
|
+
var digest = sha256Hex('one token');
|
|
663
|
+
expect(internals.matches(digest, digest)).toBe(true);
|
|
664
|
+
expect(internals.matches(sha256Hex('another token'), digest)).toBe(false);
|
|
665
|
+
expect(internals.matches(null, digest)).toBe(false);
|
|
666
|
+
expect(internals.matches(undefined, digest)).toBe(false);
|
|
667
|
+
expect(internals.matches('', digest)).toBe(false);
|
|
352
668
|
});
|
|
353
669
|
it('an expiry is live only as a valid timestamp in the future', function () {
|
|
354
670
|
expect(internals.isLive((0, moment_1.default)().add(1, 'minute'))).toBe(true);
|
|
@@ -357,34 +673,107 @@ describe('executePasswordReset route', function () {
|
|
|
357
673
|
expect(internals.isLive(undefined)).toBe(false);
|
|
358
674
|
expect(internals.isLive(moment_1.default.invalid())).toBe(false);
|
|
359
675
|
});
|
|
360
|
-
it(
|
|
361
|
-
var
|
|
676
|
+
it("redeem writes only while the row still carries the token's digest", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
677
|
+
var _a, user, live, before, _b, row;
|
|
678
|
+
return __generator(this, function (_c) {
|
|
679
|
+
switch (_c.label) {
|
|
680
|
+
case 0: return [4 /*yield*/, armResetToken('reset-redeem@test.local')];
|
|
681
|
+
case 1:
|
|
682
|
+
_a = _c.sent(), user = _a.user, live = _a.token;
|
|
683
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
684
|
+
case 2:
|
|
685
|
+
before = (_c.sent()).password;
|
|
686
|
+
return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, unissuedToken(), 'other-hash')).resolves.toBe(false)];
|
|
687
|
+
case 3:
|
|
688
|
+
_c.sent();
|
|
689
|
+
return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, sha256Hex(live), 'other-hash')).resolves.toBe(false)];
|
|
690
|
+
case 4:
|
|
691
|
+
_c.sent();
|
|
692
|
+
_b = expect;
|
|
693
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
694
|
+
case 5:
|
|
695
|
+
_b.apply(void 0, [(_c.sent()).password]).toBe(before);
|
|
696
|
+
return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true)];
|
|
697
|
+
case 6:
|
|
698
|
+
_c.sent();
|
|
699
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
700
|
+
case 7:
|
|
701
|
+
row = _c.sent();
|
|
702
|
+
expect(row.password).toBe('new-hash');
|
|
703
|
+
expect(row.passwordResetToken).toBeNull();
|
|
704
|
+
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
705
|
+
return [2 /*return*/];
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}); });
|
|
709
|
+
it('concurrent redeems of one token: exactly one resolves true, and the row holds the winner', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
710
|
+
var _a, user, token, hashes, results, row;
|
|
362
711
|
return __generator(this, function (_b) {
|
|
363
712
|
switch (_b.label) {
|
|
364
|
-
case 0:
|
|
365
|
-
live = mintToken();
|
|
366
|
-
return [4 /*yield*/, armResetToken('reset-redeem@test.local', live, (0, moment_1.default)().add(1, 'hour'))];
|
|
713
|
+
case 0: return [4 /*yield*/, armResetToken('reset-redeem-race@test.local')];
|
|
367
714
|
case 1:
|
|
368
|
-
|
|
715
|
+
_a = _b.sent(), user = _a.user, token = _a.token;
|
|
716
|
+
hashes = ['hash-1', 'hash-2', 'hash-3', 'hash-4', 'hash-5', 'hash-6', 'hash-7', 'hash-8'];
|
|
717
|
+
return [4 /*yield*/, Promise.all(hashes.map(function (hash) { return new PasswordResetToken_1.PasswordResetToken().redeem(user, token, hash); }))];
|
|
718
|
+
case 2:
|
|
719
|
+
results = _b.sent();
|
|
720
|
+
expect(results.filter(function (redeemed) { return redeemed; })).toHaveLength(1);
|
|
369
721
|
return [4 /*yield*/, userRow(user.id)];
|
|
722
|
+
case 3:
|
|
723
|
+
row = _b.sent();
|
|
724
|
+
expect(row.password).toBe(hashes[results.indexOf(true)]);
|
|
725
|
+
expect(row.passwordResetToken).toBeNull();
|
|
726
|
+
return [2 /*return*/];
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}); });
|
|
730
|
+
it('revoke clears the row only while it still carries the token — a newer token is left alone', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
731
|
+
var _a, user, withdrawn, newer, _b, row;
|
|
732
|
+
return __generator(this, function (_c) {
|
|
733
|
+
switch (_c.label) {
|
|
734
|
+
case 0: return [4 /*yield*/, armResetToken('reset-revoke@test.local')];
|
|
735
|
+
case 1:
|
|
736
|
+
_a = _c.sent(), user = _a.user, withdrawn = _a.token;
|
|
737
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
|
|
370
738
|
case 2:
|
|
371
|
-
|
|
372
|
-
return [4 /*yield*/,
|
|
739
|
+
newer = _c.sent();
|
|
740
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().revoke(user, withdrawn)];
|
|
373
741
|
case 3:
|
|
374
|
-
|
|
375
|
-
|
|
742
|
+
_c.sent();
|
|
743
|
+
_b = expect;
|
|
376
744
|
return [4 /*yield*/, userRow(user.id)];
|
|
377
745
|
case 4:
|
|
378
|
-
|
|
379
|
-
return [4 /*yield*/,
|
|
746
|
+
_b.apply(void 0, [(_c.sent()).passwordResetToken]).toBe(sha256Hex(newer));
|
|
747
|
+
return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().revoke(user, newer)];
|
|
380
748
|
case 5:
|
|
381
|
-
|
|
749
|
+
_c.sent();
|
|
382
750
|
return [4 /*yield*/, userRow(user.id)];
|
|
383
751
|
case 6:
|
|
384
|
-
row =
|
|
385
|
-
expect(row.password).toBe('new-hash');
|
|
752
|
+
row = _c.sent();
|
|
386
753
|
expect(row.passwordResetToken).toBeNull();
|
|
387
754
|
expect(row.passwordResetTokenExpiration).toBeNull();
|
|
755
|
+
expect(row.password).toBe('test');
|
|
756
|
+
return [2 /*return*/];
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}); });
|
|
760
|
+
it('mintedAt is the moment the outstanding token was minted — undefined when the row carries none', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
761
|
+
var idle, user, mintedAt, _a, _b;
|
|
762
|
+
return __generator(this, function (_c) {
|
|
763
|
+
switch (_c.label) {
|
|
764
|
+
case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: 'reset-minted-at-idle@test.local' })];
|
|
765
|
+
case 1:
|
|
766
|
+
idle = _c.sent();
|
|
767
|
+
return [4 /*yield*/, armResetToken('reset-minted-at@test.local')];
|
|
768
|
+
case 2:
|
|
769
|
+
user = (_c.sent()).user;
|
|
770
|
+
expect(new PasswordResetToken_1.PasswordResetToken().mintedAt(idle)).toBeUndefined();
|
|
771
|
+
_b = (_a = new PasswordResetToken_1.PasswordResetToken()).mintedAt;
|
|
772
|
+
return [4 /*yield*/, userRow(user.id)];
|
|
773
|
+
case 3:
|
|
774
|
+
mintedAt = _b.apply(_a, [_c.sent()]);
|
|
775
|
+
expect(mintedAt).toBeDefined();
|
|
776
|
+
expect(Math.abs((0, moment_1.default)().diff(mintedAt, 'seconds'))).toBeLessThan(30);
|
|
388
777
|
return [2 /*return*/];
|
|
389
778
|
}
|
|
390
779
|
});
|