@proteinjs/user-server 1.22.2 → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +15 -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 +5 -0
  38. package/dist/src/services/Signup.d.ts.map +1 -1
  39. package/dist/src/services/Signup.js +28 -6
  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 +717 -0
  56. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  57. package/dist/test/SignupRoute.test.js +80 -1
  58. package/dist/test/SignupRoute.test.js.map +1 -1
  59. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  60. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  61. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  62. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  63. package/generated/index.ts +1 -1
  64. package/package.json +6 -5
  65. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  66. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  67. package/src/authentication/authenticate.ts +1 -1
  68. package/src/authorization/userCache.ts +3 -2
  69. package/src/routes/devLogin.ts +22 -10
  70. package/src/routes/executePasswordReset.ts +6 -5
  71. package/src/routes/initiatePasswordReset.ts +1 -1
  72. package/src/routes/signup.ts +5 -2
  73. package/src/routes/validateResetPasswordToken.ts +5 -1
  74. package/src/services/MachineCredentials.ts +44 -9
  75. package/src/services/Roles.ts +6 -2
  76. package/src/services/Signup.ts +30 -4
  77. package/test/DevLogin.test.ts +35 -1
  78. package/test/LogCapture.test.ts +29 -0
  79. package/test/LogCapture.ts +16 -0
  80. package/test/MachineAccounts.integration.test.ts +97 -22
  81. package/test/NoAddressesInLogs.integration.test.ts +409 -0
  82. package/test/SignupRoute.test.ts +54 -1
  83. package/test/SocketIOSessionWatcher.test.ts +86 -0
