@proteinjs/user-server 1.22.2 → 1.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
  5. package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
  6. package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
  7. package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
  8. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
  9. package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
  10. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
  11. package/dist/src/authentication/authenticate.js +2 -2
  12. package/dist/src/authentication/authenticate.js.map +1 -1
  13. package/dist/src/authorization/userCache.d.ts.map +1 -1
  14. package/dist/src/authorization/userCache.js +3 -2
  15. package/dist/src/authorization/userCache.js.map +1 -1
  16. package/dist/src/routes/devLogin.d.ts.map +1 -1
  17. package/dist/src/routes/devLogin.js +29 -14
  18. package/dist/src/routes/devLogin.js.map +1 -1
  19. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  20. package/dist/src/routes/executePasswordReset.js +7 -6
  21. package/dist/src/routes/executePasswordReset.js.map +1 -1
  22. package/dist/src/routes/initiatePasswordReset.js +2 -2
  23. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  24. package/dist/src/routes/signup.d.ts.map +1 -1
  25. package/dist/src/routes/signup.js +5 -2
  26. package/dist/src/routes/signup.js.map +1 -1
  27. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  28. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  29. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  30. package/dist/src/services/MachineCredentials.d.ts +10 -1
  31. package/dist/src/services/MachineCredentials.d.ts.map +1 -1
  32. package/dist/src/services/MachineCredentials.js +46 -15
  33. package/dist/src/services/MachineCredentials.js.map +1 -1
  34. package/dist/src/services/Roles.d.ts.map +1 -1
  35. package/dist/src/services/Roles.js +6 -2
  36. package/dist/src/services/Roles.js.map +1 -1
  37. package/dist/src/services/Signup.d.ts +15 -0
  38. package/dist/src/services/Signup.d.ts.map +1 -1
  39. package/dist/src/services/Signup.js +79 -27
  40. package/dist/src/services/Signup.js.map +1 -1
  41. package/dist/test/DevLogin.test.js +68 -0
  42. package/dist/test/DevLogin.test.js.map +1 -1
  43. package/dist/test/LogCapture.d.ts +10 -0
  44. package/dist/test/LogCapture.d.ts.map +1 -1
  45. package/dist/test/LogCapture.js +19 -0
  46. package/dist/test/LogCapture.js.map +1 -1
  47. package/dist/test/LogCapture.test.d.ts +2 -0
  48. package/dist/test/LogCapture.test.d.ts.map +1 -0
  49. package/dist/test/LogCapture.test.js +86 -0
  50. package/dist/test/LogCapture.test.js.map +1 -0
  51. package/dist/test/MachineAccounts.integration.test.js +156 -28
  52. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  53. package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
  54. package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
  55. package/dist/test/NoAddressesInLogs.integration.test.js +719 -0
  56. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  57. package/dist/test/SignupRace.integration.test.d.ts +2 -0
  58. package/dist/test/SignupRace.integration.test.d.ts.map +1 -0
  59. package/dist/test/SignupRace.integration.test.js +320 -0
  60. package/dist/test/SignupRace.integration.test.js.map +1 -0
  61. package/dist/test/SignupRoute.test.js +80 -1
  62. package/dist/test/SignupRoute.test.js.map +1 -1
  63. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  64. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  65. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  66. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  67. package/generated/index.ts +1 -1
  68. package/package.json +6 -5
  69. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  70. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  71. package/src/authentication/authenticate.ts +1 -1
  72. package/src/authorization/userCache.ts +3 -2
  73. package/src/routes/devLogin.ts +22 -10
  74. package/src/routes/executePasswordReset.ts +6 -5
  75. package/src/routes/initiatePasswordReset.ts +1 -1
  76. package/src/routes/signup.ts +5 -2
  77. package/src/routes/validateResetPasswordToken.ts +5 -1
  78. package/src/services/MachineCredentials.ts +44 -9
  79. package/src/services/Roles.ts +6 -2
  80. package/src/services/Signup.ts +60 -15
  81. package/test/DevLogin.test.ts +35 -1
  82. package/test/LogCapture.test.ts +29 -0
  83. package/test/LogCapture.ts +16 -0
  84. package/test/MachineAccounts.integration.test.ts +97 -22
  85. package/test/NoAddressesInLogs.integration.test.ts +410 -0
  86. package/test/SignupRace.integration.test.ts +230 -0
  87. package/test/SignupRoute.test.ts +54 -1
  88. package/test/SocketIOSessionWatcher.test.ts +86 -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, adopt-in-place by email (id + password survive — the old credential still logs in),
