@proteinjs/user-server 1.16.1 → 1.18.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 (47) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/routes/devLogin.d.ts +10 -0
  5. package/dist/src/routes/devLogin.d.ts.map +1 -1
  6. package/dist/src/routes/devLogin.js +28 -9
  7. package/dist/src/routes/devLogin.js.map +1 -1
  8. package/dist/src/services/Roles.d.ts +24 -1
  9. package/dist/src/services/Roles.d.ts.map +1 -1
  10. package/dist/src/services/Roles.js +92 -5
  11. package/dist/src/services/Roles.js.map +1 -1
  12. package/dist/src/services/UpdateUserInfo.d.ts +24 -1
  13. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  14. package/dist/src/services/UpdateUserInfo.js +79 -18
  15. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  16. package/dist/test/DevLogin.test.js +13 -41
  17. package/dist/test/DevLogin.test.js.map +1 -1
  18. package/dist/test/DevLoginBootstrapAdmin.test.d.ts +2 -0
  19. package/dist/test/DevLoginBootstrapAdmin.test.d.ts.map +1 -0
  20. package/dist/test/DevLoginBootstrapAdmin.test.js +384 -0
  21. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -0
  22. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  23. package/dist/test/Roles.integration.test.js +76 -0
  24. package/dist/test/Roles.integration.test.js.map +1 -1
  25. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts +2 -0
  26. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts.map +1 -0
  27. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js +224 -0
  28. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js.map +1 -0
  29. package/dist/test/UpdateUserInfoRefresh.test.d.ts +2 -0
  30. package/dist/test/UpdateUserInfoRefresh.test.d.ts.map +1 -0
  31. package/dist/test/UpdateUserInfoRefresh.test.js +99 -0
  32. package/dist/test/UpdateUserInfoRefresh.test.js.map +1 -0
  33. package/dist/test/devLoginHarness.d.ts +16 -0
  34. package/dist/test/devLoginHarness.d.ts.map +1 -0
  35. package/dist/test/devLoginHarness.js +79 -0
  36. package/dist/test/devLoginHarness.js.map +1 -0
  37. package/generated/index.ts +1 -1
  38. package/package.json +3 -3
  39. package/src/routes/devLogin.ts +17 -0
  40. package/src/services/Roles.ts +62 -5
  41. package/src/services/UpdateUserInfo.ts +68 -13
  42. package/test/DevLogin.test.ts +5 -36
  43. package/test/DevLoginBootstrapAdmin.test.ts +175 -0
  44. package/test/Roles.integration.test.ts +41 -0
  45. package/test/UpdateUserInfoClearAvatarByAdmin.test.ts +109 -0
  46. package/test/UpdateUserInfoRefresh.test.ts +40 -0
  47. package/test/devLoginHarness.ts +39 -0
@@ -1,15 +1,18 @@
1
1
  import sharp from 'sharp';
2
2
  import heicDecode from 'heic-decode';
3
3
  import { getDbAsSystem } from '@proteinjs/db';
4
- import { FileStorage } from '@proteinjs/db-file';
4
+ import { File, FileStorage, tables as fileTables } from '@proteinjs/db-file';
5
5
  import {
6
6
  tables,
7
7
  User,
8
+ UserAuth,
8
9
  UserRepo,
10
+ USER_PERMISSIONS,
9
11
  UpdatePasswordResponse,
10
12
  UpdatedUser,
11
13
  UpdateUserInfoService,
12
14
  AvatarCrop,
15
+ getScopedDbAsSystem,
13
16
  } from '@proteinjs/user';
14
17
  import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
15
18
  import { PasswordHasher } from '../authentication/PasswordHasher';
@@ -131,8 +134,22 @@ export class UpdateUserInfo implements UpdateUserInfoService {
131
134
  return await this.setAvatar({ avatarEmoji: trimmed, avatarFileId: null });
132
135
  }
133
136
 