@@ -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
+ });
@@ -35,7 +35,7 @@ jest.mock('@proteinjs/email-server', () => ({
35
35
 
36
36
  import { getDbAsSystem, Reference } from '@proteinjs/db';
37
37
  import { SourceRepository } from '@proteinjs/reflection';
38
- import { tables } from '@proteinjs/user';
38
+ import { MachineAccount, tables } from '@proteinjs/user';
39
39
  import { signup } from '../src/routes/signup';
40
40
  import { createPassportRequest } from './passportSessionHarness';
41
41
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
@@ -45,6 +45,31 @@ const testEnv = new UserServerTestEnvironment();
45
45
  const INVITE_CONFIG_KEY = '@proteinjs/user-server/DefaultInviteConfigFactory';
46
46
 
47
47
  type ObjectCache = { objectCache: Record<string, unknown[]> };
48
+ type NamedObjectCache = {
49
+ namedObjectCache: Record<string, { qualifiedName: string; packageName: string; object: unknown }[]>;
50
+ };
51
+
52
+ /** A code-declared machine account owning an address (the declaration only — never booted here). */
53
+ class DeclaredMachineAccount extends MachineAccount {
54
+ id = 'declared-machine';
55
+ email = 'declared.machine@test.local';
56
+ accountName = 'Declared machine';
57
+ roles = [];
58
+ secretName = 'declared-machine-secret';
59
+ }
60
+
61
+ const LOADER_KEY = '@proteinjs/db/SourceRecordLoader';
62
+
63
+ /** Seed the build's source-record declarations (the machine-account read resolves them by name). */
64
+ const setDeclarations = (declarations: unknown[]) => {
65
+ (SourceRepository.get() as unknown as NamedObjectCache).namedObjectCache[LOADER_KEY] = declarations.map(
66
+ (object, i) => ({
67
+ qualifiedName: `@proteinjs/user-server-test/Declared${i}`,
68
+ packageName: '@test/declarer',
69
+ object,
70
+ })
71
+ );
72
+ };
48
73
 
49
74
  /** Seed the invite-config lookup (tests don't load the generated source graph). */
50
75
  const setInviteConfig = (factories: unknown[]) => {
@@ -94,12 +119,14 @@ describe('signup route — auto-login after signup', () => {
94
119
  }, 120000);
95
120
 
96
121
  afterAll(async () => {
122
+ delete (SourceRepository.get() as unknown as NamedObjectCache).namedObjectCache[LOADER_KEY];
97
123
  await testEnv.afterAll();
98
124
  });
99
125
 
100
126
  beforeEach(async () => {
101
127
  jest.clearAllMocks();
102
128
  setInviteConfig([]);
129
+ setDeclarations([]);
103
130
  const db = getDbAsSystem();
104
131
  await db.delete(tables.Invite, {});
105
132
  await db.delete(tables.User, {});
@@ -180,6 +207,32 @@ describe('signup route — auto-login after signup', () => {
180
207
  expect(sendEmail).toHaveBeenCalledTimes(1); // "account already exists" email
181
208
  });
182
209
 
210
+ it(`an address a machine-account declaration owns can't be registered — by signup or by invite: plain words, no account, no session`, async () => {
211
+ setDeclarations([new DeclaredMachineAccount()]);
212
+
213
+ const direct = await invokeSignup({
214
+ name: 'Squatter',
215
+ email: 'Declared.Machine@test.local',
216
+ password: 'pw-squat-1',
217
+ });
218
+ expect(direct.body).toEqual({ error: "This address can't be registered." });
219
+ expect(direct.loggedInAs).toBeUndefined();
220
+ expect(direct.sessionEvents).toEqual([]);
221
+
222
+ await getDbAsSystem().insert(tables.Invite, {
223
+ email: 'declared.machine@test.local',
224
+ token: 'invite-token-declared',
225
+ tokenExpiresAt: moment().add(1, 'day'),
226
+ invitedBy: new Reference(tables.User.name, 'inviter-1'),
227
+ });
228
+ const invited = await invokeSignup({ name: 'Squatter', password: 'pw-squat-2', token: 'invite-token-declared' });
229
+ expect(invited.body).toEqual({ error: "This address can't be registered." });
230
+ expect(invited.loggedInAs).toBeUndefined();
231
+
232
+ expect(await getUserRow('declared.machine@test.local')).toBeUndefined();
233
+ expect(sendEmail).not.toHaveBeenCalled();
234
+ });
235
+
183
236
  it('an unknown invite token gets the honest error — no session, no account', async () => {
184
237
  const outcome = await invokeSignup({
185
238
  name: 'Token Guess',
@@ -0,0 +1,86 @@
1
+ import { createServer } from 'http';
2
+ import { QueryBuilder } from '@proteinjs/db';
3
+ import { SourceRepository } from '@proteinjs/reflection';
4
+ import { SocketIOServerRepo } from '@proteinjs/server';
5
+ import { Session } from '@proteinjs/user';
6
+ import { SocketIOSessionWatcher } from '../src/authentication/SocketIOSessionWatcher';
7
+
8
+ type ObjectCache = { objectCache: Record<string, unknown[]> };
9
+
10
+ /** What the watcher asked the socket server to do: which rooms (session ids) it closed. */
11
+ type DisconnectCall = { rooms: string[]; close: boolean };
12
+
13
+ /**
14
+ * The socket server this file installs through the repo's own factory seam — a stand-in that
15
+ * records every `in(rooms).disconnectSockets(close)` it receives (no socket.io client is part of
16
+ * this package, so no real socket can connect to observe).
17
+ */
18
+ class RecordingSocketIOServer {
19
+ readonly calls: DisconnectCall[] = [];
20
+
21
+ in(rooms: string | string[]) {
22
+ return {
23
+ disconnectSockets: (close: boolean) => {
24
+ this.calls.push({ rooms: ([] as string[]).concat(rooms), close });
25
+ },
26
+ };
27
+ }
28
+ }
29
+
30
+ const session = (sessionId: string): Session =>
31
+ ({ id: `row-${sessionId}`, sessionId, session: '{}', userEmail: 'someone@test.local' }) as unknown as Session;
32
+
33
+ const deleteSessions = async (sessions: Session[]) =>
34
+ await new SocketIOSessionWatcher().afterDelete(
35
+ sessions.length,
36
+ sessions,
37
+ new QueryBuilder<Session>('session'),
38
+ new QueryBuilder<Session>('session')
39
+ );
40
+
41
+ /**
42
+ * Deleting session rows closes their sockets — and a deletion is not always made by a running
43
+ * server: a boot's source-record sync (and a migration Job, which never starts one) deactivates a
44
+ * withdrawn machine account and deletes its sessions before any socket server exists.
45
+ */
46
+ describe('SocketIOSessionWatcher', () => {
47
+ it('a deletion before the socket server exists resolves — no socket can be open yet', async () => {
48
+ expect(SocketIOServerRepo.getSocketIOServerIfExists()).toBeUndefined();
49
+
50
+ await expect(deleteSessions([session('before-boot')])).resolves.toBeUndefined();
51
+ });
52
+
53
+ describe('with the socket server up', () => {
54
+ const server = new RecordingSocketIOServer();
55
+
56
+ beforeAll(async () => {
57
+ (SourceRepository.get() as unknown as ObjectCache).objectCache['@proteinjs/event/DefaultSocketIOServerFactory'] =
58
+ [{ createSocketIOServer: async () => server }];
59
+ await SocketIOServerRepo.createSocketIOServer(createServer());
60
+ });
61
+
62
+ afterAll(() => {
63
+ delete (SourceRepository.get() as unknown as ObjectCache).objectCache[
64
+ '@proteinjs/event/DefaultSocketIOServerFactory'
65
+ ];
66
+ });
67
+
68
+ beforeEach(() => {
69
+ server.calls.length = 0;
70
+ });
71
+
72
+ it(`closes exactly the deleted sessions' sockets`, async () => {
73
+ await deleteSessions([session('s-1'), session('s-2')]);
74
+
75
+ expect(server.calls).toEqual([{ rooms: ['s-1', 's-2'], close: true }]);
76
+ });
77
+
78
+ it('a deletion with no session ids closes nothing — an empty room list would address every socket', async () => {
79
+ // socket.io's in-memory adapter applies an operation with NO rooms to every socket on the
80
+ // namespace (`apply`: `if (rooms.size) … else for every sid`), so `in([])` must never be sent.
81
+ await deleteSessions([]);
82
+
83
+ expect(server.calls).toEqual([]);
84
+ });
85
+ });
86
+ });