@proteinjs/user-server 1.6.0 → 1.8.0

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 (114) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +9 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/authentication/PasswordHasher.d.ts +45 -0
  10. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
  11. package/dist/src/authentication/PasswordHasher.js +152 -0
  12. package/dist/src/authentication/PasswordHasher.js.map +1 -0
  13. package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
  14. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
  15. package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
  16. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
  17. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  18. package/dist/src/authentication/authenticate.js +41 -13
  19. package/dist/src/authentication/authenticate.js.map +1 -1
  20. package/dist/src/authorization/userCache.d.ts.map +1 -1
  21. package/dist/src/authorization/userCache.js +27 -3
  22. package/dist/src/authorization/userCache.js.map +1 -1
  23. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
  24. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
  25. package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
  26. package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
  27. package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
  28. package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
  29. package/dist/src/emails/AccountDeletionEmails.js +98 -0
  30. package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
  31. package/dist/src/routes/devLogin.d.ts +15 -5
  32. package/dist/src/routes/devLogin.d.ts.map +1 -1
  33. package/dist/src/routes/devLogin.js +52 -15
  34. package/dist/src/routes/devLogin.js.map +1 -1
  35. package/dist/src/routes/executePasswordReset.js +5 -3
  36. package/dist/src/routes/executePasswordReset.js.map +1 -1
  37. package/dist/src/routes/login.d.ts.map +1 -1
  38. package/dist/src/routes/login.js +26 -2
  39. package/dist/src/routes/login.js.map +1 -1
  40. package/dist/src/services/AccountDeletion.d.ts +52 -0
  41. package/dist/src/services/AccountDeletion.d.ts.map +1 -0
  42. package/dist/src/services/AccountDeletion.js +426 -0
  43. package/dist/src/services/AccountDeletion.js.map +1 -0
  44. package/dist/src/services/MachineCredentials.d.ts +21 -0
  45. package/dist/src/services/MachineCredentials.d.ts.map +1 -0
  46. package/dist/src/services/MachineCredentials.js +164 -0
  47. package/dist/src/services/MachineCredentials.js.map +1 -0
  48. package/dist/src/services/Roles.d.ts.map +1 -1
  49. package/dist/src/services/Roles.js +7 -0
  50. package/dist/src/services/Roles.js.map +1 -1
  51. package/dist/src/services/SetUserStatus.d.ts +18 -0
  52. package/dist/src/services/SetUserStatus.d.ts.map +1 -0
  53. package/dist/src/services/SetUserStatus.js +118 -0
  54. package/dist/src/services/SetUserStatus.js.map +1 -0
  55. package/dist/src/services/Signup.d.ts +16 -1
  56. package/dist/src/services/Signup.d.ts.map +1 -1
  57. package/dist/src/services/Signup.js +55 -16
  58. package/dist/src/services/Signup.js.map +1 -1
  59. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  60. package/dist/src/services/UpdateUserInfo.js +20 -17
  61. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  62. package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
  63. package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
  64. package/dist/test/AccountDeletion.integration.test.js +710 -0
  65. package/dist/test/AccountDeletion.integration.test.js.map +1 -0
  66. package/dist/test/DevLogin.test.d.ts +2 -0
  67. package/dist/test/DevLogin.test.d.ts.map +1 -0
  68. package/dist/test/DevLogin.test.js +262 -0
  69. package/dist/test/DevLogin.test.js.map +1 -0
  70. package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
  71. package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
  72. package/dist/test/MachineAccounts.integration.test.js +597 -0
  73. package/dist/test/MachineAccounts.integration.test.js.map +1 -0
  74. package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
  75. package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
  76. package/dist/test/PasswordMigration.integration.test.js +332 -0
  77. package/dist/test/PasswordMigration.integration.test.js.map +1 -0
  78. package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
  79. package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
  80. package/dist/test/SetUserStatus.integration.test.js +266 -0
  81. package/dist/test/SetUserStatus.integration.test.js.map +1 -0
  82. package/dist/test/UserCacheMissingAccount.test.d.ts +2 -0
  83. package/dist/test/UserCacheMissingAccount.test.d.ts.map +1 -0
  84. package/dist/test/UserCacheMissingAccount.test.js +104 -0
  85. package/dist/test/UserCacheMissingAccount.test.js.map +1 -0
  86. package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
  87. package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
  88. package/dist/test/UserStatusGate.integration.test.js +162 -0
  89. package/dist/test/UserStatusGate.integration.test.js.map +1 -0
  90. package/generated/index.ts +9 -1
  91. package/index.ts +3 -0
  92. package/package.json +8 -6
  93. package/src/authentication/PasswordHasher.ts +87 -0
  94. package/src/authentication/UserStatusTableWatcher.ts +56 -0
  95. package/src/authentication/authenticate.ts +31 -6
  96. package/src/authorization/userCache.ts +25 -2
  97. package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
  98. package/src/emails/AccountDeletionEmails.ts +39 -0
  99. package/src/routes/devLogin.ts +45 -7
  100. package/src/routes/executePasswordReset.ts +2 -2
  101. package/src/routes/login.ts +23 -0
  102. package/src/services/AccountDeletion.ts +291 -0
  103. package/src/services/MachineCredentials.ts +94 -0
  104. package/src/services/Roles.ts +10 -0
  105. package/src/services/SetUserStatus.ts +56 -0
  106. package/src/services/Signup.ts +43 -12
  107. package/src/services/UpdateUserInfo.ts +4 -5
  108. package/test/AccountDeletion.integration.test.ts +391 -0
  109. package/test/DevLogin.test.ts +160 -0
  110. package/test/MachineAccounts.integration.test.ts +312 -0
  111. package/test/PasswordMigration.integration.test.ts +132 -0
  112. package/test/SetUserStatus.integration.test.ts +120 -0
  113. package/test/UserCacheMissingAccount.test.ts +42 -0
  114. package/test/UserStatusGate.integration.test.ts +68 -0
