@proteinjs/user-server 1.7.0 → 1.9.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 (153) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +11 -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/DbSessionStore.d.ts +3 -2
  10. package/dist/src/authentication/DbSessionStore.d.ts.map +1 -1
  11. package/dist/src/authentication/DbSessionStore.js +3 -2
  12. package/dist/src/authentication/DbSessionStore.js.map +1 -1
  13. package/dist/src/authentication/PasswordHasher.d.ts +45 -0
  14. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
  15. package/dist/src/authentication/PasswordHasher.js +152 -0
  16. package/dist/src/authentication/PasswordHasher.js.map +1 -0
  17. package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
  18. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
  19. package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
  20. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
  21. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  22. package/dist/src/authentication/authenticate.js +41 -13
  23. package/dist/src/authentication/authenticate.js.map +1 -1
  24. package/dist/src/authentication/establishSession.d.ts +30 -0
  25. package/dist/src/authentication/establishSession.d.ts.map +1 -0
  26. package/dist/src/authentication/establishSession.js +83 -0
  27. package/dist/src/authentication/establishSession.js.map +1 -0
  28. package/dist/src/authorization/userCache.d.ts.map +1 -1
  29. package/dist/src/authorization/userCache.js +10 -1
  30. package/dist/src/authorization/userCache.js.map +1 -1
  31. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
  32. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
  33. package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
  34. package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
  35. package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
  36. package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
  37. package/dist/src/emails/AccountDeletionEmails.js +98 -0
  38. package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
  39. package/dist/src/routes/devLogin.d.ts.map +1 -1
  40. package/dist/src/routes/devLogin.js +6 -12
  41. package/dist/src/routes/devLogin.js.map +1 -1
  42. package/dist/src/routes/executePasswordReset.js +5 -3
  43. package/dist/src/routes/executePasswordReset.js.map +1 -1
  44. package/dist/src/routes/login.d.ts.map +1 -1
  45. package/dist/src/routes/login.js +27 -4
  46. package/dist/src/routes/login.js.map +1 -1
  47. package/dist/src/routes/logout.d.ts +10 -0
  48. package/dist/src/routes/logout.d.ts.map +1 -1
  49. package/dist/src/routes/logout.js +13 -32
  50. package/dist/src/routes/logout.js.map +1 -1
  51. package/dist/src/routes/signup.d.ts +19 -0
  52. package/dist/src/routes/signup.d.ts.map +1 -0
  53. package/dist/src/routes/signup.js +102 -0
  54. package/dist/src/routes/signup.js.map +1 -0
  55. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  56. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  57. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  58. package/dist/src/services/AccountDeletion.d.ts +52 -0
  59. package/dist/src/services/AccountDeletion.d.ts.map +1 -0
  60. package/dist/src/services/AccountDeletion.js +426 -0
  61. package/dist/src/services/AccountDeletion.js.map +1 -0
  62. package/dist/src/services/MachineCredentials.d.ts +21 -0
  63. package/dist/src/services/MachineCredentials.d.ts.map +1 -0
  64. package/dist/src/services/MachineCredentials.js +164 -0
  65. package/dist/src/services/MachineCredentials.js.map +1 -0
  66. package/dist/src/services/Roles.d.ts.map +1 -1
  67. package/dist/src/services/Roles.js +7 -0
  68. package/dist/src/services/Roles.js.map +1 -1
  69. package/dist/src/services/SetUserStatus.d.ts +18 -0
  70. package/dist/src/services/SetUserStatus.d.ts.map +1 -0
  71. package/dist/src/services/SetUserStatus.js +118 -0
  72. package/dist/src/services/SetUserStatus.js.map +1 -0
  73. package/dist/src/services/Signup.d.ts +20 -3
  74. package/dist/src/services/Signup.d.ts.map +1 -1
  75. package/dist/src/services/Signup.js +30 -19
  76. package/dist/src/services/Signup.js.map +1 -1
  77. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  78. package/dist/src/services/UpdateUserInfo.js +20 -17
  79. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  80. package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
  81. package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
  82. package/dist/test/AccountDeletion.integration.test.js +718 -0
  83. package/dist/test/AccountDeletion.integration.test.js.map +1 -0
  84. package/dist/test/DevLogin.test.js +27 -31
  85. package/dist/test/DevLogin.test.js.map +1 -1
  86. package/dist/test/LogoutRoute.test.d.ts +2 -0
  87. package/dist/test/LogoutRoute.test.d.ts.map +1 -0
  88. package/dist/test/LogoutRoute.test.js +81 -0
  89. package/dist/test/LogoutRoute.test.js.map +1 -0
  90. package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
  91. package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
  92. package/dist/test/MachineAccounts.integration.test.js +597 -0
  93. package/dist/test/MachineAccounts.integration.test.js.map +1 -0
  94. package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
  95. package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
  96. package/dist/test/PasswordMigration.integration.test.js +332 -0
  97. package/dist/test/PasswordMigration.integration.test.js.map +1 -0
  98. package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
  99. package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
  100. package/dist/test/SetUserStatus.integration.test.js +266 -0
  101. package/dist/test/SetUserStatus.integration.test.js.map +1 -0
  102. package/dist/test/Signup.test.js +4 -2
  103. package/dist/test/Signup.test.js.map +1 -1
  104. package/dist/test/SignupRoute.test.d.ts +2 -0
  105. package/dist/test/SignupRoute.test.d.ts.map +1 -0
  106. package/dist/test/SignupRoute.test.js +338 -0
  107. package/dist/test/SignupRoute.test.js.map +1 -0
  108. package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
  109. package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
  110. package/dist/test/UserStatusGate.integration.test.js +162 -0
  111. package/dist/test/UserStatusGate.integration.test.js.map +1 -0
  112. package/dist/test/ValidateResetToken.test.d.ts +2 -0
  113. package/dist/test/ValidateResetToken.test.d.ts.map +1 -0
  114. package/dist/test/ValidateResetToken.test.js +170 -0
  115. package/dist/test/ValidateResetToken.test.js.map +1 -0
  116. package/dist/test/passportSessionHarness.d.ts +31 -0
  117. package/dist/test/passportSessionHarness.d.ts.map +1 -0
  118. package/dist/test/passportSessionHarness.js +107 -0
  119. package/dist/test/passportSessionHarness.js.map +1 -0
  120. package/generated/index.ts +11 -1
  121. package/index.ts +3 -0
  122. package/package.json +11 -7
  123. package/src/authentication/DbSessionStore.ts +3 -2
  124. package/src/authentication/PasswordHasher.ts +87 -0
  125. package/src/authentication/UserStatusTableWatcher.ts +56 -0
  126. package/src/authentication/authenticate.ts +31 -6
  127. package/src/authentication/establishSession.ts +33 -0
  128. package/src/authorization/userCache.ts +9 -1
  129. package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
  130. package/src/emails/AccountDeletionEmails.ts +39 -0
  131. package/src/routes/devLogin.ts +4 -6
  132. package/src/routes/executePasswordReset.ts +2 -2
  133. package/src/routes/login.ts +25 -3
  134. package/src/routes/logout.ts +13 -18
  135. package/src/routes/signup.ts +55 -0
  136. package/src/routes/validateResetPasswordToken.ts +5 -1
  137. package/src/services/AccountDeletion.ts +291 -0
  138. package/src/services/MachineCredentials.ts +94 -0
  139. package/src/services/Roles.ts +10 -0
  140. package/src/services/SetUserStatus.ts +56 -0
  141. package/src/services/Signup.ts +25 -6
  142. package/src/services/UpdateUserInfo.ts +4 -5
  143. package/test/AccountDeletion.integration.test.ts +395 -0
  144. package/test/DevLogin.test.ts +16 -18
  145. package/test/LogoutRoute.test.ts +33 -0
  146. package/test/MachineAccounts.integration.test.ts +312 -0
  147. package/test/PasswordMigration.integration.test.ts +132 -0
  148. package/test/SetUserStatus.integration.test.ts +120 -0
  149. package/test/Signup.test.ts +4 -2
  150. package/test/SignupRoute.test.ts +219 -0
  151. package/test/UserStatusGate.integration.test.ts +68 -0
  152. package/test/ValidateResetToken.test.ts +86 -0
  153. package/test/passportSessionHarness.ts +58 -0
