@proteinjs/user-server 1.22.1 → 1.22.3
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 +28 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +9 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
- package/dist/src/authentication/PasswordHasher.js +25 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
- package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
- package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
- package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
- package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
- package/dist/src/authentication/authenticate.d.ts +15 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +55 -20
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +3 -2
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +29 -14
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/routes/executePasswordReset.d.ts +4 -0
- package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/executePasswordReset.js +12 -4
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
- package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
- package/dist/src/routes/initiatePasswordReset.js +28 -10
- package/dist/src/routes/initiatePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts +8 -0
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +63 -22
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/routes/signup.d.ts.map +1 -1
- package/dist/src/routes/signup.js +5 -2
- package/dist/src/routes/signup.js.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
- package/dist/src/routes/validateResetPasswordToken.js +5 -1
- package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
- package/dist/src/services/MachineCredentials.d.ts +10 -1
- package/dist/src/services/MachineCredentials.d.ts.map +1 -1
- package/dist/src/services/MachineCredentials.js +46 -15
- package/dist/src/services/MachineCredentials.js.map +1 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +6 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/Signup.d.ts +5 -0
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +28 -6
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/src/throttle/ClientAddress.d.ts +35 -0
- package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
- package/dist/src/throttle/ClientAddress.js +48 -0
- package/dist/src/throttle/ClientAddress.js.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
- package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
- package/dist/src/throttle/PasswordResetThrottle.js +59 -0
- package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
- package/dist/src/throttle/RequestDigests.d.ts +42 -0
- package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
- package/dist/src/throttle/RequestDigests.js +96 -0
- package/dist/src/throttle/RequestDigests.js.map +1 -0
- package/dist/src/throttle/SignInThrottle.d.ts +67 -0
- package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
- package/dist/src/throttle/SignInThrottle.js +91 -0
- package/dist/src/throttle/SignInThrottle.js.map +1 -0
- package/dist/src/throttle/SlidingWindow.d.ts +51 -0
- package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
- package/dist/src/throttle/SlidingWindow.js +85 -0
- package/dist/src/throttle/SlidingWindow.js.map +1 -0
- package/dist/test/ClientAddress.test.d.ts +2 -0
- package/dist/test/ClientAddress.test.d.ts.map +1 -0
- package/dist/test/ClientAddress.test.js +51 -0
- package/dist/test/ClientAddress.test.js.map +1 -0
- package/dist/test/DevLogin.test.js +68 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/InitiatePasswordReset.test.js +373 -5
- package/dist/test/InitiatePasswordReset.test.js.map +1 -1
- package/dist/test/LogCapture.d.ts +10 -0
- package/dist/test/LogCapture.d.ts.map +1 -1
- package/dist/test/LogCapture.js +19 -0
- package/dist/test/LogCapture.js.map +1 -1
- package/dist/test/LogCapture.test.d.ts +2 -0
- package/dist/test/LogCapture.test.d.ts.map +1 -0
- package/dist/test/LogCapture.test.js +86 -0
- package/dist/test/LogCapture.test.js.map +1 -0
- package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
- package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
- package/dist/test/LoginThrottle.integration.test.js +616 -0
- package/dist/test/LoginThrottle.integration.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +156 -28
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
- package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
- package/dist/test/NoAddressesInLogs.integration.test.js +717 -0
- package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
- package/dist/test/RequestDigests.test.d.ts +2 -0
- package/dist/test/RequestDigests.test.d.ts.map +1 -0
- package/dist/test/RequestDigests.test.js +73 -0
- package/dist/test/RequestDigests.test.js.map +1 -0
- package/dist/test/SignupRoute.test.js +80 -1
- package/dist/test/SignupRoute.test.js.map +1 -1
- package/dist/test/SlidingWindow.test.d.ts +2 -0
- package/dist/test/SlidingWindow.test.d.ts.map +1 -0
- package/dist/test/SlidingWindow.test.js +72 -0
- package/dist/test/SlidingWindow.test.js.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
- package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
- package/dist/test/SocketIOSessionWatcher.test.js +144 -0
- package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +5 -0
- package/package.json +6 -5
- package/src/authentication/PasswordHasher.ts +18 -1
- package/src/authentication/SocketIOSessionWatcher.ts +13 -1
- package/src/authentication/UserStatusTableWatcher.ts +2 -1
- package/src/authentication/authenticate.ts +36 -9
- package/src/authorization/userCache.ts +3 -2
- package/src/routes/devLogin.ts +22 -10
- package/src/routes/executePasswordReset.ts +11 -3
- package/src/routes/initiatePasswordReset.ts +29 -11
- package/src/routes/login.ts +39 -8
- package/src/routes/signup.ts +5 -2
- package/src/routes/validateResetPasswordToken.ts +5 -1
- package/src/services/MachineCredentials.ts +44 -9
- package/src/services/Roles.ts +6 -2
- package/src/services/Signup.ts +30 -4
- package/src/throttle/ClientAddress.ts +47 -0
- package/src/throttle/PasswordResetThrottle.ts +62 -0
- package/src/throttle/RequestDigests.ts +97 -0
- package/src/throttle/SignInThrottle.ts +98 -0
- package/src/throttle/SlidingWindow.ts +90 -0
- package/test/ClientAddress.test.ts +54 -0
- package/test/DevLogin.test.ts +35 -1
- package/test/InitiatePasswordReset.test.ts +212 -6
- package/test/LogCapture.test.ts +29 -0
- package/test/LogCapture.ts +16 -0
- package/test/LoginThrottle.integration.test.ts +339 -0
- package/test/MachineAccounts.integration.test.ts +97 -22
- package/test/NoAddressesInLogs.integration.test.ts +409 -0
- package/test/RequestDigests.test.ts +81 -0
- package/test/SignupRoute.test.ts +54 -1
- package/test/SlidingWindow.test.ts +85 -0
- package/test/SocketIOSessionWatcher.test.ts +86 -0
- package/test/setup.js +2 -0
|
@@ -6,6 +6,7 @@ import { SourceRepository } from '@proteinjs/reflection';
|
|
|
6
6
|
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
7
7
|
import { MachineAccount, RoleCatalogEntry, User, UserRepo, tables } from '@proteinjs/user';
|
|
8
8
|
import { authenticate } from '../src/authentication/authenticate';
|
|
9
|
+
import { SocketIOSessionWatcher } from '../src/authentication/SocketIOSessionWatcher';
|
|
9
10
|
import { UserStatusTableWatcher } from '../src/authentication/UserStatusTableWatcher';
|
|
10
11
|
import { MachineCredentials } from '../src/services/MachineCredentials';
|
|
11
12
|
import { Roles } from '../src/services/Roles';
|
|
@@ -48,10 +49,12 @@ const resetTableWatcherMap = () => {
|
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Machine accounts as source records, at the user layer: declare-only on the REAL mixed user
|
|
51
|
-
* table,
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* table, a declared address held by a row the sync does not own (a person's signup, a hand-made
|
|
53
|
+
* row) REFUSED — never taken over, its credential and sessions never inherited — and said so on
|
|
54
|
+
* the admin list and the mint; removed-from-source auto-deactivation with session kill through
|
|
55
|
+
* the categorical UserStatusTableWatcher, completing at boot before any socket server exists;
|
|
56
|
+
* the machine/human ledger split (Roles refuses machine targets), declaration validation, and
|
|
57
|
+
* credential minting (hash-only at rest, plaintext shown once).
|
|
55
58
|
*/
|
|
56
59
|
describe('Machine accounts as source records', () => {
|
|
57
60
|
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
@@ -90,8 +93,10 @@ describe('Machine accounts as source records', () => {
|
|
|
90
93
|
'@proteinjs/user-auth/AuthenticatedUserRepo'
|
|
91
94
|
] = [new UserRepo()];
|
|
92
95
|
objectCache()['@proteinjs/user/RoleCatalogEntry'] = [new OpsRole()];
|
|
93
|
-
//
|
|
94
|
-
|
|
96
|
+
// The two session watchers a real server registers, rebuilt from the seeded cache — and no
|
|
97
|
+
// socket server exists in this process: the shape of a boot's sync (and of a migration Job,
|
|
98
|
+
// which never starts one), where deactivations delete sessions before the server is up.
|
|
99
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher(), new SocketIOSessionWatcher()];
|
|
95
100
|
resetTableWatcherMap();
|
|
96
101
|
}, 120000);
|
|
97
102
|
|
|
@@ -139,32 +144,102 @@ describe('Machine accounts as source records', () => {
|
|
|
139
144
|
expect(humanAfter).toMatchObject({ name: 'A human', roles: ['ops'] });
|
|
140
145
|
});
|
|
141
146
|
|
|
142
|
-
it('
|
|
143
|
-
//
|
|
144
|
-
//
|
|
147
|
+
it(`a person's row holding a declared address refuses the declaration: no roles, no machine flag, their password and sessions stand; the list and the mint say so`, async () => {
|
|
148
|
+
// A person registered the address before the declaration shipped (inviteless signup): their
|
|
149
|
+
// own row, their own password, a live session — never a machine.
|
|
150
|
+
const person = await testEnv.createUser({ name: 'A person', email: 'machine-ops@test.local' });
|
|
151
|
+
await insertSession('person-session', 'machine-ops@test.local');
|
|
152
|
+
class Sibling extends TestOpsMachineAccount {
|
|
153
|
+
id = 'machine-sibling';
|
|
154
|
+
email = 'machine-sibling@test.local';
|
|
155
|
+
accountName = 'Sibling machine';
|
|
156
|
+
secretName = 'sibling-secret';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The boot completes and the rest of the declarations land.
|
|
160
|
+
await boot([new TestOpsMachineAccount(), new Sibling()]);
|
|
161
|
+
expect(await getDbAsSystem().get(tables.User, { email: 'machine-sibling@test.local' })).toMatchObject({
|
|
162
|
+
roles: ['ops'],
|
|
163
|
+
isLoadedFromSource: true,
|
|
164
|
+
machine: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The person's row is untouched: none of the declared roles, not a machine, not source-owned.
|
|
168
|
+
const row = await machineRow();
|
|
169
|
+
expect(row.id).toBe(person.id);
|
|
170
|
+
expect(row).toMatchObject({ name: 'A person', roles: [], emailVerified: true });
|
|
171
|
+
expect(row.machine).toBeFalsy();
|
|
172
|
+
expect(row.isLoadedFromSource).toBeFalsy();
|
|
173
|
+
expect(row.password).toBe(person.password);
|
|
174
|
+
expect(row.updated.valueOf()).toBe(person.updated.valueOf());
|
|
175
|
+
expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
|
|
176
|
+
|
|
177
|
+
// The admin list names the refusal; the mint refuses in the same words and changes nothing.
|
|
178
|
+
const view = (await new MachineCredentials().listMachineAccounts()).find(
|
|
179
|
+
(account) => account.email === 'machine-ops@test.local'
|
|
180
|
+
);
|
|
181
|
+
expect(view).toMatchObject({
|
|
182
|
+
status: 'declaration refused',
|
|
183
|
+
refusal: 'a person holds this address',
|
|
184
|
+
hasCredential: false,
|
|
185
|
+
});
|
|
186
|
+
await expect(new MachineCredentials().mintCredential('machine-ops@test.local')).rejects.toThrow(
|
|
187
|
+
/declaration for 'machine-ops@test\.local' was refused at boot: a person holds this address/
|
|
188
|
+
);
|
|
189
|
+
expect((await machineRow()).password).toBe(person.password);
|
|
190
|
+
expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
|
|
191
|
+
|
|
192
|
+
// Idempotent: the next boot refuses again and still grants nothing.
|
|
193
|
+
await boot([new TestOpsMachineAccount(), new Sibling()]);
|
|
194
|
+
const again = await machineRow();
|
|
195
|
+
expect(again).toMatchObject({ roles: [] });
|
|
196
|
+
expect(again.isLoadedFromSource).toBeFalsy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a hand-made machine row the sync never owned is refused too — no row is taken over by a declaration any more', async () => {
|
|
200
|
+
// The pre-declaration provisioning shape: a row made by hand and marked machine, with a
|
|
201
|
+
// provisioned credential and runtime-granted roles.
|
|
145
202
|
const handMade = await testEnv.createUser({
|
|
146
203
|
name: 'Hand-made bridge',
|
|
147
204
|
email: 'machine-ops@test.local',
|
|
148
205
|
roles: ['ops', 'stale-role'],
|
|
149
206
|
});
|
|
207
|
+
await getDbAsSystem().update(tables.User, { id: handMade.id, machine: true });
|
|
150
208
|
|
|
151
209
|
await boot([new TestOpsMachineAccount()]);
|
|
152
210
|
|
|
153
|
-
const
|
|
154
|
-
expect(
|
|
155
|
-
|
|
156
|
-
name: '
|
|
157
|
-
roles: ['ops'],
|
|
158
|
-
status: 'active',
|
|
159
|
-
isLoadedFromSource: true,
|
|
160
|
-
// Adoption stamps the explicit machine column onto the hand-made row (the deployed-env
|
|
161
|
-
// provisioning shape — e.g. a hand-provisioned deploy account becomes honest on boot).
|
|
211
|
+
const row = await machineRow();
|
|
212
|
+
expect(row).toMatchObject({
|
|
213
|
+
id: handMade.id,
|
|
214
|
+
name: 'Hand-made bridge',
|
|
215
|
+
roles: ['ops', 'stale-role'],
|
|
162
216
|
machine: true,
|
|
163
217
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(
|
|
218
|
+
expect(row.isLoadedFromSource).toBeFalsy();
|
|
219
|
+
expect(row.password).toBe(handMade.password);
|
|
220
|
+
const [view] = await new MachineCredentials().listMachineAccounts();
|
|
221
|
+
expect(view).toMatchObject({
|
|
222
|
+
status: 'declaration refused',
|
|
223
|
+
refusal: 'a hand-made machine row holds this address',
|
|
224
|
+
hasCredential: false,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('withdrawing a declaration while its account holds a live session: the boot completes before any socket server exists — the row deactivated, the session gone', async () => {
|
|
229
|
+
class Keeper extends TestOpsMachineAccount {
|
|
230
|
+
id = 'machine-keeper';
|
|
231
|
+
email = 'machine-keeper@test.local';
|
|
232
|
+
accountName = 'Keeper machine';
|
|
233
|
+
secretName = 'keeper-secret';
|
|
234
|
+
}
|
|
235
|
+
await boot([new TestOpsMachineAccount(), new Keeper()]);
|
|
236
|
+
await insertSession('machine-live-session', 'machine-ops@test.local');
|
|
237
|
+
|
|
238
|
+
// The declaration is withdrawn; the package still boots with its other declaration.
|
|
239
|
+
await boot([new Keeper()]);
|
|
240
|
+
|
|
241
|
+
expect((await machineRow()).status).toBe('deactivated');
|
|
242
|
+
expect(await sessionEmails()).toEqual([]);
|
|
168
243
|
});
|
|
169
244
|
|
|
170
245
|
it('removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates', async () => {
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import sha256 from 'crypto-js/sha256';
|
|
3
|
+
import { SourceRecordSyncRunner, getDbAsSystem } from '@proteinjs/db';
|
|
4
|
+
import { EmailSender, MailSink } from '@proteinjs/email-server';
|
|
5
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
6
|
+
import { MachineAccount, RoleCatalogEntry, UserRepo, tables } from '@proteinjs/user';
|
|
7
|
+
import { RequestDigests } from '@proteinjs/util-node';
|
|
8
|
+
import { authenticate } from '../src/authentication/authenticate';
|
|
9
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
10
|
+
import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
|
|
11
|
+
import { UserStatusTableWatcher } from '../src/authentication/UserStatusTableWatcher';
|
|
12
|
+
import { userCache } from '../src/authorization/userCache';
|
|
13
|
+
import { executePasswordReset } from '../src/routes/executePasswordReset';
|
|
14
|
+
import { initiatePasswordReset } from '../src/routes/initiatePasswordReset';
|
|
15
|
+
import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
|
|
16
|
+
import { signup } from '../src/routes/signup';
|
|
17
|
+
import { MachineCredentials } from '../src/services/MachineCredentials';
|
|
18
|
+
import { Signup } from '../src/services/Signup';
|
|
19
|
+
import { invokeDevLogin } from './devLoginHarness';
|
|
20
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
21
|
+
import { LogCapture } from './LogCapture';
|
|
22
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
23
|
+
|
|
24
|
+
const testEnv = new UserServerTestEnvironment();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* No e-mail address reaches the server log from the doors that handle one — every line that names
|
|
28
|
+
* a person names them by a keyed digest (`RequestDigests`, from `@proteinjs/util-node`): the
|
|
29
|
+
* account digest for an account (or the address a sign-in or reset door was asked about), the
|
|
30
|
+
* address digest for a bare address (an invitee); the mail lines beside them (the email server's)
|
|
31
|
+
* add the recipient's domain. Driven end to end against the Spanner emulator and the mail sink, the log captured as
|
|
32
|
+
* the default writer emits it (every console level, every logger), on every path through:
|
|
33
|
+
* - the reset door: an unknown address, a mailed link, a repeat inside five minutes, a failed send;
|
|
34
|
+
* - the reset link: redeemed, presented again, expired, and an expired link validated;
|
|
35
|
+
* - an invite: sent, re-sent, a send that fails, a re-send that fails;
|
|
36
|
+
* - signup: an account created, then the same address again;
|
|
37
|
+
* - sign-in and sessions: a deactivated account refused, a session on a deactivated account, a
|
|
38
|
+
* session on a missing account;
|
|
39
|
+
* - the sessions a deactivation kills, and a minted machine credential;
|
|
40
|
+
* - the dev doors: a missing test account created, the first-admin grant, a session established;
|
|
41
|
+
* - a caught error whose words name an address: a double sign-up's unique-index refusal (the
|
|
42
|
+
* database's own words), an invite whose send or write fails with the address in its error.
|
|
43
|
+
*
|
|
44
|
+
* The one line that keeps an address is the dev role-bootstrap door's `[dev-bootstrap]` marker —
|
|
45
|
+
* development only, and read back by address by the development tooling — so it is closed here.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type RouteOutcome = { status: number; body?: any };
|
|
49
|
+
|
|
50
|
+
const invoke = async (
|
|
51
|
+
route: { onRequest: (request: never, response: never) => Promise<void> },
|
|
52
|
+
request: Record<string, unknown>
|
|
53
|
+
): Promise<RouteOutcome> => {
|
|
54
|
+
const outcome: RouteOutcome = { status: 200 };
|
|
55
|
+
const response = {
|
|
56
|
+
status(code: number) {
|
|
57
|
+
outcome.status = code;
|
|
58
|
+
return this;
|
|
59
|
+
},
|
|
60
|
+
send(body?: unknown) {
|
|
61
|
+
outcome.body = body;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
await route.onRequest(request as never, response as never);
|
|
65
|
+
return outcome;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type SourceRepositoryInternals = {
|
|
69
|
+
objectCache: Record<string, unknown[]>;
|
|
70
|
+
namedObjectCache: Record<string, { qualifiedName: string; packageName: string; object: unknown }[]>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
74
|
+
const namedObjectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).namedObjectCache;
|
|
75
|
+
|
|
76
|
+
/** The deactivation watcher map is built once per process from the cache; rebuild it from what is seeded now. */
|
|
77
|
+
const resetTableWatcherMap = () => {
|
|
78
|
+
const runner = (getDbAsSystem() as unknown as { tableWatcherRunner: object }).tableWatcherRunner;
|
|
79
|
+
(runner.constructor as { tableWatcherMap?: unknown }).tableWatcherMap = undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
class OpsRole implements RoleCatalogEntry {
|
|
83
|
+
role = 'ops';
|
|
84
|
+
description = 'Operational machinery';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class LogsMachineAccount extends MachineAccount {
|
|
88
|
+
id = 'machine-logs-ops';
|
|
89
|
+
email = 'machine-logs@test.local';
|
|
90
|
+
accountName = 'Logs ops machine';
|
|
91
|
+
roles = ['ops'];
|
|
92
|
+
secretName = 'logs-ops-secret';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DEV_ENV = ['DEVELOPMENT', 'DEV_AUTO_LOGIN_EMAIL', 'DEV_BOOTSTRAP_ADMIN_EMAIL', 'DEV_BOOTSTRAP_ROLES'] as const;
|
|
96
|
+
|
|
97
|
+
describe('no e-mail address reaches the server log', () => {
|
|
98
|
+
const digests = new RequestDigests();
|
|
99
|
+
const originalEnv: Record<string, string | undefined> = {};
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
await testEnv.beforeAll();
|
|
103
|
+
for (const key of ['EMAIL_TRANSPORT', ...DEV_ENV]) {
|
|
104
|
+
originalEnv[key] = process.env[key];
|
|
105
|
+
}
|
|
106
|
+
process.env.EMAIL_TRANSPORT = 'sink';
|
|
107
|
+
const cache = objectCache();
|
|
108
|
+
cache['@proteinjs/email-server/DefaultEmailConfigFactory'] = [
|
|
109
|
+
{
|
|
110
|
+
getEmailConfig: () => ({
|
|
111
|
+
host: 'smtp.test.local',
|
|
112
|
+
port: 465,
|
|
113
|
+
secure: true,
|
|
114
|
+
from: '"Example" <hi@example.com>',
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
cache['@proteinjs/email-server/DefaultPasswordResetEmailConfigFactory'] = [
|
|
119
|
+
{ getConfig: () => ({ getEmailContent: (path: string) => ({ text: `Reset: https://app.test.local/${path}` }) }) },
|
|
120
|
+
];
|
|
121
|
+
cache['@proteinjs/email-server/DefaultInviteEmailConfigFactory'] = [
|
|
122
|
+
{
|
|
123
|
+
getConfig: () => ({
|
|
124
|
+
options: { subject: 'Your invite' },
|
|
125
|
+
getEmailContent: (path: string) => ({ text: `Accept: /${path}`, html: `<a href="/${path}">Accept</a>` }),
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
cache['@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory'] = [
|
|
130
|
+
{ getConfig: () => ({ getNewUserEmailContent: () => ({ text: 'welcome', html: '<p>welcome</p>' }) }) },
|
|
131
|
+
];
|
|
132
|
+
// Invite-optional, the library default — the suites load src, never the generated source graph.
|
|
133
|
+
cache['@proteinjs/user-server/DefaultInviteConfigFactory'] = [{ getConfig: () => ({ isInviteOnly: false }) }];
|
|
134
|
+
cache['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
135
|
+
cache['@proteinjs/user/RoleCatalogEntry'] = [new OpsRole()];
|
|
136
|
+
cache['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher()];
|
|
137
|
+
resetTableWatcherMap();
|
|
138
|
+
}, 120000);
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
const cache = objectCache();
|
|
142
|
+
for (const key of [
|
|
143
|
+
'@proteinjs/email-server/DefaultEmailConfigFactory',
|
|
144
|
+
'@proteinjs/email-server/DefaultPasswordResetEmailConfigFactory',
|
|
145
|
+
'@proteinjs/email-server/DefaultInviteEmailConfigFactory',
|
|
146
|
+
'@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory',
|
|
147
|
+
'@proteinjs/user-server/DefaultInviteConfigFactory',
|
|
148
|
+
'@proteinjs/user-auth/AuthenticatedUserRepo',
|
|
149
|
+
'@proteinjs/user/RoleCatalogEntry',
|
|
150
|
+
'@proteinjs/db/TableWatcher',
|
|
151
|
+
'@proteinjs/db/SourceRecordLoader',
|
|
152
|
+
]) {
|
|
153
|
+
delete cache[key];
|
|
154
|
+
}
|
|
155
|
+
delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
156
|
+
resetTableWatcherMap();
|
|
157
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
delete process.env[key];
|
|
160
|
+
} else {
|
|
161
|
+
process.env[key] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await testEnv.afterAll();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
MailSink.get().clear();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('the reset door: an unknown address, a mailed link, a repeat inside five minutes, a failed send', async () => {
|
|
172
|
+
await testEnv.createUser({ name: 'Reset Mailed', email: 'reset-mailed@test.local' });
|
|
173
|
+
await testEnv.createUser({ name: 'Reset Fails', email: 'reset-fails@test.local' });
|
|
174
|
+
|
|
175
|
+
const log = await LogCapture.during(async () => {
|
|
176
|
+
await invoke(initiatePasswordReset, { body: { email: 'reset-nobody@test.local' } });
|
|
177
|
+
await invoke(initiatePasswordReset, { body: { email: 'Reset-Mailed@test.local' } });
|
|
178
|
+
await invoke(initiatePasswordReset, { body: { email: 'reset-mailed@test.local' } });
|
|
179
|
+
const failing = jest
|
|
180
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
181
|
+
.mockRejectedValueOnce(new Error('Failed to send email'));
|
|
182
|
+
try {
|
|
183
|
+
await invoke(initiatePasswordReset, { body: { email: 'reset-fails@test.local' } });
|
|
184
|
+
} finally {
|
|
185
|
+
failing.mockRestore();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(MailSink.get().list()).toHaveLength(1);
|
|
190
|
+
expect(log.addresses).toEqual([]);
|
|
191
|
+
for (const address of ['reset-nobody@test.local', 'reset-mailed@test.local', 'reset-fails@test.local']) {
|
|
192
|
+
expect(log.text).toContain(digests.account(address));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('the reset link: redeemed, presented again, expired, and an expired link validated', async () => {
|
|
197
|
+
const redeemer = await testEnv.createUser({ name: 'Reset Redeemer', email: 'reset-redeem@test.local' });
|
|
198
|
+
const token = await new PasswordResetToken().mint(redeemer);
|
|
199
|
+
const lapsed = await testEnv.createUser({ name: 'Reset Lapsed', email: 'reset-lapsed@test.local' });
|
|
200
|
+
const lapsedToken = await new PasswordResetToken().mint(lapsed);
|
|
201
|
+
await getDbAsSystem().update(tables.User, {
|
|
202
|
+
id: lapsed.id,
|
|
203
|
+
passwordResetTokenExpiration: moment().subtract(1, 'hour'),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const log = await LogCapture.during(async () => {
|
|
207
|
+
expect((await invoke(executePasswordReset, { body: { token, newPassword: 'fresh-password' } })).status).toBe(200);
|
|
208
|
+
expect((await invoke(executePasswordReset, { body: { token, newPassword: 'again-password' } })).status).toBe(400);
|
|
209
|
+
expect(
|
|
210
|
+
(await invoke(executePasswordReset, { body: { token: lapsedToken, newPassword: 'late-password' } })).status
|
|
211
|
+
).toBe(400);
|
|
212
|
+
expect((await invoke(validateResetPasswordToken, { query: { token: lapsedToken } })).body.isValid).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(log.addresses).toEqual([]);
|
|
216
|
+
expect(log.text).toContain(digests.account('reset-redeem@test.local'));
|
|
217
|
+
expect(log.text).toContain(digests.account('reset-lapsed@test.local'));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('an invite: sent, re-sent, a send that fails, a re-send that fails — the invitee by its address digest', async () => {
|
|
221
|
+
const inviter = await testEnv.createUser({ name: 'Inviter', email: 'logs-inviter@test.local', roles: ['admin'] });
|
|
222
|
+
testEnv.actAs(inviter);
|
|
223
|
+
|
|
224
|
+
const log = await LogCapture.during(async () => {
|
|
225
|
+
expect(await new Signup().sendInvite('Invitee@Example.invalid')).toEqual({ sent: true });
|
|
226
|
+
expect(await new Signup().resendInvite('invitee@example.invalid')).toEqual({ sent: true });
|
|
227
|
+
const failing = jest
|
|
228
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
229
|
+
.mockRejectedValue(new Error('Failed to send email'));
|
|
230
|
+
try {
|
|
231
|
+
expect((await new Signup().sendInvite('invitee-fails@example.invalid')).sent).toBe(false);
|
|
232
|
+
expect((await new Signup().resendInvite('invitee@example.invalid')).sent).toBe(false);
|
|
233
|
+
} finally {
|
|
234
|
+
failing.mockRestore();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(MailSink.get().list()).toHaveLength(2);
|
|
239
|
+
expect(log.addresses).toEqual([]);
|
|
240
|
+
expect(log.text).toContain(digests.address('invitee-fails@example.invalid'));
|
|
241
|
+
expect(log.text).toContain(digests.address('invitee@example.invalid'));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('signup: an account created, then the same address again', async () => {
|
|
245
|
+
const log = await LogCapture.during(async () => {
|
|
246
|
+
const user = { name: 'Signer', email: 'Logs-Signup@test.local', password: 'a-password' };
|
|
247
|
+
expect((await new Signup().createUser(user)).outcome).toBe('created');
|
|
248
|
+
expect((await new Signup().createUser(user)).outcome).toBe('exists');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(log.addresses).toEqual([]);
|
|
252
|
+
expect(log.text).toContain(digests.account('logs-signup@test.local'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("a double sign-up race: the database's refusal names the address — the log carries its digest instead", async () => {
|
|
256
|
+
// Both requests pass the existence check before either writes: each one's password hash is held
|
|
257
|
+
// until both have arrived, so both inserts race and the unique index on the address refuses one —
|
|
258
|
+
// the refusal's own words name the address it refused.
|
|
259
|
+
const hash = PasswordHasher.prototype.hash;
|
|
260
|
+
let arrived = 0;
|
|
261
|
+
let bothArrived!: () => void;
|
|
262
|
+
const both = new Promise<void>((resolve) => (bothArrived = resolve));
|
|
263
|
+
const held = jest.spyOn(PasswordHasher.prototype, 'hash').mockImplementation(async function (
|
|
264
|
+
this: PasswordHasher,
|
|
265
|
+
password: string
|
|
266
|
+
) {
|
|
267
|
+
const hashed = await hash.call(this, password);
|
|
268
|
+
if (++arrived === 2) {
|
|
269
|
+
bothArrived();
|
|
270
|
+
}
|
|
271
|
+
await both;
|
|
272
|
+
return hashed;
|
|
273
|
+
});
|
|
274
|
+
const body = { name: 'Racer', email: 'Logs-Race@test.local', password: 'a-password' };
|
|
275
|
+
|
|
276
|
+
let outcomes: (RouteOutcome & { loggedInAs?: string })[] = [];
|
|
277
|
+
const log = await LogCapture.during(async () => {
|
|
278
|
+
try {
|
|
279
|
+
outcomes = await Promise.all(
|
|
280
|
+
[0, 1].map(async () => {
|
|
281
|
+
const { request } = await createPassportRequest({ body });
|
|
282
|
+
const outcome = await invoke(signup, request);
|
|
283
|
+
return { ...outcome, loggedInAs: request.session.passport?.user };
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
} finally {
|
|
287
|
+
held.mockRestore();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// One account, one session; the loser was refused by the index and logged it — the refusal
|
|
292
|
+
// naming the index, the address in its words replaced by the address's digest.
|
|
293
|
+
expect((await getDbAsSystem().query(tables.User, { email: 'logs-race@test.local' })).length).toBe(1);
|
|
294
|
+
expect(outcomes.filter((outcome) => outcome.loggedInAs === 'logs-race@test.local')).toHaveLength(1);
|
|
295
|
+
const refusal = log.linesContaining('Signup failed');
|
|
296
|
+
expect(refusal.lines).toHaveLength(1);
|
|
297
|
+
expect(refusal.text).toContain('user_email_unique');
|
|
298
|
+
expect(refusal.addresses).toEqual([]);
|
|
299
|
+
expect(refusal.text).toContain(digests.address('logs-race@test.local'));
|
|
300
|
+
// The database driver's own line for the refused statement is the driver's (the version a
|
|
301
|
+
// package lock pins decides whether it prints bound values); this door's lines are the ones above.
|
|
302
|
+
expect(log.linesContaining('[signup]').addresses).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('an invite whose send or write fails with the address in its error: the invitee by its digest, the error too', async () => {
|
|
306
|
+
const inviter = await testEnv.createUser({ name: 'Inviter', email: 'logs-inviter-2@test.local', roles: ['admin'] });
|
|
307
|
+
testEnv.actAs(inviter);
|
|
308
|
+
await new Signup().sendInvite('invitee-standing@example.invalid');
|
|
309
|
+
// What a caught error looks like when its words are the address's: a refusal naming the key.
|
|
310
|
+
const naming = (address: string) =>
|
|
311
|
+
Object.assign(new Error(`6 ALREADY_EXISTS: Row [${address}] in table Invite already exists`), {
|
|
312
|
+
code: 6,
|
|
313
|
+
details: `Row [${address}] in table Invite already exists`,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const log = await LogCapture.during(async () => {
|
|
317
|
+
const failing = jest
|
|
318
|
+
.spyOn(EmailSender.prototype, 'sendEmail')
|
|
319
|
+
.mockImplementation(async (options) => Promise.reject(naming(String(options.to))));
|
|
320
|
+
try {
|
|
321
|
+
expect((await new Signup().sendInvite('Invitee-Named@Example.invalid')).sent).toBe(false);
|
|
322
|
+
expect((await new Signup().resendInvite('invitee-standing@example.invalid')).sent).toBe(false);
|
|
323
|
+
} finally {
|
|
324
|
+
failing.mockRestore();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(log.text).toContain('Error sending invite');
|
|
329
|
+
expect(log.text).toContain('Error re-sending invite');
|
|
330
|
+
expect(log.text).toContain('in table Invite already exists');
|
|
331
|
+
expect(log.addresses).toEqual([]);
|
|
332
|
+
expect(log.text).toContain(digests.address('invitee-named@example.invalid'));
|
|
333
|
+
expect(log.text).toContain(digests.address('invitee-standing@example.invalid'));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('sign-in and sessions: a deactivated account refused, a session on a deactivated account, a session on a missing account', async () => {
|
|
337
|
+
await getDbAsSystem().insert(tables.User, {
|
|
338
|
+
name: 'Deactivated',
|
|
339
|
+
email: 'logs-deactivated@test.local',
|
|
340
|
+
password: sha256('correct-password').toString(),
|
|
341
|
+
emailVerified: true,
|
|
342
|
+
roles: [],
|
|
343
|
+
status: 'deactivated',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const log = await LogCapture.during(async () => {
|
|
347
|
+
expect(await authenticate('Logs-Deactivated@test.local', 'correct-password')).toBe(
|
|
348
|
+
'This account has been deactivated'
|
|
349
|
+
);
|
|
350
|
+
expect((await userCache.create('logs-session-1', 'logs-deactivated@test.local')).id).toBe('guest');
|
|
351
|
+
expect((await userCache.create('logs-session-2', 'logs-missing@test.local')).id).toBe('guest');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(log.addresses).toEqual([]);
|
|
355
|
+
expect(log.text).toContain(digests.account('logs-deactivated@test.local'));
|
|
356
|
+
expect(log.text).toContain(digests.account('logs-missing@test.local'));
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('the sessions a deactivation kills, and a minted machine credential', async () => {
|
|
360
|
+
const human = await testEnv.createUser({ name: 'Soon Deactivated', email: 'logs-kill@test.local' });
|
|
361
|
+
const expires = new Date(Date.now() + 60 * 60 * 1000);
|
|
362
|
+
await getDbAsSystem().insert(tables.Session, {
|
|
363
|
+
sessionId: 'logs-kill',
|
|
364
|
+
session: '{}',
|
|
365
|
+
expires,
|
|
366
|
+
userEmail: human.email,
|
|
367
|
+
});
|
|
368
|
+
const declarations = [new LogsMachineAccount()];
|
|
369
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
|
|
370
|
+
namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = declarations.map((object) => ({
|
|
371
|
+
qualifiedName: '@proteinjs/user-server-test/LogsMachineAccount',
|
|
372
|
+
packageName: '@proteinjs/user-server',
|
|
373
|
+
object,
|
|
374
|
+
}));
|
|
375
|
+
await new SourceRecordSyncRunner().load();
|
|
376
|
+
|
|
377
|
+
const log = await LogCapture.during(async () => {
|
|
378
|
+
await getDbAsSystem().update(tables.User, { id: human.id, status: 'deactivated' });
|
|
379
|
+
expect((await new MachineCredentials().mintCredential('machine-logs@test.local')).secretName).toBe(
|
|
380
|
+
'logs-ops-secret'
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(await getDbAsSystem().query(tables.Session, { sessionId: 'logs-kill' })).toEqual([]);
|
|
385
|
+
expect(log.addresses).toEqual([]);
|
|
386
|
+
expect(log.text).toContain(digests.account('logs-kill@test.local'));
|
|
387
|
+
expect(log.text).toContain(digests.account('machine-logs@test.local'));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('the dev doors: a missing test account created, the first-admin grant, a session established', async () => {
|
|
391
|
+
process.env.DEVELOPMENT = 'true';
|
|
392
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = 'logs-dev@test.local';
|
|
393
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = 'logs-dev@test.local';
|
|
394
|
+
delete process.env.DEV_BOOTSTRAP_ROLES;
|
|
395
|
+
// The first-admin door grants only while no account holds admin.
|
|
396
|
+
const admins = await getDbAsSystem().query(tables.User, {});
|
|
397
|
+
for (const admin of admins.filter((user) => (user.roles ?? []).includes('admin'))) {
|
|
398
|
+
await getDbAsSystem().update(tables.User, { id: admin.id, roles: [] });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const log = await LogCapture.during(async () => {
|
|
402
|
+
expect((await invokeDevLogin()).loggedInAs).toBe('logs-dev@test.local');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(log.text).toContain('Dev bootstrap admin door: granted');
|
|
406
|
+
expect(log.addresses).toEqual([]);
|
|
407
|
+
expect(log.text).toContain(digests.account('logs-dev@test.local'));
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { RequestDigests } from '../src/throttle/RequestDigests';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two keyed digests the throttled doors key their windows on and write on their log lines:
|
|
6
|
+
* the account digest (a keyed hash of the lowercased address) and the coarse IP hash. Both are
|
|
7
|
+
* stable for one key (so an operator can say "one account, one device" across lines, pods and
|
|
8
|
+
* restarts) and useless without it (never the address, never a plain hash a list of addresses
|
|
9
|
+
* could reverse).
|
|
10
|
+
*/
|
|
11
|
+
describe('RequestDigests', () => {
|
|
12
|
+
const digests = new RequestDigests({ secret: 'test-session-secret' });
|
|
13
|
+
|
|
14
|
+
it('the account digest is stable for one address whatever its case or surrounding spaces', () => {
|
|
15
|
+
const digest = digests.account('Ada.Lovelace@Example.com');
|
|
16
|
+
|
|
17
|
+
expect(digest).toMatch(/^[0-9a-f]{16}$/);
|
|
18
|
+
expect(digests.account('ada.lovelace@example.com')).toBe(digest);
|
|
19
|
+
expect(digests.account(' ADA.LOVELACE@EXAMPLE.COM ')).toBe(digest);
|
|
20
|
+
expect(digests.account('ada.lovelace@example.org')).not.toBe(digest);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('the account digest is keyed: another key gives another digest, and it is no plain hash of the address', () => {
|
|
24
|
+
const address = 'ada.lovelace@example.com';
|
|
25
|
+
const digest = digests.account(address);
|
|
26
|
+
|
|
27
|
+
expect(new RequestDigests({ secret: 'another-secret' }).account(address)).not.toBe(digest);
|
|
28
|
+
expect(createHash('sha256').update(address).digest('hex')).not.toContain(digest);
|
|
29
|
+
expect(digest).not.toContain('ada');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('the account digest and the coarse IP hash are separate: one value never reads as the other', () => {
|
|
33
|
+
expect(digests.account('203.0.113.7')).not.toBe(digests.coarseIp('203.0.113.7'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('the coarse IP hash is stable for one IPv4 address and differs for the next', () => {
|
|
37
|
+
const hash = digests.coarseIp('203.0.113.7');
|
|
38
|
+
|
|
39
|
+
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
|
40
|
+
expect(digests.coarseIp('203.0.113.7')).toBe(hash);
|
|
41
|
+
expect(digests.coarseIp('::ffff:203.0.113.7')).toBe(hash);
|
|
42
|
+
expect(digests.coarseIp('203.0.113.8')).not.toBe(hash);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('IPv6 is coarsened to its /64 — one device rotates freely inside its own /64', () => {
|
|
46
|
+
const hash = digests.coarseIp('2001:db8:1:2:aaaa:bbbb:cccc:dddd');
|
|
47
|
+
|
|
48
|
+
expect(digests.coarseIp('2001:db8:1:2::1')).toBe(hash);
|
|
49
|
+
expect(digests.coarseIp('2001:0db8:0001:0002:ffff:0:0:1')).toBe(hash);
|
|
50
|
+
expect(digests.coarseIp('2001:db8:1:3::1')).not.toBe(hash);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('with no key configured the digests refuse to run — never a plain hash, never a key of their own', () => {
|
|
54
|
+
const original = process.env.SESSION_SECRET;
|
|
55
|
+
delete process.env.SESSION_SECRET;
|
|
56
|
+
try {
|
|
57
|
+
expect(() => new RequestDigests().account('ada.lovelace@example.com')).toThrow(/SESSION_SECRET/);
|
|
58
|
+
expect(() => new RequestDigests({ secret: '' }).coarseIp('203.0.113.7')).toThrow(/SESSION_SECRET/);
|
|
59
|
+
} finally {
|
|
60
|
+
if (original !== undefined) {
|
|
61
|
+
process.env.SESSION_SECRET = original;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reads the key the deployment configures (SESSION_SECRET) when none is passed', () => {
|
|
67
|
+
const original = process.env.SESSION_SECRET;
|
|
68
|
+
process.env.SESSION_SECRET = 'test-session-secret';
|
|
69
|
+
try {
|
|
70
|
+
expect(new RequestDigests().account('ada.lovelace@example.com')).toBe(
|
|
71
|
+
digests.account('ada.lovelace@example.com')
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
if (original === undefined) {
|
|
75
|
+
delete process.env.SESSION_SECRET;
|
|
76
|
+
} else {
|
|
77
|
+
process.env.SESSION_SECRET = original;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|