@@ -0,0 +1,391 @@
1
+ import sha256 from 'crypto-js/sha256';
2
+ import moment from 'moment';
3
+ import { Reference, getDbAsSystem } from '@proteinjs/db';
4
+ import { SourceRepository } from '@proteinjs/reflection';
5
+ // Deep import (test-only): ServiceAuth is the real RPC door but is not index-exported by
6
+ // @proteinjs/service — the door test below runs the actual gate rather than pinning metadata.
7
+ import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
8
+ import { AccessGrant, ManifestGrant, User, UserRepo, guestUser, tables } from '@proteinjs/user';
9
+ import { AccountDeletion } from '../src/services/AccountDeletion';
10
+ import { authenticate } from '../src/authentication/authenticate';
11
+ import { login } from '../src/routes/login';
12
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
13
+
14
+ const testEnv = new UserServerTestEnvironment();
15
+
16
+ const PASSWORD_A = 'alpha-password';
17
+ const PASSWORD_B = 'beta-password';
18
+ const RESOURCE_TABLE = 'file';
19
+
20
+ /**
21
+ * BUILD §9.1 — the deactivation + cancel suite, two users, outcomes only (rows written against
22
+ * the emulator, never calls made). Fixture: A owns r1/r2 (shared outbound to B), B owns r3
23
+ * (shared inbound to A). A's deletion must revoke both directions while B's own grant — and
24
+ * A's owner grants, the purge walker's terminal targets — survive.
25
+ */
26
+ describe('AccountDeletion — deactivation, manifest, resume, cancel-by-login', () => {
27
+ let userA: User;
28
+ let userB: User;
29
+ const r1 = 'resource-a-one';
30
+ const r2 = 'resource-a-two';
31
+ const r3 = 'resource-b-three';
32
+ let ownerA1: AccessGrant;
33
+ let ownerA2: AccessGrant;
34
+ let outbound1: AccessGrant;
35
+ let outbound2: AccessGrant;
36
+ let inbound: AccessGrant;
37
+ let ownerB: AccessGrant;
38
+
39
+ beforeAll(async () => {
40
+ await testEnv.beforeAll();
41
+ }, 120000);
42
+
43
+ afterAll(async () => {
44
+ await testEnv.afterAll();
45
+ });
46
+
47
+ beforeEach(async () => {
48
+ const db = getDbAsSystem();
49
+ await db.delete(tables.AccountDeletion, {});
50
+ await db.delete(tables.AccessGrant, {});
51
+ await db.delete(tables.Session, {});
52
+ await db.delete(tables.UserStatusEvent, {});
53
+ await db.delete(tables.User, {});
54
+
55
+ userA = await createAccount('deleter@test.local', PASSWORD_A);
56
+ userB = await createAccount('keeper@test.local', PASSWORD_B);
57
+
58
+ ownerA1 = await insertGrant(userA.id, r1, 'owner');
59
+ ownerA2 = await insertGrant(userA.id, r2, 'owner');
60
+ outbound1 = await insertGrant(userB.id, r1, 'read');
61
+ outbound2 = await insertGrant(userB.id, r2, 'write');
62
+ ownerB = await insertGrant(userB.id, r3, 'owner');
63
+ inbound = await insertGrant(userA.id, r3, 'read');
64
+
65
+ await insertSession('session-a-1', userA.email);
66
+ await insertSession('session-a-2', userA.email);
67
+ await insertSession('session-b-1', userB.email);
68
+ });
69
+
70
+ /** Insert an account the way the product stores it: lowercased email, sha256 password. */
71
+ const createAccount = async (email: string, password: string) => {
72
+ return await getDbAsSystem().insert(tables.User, {
73
+ name: 'Deletion test user',
74
+ email,
75
+ password: sha256(password).toString(),
76
+ emailVerified: true,
77
+ roles: [],
78
+ });
79
+ };
80
+
81
+ const insertGrant = async (principalId: string, resourceId: string, accessLevel: AccessGrant['accessLevel']) => {
82
+ return await getDbAsSystem().insert(tables.AccessGrant, {
83
+ principal: new Reference(tables.User.name, principalId),
84
+ resource: new Reference(RESOURCE_TABLE, resourceId),
85
+ resourceTable: RESOURCE_TABLE,
86
+ accessLevel,
87
+ });
88
+ };
89
+
90
+ const insertSession = async (sessionId: string, userEmail: string) => {
91
+ return await getDbAsSystem().insert(tables.Session, {
92
+ sessionId,
93
+ session: '{}',
94
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
95
+ userEmail,
96
+ });
97
+ };
98
+
99
+ const grantRows = async () => await getDbAsSystem().query(tables.AccessGrant, {});
100
+ const sessionRows = async () => await getDbAsSystem().query(tables.Session, {});
101
+ const auditRows = async () => await getDbAsSystem().query(tables.UserStatusEvent, {});
102
+ const deletionRow = async (userId: string) => await getDbAsSystem().get(tables.AccountDeletion, { userId });
103
+ const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
104
+
105
+ const sortById = <T extends { id: string }>(rows: T[]) => [...rows].sort((a, b) => a.id.localeCompare(b.id));
106
+
107
+ const expectedManifest = (): ManifestGrant[] => [
108
+ { id: inbound.id, principal: userA.id, resource: r3, resourceTable: RESOURCE_TABLE, accessLevel: 'read' },
109
+ { id: outbound1.id, principal: userB.id, resource: r1, resourceTable: RESOURCE_TABLE, accessLevel: 'read' },
110
+ { id: outbound2.id, principal: userB.id, resource: r2, resourceTable: RESOURCE_TABLE, accessLevel: 'write' },
111
+ ];
112
+
113
+ /** Drive the real login route with a fake request/response pair. */
114
+ const invokeLogin = async (email: string, password: string) => {
115
+ let loggedInAs: string | undefined;
116
+ let sent: any;
117
+ const response: any = {
118
+ send: (body: any) => {
119
+ sent = body;
120
+ },
121
+ status: () => response,
122
+ };
123
+ const request: any = {
124
+ body: { email, password },
125
+ login: (loginEmail: string, callback: () => void) => {
126
+ loggedInAs = loginEmail;
127
+ callback();
128
+ },
129
+ };
130
+ await login.onRequest(request, response);
131
+ return { loggedInAs, sent };
132
+ };
133
+
134
+ it('the service door admits any signed-in account and refuses guests', () => {
135
+ // ServiceAuth funnels through UserAuth, which resolves the current user from the
136
+ // source-graph-registered AuthenticatedUserRepo — seed it the way the env seeds session
137
+ // storage (tests do not load the generated source graph).
138
+ (SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache[
139
+ '@proteinjs/user-auth/AuthenticatedUserRepo'
140
+ ] = [new UserRepo()];
141
+ const canRun = () =>
142
+ ServiceAuth.canRunService(new AccountDeletion(), { name: 'requestDeletion' } as any, [PASSWORD_A]);
143
+
144
+ testEnv.actAs(userA); // a plain account with no roles at all
145
+ expect(canRun()).toBe(true);
146
+
147
+ testEnv.actAs(guestUser as User);
148
+ expect(canRun()).toBe(false);
149
+ });
150
+
151
+ it('deactivates: revokes grants both directions, persists the full manifest, flips + audits standing, kills sessions', async () => {
152
+ testEnv.actAs(userA);
153
+ const result = await new AccountDeletion().requestDeletion(PASSWORD_A);
154
+
155
+ // Grants: outbound (B on r1/r2) and inbound (A on r3) revoked; A's owner grants (the purge
156
+ // walker's terminal targets) and B's own owner grant survive.
157
+ const remaining = await grantRows();
158
+ expect(sortById(remaining).map((grant) => grant.id)).toEqual(
159
+ sortById([ownerA1, ownerA2, ownerB]).map((grant) => grant.id)
160
+ );
161
+
162
+ // Manifest row: full grant set both directions, owned-resource snapshot, grace phase.
163
+ const deletion = await deletionRow(userA.id);
164
+ expect(deletion).toBeTruthy();
165
+ expect(deletion.phase).toBe('grace');
166
+ expect(deletion.leaseSeq).toBe(0);
167
+ expect(deletion.userEmail).toBe(userA.email);
168
+ expect([...deletion.ownedResourceIds].sort()).toEqual([r1, r2].sort());
169
+ expect(sortById(deletion.manifestGrants)).toEqual(sortById(expectedManifest()));
170
+
171
+ // purgeAfter ≈ now + 30 days, and the service returned the same stamp.
172
+ const hoursOut = moment(deletion.purgeAfter).diff(moment(), 'hours', true);
173
+ expect(hoursOut).toBeGreaterThan(719);
174
+ expect(hoursOut).toBeLessThan(721);
175
+ expect(Math.abs(result.purgeAfter.valueOf() - moment(deletion.purgeAfter).valueOf())).toBeLessThan(1000);
176
+
177
+ // User row: audited flip + deletion stamps.
178
+ const flipped = await userRow(userA.id);
179
+ expect(flipped.status).toBe('deactivated');
180
+ expect(flipped.deleteRequestedAt).toBeTruthy();
181
+ expect(Math.abs(moment(flipped.purgeAfter).valueOf() - moment(deletion.purgeAfter).valueOf())).toBeLessThan(1000);
182
+ const events = await auditRows();
183
+ expect(events).toHaveLength(1);
184
+ expect(events[0]).toMatchObject({ actor: userA.id, target: userA.id, status: 'deactivated' });
185
+
186
+ // Sessions: A's two killed, B's untouched.
187
+ const sessions = await sessionRows();
188
+ expect(sessions).toHaveLength(1);
189
+ expect(sessions[0].userEmail).toBe(userB.email);
190
+ });
191
+
192
+ it('refuses a wrong password with zero writes', async () => {
193
+ testEnv.actAs(userA);
194
+ await expect(new AccountDeletion().requestDeletion('wrong-password')).rejects.toThrow('Password incorrect');
195
+
196
+ expect(await deletionRow(userA.id)).toBeFalsy();
197
+ expect(await grantRows()).toHaveLength(6);
198
+ expect(await sessionRows()).toHaveLength(3);
199
+ expect(await auditRows()).toHaveLength(0);
200
+ const untouched = await userRow(userA.id);
201
+ expect(untouched.status).toBe('active');
202
+ expect(untouched.deleteRequestedAt).toBeFalsy();
203
+ expect(untouched.purgeAfter).toBeFalsy();
204
+ });
205
+
206
+ it('resume keeps the FULL manifest after a crash mid-revocation — never re-enumerates', async () => {
207
+ testEnv.actAs(userA);
208
+ const service = new AccountDeletion();
209
+ await service.requestDeletion(PASSWORD_A);
210
+ const persisted = await deletionRow(userA.id);
211
+ expect(persisted.manifestGrants).toHaveLength(3);
212
+
213
+ // Simulate the §3.3 crash window (mid step 4): two of the revoked grants were never actually
214
+ // deleted — restore them with their ORIGINAL ids — and the user row/sessions never flipped.
215
+ for (const grant of [outbound1, outbound2]) {
216
+ await getDbAsSystem().insert(tables.AccessGrant, {
217
+ id: grant.id,
218
+ principal: new Reference(tables.User.name, grant.principal._id as string),
219
+ resource: new Reference(RESOURCE_TABLE, grant.resource._id as string),
220
+ resourceTable: RESOURCE_TABLE,
221
+ accessLevel: grant.accessLevel,
222
+ } as any);
223
+ }
224
+ await getDbAsSystem().update(tables.User, {
225
+ id: userA.id,
226
+ status: 'active',
227
+ deleteRequestedAt: null,
228
+ purgeAfter: null,
229
+ });
230
+ await insertSession('session-a-3', userA.email);
231
+
232
+ // The user's retry resumes from the stored manifest.
233
+ await service.requestDeletion(PASSWORD_A);
234
+
235
+ // THE red assertion: the manifest still holds the FULL pre-crash set. A re-enumerating
236
+ // implementation rebuilds it from the shrunken live grants (the inbound grant is already
237
+ // gone) and this fails.
238
+ const resumed = await deletionRow(userA.id);
239
+ expect(sortById(resumed.manifestGrants)).toEqual(sortById(expectedManifest()));
240
+ expect([...resumed.ownedResourceIds].sort()).toEqual([r1, r2].sort());
241
+
242
+ // And the resume completed the crashed pass: revocation drained, standing flipped, session dead.
243
+ const remaining = await grantRows();
244
+ expect(sortById(remaining).map((grant) => grant.id)).toEqual(
245
+ sortById([ownerA1, ownerA2, ownerB]).map((grant) => grant.id)
246
+ );
247
+ expect((await userRow(userA.id)).status).toBe('deactivated');
248
+ expect((await sessionRows()).map((session) => session.userEmail)).toEqual([userB.email]);
249
+ });
250
+
251
+ it('cancel restores grants (fresh ids), reactivates + audits, nulls stamps, deletes the manifest row', async () => {
252
+ testEnv.actAs(userA);
253
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
254
+ const manifest = (await deletionRow(userA.id)).manifestGrants;
255
+
256
+ const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
257
+ expect(outcome).toBe('restored');
258
+
259
+ // Every revoked grant is back — matched by (principal, resource, accessLevel), with a FRESH id.
260
+ const all = await grantRows();
261
+ expect(all).toHaveLength(6);
262
+ for (const entry of manifest) {
263
+ const matches = all.filter(
264
+ (grant) =>
265
+ grant.principal._id === entry.principal &&
266
+ grant.resource._id === entry.resource &&
267
+ grant.accessLevel === entry.accessLevel
268
+ );
269
+ expect(matches).toHaveLength(1);
270
+ expect(matches[0].id).not.toBe(entry.id);
271
+ }
272
+
273
+ // User row: active again through the audited path, stamps nulled.
274
+ const restored = await userRow(userA.id);
275
+ expect(restored.status).toBe('active');
276
+ expect(restored.deleteRequestedAt).toBeFalsy();
277
+ expect(restored.purgeAfter).toBeFalsy();
278
+ const events = await auditRows();
279
+ expect(events.map((event) => event.status).sort()).toEqual(['active', 'deactivated']);
280
+
281
+ expect(await deletionRow(userA.id)).toBeFalsy();
282
+ });
283
+
284
+ it('cancel resumes a crashed prior cancel: restoring-phase re-entry, no duplicate grants', async () => {
285
+ testEnv.actAs(userA);
286
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
287
+ const deletion = await deletionRow(userA.id);
288
+
289
+ // A prior cancel claimed the row, restored ONE grant, then crashed.
290
+ await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'restoring' });
291
+ const first = deletion.manifestGrants[0];
292
+ await getDbAsSystem().insert(tables.AccessGrant, {
293
+ principal: new Reference(tables.User.name, first.principal),
294
+ resource: new Reference(first.resourceTable as string, first.resource),
295
+ resourceTable: first.resourceTable,
296
+ accessLevel: first.accessLevel,
297
+ });
298
+
299
+ const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
300
+ expect(outcome).toBe('restored');
301
+
302
+ // Idempotent re-insert: exactly one grant per manifest entry — no duplicates.
303
+ const all = await grantRows();
304
+ expect(all).toHaveLength(6);
305
+ for (const entry of deletion.manifestGrants) {
306
+ const matches = all.filter(
307
+ (grant) =>
308
+ grant.principal._id === entry.principal &&
309
+ grant.resource._id === entry.resource &&
310
+ grant.accessLevel === entry.accessLevel
311
+ );
312
+ expect(matches).toHaveLength(1);
313
+ }
314
+ expect((await userRow(userA.id)).status).toBe('active');
315
+ expect(await deletionRow(userA.id)).toBeFalsy();
316
+ });
317
+
318
+ it('cancel refuses once the purge walker has claimed the row', async () => {
319
+ testEnv.actAs(userA);
320
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
321
+ const deletion = await deletionRow(userA.id);
322
+ await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
323
+
324
+ const outcome = await new AccountDeletion().cancelPendingDeletion(userA.email);
325
+ expect(outcome).toBe('purging');
326
+
327
+ // Nothing restored: standing stays deactivated, grants stay revoked, manifest row stays.
328
+ expect((await userRow(userA.id)).status).toBe('deactivated');
329
+ expect(await grantRows()).toHaveLength(3);
330
+ expect((await deletionRow(userA.id)).phase).toBe('purging');
331
+ });
332
+
333
+ it('cancel is a no-op for an account with no pending deletion', async () => {
334
+ expect(await new AccountDeletion().cancelPendingDeletion(userB.email)).toBe('not-pending');
335
+ expect(await grantRows()).toHaveLength(6);
336
+ });
337
+
338
+ it('authenticate matrix: active, staff-deactivated, pending-deletion, mid-purge', async () => {
339
+ // Active + correct credentials.
340
+ expect(await authenticate(userB.email, PASSWORD_B)).toBe(true);
341
+
342
+ // Staff deactivation (no deleteRequestedAt) stays refused.
343
+ await getDbAsSystem().update(tables.User, { id: userB.id, status: 'deactivated' });
344
+ expect(await authenticate(userB.email, PASSWORD_B)).toBe('This account has been deactivated');
345
+
346
+ // Pending deletion: credentials valid — the login route decides (cancel hook).
347
+ testEnv.actAs(userA);
348
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
349
+ expect(await authenticate(userA.email, PASSWORD_A)).toBe(true);
350
+
351
+ // Mid-purge: still credentials-valid here; the route's cancel hook returns the refusal.
352
+ const deletion = await deletionRow(userA.id);
353
+ await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
354
+ expect(await authenticate(userA.email, PASSWORD_A)).toBe(true);
355
+ });
356
+
357
+ it('cancel-by-login: logging back in during grace restores the account before the session starts', async () => {
358
+ testEnv.actAs(userA);
359
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
360
+
361
+ const { loggedInAs, sent } = await invokeLogin(userA.email, PASSWORD_A);
362
+
363
+ expect(sent).toEqual({});
364
+ expect(loggedInAs).toBe(userA.email);
365
+ expect((await userRow(userA.id)).status).toBe('active');
366
+ expect(await grantRows()).toHaveLength(6);
367
+ expect(await deletionRow(userA.id)).toBeFalsy();
368
+ });
369
+
370
+ it('login refuses a mid-purge account with the no-longer-restorable message', async () => {
371
+ testEnv.actAs(userA);
372
+ await new AccountDeletion().requestDeletion(PASSWORD_A);
373
+ const deletion = await deletionRow(userA.id);
374
+ await getDbAsSystem().update(tables.AccountDeletion, { id: deletion.id, phase: 'purging' });
375
+
376
+ const { loggedInAs, sent } = await invokeLogin(userA.email, PASSWORD_A);
377
+
378
+ expect(sent).toEqual({ error: 'This account is being deleted and can no longer be restored.' });
379
+ expect(loggedInAs).toBeUndefined();
380
+ expect((await userRow(userA.id)).status).toBe('deactivated');
381
+ });
382
+
383
+ it('login refuses a staff-deactivated account outright', async () => {
384
+ await getDbAsSystem().update(tables.User, { id: userB.id, status: 'deactivated' });
385
+
386
+ const { loggedInAs, sent } = await invokeLogin(userB.email, PASSWORD_B);
387
+
388
+ expect(sent).toEqual({ error: 'This account has been deactivated' });
389
+ expect(loggedInAs).toBeUndefined();
390
+ });
391
+ });
@@ -0,0 +1,160 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { tables } from '@proteinjs/user';
3
+ import { PasswordHasher } from '../src/authentication/PasswordHasher';
4
+ import { devLogin } from '../src/routes/devLogin';
5
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
6
+
7
+ const testEnv = new UserServerTestEnvironment();
8
+
9
+ /**
10
+ * `GET /dev/login` multi-user dev bootstrap. The route mints a session for the account named by
11
+ * `?email=` (absent → `DEV_AUTO_LOGIN_EMAIL`), auto-creating a missing account through the normal
12
+ * signup creation path (`Signup.createAccount`) so parallel agent-driven verification can
13
+ * self-serve distinct users. Two rails under test:
14
+ * - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
15
+ * - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
16
+ * must not mint sessions (much less accounts) for arbitrary domains. Others 400.
17
+ */
18
+
19
+ const ENV_EMAIL = 'dev@test.local';
20
+
21
+ type RouteOutcome = {
22
+ loggedInAs?: string;
23
+ sessionSaved: boolean;
24
+ status?: number;
25
+ body?: unknown;
26
+ redirect?: string;
27
+ };
28
+
29
+ const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOutcome> => {
30
+ const outcome: RouteOutcome = { sessionSaved: false };
31
+ const request = {
32
+ query,
33
+ login: (email: string, done: () => void) => {
34
+ outcome.loggedInAs = email;
35
+ done();
36
+ },
37
+ session: {
38
+ save: (done: () => void) => {
39
+ outcome.sessionSaved = true;
40
+ done();
41
+ },
42
+ },
43
+ };
44
+ const response = {
45
+ status(code: number) {
46
+ outcome.status = code;
47
+ return this;
48
+ },
49
+ send(body?: unknown) {
50
+ outcome.body = body;
51
+ },
52
+ redirect(path: string) {
53
+ outcome.redirect = path;
54
+ },
55
+ };
56
+ await devLogin.onRequest(request as never, response as never);
57
+ return outcome;
58
+ };
59
+
60
+ const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
61
+
62
+ describe('devLogin route', () => {
63
+ const originalEnv = {
64
+ DEVELOPMENT: process.env.DEVELOPMENT,
65
+ DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
66
+ };
67
+
68
+ beforeAll(async () => {
69
+ await testEnv.beforeAll();
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await testEnv.afterAll();
74
+ });
75
+
76
+ beforeEach(() => {
77
+ process.env.DEVELOPMENT = 'true';
78
+ process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
79
+ });
80
+
81
+ afterEach(() => {
82
+ for (const [key, value] of Object.entries(originalEnv)) {
83
+ if (value === undefined) {
84
+ delete process.env[key];
85
+ } else {
86
+ process.env[key] = value;
87
+ }
88
+ }
89
+ });
90
+
91
+ it('pins the default path: no ?email → session for DEV_AUTO_LOGIN_EMAIL, redirect to /', async () => {
92
+ await testEnv.createUser({ name: 'Dev Default', email: ENV_EMAIL });
93
+
94
+ const outcome = await invokeDevLogin();
95
+
96
+ expect(outcome.loggedInAs).toBe(ENV_EMAIL);
97
+ expect(outcome.sessionSaved).toBe(true);
98
+ expect(outcome.redirect).toBe('/');
99
+ expect(outcome.status).toBeUndefined();
100
+ });
101
+
102
+ it('?email selects an existing same-domain account for the session', async () => {
103
+ await testEnv.createUser({ name: 'Agent Two', email: 'agent2@test.local' });
104
+
105
+ const outcome = await invokeDevLogin({ email: 'agent2@test.local' });
106
+
107
+ expect(outcome.loggedInAs).toBe('agent2@test.local');
108
+ expect(outcome.sessionSaved).toBe(true);
109
+ expect(outcome.redirect).toBe('/');
110
+ });
111
+
112
+ it('?email for a nonexistent same-domain account creates it as a normal test user, then logs in', async () => {
113
+ expect(await getUserRow('agent3@test.local')).toBeUndefined();
114
+
115
+ const outcome = await invokeDevLogin({ email: 'agent3@test.local' });
116
+
117
+ expect(outcome.loggedInAs).toBe('agent3@test.local');
118
+ expect(outcome.sessionSaved).toBe(true);
119
+ expect(outcome.redirect).toBe('/');
120
+
121
+ // Created through the normal signup creation path: argon2id-hashed 'test' password (the
122
+ // seeded test-user convention — interactive login with password 'test' works too), no roles.
123
+ const created = await getUserRow('agent3@test.local');
124
+ expect(created).toBeDefined();
125
+ expect(created!.password.startsWith('$argon2id$')).toBe(true);
126
+ expect(await new PasswordHasher().verify(created!.password, 'test')).toBe(true);
127
+ expect(created!.roles).toEqual([]);
128
+ expect(created!.name).toBeTruthy();
129
+ });
130
+
131
+ it('rejects a ?email outside the DEV_AUTO_LOGIN_EMAIL domain with 400 — no session, no account', async () => {
132
+ const outcome = await invokeDevLogin({ email: 'intruder@evil.example' });
133
+
134
+ expect(outcome.status).toBe(400);
135
+ expect(outcome.loggedInAs).toBeUndefined();
136
+ expect(outcome.sessionSaved).toBe(false);
137
+ expect(outcome.redirect).toBeUndefined();
138
+ expect(await getUserRow('intruder@evil.example')).toBeUndefined();
139
+ });
140
+
141
+ it('404s when DEVELOPMENT is unset, even with a ?email', async () => {
142
+ delete process.env.DEVELOPMENT;
143
+
144
+ const outcome = await invokeDevLogin({ email: 'agent4@test.local' });
145
+
146
+ expect(outcome.status).toBe(404);
147
+ expect(outcome.loggedInAs).toBeUndefined();
148
+ expect(await getUserRow('agent4@test.local')).toBeUndefined();
149
+ });
150
+
151
+ it('404s when DEV_AUTO_LOGIN_EMAIL is unset, even with a ?email', async () => {
152
+ delete process.env.DEV_AUTO_LOGIN_EMAIL;
153
+
154
+ const outcome = await invokeDevLogin({ email: 'agent5@test.local' });
155
+
156
+ expect(outcome.status).toBe(404);
157
+ expect(outcome.loggedInAs).toBeUndefined();
158
+ expect(await getUserRow('agent5@test.local')).toBeUndefined();
159
+ });
160
+ });