@@ -61,9 +61,11 @@ describe('Signup service auth', () => {
61
61
  expect(canAccess('revokeInvite')).toBe(true);
62
62
  });
63
63
 
64
- it('keeps the signup flow itself open (createUser / initializeSignup)', () => {
64
+ it('keeps the pre-submit signup flow open (initializeSignup)', () => {
65
+ // Account creation itself is no longer RPC-reachable — it moved to the signup ROUTE
66
+ // (src/routes/signup.ts, covered by SignupRoute.test.ts), which establishes the session
67
+ // in the same request (auto-login).
65
68
  setUser([]);
66
- expect(canAccess('createUser')).toBe(true);
67
69
  expect(canAccess('initializeSignup')).toBe(true);
68
70
  });
69
71
  });
@@ -0,0 +1,219 @@
1
+ import moment from 'moment';
2
+
3
+ /**
4
+ * `POST /user/signup` — signup with auto-login. Signup used to be an RPC service, and services
5
+ * never see the request, so the client was bounced to the login form to type the credentials it
6
+ * had JUST submitted. Session establishment is a request-level concern; signup now lives at the
7
+ * route layer beside its siblings (login, dev login) and mints the session through the same
8
+ * single owner (`establishSession`) in the same request.
9
+ *
10
+ * Covered here, outcomes only (rows written, session minted), against the Spanner emulator:
11
+ * - plain signup: account row created, session established in the same request;
12
+ * - invited signup: email resolved from the invite, invite consumed, session established;
13
+ * - already-registered email: response body indistinguishable from success, but NO session —
14
+ * auto-login must never hand out a session for an account the caller didn't just create;
15
+ * - invalid token / invite-only violations: honest error, no session, no account row.
16
+ *
17
+ * The email transport is stubbed at the module boundary (SignupInvite.test.ts pattern); the db
18
+ * stays real.
19
+ */
20
+
21
+ const sendEmail = jest.fn();
22
+
23
+ jest.mock('@proteinjs/email-server', () => ({
24
+ EmailSender: jest.fn().mockImplementation(() => ({ sendEmail })),
25
+ getDefaultInviteEmailConfigFactory: () => ({
26
+ getConfig: () => ({ getEmailContent: () => ({ text: 'invite', html: '<p>invite</p>' }) }),
27
+ }),
28
+ getDefaultSignupConfirmationEmailConfigFactory: () => ({
29
+ getConfig: () => ({
30
+ getNewUserEmailContent: () => ({ text: 'welcome', html: '<p>welcome</p>' }),
31
+ getExistingUserEmailContent: () => ({ text: 'exists', html: '<p>exists</p>' }),
32
+ }),
33
+ }),
34
+ }));
35
+
36
+ import { getDbAsSystem } from '@proteinjs/db';
37
+ import { SourceRepository } from '@proteinjs/reflection';
38
+ import { tables } from '@proteinjs/user';
39
+ import { signup } from '../src/routes/signup';
40
+ import { createPassportRequest } from './passportSessionHarness';
41
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
42
+
43
+ const testEnv = new UserServerTestEnvironment();
44
+
45
+ const INVITE_CONFIG_KEY = '@proteinjs/user-server/DefaultInviteConfigFactory';
46
+
47
+ type ObjectCache = { objectCache: Record<string, unknown[]> };
48
+
49
+ /** Seed the invite-config lookup (tests don't load the generated source graph). */
50
+ const setInviteConfig = (factories: unknown[]) => {
51
+ (SourceRepository.get() as unknown as ObjectCache).objectCache[INVITE_CONFIG_KEY] = factories;
52
+ };
53
+
54
+ type RouteOutcome = {
55
+ loggedInAs?: string;
56
+ /**
57
+ * 'regenerate' / 'login' / 'save' in call order — recorded by the REAL passport 0.6 login
58
+ * machinery (see passportSessionHarness): regenerate FIRST (a fresh session id on privilege
59
+ * change, so an attacker-planted pre-auth sid never survives signup/login: session fixation),
60
+ * then the bind of the account onto the fresh session, then save (the row must commit before
61
+ * the response). Exactly one of each — a wrapper-level regenerate or save around
62
+ * `request.login` would double up and break the single-owner contract.
63
+ */
64
+ sessionEvents: string[];
65
+ status?: number;
66
+ body?: { error?: string };
67
+ };
68
+
69
+ const invokeSignup = async (body: Record<string, unknown>): Promise<RouteOutcome> => {
70
+ const { request, events } = await createPassportRequest({ body });
71
+ const outcome: RouteOutcome = { sessionEvents: events };
72
+ const response = {
73
+ status(code: number) {
74
+ outcome.status = code;
75
+ return this;
76
+ },
77
+ send(body?: unknown) {
78
+ outcome.body = body as RouteOutcome['body'];
79
+ },
80
+ };
81
+ await signup.onRequest(request as never, response as never);
82
+ // The OUTCOME of establishment: the account email bound onto the (post-regeneration) session.
83
+ outcome.loggedInAs = request.session.passport?.user;
84
+ return outcome;
85
+ };
86
+
87
+ const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
88
+ const userRowCount = async (email: string) => (await getDbAsSystem().query(tables.User, { email })).length;
89
+
90
+ describe('signup route — auto-login after signup', () => {
91
+ beforeAll(async () => {
92
+ await testEnv.beforeAll();
93
+ setInviteConfig([]); // default config: invite optional
94
+ }, 120000);
95
+
96
+ afterAll(async () => {
97
+ await testEnv.afterAll();
98
+ });
99
+
100
+ beforeEach(async () => {
101
+ jest.clearAllMocks();
102
+ setInviteConfig([]);
103
+ const db = getDbAsSystem();
104
+ await db.delete(tables.Invite, {});
105
+ await db.delete(tables.User, {});
106
+ });
107
+
108
+ it('plain signup creates the account and establishes the session in the same request', async () => {
109
+ const outcome = await invokeSignup({
110
+ name: 'Plain Signup',
111
+ email: 'Signup.Plain@Test.local',
112
+ password: 'pw-plain-1',
113
+ });
114
+
115
+ expect(outcome.body).toEqual({});
116
+ expect(outcome.loggedInAs).toBe('signup.plain@test.local');
117
+ // Fresh id on privilege change (fixation), then login, then commit-before-response (the
118
+ // client's follow-up navigation must never race the store write and land on /login).
119
+ expect(outcome.sessionEvents).toEqual(['regenerate', 'login', 'save']);
120
+
121
+ const created = await getUserRow('signup.plain@test.local');
122
+ expect(created).toBeDefined();
123
+ // At-rest contract: argon2id from the first write (the per-login-migration KDF stores
124
+ // new credentials as argon2id directly — never a legacy sha256 row).
125
+ expect(created!.password).toMatch(/^\$argon2id\$/);
126
+ expect(created!.password).not.toContain('pw-plain-1');
127
+ expect(created!.emailVerified).toBe(false);
128
+ expect(created!.roles).toEqual([]);
129
+ expect(sendEmail).toHaveBeenCalledTimes(1); // welcome email still goes out
130
+ });
131
+
132
+ it('invited signup resolves the email from the invite, consumes it, and establishes the session', async () => {
133
+ const db = getDbAsSystem();
134
+ await db.insert(tables.Invite, {
135
+ email: 'invited@test.local',
136
+ token: 'invite-token-1',
137
+ tokenExpiresAt: moment().add(1, 'day'),
138
+ invitedBy: 'inviter-1',
139
+ });
140
+
141
+ const outcome = await invokeSignup({
142
+ name: 'Invited Signup',
143
+ password: 'pw-invited-1',
144
+ token: 'invite-token-1',
145
+ });
146
+
147
+ expect(outcome.body).toEqual({});
148
+ expect(outcome.loggedInAs).toBe('invited@test.local');
149
+ expect(outcome.sessionEvents).toEqual(['regenerate', 'login', 'save']);
150
+
151
+ const created = await getUserRow('invited@test.local');
152
+ expect(created).toBeDefined();
153
+ expect(created!.emailVerified).toBe(true); // the email came from the invite record
154
+ expect(created!.invitedBy).toBe('inviter-1');
155
+ expect(await db.get(tables.Invite, { token: 'invite-token-1' })).toBeUndefined();
156
+ });
157
+
158
+ it('an already-registered email answers indistinguishably but mints NO session', async () => {
159
+ await testEnv.createUser({ name: 'Existing User', email: 'existing@test.local' });
160
+
161
+ const outcome = await invokeSignup({
162
+ name: 'Existing User',
163
+ email: 'existing@test.local',
164
+ password: 'pw-existing-1',
165
+ });
166
+
167
+ // The response body must be exactly the success shape (anti-enumeration: existence is
168
+ // reported to the mailbox owner by email, never to the caller)...
169
+ expect(outcome.body).toEqual({});
170
+ // ...but auto-login must never mint a session for an account the caller didn't just create.
171
+ expect(outcome.loggedInAs).toBeUndefined();
172
+ expect(outcome.sessionEvents).toEqual([]);
173
+
174
+ expect(await userRowCount('existing@test.local')).toBe(1);
175
+ const existing = await getUserRow('existing@test.local');
176
+ expect(existing!.password).toBe('test'); // untouched — seeded by testEnv.createUser
177
+ expect(sendEmail).toHaveBeenCalledTimes(1); // "account already exists" email
178
+ });
179
+
180
+ it('an unknown invite token gets the honest error — no session, no account', async () => {
181
+ const outcome = await invokeSignup({
182
+ name: 'Token Guess',
183
+ password: 'pw-guess-1',
184
+ token: 'never-existed',
185
+ });
186
+
187
+ expect(outcome.body?.error).toMatch(/no longer valid/i);
188
+ expect(outcome.loggedInAs).toBeUndefined();
189
+ expect(outcome.sessionEvents).toEqual([]);
190
+ expect((await getDbAsSystem().query(tables.User, {})).length).toBe(0);
191
+ });
192
+
193
+ it('invite-only mode refuses a tokenless signup — no session, no account', async () => {
194
+ setInviteConfig([{ getConfig: () => ({ isInviteOnly: true }) }]);
195
+
196
+ const outcome = await invokeSignup({
197
+ name: 'No Invite',
198
+ email: 'no.invite@test.local',
199
+ password: 'pw-noinvite-1',
200
+ });
201
+
202
+ expect(outcome.body?.error).toMatch(/invite is required/i);
203
+ expect(outcome.loggedInAs).toBeUndefined();
204
+ expect(outcome.sessionEvents).toEqual([]);
205
+ expect(await getUserRow('no.invite@test.local')).toBeUndefined();
206
+ });
207
+
208
+ it('a blank name or password is refused before any write', async () => {
209
+ const missingName = await invokeSignup({ email: 'blank@test.local', password: 'pw-blank-1' });
210
+ expect(missingName.body?.error).toBeTruthy();
211
+ expect(missingName.sessionEvents).toEqual([]);
212
+
213
+ const missingPassword = await invokeSignup({ name: 'Blank', email: 'blank@test.local' });
214
+ expect(missingPassword.body?.error).toBeTruthy();
215
+ expect(missingPassword.sessionEvents).toEqual([]);
216
+
217
+ expect(await getUserRow('blank@test.local')).toBeUndefined();
218
+ });
219
+ });
@@ -0,0 +1,68 @@
1
+ import sha256 from 'crypto-js/sha256';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { tables } from '@proteinjs/user';
4
+ import { authenticate } from '../src/authentication/authenticate';
5
+ import { userCache } from '../src/authorization/userCache';
6
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
7
+
8
+ const testEnv = new UserServerTestEnvironment();
9
+
10
+ /**
11
+ * The deactivation gate, both halves, asserted on outcomes against the emulator:
12
+ * - login half (authenticate): correct credentials on a deactivated account are refused;
13
+ * - session half (userCache): the session cache is rebuilt per request, so a live session stops
14
+ * resolving the moment its account is deactivated — requests run as the unauthenticated guest.
15
+ */
16
+ describe('user status gate — login refusal and session resolution', () => {
17
+ beforeAll(async () => {
18
+ await testEnv.beforeAll();
19
+ }, 120000);
20
+
21
+ afterAll(async () => {
22
+ await testEnv.afterAll();
23
+ });
24
+
25
+ /** Insert an account the way the product stores it: lowercased email, sha256 password. */
26
+ const createAccount = async (email: string, password: string, status?: 'active' | 'deactivated') => {
27
+ return await getDbAsSystem().insert(tables.User, {
28
+ name: 'Gate test user',
29
+ email,
30
+ password: sha256(password).toString(),
31
+ emailVerified: true,
32
+ roles: [],
33
+ ...(status ? { status } : {}),
34
+ });
35
+ };
36
+
37
+ it('refuses login for a deactivated account even with correct credentials', async () => {
38
+ await createAccount('deactivated-login@test.local', 'correct-password', 'deactivated');
39
+
40
+ const result = await authenticate('deactivated-login@test.local', 'correct-password');
41
+
42
+ expect(result).toBe('This account has been deactivated');
43
+ });
44
+
45
+ it('logs an active account in unaffected', async () => {
46
+ await createAccount('active-login@test.local', 'correct-password');
47
+
48
+ expect(await authenticate('active-login@test.local', 'correct-password')).toBe(true);
49
+ expect(await authenticate('active-login@test.local', 'wrong-password')).toBe('User name or password incorrect');
50
+ });
51
+
52
+ it('stops resolving a live session once its user is deactivated, and resumes on reactivation', async () => {
53
+ const account = await createAccount('live-session@test.local', 'correct-password');
54
+
55
+ const before = await userCache.create('live-session', 'live-session@test.local');
56
+ expect(before.id).toBe(account.id);
57
+ expect(before.email).toBe('live-session@test.local');
58
+
59
+ await getDbAsSystem().update(tables.User, { id: account.id, status: 'deactivated' });
60
+ const during = await userCache.create('live-session', 'live-session@test.local');
61
+ expect(during.id).toBe('guest');
62
+ expect(during.roles).toEqual([]);
63
+
64
+ await getDbAsSystem().update(tables.User, { id: account.id, status: 'active' });
65
+ const after = await userCache.create('live-session', 'live-session@test.local');
66
+ expect(after.id).toBe(account.id);
67
+ });
68
+ });
@@ -0,0 +1,86 @@
1
+ import moment from 'moment';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { tables } from '@proteinjs/user';
4
+ import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
5
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
6
+
7
+ const testEnv = new UserServerTestEnvironment();
8
+
9
+ /**
10
+ * `GET /user/validate-reset-token`. Beyond the valid/expired/unknown verdicts, the valid
11
+ * response carries the account EMAIL: the reset page renders it as the read-only
12
+ * `autocomplete="username"` field so password managers can associate the updated password
13
+ * with the stored credential. The email only ever rides a VALID token's response — the
14
+ * token was delivered to that very inbox, so it reveals nothing the holder doesn't know —
15
+ * while invalid/expired verdicts stay email-free (no account-probing oracle).
16
+ */
17
+
18
+ type RouteOutcome = { status?: number; body?: any };
19
+
20
+ const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutcome> => {
21
+ const outcome: RouteOutcome = {};
22
+ const response = {
23
+ status(code: number) {
24
+ outcome.status = code;
25
+ return this;
26
+ },
27
+ send(body?: unknown) {
28
+ outcome.body = body;
29
+ },
30
+ };
31
+ await validateResetPasswordToken.onRequest({ query } as never, response as never);
32
+ return outcome;
33
+ };
34
+
35
+ const armResetToken = async (email: string, token: string, expiration: moment.Moment) => {
36
+ const user = await testEnv.createUser({ name: 'Reset User', email });
37
+ await getDbAsSystem().update(tables.User, {
38
+ id: user.id,
39
+ passwordResetToken: token,
40
+ passwordResetTokenExpiration: expiration,
41
+ });
42
+ };
43
+
44
+ describe('validateResetPasswordToken route', () => {
45
+ beforeAll(async () => {
46
+ await testEnv.beforeAll();
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await testEnv.afterAll();
51
+ });
52
+
53
+ it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
54
+ await armResetToken('reset-valid@test.local', 'tok-valid-1', moment().add(1, 'hour'));
55
+
56
+ const outcome = await invokeValidate({ token: 'tok-valid-1' });
57
+
58
+ expect(outcome.status).toBe(200);
59
+ expect(outcome.body).toEqual({ isValid: true, email: 'reset-valid@test.local' });
60
+ });
61
+
62
+ it('an expired token resolves invalid and leaks no email', async () => {
63
+ await armResetToken('reset-expired@test.local', 'tok-expired-1', moment().subtract(1, 'minute'));
64
+
65
+ const outcome = await invokeValidate({ token: 'tok-expired-1' });
66
+
67
+ expect(outcome.status).toBe(200);
68
+ expect(outcome.body.isValid).toBe(false);
69
+ expect(outcome.body.email).toBeUndefined();
70
+ });
71
+
72
+ it('an unknown token resolves invalid and leaks no email', async () => {
73
+ const outcome = await invokeValidate({ token: 'tok-never-issued' });
74
+
75
+ expect(outcome.status).toBe(200);
76
+ expect(outcome.body.isValid).toBe(false);
77
+ expect(outcome.body.email).toBeUndefined();
78
+ });
79
+
80
+ it('a missing token is a 400', async () => {
81
+ const outcome = await invokeValidate({});
82
+
83
+ expect(outcome.status).toBe(400);
84
+ expect(outcome.body.isValid).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,58 @@
1
+ import passport from 'passport';
2
+
3
+ /** Session-lifecycle events in call order — the session-establishment contract under test. */
4
+ export type SessionEvent = 'regenerate' | 'login' | 'save';
5
+
6
+ export type PassportRequest = {
7
+ /** Express-shaped request whose `login`/`logout` are REAL passport machinery. */
8
+ request: any;
9
+ /**
10
+ * 'regenerate' / 'login' / 'save' in call order. The full contract of session establishment:
11
+ * regenerate FIRST (a fresh session id on privilege change — session fixation), then the bind
12
+ * of the account onto the fresh session, then save (the row must commit before the response).
13
+ */
14
+ events: SessionEvent[];
15
+ };
16
+
17
+ /**
18
+ * Builds a request whose `request.login` / `request.logout` are the GENUINE passport 0.6
19
+ * machinery, wired through `authenticator.initialize()` — the same middleware the server runs —
20
+ * over an express-session-shaped session mock.
21
+ *
22
+ * Why real passport and not a hand-mocked `login`: since passport 0.6 (the CVE-2022-25896
23
+ * session-fixation fix), the regenerate and the commit-before-response save live INSIDE
24
+ * `request.login` (SessionManager.logIn: regenerate → serializeUser → bind → save). A mocked
25
+ * `login` would assert nothing about either — the door suites must drive the real owner to
26
+ * prove the doors inherit the contract.
27
+ *
28
+ * serializeUser mirrors @proteinjs/server's identity pass-through (the session stores the
29
+ * account email directly), instrumented to record the bind ('login') between 'regenerate' and
30
+ * 'save'. The session mock replicates the one express-session behavior passport depends on:
31
+ * `regenerate` REPLACES `request.session` with a fresh session (new id, no passport state), so
32
+ * the bind provably lands on the post-regeneration session.
33
+ */
34
+ export const createPassportRequest = async (extra: Record<string, unknown> = {}): Promise<PassportRequest> => {
35
+ const events: SessionEvent[] = [];
36
+ const authenticator = new passport.Passport();
37
+ authenticator.serializeUser((user: unknown, done: (err: unknown, id?: unknown) => void) => {
38
+ events.push('login');
39
+ done(null, user);
40
+ });
41
+
42
+ const request: any = { ...extra };
43
+ const makeSession = (): any => ({
44
+ regenerate: (done: (err?: unknown) => void) => {
45
+ events.push('regenerate');
46
+ request.session = makeSession();
47
+ done();
48
+ },
49
+ save: (done: (err?: unknown) => void) => {
50
+ events.push('save');
51
+ done();
52
+ },
53
+ });
54
+ request.session = makeSession();
55
+
56
+ await new Promise<void>((resolve) => authenticator.initialize()(request, {} as never, () => resolve()));
57
+ return { request, events };
58
+ };