52
- * removed-from-source auto-deactivation with session kill through the categorical
53
- * UserStatusTableWatcher, the machine/human ledger split (Roles refuses machine targets),
54
- * declaration validation, and credential minting (hash-only at rest, plaintext shown once).
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
- // Exactly the deactivation watcher observes this run, rebuilt from the seeded cache.
94
- objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher()];
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('adopt-in-place by email: the hand-made row keeps its id and password; declared fields revert', async () => {
143
- // The deployed-env shape: a hand-made machine row with an env-random id, a provisioned
144
- // credential, and runtime-granted roles that have drifted from the declaration.
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 adopted = await machineRow();
154
- expect(adopted.id).toBe(handMade.id);
155
- expect(adopted).toMatchObject({
156
- name: 'Test ops machine',
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
- // The credential survived adoption: the account still authenticates with its old password
165
- // (createUser stores sha256('test')... it stores the raw string; assert equality instead).
166
- expect(adopted.password).toBe(handMade.password);
167
- expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
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,410 @@
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 loser answers 'exists' — the same bytes, no session, no refusal line, no address", 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's refused insert re-read the address and answered 'exists':
292
+ // both bodies are the existing-address response (`{}`), no refusal line is written, and the two
293
+ // account lines carry the account digest.
294
+ expect((await getDbAsSystem().query(tables.User, { email: 'logs-race@test.local' })).length).toBe(1);
295
+ expect(outcomes.filter((outcome) => outcome.loggedInAs === 'logs-race@test.local')).toHaveLength(1);
296
+ expect(outcomes.map((outcome) => outcome.body)).toEqual([{}, {}]);
297
+ expect(log.linesContaining('Signup failed').lines).toHaveLength(0);
298
+ expect(log.linesContaining('already exists').text).toContain(digests.account('logs-race@test.local'));
299
+ expect(log.linesContaining('Created user').text).toContain(digests.account('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
+ expect(log.linesContaining('[Signup.createUser]').addresses).toEqual([]);
304
+ });
305
+
306
+ it('an invite whose send or write fails with the address in its error: the invitee by its digest, the error too', async () => {
307
+ const inviter = await testEnv.createUser({ name: 'Inviter', email: 'logs-inviter-2@test.local', roles: ['admin'] });
308
+ testEnv.actAs(inviter);
309
+ await new Signup().sendInvite('invitee-standing@example.invalid');
310
+ // What a caught error looks like when its words are the address's: a refusal naming the key.
311
+ const naming = (address: string) =>
312
+ Object.assign(new Error(`6 ALREADY_EXISTS: Row [${address}] in table Invite already exists`), {
313
+ code: 6,
314
+ details: `Row [${address}] in table Invite already exists`,
315
+ });
316
+
317
+ const log = await LogCapture.during(async () => {
318
+ const failing = jest
319
+ .spyOn(EmailSender.prototype, 'sendEmail')
320
+ .mockImplementation(async (options) => Promise.reject(naming(String(options.to))));
321
+ try {
322
+ expect((await new Signup().sendInvite('Invitee-Named@Example.invalid')).sent).toBe(false);
323
+ expect((await new Signup().resendInvite('invitee-standing@example.invalid')).sent).toBe(false);
324
+ } finally {
325
+ failing.mockRestore();
326
+ }
327
+ });
328
+
329
+ expect(log.text).toContain('Error sending invite');
330
+ expect(log.text).toContain('Error re-sending invite');
331
+ expect(log.text).toContain('in table Invite already exists');
332
+ expect(log.addresses).toEqual([]);
333
+ expect(log.text).toContain(digests.address('invitee-named@example.invalid'));
334
+ expect(log.text).toContain(digests.address('invitee-standing@example.invalid'));
335
+ });
336
+
337
+ it('sign-in and sessions: a deactivated account refused, a session on a deactivated account, a session on a missing account', async () => {
338
+ await getDbAsSystem().insert(tables.User, {
339
+ name: 'Deactivated',
340
+ email: 'logs-deactivated@test.local',
341
+ password: sha256('correct-password').toString(),
342
+ emailVerified: true,
343
+ roles: [],
344
+ status: 'deactivated',
345
+ });
346
+
347
+ const log = await LogCapture.during(async () => {
348
+ expect(await authenticate('Logs-Deactivated@test.local', 'correct-password')).toBe(
349
+ 'This account has been deactivated'
350
+ );
351
+ expect((await userCache.create('logs-session-1', 'logs-deactivated@test.local')).id).toBe('guest');
352
+ expect((await userCache.create('logs-session-2', 'logs-missing@test.local')).id).toBe('guest');
353
+ });
354
+
355
+ expect(log.addresses).toEqual([]);
356
+ expect(log.text).toContain(digests.account('logs-deactivated@test.local'));
357
+ expect(log.text).toContain(digests.account('logs-missing@test.local'));
358
+ });
359
+
360
+ it('the sessions a deactivation kills, and a minted machine credential', async () => {
361
+ const human = await testEnv.createUser({ name: 'Soon Deactivated', email: 'logs-kill@test.local' });
362
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
363
+ await getDbAsSystem().insert(tables.Session, {
364
+ sessionId: 'logs-kill',
365
+ session: '{}',
366
+ expires,
367
+ userEmail: human.email,
368
+ });
369
+ const declarations = [new LogsMachineAccount()];
370
+ objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
371
+ namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = declarations.map((object) => ({
372
+ qualifiedName: '@proteinjs/user-server-test/LogsMachineAccount',
373
+ packageName: '@proteinjs/user-server',
374
+ object,
375
+ }));
376
+ await new SourceRecordSyncRunner().load();
377
+
378
+ const log = await LogCapture.during(async () => {
379
+ await getDbAsSystem().update(tables.User, { id: human.id, status: 'deactivated' });
380
+ expect((await new MachineCredentials().mintCredential('machine-logs@test.local')).secretName).toBe(
381
+ 'logs-ops-secret'
382
+ );
383
+ });
384
+
385
+ expect(await getDbAsSystem().query(tables.Session, { sessionId: 'logs-kill' })).toEqual([]);
386
+ expect(log.addresses).toEqual([]);
387
+ expect(log.text).toContain(digests.account('logs-kill@test.local'));
388
+ expect(log.text).toContain(digests.account('machine-logs@test.local'));
389
+ });
390
+
391
+ it('the dev doors: a missing test account created, the first-admin grant, a session established', async () => {
392
+ process.env.DEVELOPMENT = 'true';
393
+ process.env.DEV_AUTO_LOGIN_EMAIL = 'logs-dev@test.local';
394
+ process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = 'logs-dev@test.local';
395
+ delete process.env.DEV_BOOTSTRAP_ROLES;
396
+ // The first-admin door grants only while no account holds admin.
397
+ const admins = await getDbAsSystem().query(tables.User, {});
398
+ for (const admin of admins.filter((user) => (user.roles ?? []).includes('admin'))) {
399
+ await getDbAsSystem().update(tables.User, { id: admin.id, roles: [] });
400
+ }
401
+
402
+ const log = await LogCapture.during(async () => {
403
+ expect((await invokeDevLogin()).loggedInAs).toBe('logs-dev@test.local');
404
+ });
405
+
406
+ expect(log.text).toContain('Dev bootstrap admin door: granted');
407
+ expect(log.addresses).toEqual([]);
408
+ expect(log.text).toContain(digests.account('logs-dev@test.local'));
409
+ });
410
+ });