@proteinjs/user-server 1.7.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.
- package/CHANGELOG.md +19 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +9 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +45 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
- package/dist/src/authentication/PasswordHasher.js +152 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +41 -13
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +10 -1
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.js +98 -0
- package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
- package/dist/src/routes/executePasswordReset.js +5 -3
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +26 -2
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/services/AccountDeletion.d.ts +52 -0
- package/dist/src/services/AccountDeletion.d.ts.map +1 -0
- package/dist/src/services/AccountDeletion.js +426 -0
- package/dist/src/services/AccountDeletion.js.map +1 -0
- package/dist/src/services/MachineCredentials.d.ts +21 -0
- package/dist/src/services/MachineCredentials.d.ts.map +1 -0
- package/dist/src/services/MachineCredentials.js +164 -0
- package/dist/src/services/MachineCredentials.js.map +1 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +7 -0
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/SetUserStatus.d.ts +18 -0
- package/dist/src/services/SetUserStatus.d.ts.map +1 -0
- package/dist/src/services/SetUserStatus.js +118 -0
- package/dist/src/services/SetUserStatus.js.map +1 -0
- package/dist/src/services/Signup.d.ts +2 -2
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +22 -17
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +20 -17
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
- package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
- package/dist/test/AccountDeletion.integration.test.js +710 -0
- package/dist/test/AccountDeletion.integration.test.js.map +1 -0
- package/dist/test/DevLogin.test.js +12 -11
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
- package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +597 -0
- package/dist/test/MachineAccounts.integration.test.js.map +1 -0
- package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
- package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
- package/dist/test/PasswordMigration.integration.test.js +332 -0
- package/dist/test/PasswordMigration.integration.test.js.map +1 -0
- package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
- package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
- package/dist/test/SetUserStatus.integration.test.js +266 -0
- package/dist/test/SetUserStatus.integration.test.js.map +1 -0
- package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
- package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
- package/dist/test/UserStatusGate.integration.test.js +162 -0
- package/dist/test/UserStatusGate.integration.test.js.map +1 -0
- package/generated/index.ts +9 -1
- package/index.ts +3 -0
- package/package.json +8 -6
- package/src/authentication/PasswordHasher.ts +87 -0
- package/src/authentication/UserStatusTableWatcher.ts +56 -0
- package/src/authentication/authenticate.ts +31 -6
- package/src/authorization/userCache.ts +9 -1
- package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
- package/src/emails/AccountDeletionEmails.ts +39 -0
- package/src/routes/executePasswordReset.ts +2 -2
- package/src/routes/login.ts +23 -0
- package/src/services/AccountDeletion.ts +291 -0
- package/src/services/MachineCredentials.ts +94 -0
- package/src/services/Roles.ts +10 -0
- package/src/services/SetUserStatus.ts +56 -0
- package/src/services/Signup.ts +4 -4
- package/src/services/UpdateUserInfo.ts +4 -5
- package/test/AccountDeletion.integration.test.ts +391 -0
- package/test/DevLogin.test.ts +5 -4
- package/test/MachineAccounts.integration.test.ts +312 -0
- package/test/PasswordMigration.integration.test.ts +132 -0
- package/test/SetUserStatus.integration.test.ts +120 -0
- 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
|
+
});
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import sha256 from 'crypto-js/sha256';
|
|
2
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
2
|
import { tables } from '@proteinjs/user';
|
|
3
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
4
4
|
import { devLogin } from '../src/routes/devLogin';
|
|
5
5
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
6
|
|
|
@@ -118,11 +118,12 @@ describe('devLogin route', () => {
|
|
|
118
118
|
expect(outcome.sessionSaved).toBe(true);
|
|
119
119
|
expect(outcome.redirect).toBe('/');
|
|
120
120
|
|
|
121
|
-
// Created through the normal signup creation path:
|
|
122
|
-
// test-user convention — interactive login with password 'test' works too), no roles.
|
|
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
123
|
const created = await getUserRow('agent3@test.local');
|
|
124
124
|
expect(created).toBeDefined();
|
|
125
|
-
expect(created!.password
|
|
125
|
+
expect(created!.password.startsWith('$argon2id$')).toBe(true);
|
|
126
|
+
expect(await new PasswordHasher().verify(created!.password, 'test')).toBe(true);
|
|
126
127
|
expect(created!.roles).toEqual([]);
|
|
127
128
|
expect(created!.name).toBeTruthy();
|
|
128
129
|
});
|