134
- async clearAvatar(): Promise<UpdatedUser> {
135
- return await this.setAvatar({ avatarEmoji: null, avatarFileId: null });
137
+ async clearAvatar(userId?: string): Promise<UpdatedUser> {
138
+ return await this.setAvatar({ avatarEmoji: null, avatarFileId: null }, userId);
139
+ }
140
+
141
+ /**
142
+ * The read-only half of `saveUserInfo`: no write, just the stored row pulled back into the
143
+ * session cache. Every mutation through this service already refreshes the cache, so this is
144
+ * for changes made ELSEWHERE — another device, another tab, a user manager — which the cached
145
+ * user would otherwise keep hiding until the next sign-in.
146
+ */
147
+ async refresh(): Promise<UpdatedUser> {
148
+ const userRepo = new UserRepo();
149
+ const updated = await getDbAsSystem().get(tables.User, { id: userRepo.getUser().id });
150
+ delete (updated as Partial<User>).password; // the same password-less shape userCache caches
151
+ userRepo.setUser(updated);
152
+ return updated;
136
153
  }
137
154
 
138
155
  /**
@@ -140,13 +157,25 @@ export class UpdateUserInfo implements UpdateUserInfoService {
140
157
  * the exactly-one-active invariant (photo OR emoji, never both). Also the previous photo's
141
158
  * cleanup point: an avatar file the user row no longer points to is unreachable (the /avatar
142
159
  * route serves only the id on the user row), so it is deleted here, after the row moves off it.
160
+ *
161
+ * `userId` names ANOTHER person (see {@link resolveTarget} for the permission that admits it);
162
+ * everything below then applies to that person's row and their previous photo.
143
163
  */
144
- private async setAvatar(avatar: { avatarEmoji: string | null; avatarFileId: string | null }): Promise<UpdatedUser> {
145
- const userId = new UserRepo().getUser().id;
146
- const previousAvatarFileId = (await getDbAsSystem().get(tables.User, { id: userId })).avatarFileId;
147
- const updated = await this.saveUserInfo(avatar);
164
+ private async setAvatar(
165
+ avatar: { avatarEmoji: string | null; avatarFileId: string | null },
166
+ userId?: string
167
+ ): Promise<UpdatedUser> {
168
+ const { targetUserId } = this.resolveTarget(userId);
169
+ const previousAvatarFileId = (await getDbAsSystem().get(tables.User, { id: targetUserId })).avatarFileId;
170
+ const updated = await this.saveUserInfo(avatar, userId);
148
171
  if (previousAvatarFileId && previousAvatarFileId !== avatar.avatarFileId) {
149
- await new FileStorage().deleteFile(previousAvatarFileId);
172
+ // Deleted as SYSTEM, like the user-row write it accompanies: the file lives in the TARGET's
173
+ // scope, and a caller-scoped delete (`FileStorage.deleteFile`) would silently match nothing
174
+ // when a user manager clears someone else's photo, orphaning the bytes. This door has
175
+ // already made its own access decision, the same shape as the /avatar route's system read.
176
+ // The bytes still die with the row — `FileStorageTableWatcher` fires for every file-row
177
+ // delete path, system sweeps included.
178
+ await getScopedDbAsSystem<File>().delete(fileTables.File, { id: previousAvatarFileId });
150
179
  }
151
180
 
152
181
  return updated;
@@ -159,18 +188,44 @@ export class UpdateUserInfo implements UpdateUserInfoService {
159
188
  * long-lived contexts that keep their session data (sockets) — serving the STALE user until
160
189
  * re-login (the rename wart). Fusing persist + refresh here means no future mutation can
161
190
  * reintroduce that class of staleness.
191
+ *
192
+ * The refresh is the CALLER's own cache, so it happens only when the caller is the target. A
193
+ * user manager changing someone else writes the row and stops there: refreshing would hand the
194
+ * admin the target's row as their own identity, and there is no way to reach the target's
195
+ * session from here anyway — their next sign-in reads the row.
162
196
  */
163
- private async saveUserInfo(changes: Partial<User>): Promise<UpdatedUser> {
197
+ private async saveUserInfo(changes: Partial<User>, userId?: string): Promise<UpdatedUser> {
164
198
  const userRepo = new UserRepo();
165
- const userId = userRepo.getUser().id;
199
+ const { targetUserId, targetIsCaller } = this.resolveTarget(userId);
166
200
  const db = getDbAsSystem();
167
- await db.update(tables.User, changes, { id: userId });
168
- const updated = await db.get(tables.User, { id: userId });
201
+ await db.update(tables.User, changes, { id: targetUserId });
202
+ const updated = await db.get(tables.User, { id: targetUserId });
169
203
  delete (updated as Partial<User>).password; // the same password-less shape userCache caches
170
- userRepo.setUser(updated);
204
+ if (targetIsCaller) {
205
+ userRepo.setUser(updated);
206
+ }
207
+
171
208
  return updated;
172
209
  }
173
210
 
211
+ /**
212
+ * The user a mutation targets, and whether that is the caller.
213
+ *
214
+ * The service door is `allUsers` — it has to be, every signed-in user manages their own
215
+ * profile through it — so naming ANOTHER person's id is authorized here, in-body and
216
+ * fail-closed: only a `users` permission holder may do it.
217
+ */
218
+ private resolveTarget(userId?: string): { targetUserId: string; targetIsCaller: boolean } {
219
+ const callerId = new UserRepo().getUser().id;
220
+ const targetUserId = userId ?? callerId;
221
+ const targetIsCaller = targetUserId === callerId;
222
+ if (!targetIsCaller && !UserAuth.hasPermission(USER_PERMISSIONS.users)) {
223
+ throw new Error(`Only a user manager can change another person's avatar.`);
224
+ }
225
+
226
+ return { targetUserId, targetIsCaller };
227
+ }
228
+
174
229
  /**
175
230
  * THE avatar image pipeline — the only place avatar pixels are ever resampled: decode,
176
231
  * auto-orient from EXIF, extract the crop frame (client-framed, or the centered square),
@@ -1,8 +1,7 @@
1
1
  import { getDbAsSystem } from '@proteinjs/db';
2
2
  import { tables } from '@proteinjs/user';
3
3
  import { PasswordHasher } from '../src/authentication/PasswordHasher';
4
- import { devLogin } from '../src/routes/devLogin';
5
- import { createPassportRequest } from './passportSessionHarness';
4
+ import { invokeDevLogin } from './devLoginHarness';
6
5
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
7
6
 
8
7
  const testEnv = new UserServerTestEnvironment();
@@ -15,50 +14,19 @@ const testEnv = new UserServerTestEnvironment();
15
14
  * - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
16
15
  * - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
17
16
  * must not mint sessions (much less accounts) for arbitrary domains. Others 400.
17
+ * The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`) has its own suite, DevLoginBootstrapAdmin.test.ts;
18
+ * here the variable is unset, so every created account is role-less.
18
19
  */
19
20
 
20
21
  const ENV_EMAIL = 'dev@test.local';
21
22
 
22
- type RouteOutcome = {
23
- loggedInAs?: string;
24
- sessionRegenerated: boolean;
25
- sessionSaved: boolean;
26
- status?: number;
27
- body?: unknown;
28
- redirect?: string;
29
- };
30
-
31
- const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOutcome> => {
32
- // REAL passport login machinery (see passportSessionHarness): the full contract
33
- // (regenerate → login → save) is pinned by SignupRoute.test.ts — here we assert the dev door
34
- // INHERITS it.
35
- const { request, events } = await createPassportRequest({ query });
36
- const outcome: RouteOutcome = { sessionRegenerated: false, sessionSaved: false };
37
- const response = {
38
- status(code: number) {
39
- outcome.status = code;
40
- return this;
41
- },
42
- send(body?: unknown) {
43
- outcome.body = body;
44
- },
45
- redirect(path: string) {
46
- outcome.redirect = path;
47
- },
48
- };
49
- await devLogin.onRequest(request as never, response as never);
50
- outcome.loggedInAs = request.session.passport?.user;
51
- outcome.sessionRegenerated = events.includes('regenerate');
52
- outcome.sessionSaved = events.includes('save');
53
- return outcome;
54
- };
55
-
56
23
  const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
57
24
 
58
25
  describe('devLogin route', () => {
59
26
  const originalEnv = {
60
27
  DEVELOPMENT: process.env.DEVELOPMENT,
61
28
  DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
29
+ DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
62
30
  };
63
31
 
64
32
  beforeAll(async () => {
@@ -72,6 +40,7 @@ describe('devLogin route', () => {
72
40
  beforeEach(() => {
73
41
  process.env.DEVELOPMENT = 'true';
74
42
  process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
43
+ delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
75
44
  });
76
45
 
77
46
  afterEach(() => {
@@ -0,0 +1,175 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { tables } from '@proteinjs/user';
3
+ import { invokeDevLogin } from './devLoginHarness';
4
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
5
+
6
+ const testEnv = new UserServerTestEnvironment();
7
+
8
+ /**
9
+ * `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login` (n3xa plans/DEV_ESTATES.md
10
+ * D3, founder-ruled 2026-09-04). A dev estate on a fresh real database has no privileged account
11
+ * to grant from, and the no-raw-DB rule forbids the emulator-era seed scripts there — so the ONE
12
+ * sanctioned account door mints break-glass, once:
13
+ * - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
14
+ * before, and the variable changes nothing;
15
+ * - only while NO account carries 'admin' (the same membership test the app's admin checks make);
16
+ * - only for the request whose resolved address equals the variable exactly (case-normalized
17
+ * the way every account email is);
18
+ * - once, audited like any grant (a role_grant_event row; actor = the account itself — the door
19
+ * acts for nobody else); a later call finds an admin and grants nothing.
20
+ * Outcomes are asserted on the rows (roles, audit), never on calls.
21
+ */
22
+
23
+ const ENV_EMAIL = 'dev@test.local';
24
+ const BOOTSTRAP_EMAIL = 'owner@test.local';
25
+
26
+ const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
27
+ const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
28
+ const adminRows = async () =>
29
+ (await getDbAsSystem().query(tables.User, {})).filter((user) => (user.roles ?? []).includes('admin'));
30
+
31
+ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
32
+ const originalEnv = {
33
+ DEVELOPMENT: process.env.DEVELOPMENT,
34
+ DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
35
+ DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
36
+ };
37
+
38
+ beforeAll(async () => {
39
+ await testEnv.beforeAll();
40
+ });
41
+
42
+ afterAll(async () => {
43
+ await testEnv.afterAll();
44
+ });
45
+
46
+ beforeEach(async () => {
47
+ process.env.DEVELOPMENT = 'true';
48
+ process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
49
+ process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = BOOTSTRAP_EMAIL;
50
+ // Every case starts from a fresh database: no accounts, no audit trail.
51
+ const db = getDbAsSystem();
52
+ await db.delete(tables.RoleGrantEvent, {});
53
+ await db.delete(tables.User, {});
54
+ });
55
+
56
+ afterEach(() => {
57
+ for (const [key, value] of Object.entries(originalEnv)) {
58
+ if (value === undefined) {
59
+ delete process.env[key];
60
+ } else {
61
+ process.env[key] = value;
62
+ }
63
+ }
64
+ });
65
+
66
+ it('a fresh database + the matching address: the account is created carrying admin, audited once, and logged in', async () => {
67
+ expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
68
+
69
+ const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
70
+
71
+ expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
72
+ expect(outcome.sessionSaved).toBe(true);
73
+ expect(outcome.redirect).toBe('/');
74
+ const owner = await userRow(BOOTSTRAP_EMAIL);
75
+ expect(owner!.roles).toEqual(['admin']);
76
+ const events = await auditRows();
77
+ expect(events).toHaveLength(1);
78
+ expect(events[0]).toMatchObject({ actor: owner!.id, target: owner!.id, role: 'admin', action: 'grant' });
79
+ expect(await adminRows()).toHaveLength(1);
80
+ });
81
+
82
+ it('a second call for the same address changes nothing: still exactly one admin, one grant', async () => {
83
+ await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
84
+
85
+ const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
86
+
87
+ expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
88
+ expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
89
+ expect(await auditRows()).toHaveLength(1);
90
+ expect(await adminRows()).toHaveLength(1);
91
+ });
92
+
93
+ it('an existing role-less account at the matching address is granted admin on its next login — the door serves created and loaded accounts alike', async () => {
94
+ await testEnv.createUser({ name: 'Owner', email: BOOTSTRAP_EMAIL });
95
+
96
+ const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
97
+
98
+ expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
99
+ expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
100
+ expect(await auditRows()).toHaveLength(1);
101
+ });
102
+
103
+ it('the default path is a request for DEV_AUTO_LOGIN_EMAIL: when that IS the bootstrap address, the default account carries admin', async () => {
104
+ process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = ENV_EMAIL;
105
+
106
+ const outcome = await invokeDevLogin();
107
+
108
+ expect(outcome.loggedInAs).toBe(ENV_EMAIL);
109
+ expect((await userRow(ENV_EMAIL))!.roles).toEqual(['admin']);
110
+ expect(await auditRows()).toHaveLength(1);
111
+ });
112
+
113
+ it('the match is case-normalized like every account email — never a second account for a differently-cased address', async () => {
114
+ process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = 'Owner@Test.local';
115
+
116
+ await invokeDevLogin({ email: 'owner@test.local' });
117
+
118
+ expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
119
+ expect(await adminRows()).toHaveLength(1);
120
+ });
121
+
122
+ it('an admin already exists: the matching address gets an ordinary account — no grant, no audit row', async () => {
123
+ await testEnv.createUser({ name: 'Standing admin', email: 'standing-admin@test.local', roles: ['admin'] });
124
+
125
+ const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
126
+
127
+ expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
128
+ expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual([]);
129
+ expect(await auditRows()).toHaveLength(0);
130
+ expect((await adminRows()).map((user) => user.email)).toEqual(['standing-admin@test.local']);
131
+ });
132
+
133
+ it('a different same-domain address: an ordinary account, no grant — and the bootstrap address itself stays uncreated', async () => {
134
+ const outcome = await invokeDevLogin({ email: 'agent@test.local' });
135
+
136
+ expect(outcome.loggedInAs).toBe('agent@test.local');
137
+ expect((await userRow('agent@test.local'))!.roles).toEqual([]);
138
+ expect(await auditRows()).toHaveLength(0);
139
+ expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
140
+ expect(await adminRows()).toHaveLength(0);
141
+ });
142
+
143
+ it('the address must match exactly — a plus-suffixed variant is a different account and gets nothing', async () => {
144
+ const outcome = await invokeDevLogin({ email: 'owner+lane@test.local' });
145
+
146
+ expect(outcome.loggedInAs).toBe('owner+lane@test.local');
147
+ expect((await userRow('owner+lane@test.local'))!.roles).toEqual([]);
148
+ expect(await auditRows()).toHaveLength(0);
149
+ });
150
+
151
+ it('with the variable unset the matching address is an ordinary account — the omission is the safety (test/prod never set it)', async () => {
152
+ delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
153
+
154
+ await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
155
+
156
+ expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual([]);
157
+ expect(await auditRows()).toHaveLength(0);
158
+ });
159
+
160
+ it.each([['DEVELOPMENT'], ['DEV_AUTO_LOGIN_EMAIL']])(
161
+ 'gate closed (%s unset): 404 exactly as before — no session, no account, no admin; the variable changes nothing',
162
+ async (gate) => {
163
+ delete process.env[gate];
164
+
165
+ const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
166
+
167
+ expect(outcome.status).toBe(404);
168
+ expect(outcome.loggedInAs).toBeUndefined();
169
+ expect(outcome.sessionSaved).toBe(false);
170
+ expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
171
+ expect(await auditRows()).toHaveLength(0);
172
+ expect(await adminRows()).toHaveLength(0);
173
+ }
174
+ );
175
+ });
@@ -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
+ });
@@ -0,0 +1,39 @@
1
+ import { devLogin } from '../src/routes/devLogin';
2
+ import { createPassportRequest } from './passportSessionHarness';
3
+
4
+ /** What one `GET /dev/login` did, seen from outside: the session it minted (or not) and the response it sent. */
5
+ export type DevLoginOutcome = {
6
+ loggedInAs?: string;
7
+ sessionRegenerated: boolean;
8
+ sessionSaved: boolean;
9
+ status?: number;
10
+ body?: unknown;
11
+ redirect?: string;
12
+ };
13
+
14
+ /**
15
+ * Drives the devLogin route once over the REAL passport login machinery (see
16
+ * passportSessionHarness): the full session-establishment contract (regenerate → login → save)
17
+ * is pinned by SignupRoute.test.ts — the dev-door suites assert the door INHERITS it.
18
+ */
19
+ export const invokeDevLogin = async (query?: Record<string, unknown>): Promise<DevLoginOutcome> => {
20
+ const { request, events } = await createPassportRequest({ query });
21
+ const outcome: DevLoginOutcome = { sessionRegenerated: false, sessionSaved: false };
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
+ redirect(path: string) {
31
+ outcome.redirect = path;
32
+ },
33
+ };
34
+ await devLogin.onRequest(request as never, response as never);
35
+ outcome.loggedInAs = request.session.passport?.user;
36
+ outcome.sessionRegenerated = events.includes('regenerate');
37
+ outcome.sessionSaved = events.includes('save');
38
+ return outcome;
39
+ };