@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,312 @@
|
|
|
1
|
+
import sha256 from 'crypto-js/sha256';
|
|
2
|
+
import { SourceRecordSyncRunner, getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
4
|
+
// Deep import (test-only): ServiceAuth is the real RPC door but is not index-exported by
|
|
5
|
+
// @proteinjs/service — the door test below runs the actual gate rather than pinning metadata.
|
|
6
|
+
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
7
|
+
import { MachineAccount, RoleCatalogEntry, User, UserRepo, tables } from '@proteinjs/user';
|
|
8
|
+
import { authenticate } from '../src/authentication/authenticate';
|
|
9
|
+
import { UserStatusTableWatcher } from '../src/authentication/UserStatusTableWatcher';
|
|
10
|
+
import { MachineCredentials } from '../src/services/MachineCredentials';
|
|
11
|
+
import { Roles } from '../src/services/Roles';
|
|
12
|
+
import { SetUserStatus } from '../src/services/SetUserStatus';
|
|
13
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
14
|
+
|
|
15
|
+
const testEnv = new UserServerTestEnvironment();
|
|
16
|
+
|
|
17
|
+
/** The consumer-mapped role machine declarations grant (registered into the catalog below). */
|
|
18
|
+
class OpsRole implements RoleCatalogEntry {
|
|
19
|
+
role = 'ops';
|
|
20
|
+
description = 'Operational machinery';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class TestOpsMachineAccount extends MachineAccount {
|
|
24
|
+
id = 'machine-test-ops';
|
|
25
|
+
email = 'machine-ops@test.local';
|
|
26
|
+
accountName = 'Test ops machine';
|
|
27
|
+
roles = ['ops'];
|
|
28
|
+
secretName = 'test-ops-secret';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reset `TableWatcherRunner`'s static watcher map so the NEXT Db construction recomputes it from
|
|
35
|
+
* the (just-changed) discovery seam. The class is internal to @proteinjs/db (its exports map
|
|
36
|
+
* blocks deep imports), so it's reached through a Db instance — the test-harness cast convention.
|
|
37
|
+
*/
|
|
38
|
+
const resetTableWatcherMap = () => {
|
|
39
|
+
const runner = (getDbAsSystem() as unknown as { tableWatcherRunner: object }).tableWatcherRunner;
|
|
40
|
+
(runner.constructor as { tableWatcherMap?: unknown }).tableWatcherMap = undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Machine accounts as source records, at the user layer: declare-only on the REAL mixed user
|
|
45
|
+
* table, adopt-in-place by email (id + password survive — the old credential still logs in),
|
|
46
|
+
* removed-from-source auto-deactivation with session kill through the categorical
|
|
47
|
+
* UserStatusTableWatcher, the machine/human ledger split (Roles refuses machine targets),
|
|
48
|
+
* declaration validation, and credential minting (hash-only at rest, plaintext shown once).
|
|
49
|
+
*/
|
|
50
|
+
describe('Machine accounts as source records', () => {
|
|
51
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
52
|
+
|
|
53
|
+
/** One boot of Db.init's source-record leg with these MachineAccount declarations. */
|
|
54
|
+
const boot = async (declarations: MachineAccount[]) => {
|
|
55
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
|
|
56
|
+
await new SourceRecordSyncRunner().load();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const machineRow = async () => await getDbAsSystem().get(tables.User, { email: 'machine-ops@test.local' });
|
|
60
|
+
|
|
61
|
+
const insertSession = async (sessionId: string, userEmail: string) =>
|
|
62
|
+
await getDbAsSystem().insert(tables.Session, {
|
|
63
|
+
sessionId,
|
|
64
|
+
session: '{}',
|
|
65
|
+
expires: new Date(Date.now() + 60 * 60 * 1000),
|
|
66
|
+
userEmail,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const sessionEmails = async () =>
|
|
70
|
+
(await getDbAsSystem().query(tables.Session, {})).map((session) => session.userEmail);
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
await testEnv.beforeAll();
|
|
74
|
+
(SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache[
|
|
75
|
+
'@proteinjs/user-auth/AuthenticatedUserRepo'
|
|
76
|
+
] = [new UserRepo()];
|
|
77
|
+
objectCache()['@proteinjs/user/RoleCatalogEntry'] = [new OpsRole()];
|
|
78
|
+
// Exactly the deactivation watcher observes this run, rebuilt from the seeded cache.
|
|
79
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher()];
|
|
80
|
+
resetTableWatcherMap();
|
|
81
|
+
}, 120000);
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
delete objectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
85
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
86
|
+
delete objectCache()['@proteinjs/user/RoleCatalogEntry'];
|
|
87
|
+
resetTableWatcherMap();
|
|
88
|
+
await testEnv.afterAll();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
const db = getDbAsSystem();
|
|
93
|
+
await db.delete(tables.Session, {});
|
|
94
|
+
await db.delete(tables.UserStatusEvent, {});
|
|
95
|
+
await db.delete(tables.RoleGrantEvent, {});
|
|
96
|
+
await db.delete(tables.User, {});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('declare-only on the mixed user table: the declaration lands source-owned; human rows are untouched', async () => {
|
|
100
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local', roles: ['ops'] });
|
|
101
|
+
await boot([new TestOpsMachineAccount()]);
|
|
102
|
+
|
|
103
|
+
const machine = await machineRow();
|
|
104
|
+
expect(machine).toMatchObject({
|
|
105
|
+
id: 'machine-test-ops',
|
|
106
|
+
name: 'Test ops machine',
|
|
107
|
+
roles: ['ops'],
|
|
108
|
+
emailVerified: true,
|
|
109
|
+
status: 'active',
|
|
110
|
+
isLoadedFromSource: true,
|
|
111
|
+
});
|
|
112
|
+
// No declarable password: the fresh row carries none, so authenticate's email+hash match
|
|
113
|
+
// can never admit it until a credential is minted.
|
|
114
|
+
expect(machine.password).toBeFalsy();
|
|
115
|
+
|
|
116
|
+
const humanAfter = await getDbAsSystem().get(tables.User, { id: human.id });
|
|
117
|
+
expect(humanAfter.isLoadedFromSource).toBeFalsy();
|
|
118
|
+
expect(humanAfter).toMatchObject({ name: 'A human', roles: ['ops'] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('adopt-in-place by email: the hand-made row keeps its id and password; declared fields revert', async () => {
|
|
122
|
+
// The deployed-env shape: a hand-made machine row with an env-random id, a provisioned
|
|
123
|
+
// credential, and runtime-granted roles that have drifted from the declaration.
|
|
124
|
+
const handMade = await testEnv.createUser({
|
|
125
|
+
name: 'Hand-made bridge',
|
|
126
|
+
email: 'machine-ops@test.local',
|
|
127
|
+
roles: ['ops', 'stale-role'],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await boot([new TestOpsMachineAccount()]);
|
|
131
|
+
|
|
132
|
+
const adopted = await machineRow();
|
|
133
|
+
expect(adopted.id).toBe(handMade.id);
|
|
134
|
+
expect(adopted).toMatchObject({
|
|
135
|
+
name: 'Test ops machine',
|
|
136
|
+
roles: ['ops'],
|
|
137
|
+
status: 'active',
|
|
138
|
+
isLoadedFromSource: true,
|
|
139
|
+
});
|
|
140
|
+
// The credential survived adoption: the account still authenticates with its old password
|
|
141
|
+
// (createUser stores sha256('test')... it stores the raw string; assert equality instead).
|
|
142
|
+
expect(adopted.password).toBe(handMade.password);
|
|
143
|
+
expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates', async () => {
|
|
147
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
148
|
+
await boot([new TestOpsMachineAccount()]);
|
|
149
|
+
await insertSession('machine-session', 'machine-ops@test.local');
|
|
150
|
+
await insertSession('human-session', human.email);
|
|
151
|
+
|
|
152
|
+
await boot([]);
|
|
153
|
+
|
|
154
|
+
const removed = await machineRow();
|
|
155
|
+
expect(removed).toBeDefined();
|
|
156
|
+
expect(removed.status).toBe('deactivated');
|
|
157
|
+
// The categorical watcher killed the machine sessions; the human session is untouched.
|
|
158
|
+
expect(await sessionEmails()).toEqual([human.email]);
|
|
159
|
+
// The one login door refuses a deactivated account even with matching credentials.
|
|
160
|
+
await getDbAsSystem().update(tables.User, { id: removed.id, password: sha256('bridge-pw').toString() });
|
|
161
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe('This account has been deactivated');
|
|
162
|
+
|
|
163
|
+
// Decommission is reversible in code: re-declaring reverts status via drift reversion.
|
|
164
|
+
await boot([new TestOpsMachineAccount()]);
|
|
165
|
+
expect((await machineRow()).status).toBe('active');
|
|
166
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it(`the staff toggle rides the same watcher: SetUserStatus deactivation kills the target's sessions`, async () => {
|
|
170
|
+
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
171
|
+
const target = await testEnv.createUser({ name: 'Target', email: 'target@test.local' });
|
|
172
|
+
await insertSession('target-session', target.email);
|
|
173
|
+
await insertSession('admin-session', admin.email);
|
|
174
|
+
|
|
175
|
+
testEnv.actAs(admin);
|
|
176
|
+
await new SetUserStatus().setUserStatus(target.id, 'deactivated');
|
|
177
|
+
|
|
178
|
+
expect(await sessionEmails()).toEqual([admin.email]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('the Roles service refuses machine-account targets (git is the machine audit); human grants still work', async () => {
|
|
182
|
+
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
183
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
184
|
+
await boot([new TestOpsMachineAccount()]);
|
|
185
|
+
testEnv.actAs(admin);
|
|
186
|
+
|
|
187
|
+
const machine = await machineRow();
|
|
188
|
+
await expect(new Roles().grantRole(machine.id, 'ops')).rejects.toThrow(
|
|
189
|
+
/machine account: its roles are declared in code/
|
|
190
|
+
);
|
|
191
|
+
await expect(new Roles().revokeRole(machine.id, 'ops')).rejects.toThrow(
|
|
192
|
+
/machine account: its roles are declared in code/
|
|
193
|
+
);
|
|
194
|
+
// No machine rows in the human ledger.
|
|
195
|
+
expect(await getDbAsSystem().query(tables.RoleGrantEvent, { target: machine.id })).toHaveLength(0);
|
|
196
|
+
|
|
197
|
+
await new Roles().grantRole(human.id, 'ops');
|
|
198
|
+
expect((await getDbAsSystem().get(tables.User, { id: human.id })).roles).toEqual(['ops']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('declaration validation fails boot loudly: non-lowercase email, unknown role, break-glass role', async () => {
|
|
202
|
+
class UppercaseEmail extends TestOpsMachineAccount {
|
|
203
|
+
email = 'Machine-Ops@test.local';
|
|
204
|
+
}
|
|
205
|
+
await expect(boot([new UppercaseEmail()])).rejects.toThrow(/emails must be lowercase/);
|
|
206
|
+
|
|
207
|
+
class UnknownRole extends TestOpsMachineAccount {
|
|
208
|
+
roles = ['not-a-role'];
|
|
209
|
+
}
|
|
210
|
+
await expect(boot([new UnknownRole()])).rejects.toThrow(/unknown role 'not-a-role'/);
|
|
211
|
+
|
|
212
|
+
class BreakGlass extends TestOpsMachineAccount {
|
|
213
|
+
roles = ['admin'];
|
|
214
|
+
}
|
|
215
|
+
await expect(boot([new BreakGlass()])).rejects.toThrow(/break-glass role 'admin'/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('credential minting', () => {
|
|
219
|
+
beforeEach(async () => {
|
|
220
|
+
await boot([new TestOpsMachineAccount()]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('mints a generated credential: hash-only at rest, sessions rotated, plaintext + paste note returned once', async () => {
|
|
224
|
+
await insertSession('old-credential-session', 'machine-ops@test.local');
|
|
225
|
+
|
|
226
|
+
const minted = await new MachineCredentials().mintCredential('machine-ops@test.local');
|
|
227
|
+
|
|
228
|
+
expect(minted.email).toBe('machine-ops@test.local');
|
|
229
|
+
expect(minted.password).toMatch(/^[0-9a-f]{64}$/); // 256 bits, generated — never human-chosen
|
|
230
|
+
expect(minted.secretName).toBe('test-ops-secret');
|
|
231
|
+
expect(minted.note).toMatch(/test-ops-secret/);
|
|
232
|
+
expect(minted.note).toMatch(/restart/i);
|
|
233
|
+
|
|
234
|
+
// Only the hash is stored, and it is the hash of the returned plaintext.
|
|
235
|
+
const row = await machineRow();
|
|
236
|
+
expect(row.password).toBe(sha256(minted.password).toString());
|
|
237
|
+
// The old credential's sessions died with the rotation...
|
|
238
|
+
expect(await sessionEmails()).toEqual([]);
|
|
239
|
+
// ...and the minted credential is immediately loggable.
|
|
240
|
+
expect(await authenticate('machine-ops@test.local', minted.password)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('lists declared accounts with row state for the admin surface', async () => {
|
|
244
|
+
class NotBooted extends TestOpsMachineAccount {
|
|
245
|
+
id = 'machine-not-booted';
|
|
246
|
+
email = 'machine-not-booted@test.local';
|
|
247
|
+
secretName = 'not-booted-secret';
|
|
248
|
+
}
|
|
249
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = [new TestOpsMachineAccount(), new NotBooted()];
|
|
250
|
+
|
|
251
|
+
const before = await new MachineCredentials().listMachineAccounts();
|
|
252
|
+
expect(before).toEqual([
|
|
253
|
+
{
|
|
254
|
+
email: 'machine-ops@test.local',
|
|
255
|
+
accountName: 'Test ops machine',
|
|
256
|
+
roles: ['ops'],
|
|
257
|
+
secretName: 'test-ops-secret',
|
|
258
|
+
status: 'active',
|
|
259
|
+
hasCredential: false,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
email: 'machine-not-booted@test.local',
|
|
263
|
+
accountName: 'Test ops machine',
|
|
264
|
+
roles: ['ops'],
|
|
265
|
+
secretName: 'not-booted-secret',
|
|
266
|
+
status: 'pending first boot',
|
|
267
|
+
hasCredential: false,
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
await new MachineCredentials().mintCredential('machine-ops@test.local');
|
|
272
|
+
const after = await new MachineCredentials().listMachineAccounts();
|
|
273
|
+
expect(after[0]).toMatchObject({ email: 'machine-ops@test.local', hasCredential: true });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('refuses non-machine targets: undeclared emails and human accounts', async () => {
|
|
277
|
+
await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
278
|
+
await expect(new MachineCredentials().mintCredential('human@test.local')).rejects.toThrow(
|
|
279
|
+
/No machine account is declared/
|
|
280
|
+
);
|
|
281
|
+
await expect(new MachineCredentials().mintCredential('nobody@test.local')).rejects.toThrow(
|
|
282
|
+
/No machine account is declared/
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('refuses a declared account whose row has not been loaded from source yet', async () => {
|
|
287
|
+
class NotBooted extends TestOpsMachineAccount {
|
|
288
|
+
id = 'machine-not-booted';
|
|
289
|
+
email = 'machine-not-booted@test.local';
|
|
290
|
+
}
|
|
291
|
+
// Declared (discoverable) but never booted: no source-owned row exists.
|
|
292
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = [new TestOpsMachineAccount(), new NotBooted()];
|
|
293
|
+
await expect(new MachineCredentials().mintCredential('machine-not-booted@test.local')).rejects.toThrow(
|
|
294
|
+
/has not been loaded from source yet/
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it(`the door is the 'users' permission (admin passes as break-glass)`, async () => {
|
|
299
|
+
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
300
|
+
const plain = await testEnv.createUser({ name: 'Plain', email: 'plain@test.local', roles: ['ops'] });
|
|
301
|
+
const canRun = () =>
|
|
302
|
+
ServiceAuth.canRunService(new MachineCredentials(), { name: 'mintCredential' } as any, [
|
|
303
|
+
'machine-ops@test.local',
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
testEnv.actAs(plain);
|
|
307
|
+
expect(canRun()).toBe(false);
|
|
308
|
+
testEnv.actAs(admin);
|
|
309
|
+
expect(canRun()).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import sha256 from 'crypto-js/sha256';
|
|
2
|
+
import * as argon2 from 'argon2';
|
|
3
|
+
import moment from 'moment';
|
|
4
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
5
|
+
import { tables, User } from '@proteinjs/user';
|
|
6
|
+
import { authenticate } from '../src/authentication/authenticate';
|
|
7
|
+
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
8
|
+
import { Signup } from '../src/services/Signup';
|
|
9
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
10
|
+
|
|
11
|
+
const testEnv = new UserServerTestEnvironment();
|
|
12
|
+
|
|
13
|
+
const ARGON2ID_PREFIX = '$argon2id$';
|
|
14
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The sha256→argon2id migration: authentication queries by EMAIL ONLY and compares in code
|
|
18
|
+
* (query-by-hash is dead), legacy sha256 rows upgrade in place on their next successful login
|
|
19
|
+
* (verify-then-rehash), every password write produces only the new self-describing format, and
|
|
20
|
+
* machine rows (generated 256-bit secrets) stay sha256 by design — cheap per-poll verifies.
|
|
21
|
+
*/
|
|
22
|
+
describe('Password KDF migration', () => {
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
await testEnv.beforeAll();
|
|
25
|
+
}, 120000);
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await testEnv.afterAll();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await getDbAsSystem().delete(tables.User, {});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const insertUser = async (fields: Partial<User> & { email: string; password: string }) =>
|
|
36
|
+
await getDbAsSystem().insert(tables.User, {
|
|
37
|
+
name: 'Test user',
|
|
38
|
+
emailVerified: true,
|
|
39
|
+
roles: [],
|
|
40
|
+
...fields,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
44
|
+
|
|
45
|
+
it('legacy sha256 row: login succeeds and the row is upgraded in place to argon2id', async () => {
|
|
46
|
+
const legacyHash = sha256('hunter2 was fine in 2004').toString();
|
|
47
|
+
await insertUser({ email: 'legacy@test.local', password: legacyHash });
|
|
48
|
+
|
|
49
|
+
expect(await authenticate('legacy@test.local', 'hunter2 was fine in 2004')).toBe(true);
|
|
50
|
+
|
|
51
|
+
// Outcome: the stored value CHANGED, to the self-describing argon2id format, and still
|
|
52
|
+
// matches the same credential (the encoded string carries its own per-user salt).
|
|
53
|
+
const upgraded = (await userRow('legacy@test.local')).password;
|
|
54
|
+
expect(upgraded).not.toBe(legacyHash);
|
|
55
|
+
expect(upgraded.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
56
|
+
await expect(argon2.verify(upgraded, 'hunter2 was fine in 2004')).resolves.toBe(true);
|
|
57
|
+
|
|
58
|
+
// The migrated row logs in through the argon2 leg — and is NOT rehashed again.
|
|
59
|
+
expect(await authenticate('legacy@test.local', 'hunter2 was fine in 2004')).toBe(true);
|
|
60
|
+
expect((await userRow('legacy@test.local')).password).toBe(upgraded);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('new-format row: login verifies against the stored encoded hash', async () => {
|
|
64
|
+
const encoded = await new PasswordHasher().hash('correct horse battery staple');
|
|
65
|
+
await insertUser({ email: 'modern@test.local', password: encoded });
|
|
66
|
+
|
|
67
|
+
expect(await authenticate('modern@test.local', 'correct horse battery staple')).toBe(true);
|
|
68
|
+
expect((await userRow('modern@test.local')).password).toBe(encoded);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('wrong password fails for both stored formats, and never mutates the row', async () => {
|
|
72
|
+
const legacyHash = sha256('right password').toString();
|
|
73
|
+
await insertUser({ email: 'legacy@test.local', password: legacyHash });
|
|
74
|
+
await insertUser({ email: 'modern@test.local', password: await new PasswordHasher().hash('right password') });
|
|
75
|
+
|
|
76
|
+
expect(await authenticate('legacy@test.local', 'wrong password')).toBe('User name or password incorrect');
|
|
77
|
+
expect(await authenticate('modern@test.local', 'wrong password')).toBe('User name or password incorrect');
|
|
78
|
+
|
|
79
|
+
// A failed attempt is not a migration event: the legacy row still holds its sha256.
|
|
80
|
+
expect((await userRow('legacy@test.local')).password).toBe(legacyHash);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('unknown email fails the same way (no query-by-hash oracle)', async () => {
|
|
84
|
+
expect(await authenticate('nobody@test.local', 'anything')).toBe('User name or password incorrect');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('new registrations write only the new format', async () => {
|
|
88
|
+
await new Signup().createAccount({
|
|
89
|
+
name: 'New user',
|
|
90
|
+
email: 'new@test.local',
|
|
91
|
+
password: 'first password',
|
|
92
|
+
emailVerified: false,
|
|
93
|
+
invitedBy: null,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const row = await userRow('new@test.local');
|
|
97
|
+
expect(row.password.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
98
|
+
expect(row.password).not.toBe(sha256('first password').toString());
|
|
99
|
+
expect(await authenticate('new@test.local', 'first password')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('deactivated and pending-deletion gates are unchanged across both formats', async () => {
|
|
103
|
+
// Staff-deactivated account: correct credentials are still refused a session.
|
|
104
|
+
await insertUser({ email: 'off@test.local', password: sha256('pw').toString(), status: 'deactivated' });
|
|
105
|
+
expect(await authenticate('off@test.local', 'pw')).toBe('This account has been deactivated');
|
|
106
|
+
|
|
107
|
+
// Pending-deletion account (deactivated + deleteRequestedAt): login IS the cancel signal —
|
|
108
|
+
// authenticate admits it, for migrated rows too.
|
|
109
|
+
await insertUser({
|
|
110
|
+
email: 'pending@test.local',
|
|
111
|
+
password: await new PasswordHasher().hash('pw'),
|
|
112
|
+
status: 'deactivated',
|
|
113
|
+
deleteRequestedAt: moment(),
|
|
114
|
+
});
|
|
115
|
+
expect(await authenticate('pending@test.local', 'pw')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('machine rows authenticate through the same owner but never leave sha256', async () => {
|
|
119
|
+
// The minted-credential shape: a generated 256-bit hex secret, sha256 at rest (stretching a
|
|
120
|
+
// random 256-bit secret buys nothing, and the bridge logs in fresh per poll — see
|
|
121
|
+
// PasswordHasher's machine mode).
|
|
122
|
+
const secret = '9f'.repeat(32);
|
|
123
|
+
const machineHash = sha256(secret).toString();
|
|
124
|
+
await insertUser({ email: 'machine@test.local', password: machineHash, isLoadedFromSource: true });
|
|
125
|
+
|
|
126
|
+
expect(await authenticate('machine@test.local', secret)).toBe(true);
|
|
127
|
+
|
|
128
|
+
const row = await userRow('machine@test.local');
|
|
129
|
+
expect(row.password).toBe(machineHash);
|
|
130
|
+
expect(row.password).toMatch(SHA256_HEX);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
3
|
+
// Deep import (test-only): ServiceAuth is the real RPC door but is not index-exported by
|
|
4
|
+
// @proteinjs/service — the door test below runs the actual gate rather than pinning metadata.
|
|
5
|
+
import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
|
|
6
|
+
import { User, UserRepo, tables } from '@proteinjs/user';
|
|
7
|
+
import { SetUserStatus } from '../src/services/SetUserStatus';
|
|
8
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
9
|
+
|
|
10
|
+
const testEnv = new UserServerTestEnvironment();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The SetUserStatus service is the ONE write path for account standing: the status update and
|
|
14
|
+
* its `user_status_event` audit row commit together. Outcomes asserted against the emulator:
|
|
15
|
+
* the rows written, not the calls made.
|
|
16
|
+
*
|
|
17
|
+
* The service door is exercised through ServiceAuth itself (the same gate the RPC path runs):
|
|
18
|
+
* the explicit `roles: ['admin']` block refuses everyone but admins.
|
|
19
|
+
*/
|
|
20
|
+
describe('SetUserStatus service — admin door, standing writes, audit trail', () => {
|
|
21
|
+
let admin: User;
|
|
22
|
+
let nonAdmin: User;
|
|
23
|
+
let target: User;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await testEnv.beforeAll();
|
|
27
|
+
// ServiceAuth funnels through UserAuth, which resolves the current user from the
|
|
28
|
+
// source-graph-registered AuthenticatedUserRepo — seed it the way the env seeds session
|
|
29
|
+
// storage (tests do not load the generated source graph).
|
|
30
|
+
(SourceRepository.get() as unknown as { objectCache: Record<string, unknown[]> }).objectCache[
|
|
31
|
+
'@proteinjs/user-auth/AuthenticatedUserRepo'
|
|
32
|
+
] = [new UserRepo()];
|
|
33
|
+
}, 120000);
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await testEnv.afterAll();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
const db = getDbAsSystem();
|
|
41
|
+
await db.delete(tables.UserStatusEvent, {});
|
|
42
|
+
await db.delete(tables.User, {});
|
|
43
|
+
admin = await testEnv.createUser({ name: 'Admin actor', email: 'status-admin@test.local', roles: ['admin'] });
|
|
44
|
+
nonAdmin = await testEnv.createUser({ name: 'Plain actor', email: 'status-plain@test.local', roles: ['ops'] });
|
|
45
|
+
target = await testEnv.createUser({ name: 'Target user', email: 'status-target@test.local' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const targetStatus = async () => (await getDbAsSystem().get(tables.User, { id: target.id }))!.status;
|
|
49
|
+
const auditRows = async () => await getDbAsSystem().query(tables.UserStatusEvent, {});
|
|
50
|
+
const canRun = () =>
|
|
51
|
+
ServiceAuth.canRunService(new SetUserStatus(), { name: 'setUserStatus' } as any, [target.id, 'deactivated']);
|
|
52
|
+
|
|
53
|
+
it('refuses the door for a non-admin and admits an admin', () => {
|
|
54
|
+
testEnv.actAs(nonAdmin);
|
|
55
|
+
expect(canRun()).toBe(false);
|
|
56
|
+
|
|
57
|
+
testEnv.actAs(admin);
|
|
58
|
+
expect(canRun()).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('deactivate writes the standing on the user row and one audit row', async () => {
|
|
62
|
+
testEnv.actAs(admin);
|
|
63
|
+
await new SetUserStatus().setUserStatus(target.id, 'deactivated');
|
|
64
|
+
|
|
65
|
+
expect(await targetStatus()).toBe('deactivated');
|
|
66
|
+
const events = await auditRows();
|
|
67
|
+
expect(events).toHaveLength(1);
|
|
68
|
+
expect(events[0]).toMatchObject({ actor: admin.id, target: target.id, status: 'deactivated' });
|
|
69
|
+
expect(events[0].created).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('setting the standing the user already holds changes nothing and writes no audit row', async () => {
|
|
73
|
+
testEnv.actAs(admin);
|
|
74
|
+
const service = new SetUserStatus();
|
|
75
|
+
await service.setUserStatus(target.id, 'deactivated');
|
|
76
|
+
await service.setUserStatus(target.id, 'deactivated');
|
|
77
|
+
|
|
78
|
+
expect(await targetStatus()).toBe('deactivated');
|
|
79
|
+
expect(await auditRows()).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('treats a null standing (row predating the column) as active — no write, no audit row', async () => {
|
|
83
|
+
await getDbAsSystem().update(tables.User, { id: target.id, status: null });
|
|
84
|
+
|
|
85
|
+
testEnv.actAs(admin);
|
|
86
|
+
await new SetUserStatus().setUserStatus(target.id, 'active');
|
|
87
|
+
|
|
88
|
+
expect(await targetStatus()).toBeFalsy();
|
|
89
|
+
expect(await auditRows()).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reactivate flips the standing back and audits it', async () => {
|
|
93
|
+
testEnv.actAs(admin);
|
|
94
|
+
const service = new SetUserStatus();
|
|
95
|
+
await service.setUserStatus(target.id, 'deactivated');
|
|
96
|
+
await service.setUserStatus(target.id, 'active');
|
|
97
|
+
|
|
98
|
+
expect(await targetStatus()).toBe('active');
|
|
99
|
+
const events = await auditRows();
|
|
100
|
+
expect(events).toHaveLength(2);
|
|
101
|
+
expect(events.map((event) => event.status).sort()).toEqual(['active', 'deactivated']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('refuses a standing the vocabulary does not know, and writes nothing', async () => {
|
|
105
|
+
testEnv.actAs(admin);
|
|
106
|
+
await expect(new SetUserStatus().setUserStatus(target.id, 'suspended' as any)).rejects.toThrow(
|
|
107
|
+
`'suspended' is not a known user status. Pick one of: active, deactivated.`
|
|
108
|
+
);
|
|
109
|
+
expect(await targetStatus()).toBe('active');
|
|
110
|
+
expect(await auditRows()).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('refuses a change for a user that does not exist', async () => {
|
|
114
|
+
testEnv.actAs(admin);
|
|
115
|
+
await expect(new SetUserStatus().setUserStatus('nobody-1', 'deactivated')).rejects.toThrow(
|
|
116
|
+
'No user found for id: nobody-1'
|
|
117
|
+
);
|
|
118
|
+
expect(await auditRows()).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -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
|
+
});
|