@proteinjs/user-server 1.16.0 → 1.17.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 +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/services/Roles.d.ts +4 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +13 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts +24 -1
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +79 -18
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/MachineAccounts.integration.test.js +5 -8
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/Roles.integration.test.js +76 -0
- package/dist/test/Roles.integration.test.js.map +1 -1
- package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts +2 -0
- package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts.map +1 -0
- package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js +224 -0
- package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js.map +1 -0
- package/dist/test/UpdateUserInfoRefresh.test.d.ts +2 -0
- package/dist/test/UpdateUserInfoRefresh.test.d.ts.map +1 -0
- package/dist/test/UpdateUserInfoRefresh.test.js +99 -0
- package/dist/test/UpdateUserInfoRefresh.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/package.json +4 -4
- package/src/services/Roles.ts +13 -1
- package/src/services/UpdateUserInfo.ts +68 -13
- package/test/MachineAccounts.integration.test.ts +40 -46
- package/test/Roles.integration.test.ts +41 -0
- package/test/UpdateUserInfoClearAvatarByAdmin.test.ts +109 -0
- package/test/UpdateUserInfoRefresh.test.ts +40 -0
|
@@ -203,53 +203,47 @@ describe('Machine accounts as source records', () => {
|
|
|
203
203
|
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
});
|
|
227
|
-
await insertSession('machine-session', 'machine-ops@test.local');
|
|
228
|
-
|
|
229
|
-
class RenamedOps extends TestOpsMachineAccount {
|
|
230
|
-
email = 'machine-ops-renamed@test.local';
|
|
231
|
-
}
|
|
232
|
-
await boot([new RenamedOps()]);
|
|
233
|
-
|
|
234
|
-
// One row, adopted in place: id kept (references + credential survive), email re-derived,
|
|
235
|
-
// reactivated, grants exactly the declaration's (no resurrection of stale grants).
|
|
236
|
-
expect(await getDbAsSystem().query(tables.User, {})).toHaveLength(1);
|
|
237
|
-
expect(await getDbAsSystem().get(tables.User, { email: 'machine-ops@test.local' })).toBeUndefined();
|
|
238
|
-
const adopted = await getDbAsSystem().get(tables.User, { email: 'machine-ops-renamed@test.local' });
|
|
239
|
-
expect(adopted).toMatchObject({
|
|
240
|
-
id: 'machine-test-ops',
|
|
241
|
-
name: 'Test ops machine',
|
|
242
|
-
roles: ['ops'],
|
|
243
|
-
status: 'active',
|
|
244
|
-
isLoadedFromSource: true,
|
|
245
|
-
});
|
|
246
|
-
expect(adopted.password).toBe(sha256('cred').toString());
|
|
247
|
-
// The rename transited the removal patch: the categorical watcher killed the old sessions...
|
|
248
|
-
expect(await sessionEmails()).toEqual([]);
|
|
249
|
-
// ...and the surviving credential logs in under the new email.
|
|
250
|
-
expect(await authenticate('machine-ops-renamed@test.local', 'cred')).toBe(true);
|
|
206
|
+
// Marker retired at the R5 re-merge (2026-08-31, r5asm2): the composed world's db carries the
|
|
207
|
+
// soft-removal re-adoption (integration/r5-db ba9f4ba7 content), so this leg passes — the
|
|
208
|
+
// v1.20-era it.failing cross-train marker self-flipped red exactly as designed and comes off
|
|
209
|
+
// WITH the R5 db mint (TRAIN_MANIFEST_5 §6.1; land this change with the db 1.40-era mint).
|
|
210
|
+
it('re-declared under a renamed email: the soft-removed row is adopted by its stable id — reactivated, re-derived from the declaration, credential preserved', async () => {
|
|
211
|
+
// The user table SOFT-removes (deactivate, never delete — rows carry grants/history), so a
|
|
212
|
+
// re-declaration whose email no longer matches the kept row (an account rename, or any
|
|
213
|
+
// re-declare-after-removal under a changed natural key) must adopt the row by its stable
|
|
214
|
+
// declared id instead of INSERTing into a PK collision.
|
|
215
|
+
await boot([new TestOpsMachineAccount()]);
|
|
216
|
+
// Runtime-owned credential on the row, plus role drift the re-derivation must NOT resurrect.
|
|
217
|
+
await getDbAsSystem().update(tables.User, {
|
|
218
|
+
id: 'machine-test-ops',
|
|
219
|
+
password: sha256('cred').toString(),
|
|
220
|
+
roles: ['ops', 'stale-role'],
|
|
221
|
+
});
|
|
222
|
+
await insertSession('machine-session', 'machine-ops@test.local');
|
|
223
|
+
|
|
224
|
+
class RenamedOps extends TestOpsMachineAccount {
|
|
225
|
+
email = 'machine-ops-renamed@test.local';
|
|
251
226
|
}
|
|
252
|
-
|
|
227
|
+
await boot([new RenamedOps()]);
|
|
228
|
+
|
|
229
|
+
// One row, adopted in place: id kept (references + credential survive), email re-derived,
|
|
230
|
+
// reactivated, grants exactly the declaration's (no resurrection of stale grants).
|
|
231
|
+
expect(await getDbAsSystem().query(tables.User, {})).toHaveLength(1);
|
|
232
|
+
expect(await getDbAsSystem().get(tables.User, { email: 'machine-ops@test.local' })).toBeUndefined();
|
|
233
|
+
const adopted = await getDbAsSystem().get(tables.User, { email: 'machine-ops-renamed@test.local' });
|
|
234
|
+
expect(adopted).toMatchObject({
|
|
235
|
+
id: 'machine-test-ops',
|
|
236
|
+
name: 'Test ops machine',
|
|
237
|
+
roles: ['ops'],
|
|
238
|
+
status: 'active',
|
|
239
|
+
isLoadedFromSource: true,
|
|
240
|
+
});
|
|
241
|
+
expect(adopted.password).toBe(sha256('cred').toString());
|
|
242
|
+
// The rename transited the removal patch: the categorical watcher killed the old sessions...
|
|
243
|
+
expect(await sessionEmails()).toEqual([]);
|
|
244
|
+
// ...and the surviving credential logs in under the new email.
|
|
245
|
+
expect(await authenticate('machine-ops-renamed@test.local', 'cred')).toBe(true);
|
|
246
|
+
});
|
|
253
247
|
|
|
254
248
|
it(`the staff toggle rides the same watcher: SetUserStatus deactivation kills the target's sessions`, async () => {
|
|
255
249
|
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
@@ -214,6 +214,47 @@ describe('Roles service — grant/revoke outcomes and audit trail', () => {
|
|
|
214
214
|
expect(events[0]).toMatchObject({ actor: 'actor-1', target: targetId, role: 'data-access', action: 'grant' });
|
|
215
215
|
}, 60000);
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Nobody edits their own roles. Self-service escalation is the whole reason the 'roles'
|
|
219
|
+
* permission is separated from 'users', and a self-REVOKE is the mirror hazard: the last
|
|
220
|
+
* holder locking themselves out of the door they were meant to keep. Both are refused before
|
|
221
|
+
* the no-change early return, so a self-target is always an error and never a silent no-op,
|
|
222
|
+
* and before the transaction, so nothing lands in the audit ledger.
|
|
223
|
+
*/
|
|
224
|
+
describe('self-targeted role changes', () => {
|
|
225
|
+
const selfEvents = async () =>
|
|
226
|
+
(await auditRows()).filter((event) => event.actor === actor.id && event.target === actor.id);
|
|
227
|
+
|
|
228
|
+
it('refuses a self GRANT — the actor keeps their roles and nothing is audited', async () => {
|
|
229
|
+
await expect(new Roles().grantRole(actor.id, 'ops')).rejects.toThrow(
|
|
230
|
+
`You can't grant your own roles — ask another user manager.`
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const actorRow = await getDbAsSystem().get(tables.User, { id: actor.id });
|
|
234
|
+
expect(actorRow.roles).toEqual(['admin']);
|
|
235
|
+
expect(await selfEvents()).toHaveLength(0);
|
|
236
|
+
}, 60000);
|
|
237
|
+
|
|
238
|
+
it('refuses a self REVOKE of a role the actor holds — the role stays, nothing is audited', async () => {
|
|
239
|
+
await getDbAsSystem().update(tables.User, { id: actor.id, roles: ['admin', 'ops'] });
|
|
240
|
+
|
|
241
|
+
await expect(new Roles().revokeRole(actor.id, 'ops')).rejects.toThrow(
|
|
242
|
+
`You can't revoke your own roles — ask another user manager.`
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const actorRow = await getDbAsSystem().get(tables.User, { id: actor.id });
|
|
246
|
+
expect(actorRow.roles).toEqual(['admin', 'ops']);
|
|
247
|
+
expect(await selfEvents()).toHaveLength(0);
|
|
248
|
+
}, 60000);
|
|
249
|
+
|
|
250
|
+
it('refuses a self target the catalog would otherwise no-op — a self-target is never silent', async () => {
|
|
251
|
+
// The actor does NOT hold 'ops', so a revoke would have hit the no-change early return and
|
|
252
|
+
// returned quietly. The refusal comes first: the caller always learns the act was refused.
|
|
253
|
+
await expect(new Roles().revokeRole(actor.id, 'ops')).rejects.toThrow(/your own roles/);
|
|
254
|
+
expect(await selfEvents()).toHaveLength(0);
|
|
255
|
+
}, 60000);
|
|
256
|
+
});
|
|
257
|
+
|
|
217
258
|
it('a roles-holder can still REVOKE an admin-grant-only role — de-escalation stays open (the break-glass precedent)', async () => {
|
|
218
259
|
await new Roles().grantRole(targetId, 'data-access');
|
|
219
260
|
new UserRepo().setUser({ ...actor, roles: ['roles'] });
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { File, tables as fileTables } from '@proteinjs/db-file';
|
|
4
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
5
|
+
import { tables, UpdateUserInfoService, User, UserRepo, getScopedDbAsSystem } from '@proteinjs/user';
|
|
6
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
|
+
import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
|
|
8
|
+
|
|
9
|
+
const testEnv = new UserServerTestEnvironment();
|
|
10
|
+
|
|
11
|
+
const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
|
|
12
|
+
const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Clearing SOMEONE ELSE'S avatar — the user-manager act behind the admin user surface.
|
|
16
|
+
*
|
|
17
|
+
* The service door is `allUsers` (every signed-in user manages their own profile through it), so
|
|
18
|
+
* the cross-user authorization is in-body and fail-closed: naming another person's id requires
|
|
19
|
+
* the `users` permission. Two invariants beyond the row write:
|
|
20
|
+
* - the ACTOR's session cache is never overwritten with the target's row (the mutation path
|
|
21
|
+
* refreshes the caller's cached user, and doing that for another person's row would leave the
|
|
22
|
+
* admin browsing as their target);
|
|
23
|
+
* - the target's own session cache is NOT refreshed — the write is to their row, and their next
|
|
24
|
+
* sign-in reads it.
|
|
25
|
+
* Outcomes asserted against a real Spanner emulator: rows, files, cached identity.
|
|
26
|
+
*/
|
|
27
|
+
describe('UpdateUserInfo — clearing another person avatar', () => {
|
|
28
|
+
const service: UpdateUserInfoService = new UpdateUserInfo();
|
|
29
|
+
let admin: User;
|
|
30
|
+
let kevin: User;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
await testEnv.beforeAll();
|
|
34
|
+
// The in-body permission check resolves the CALLER through UserAuth — the same session-backed
|
|
35
|
+
// repo the services use (the environment seeds session storage but not this registration).
|
|
36
|
+
(SourceRepository.get() as any).objectCache['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await testEnv.afterAll();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
admin = await testEnv.createUser({ name: 'Ada Admin', email: `admin-${Date.now()}@test.local`, roles: ['admin'] });
|
|
45
|
+
kevin = await testEnv.createUser({ name: 'Kevin', email: `kevin-${Date.now()}@test.local` });
|
|
46
|
+
testEnv.actAs(admin);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("clears the target's avatar, returns the target's row, and leaves the actor's session alone", async () => {
|
|
50
|
+
await getDbAsSystem().update(tables.User, { id: kevin.id, avatarEmoji: '🦊' });
|
|
51
|
+
|
|
52
|
+
const updated = await service.clearAvatar(kevin.id);
|
|
53
|
+
|
|
54
|
+
// The TARGET's row moved.
|
|
55
|
+
const kevinRow = await userRow(kevin.id);
|
|
56
|
+
expect(kevinRow.avatarEmoji).toBeFalsy();
|
|
57
|
+
expect(kevinRow.avatarFileId).toBeFalsy();
|
|
58
|
+
|
|
59
|
+
// The returned user is the target's row, password-less.
|
|
60
|
+
expect(updated.id).toBe(kevin.id);
|
|
61
|
+
expect(updated.name).toBe('Kevin');
|
|
62
|
+
expect((updated as any).password).toBeUndefined();
|
|
63
|
+
|
|
64
|
+
// The actor is still signed in as themselves — the session cache was not handed the target.
|
|
65
|
+
expect(new UserRepo().getUser().id).toBe(admin.id);
|
|
66
|
+
expect(new UserRepo().getUser().name).toBe('Ada Admin');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("deletes the target's stored avatar photo", async () => {
|
|
70
|
+
testEnv.actAs(kevin);
|
|
71
|
+
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 4, g: 5, b: 6 } } })
|
|
72
|
+
.png()
|
|
73
|
+
.toBuffer();
|
|
74
|
+
const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
|
|
75
|
+
expect(await getFileRow(withPhoto.avatarFileId!)).toBeTruthy();
|
|
76
|
+
testEnv.actAs(admin);
|
|
77
|
+
|
|
78
|
+
await service.clearAvatar(kevin.id);
|
|
79
|
+
|
|
80
|
+
expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
|
|
81
|
+
expect((await userRow(kevin.id)).avatarFileId).toBeFalsy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("refuses an actor without the users permission, and the target's avatar is untouched", async () => {
|
|
85
|
+
const bystander = await testEnv.createUser({ name: 'Bystander', email: `bystander-${Date.now()}@test.local` });
|
|
86
|
+
await getDbAsSystem().update(tables.User, { id: kevin.id, avatarEmoji: '🦊' });
|
|
87
|
+
testEnv.actAs(bystander);
|
|
88
|
+
|
|
89
|
+
await expect(service.clearAvatar(kevin.id)).rejects.toThrow(
|
|
90
|
+
`Only a user manager can change another person's avatar.`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect((await userRow(kevin.id)).avatarEmoji).toBe('🦊');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('with no id it still clears the CALLER own avatar and refreshes their session (the existing contract)', async () => {
|
|
97
|
+
testEnv.actAs(kevin);
|
|
98
|
+
await service.updateAvatarEmoji('🦞');
|
|
99
|
+
expect(new UserRepo().getUser().avatarEmoji).toBe('🦞');
|
|
100
|
+
|
|
101
|
+
const updated = await service.clearAvatar();
|
|
102
|
+
|
|
103
|
+
expect(updated.id).toBe(kevin.id);
|
|
104
|
+
expect(updated.avatarEmoji).toBeFalsy();
|
|
105
|
+
expect((await userRow(kevin.id)).avatarEmoji).toBeFalsy();
|
|
106
|
+
// Self path: the session cache is refreshed in the same stroke.
|
|
107
|
+
expect(new UserRepo().getUser().avatarEmoji).toBeFalsy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { tables, UserRepo } from '@proteinjs/user';
|
|
3
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
4
|
+
import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
|
|
5
|
+
|
|
6
|
+
const testEnv = new UserServerTestEnvironment();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `refresh()` is the read-only half of the persist-and-refresh helper: no write, just the stored
|
|
10
|
+
* row pulled back into the session cache. It exists for changes this session did not make — an
|
|
11
|
+
* avatar set on the phone, a rename from another tab, an admin clearing an avatar — which
|
|
12
|
+
* otherwise stay invisible here until the next sign-in, because the cached user is only ever
|
|
13
|
+
* rewritten by this session's OWN mutations.
|
|
14
|
+
*/
|
|
15
|
+
describe('UpdateUserInfo.refresh — re-reads the stored row into the session cache', () => {
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
await testEnv.beforeAll();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
await testEnv.afterAll();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('picks up a change made outside this session, and returns the row password-less', async () => {
|
|
25
|
+
const user = await testEnv.createUser({ name: 'Two Devices', email: 'refresh@test.local' });
|
|
26
|
+
testEnv.actAs(user);
|
|
27
|
+
|
|
28
|
+
// The other device's write: the row moves, this session's cached user does not.
|
|
29
|
+
await getDbAsSystem().update(tables.User, { avatarEmoji: '🦊' }, { id: user.id });
|
|
30
|
+
expect(new UserRepo().getUser().avatarEmoji).toBeFalsy();
|
|
31
|
+
|
|
32
|
+
const updated = await new UpdateUserInfo().refresh();
|
|
33
|
+
|
|
34
|
+
expect(updated.id).toBe(user.id);
|
|
35
|
+
expect(updated.avatarEmoji).toBe('🦊');
|
|
36
|
+
expect((updated as any).password).toBeUndefined();
|
|
37
|
+
// The point of the call: the cached user carries it now, with no re-login.
|
|
38
|
+
expect(new UserRepo().getUser().avatarEmoji).toBe('🦊');
|
|
39
|
+
});
|
|
40
|
+
});
|