@proteinjs/user-server 1.5.4 → 1.7.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 (67) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/generated/index.d.ts +1 -4
  3. package/dist/generated/index.d.ts.map +1 -1
  4. package/dist/generated/index.js +4 -5
  5. package/dist/generated/index.js.map +1 -1
  6. package/dist/src/authorization/userCache.d.ts.map +1 -1
  7. package/dist/src/authorization/userCache.js +18 -3
  8. package/dist/src/authorization/userCache.js.map +1 -1
  9. package/dist/src/routes/avatar.d.ts +19 -0
  10. package/dist/src/routes/avatar.d.ts.map +1 -0
  11. package/dist/src/routes/avatar.js +108 -0
  12. package/dist/src/routes/avatar.js.map +1 -0
  13. package/dist/src/routes/devLogin.d.ts +15 -5
  14. package/dist/src/routes/devLogin.d.ts.map +1 -1
  15. package/dist/src/routes/devLogin.js +61 -13
  16. package/dist/src/routes/devLogin.js.map +1 -1
  17. package/dist/src/services/Signup.d.ts +16 -1
  18. package/dist/src/services/Signup.d.ts.map +1 -1
  19. package/dist/src/services/Signup.js +49 -15
  20. package/dist/src/services/Signup.js.map +1 -1
  21. package/dist/src/services/UpdateUserInfo.d.ts +40 -2
  22. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  23. package/dist/src/services/UpdateUserInfo.js +183 -12
  24. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  25. package/dist/test/DevLogin.test.d.ts +2 -0
  26. package/dist/test/DevLogin.test.d.ts.map +1 -0
  27. package/dist/test/DevLogin.test.js +261 -0
  28. package/dist/test/DevLogin.test.js.map +1 -0
  29. package/dist/test/UpdateUserInfoAvatar.test.d.ts +2 -0
  30. package/dist/test/UpdateUserInfoAvatar.test.d.ts.map +1 -0
  31. package/dist/test/UpdateUserInfoAvatar.test.js +337 -0
  32. package/dist/test/UpdateUserInfoAvatar.test.js.map +1 -0
  33. package/dist/test/UpdateUserInfoSessionRefresh.test.d.ts +2 -0
  34. package/dist/test/UpdateUserInfoSessionRefresh.test.d.ts.map +1 -0
  35. package/dist/test/UpdateUserInfoSessionRefresh.test.js +94 -0
  36. package/dist/test/UpdateUserInfoSessionRefresh.test.js.map +1 -0
  37. package/dist/test/UserCacheMissingAccount.test.d.ts +2 -0
  38. package/dist/test/UserCacheMissingAccount.test.d.ts.map +1 -0
  39. package/dist/test/UserCacheMissingAccount.test.js +104 -0
  40. package/dist/test/UserCacheMissingAccount.test.js.map +1 -0
  41. package/dist/test/UserServerTestEnvironment.d.ts +26 -0
  42. package/dist/test/UserServerTestEnvironment.d.ts.map +1 -0
  43. package/dist/test/UserServerTestEnvironment.js +202 -0
  44. package/dist/test/UserServerTestEnvironment.js.map +1 -0
  45. package/dist/test/avatarRoute.test.d.ts +2 -0
  46. package/dist/test/avatarRoute.test.d.ts.map +1 -0
  47. package/dist/test/avatarRoute.test.js +222 -0
  48. package/dist/test/avatarRoute.test.js.map +1 -0
  49. package/generated/index.ts +4 -5
  50. package/jest.config.js +4 -1
  51. package/package.json +8 -5
  52. package/src/authorization/userCache.ts +17 -2
  53. package/src/heic-decode.d.ts +13 -0
  54. package/src/routes/avatar.ts +57 -0
  55. package/src/routes/devLogin.ts +50 -7
  56. package/src/services/Signup.ts +42 -11
  57. package/src/services/UpdateUserInfo.ts +124 -13
  58. package/test/DevLogin.test.ts +159 -0
  59. package/test/UpdateUserInfoAvatar.test.ts +174 -0
  60. package/test/UpdateUserInfoSessionRefresh.test.ts +37 -0
  61. package/test/UserCacheMissingAccount.test.ts +42 -0
  62. package/test/UserServerTestEnvironment.ts +97 -0
  63. package/test/avatarRoute.test.ts +111 -0
  64. package/test/emulatorLock.globalSetup.js +11 -0
  65. package/test/emulatorLock.globalTeardown.js +8 -0
  66. package/test/emulatorLock.js +124 -0
  67. package/test/fixtures/fixture.heic +0 -0
@@ -1,29 +1,72 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { Logger } from '@proteinjs/logger';
3
+ import { Signup } from '../services/Signup';
3
4
 
4
5
  const logger = new Logger({ name: 'devLogin' });
5
6
 
7
+ /** Lowercased domain of an email address (the whole string when there is no `@`, which can never
8
+ * equal a real env domain — so malformed params fall out at the domain rail). */
9
+ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
10
+
6
11
  /**
7
- * DEV-ONLY session bootstrap: `GET /dev/login` establishes a session for the account named by
8
- * `DEV_AUTO_LOGIN_EMAIL` — no credentials involved — so automated dev-loop testing (agent-driven
9
- * browsers) can self-serve a session instead of stalling on the login form every time a cookie
10
- * is lost.
12
+ * DEV-ONLY session bootstrap: `GET /dev/login` establishes a session — no credentials involved —
13
+ * so automated dev-loop testing (agent-driven browsers) can self-serve a session instead of
14
+ * stalling on the login form. `?email=<addr>` selects the session's account so parallel
15
+ * verification can fan out over distinct users; absent, the `DEV_AUTO_LOGIN_EMAIL` account is
16
+ * used as before.
11
17
  *
12
18
  * Double-gated, acts only when BOTH hold; otherwise the path answers 404 as if unregistered:
13
19
  * 1. `process.env.DEVELOPMENT` — the dev-server switch, never set in prod images.
14
- * 2. `DEV_AUTO_LOGIN_EMAIL` — explicit per-launch opt-in naming exactly one account.
20
+ * 2. `DEV_AUTO_LOGIN_EMAIL` — explicit per-launch opt-in naming the default account.
21
+ *
22
+ * Domain rail: `?email` must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server must not
23
+ * mint sessions (much less accounts) for arbitrary domains; anything else answers 400.
24
+ *
25
+ * A missing account is created through the normal signup creation path (`Signup.createAccount`)
26
+ * as a normal test user — password `test`, matching the seeded test-account convention, so
27
+ * interactive login works for the same identity. Composes with userCache's missing-account→guest
28
+ * seam: that covers sessions whose account was deleted AFTER minting; this ensures dev-minted
29
+ * sessions reference a real account from the start.
15
30
  */
16
31
  export const devLogin: Route = {
17
32
  path: '/dev/login',
18
33
  method: 'get',
19
34
  onRequest: async (request: any, response): Promise<void> => {
20
- const email = (process.env.DEV_AUTO_LOGIN_EMAIL ?? '').trim();
21
- if (!process.env.DEVELOPMENT || !email) {
35
+ const envEmail = (process.env.DEV_AUTO_LOGIN_EMAIL ?? '').trim();
36
+ if (!process.env.DEVELOPMENT || !envEmail) {
22
37
  response.status(404).send();
23
38
  return;
24
39
  }
25
40
 
41
+ let email = envEmail;
42
+ const emailParam = request.query?.email;
43
+ if (emailParam !== undefined) {
44
+ const requested = typeof emailParam === 'string' ? emailParam.trim() : '';
45
+ if (emailDomain(requested) !== emailDomain(envEmail)) {
46
+ response.status(400).send(`/dev/login only accepts accounts on the @${emailDomain(envEmail)} domain`);
47
+ return;
48
+ }
49
+ email = requested;
50
+ }
51
+ email = email.toLowerCase();
52
+
53
+ const creation = await new Signup().createAccount({
54
+ name: email.slice(0, email.indexOf('@')),
55
+ email,
56
+ password: 'test',
57
+ emailVerified: false, // same shape an inviteless signup produces
58
+ invitedBy: null,
59
+ });
60
+ if (creation === 'created') {
61
+ logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
62
+ }
63
+
26
64
  await new Promise((resolve) => request.login(email, resolve));
65
+ // Explicit save before redirecting: with a DB-backed session store, save-on-response-end
66
+ // races the redirected GET / — the follow-up request can read the session row before the
67
+ // write commits and render the login page (observed: first /dev/login load lands on /login,
68
+ // second succeeds). Awaiting the store write closes the race.
69
+ await new Promise((resolve) => request.session.save(resolve));
27
70
  logger.info({ message: 'Dev auto-login session established', obj: { email } });
28
71
  response.redirect('/');
29
72
  },
@@ -8,6 +8,7 @@ import {
8
8
  InitializeSignupResponse,
9
9
  UserRepo,
10
10
  Invite,
11
+ User,
11
12
  UserSignup,
12
13
  USER_PERMISSIONS,
13
14
  } from '@proteinjs/user';
@@ -96,8 +97,14 @@ export class Signup implements SignupService {
96
97
  const config = defaultEmailConfigFactory.getConfig();
97
98
  const emailSender = new EmailSender();
98
99
 
99
- const userRecord = await db.get(tables.User, { email });
100
- if (userRecord) {
100
+ const creation = await this.createAccount({
101
+ name: user.name,
102
+ email,
103
+ password: user.password,
104
+ emailVerified: invite ? true : false, // because we retrieved the email from the invite record
105
+ invitedBy: invite ? invite.invitedBy : null,
106
+ });
107
+ if (creation === 'exists') {
101
108
  logger.error({ message: `User with this email already exists`, obj: { email } });
102
109
  if (config.getExistingUserEmailContent) {
103
110
  const { text, html } = config.getExistingUserEmailContent();
@@ -112,15 +119,6 @@ export class Signup implements SignupService {
112
119
  return;
113
120
  }
114
121
 
115
- await db.insert(tables.User, {
116
- name: user.name,
117
- email,
118
- password: sha256(user.password).toString(),
119
- emailVerified: invite ? true : false, // because we retrieved the email from the invite record
120
- roles: [],
121
- invitedBy: invite ? invite.invitedBy : null,
122
- });
123
-
124
122
  const { text, html } = config.getNewUserEmailContent();
125
123
  await emailSender.sendEmail({
126
124
  to: email,
@@ -257,6 +255,39 @@ export class Signup implements SignupService {
257
255
  }
258
256
  }
259
257
 
258
+ /**
259
+ * Single owner of account-record creation: case-normalized existence check + sha256-hashed
260
+ * insert. Both doors into a user row go through here — the signup flow (`createUser`, which
261
+ * layers invite validation and confirmation emails on top) and the dev-login bootstrap
262
+ * (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
263
+ * the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
264
+ * methods), so extra class methods stay server-internal.
265
+ */
266
+ async createAccount(account: {
267
+ name: string;
268
+ email: string;
269
+ password: string;
270
+ emailVerified: boolean;
271
+ invitedBy: User['invitedBy'];
272
+ }): Promise<'created' | 'exists'> {
273
+ const db = getDbAsSystem();
274
+ const email = account.email.toLowerCase();
275
+ const existingUser = await db.get(tables.User, { email });
276
+ if (existingUser) {
277
+ return 'exists';
278
+ }
279
+
280
+ await db.insert(tables.User, {
281
+ name: account.name,
282
+ email,
283
+ password: sha256(account.password).toString(),
284
+ emailVerified: account.emailVerified,
285
+ roles: [],
286
+ invitedBy: account.invitedBy,
287
+ });
288
+ return 'created';
289
+ }
290
+
260
291
  /** Resolves an invite token, distinguishing "expired" from "never existed / already revoked". */
261
292
  private async lookupInvite(token: string): Promise<InviteLookup> {
262
293
  const db = getDbAsSystem();
@@ -1,19 +1,36 @@
1
+ import sharp from 'sharp';
2
+ import heicDecode from 'heic-decode';
3
+ import sha256 from 'crypto-js/sha256';
1
4
  import { getDbAsSystem } from '@proteinjs/db';
2
- import { tables, UserRepo, UpdatePasswordResponse, UpdateUserInfoService } from '@proteinjs/user';
5
+ import { FileStorage } from '@proteinjs/db-file';
6
+ import { tables, User, UserRepo, UpdatePasswordResponse, UpdatedUser, UpdateUserInfoService } from '@proteinjs/user';
3
7
  import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
4
- import sha256 from 'crypto-js/sha256';
5
8
 
6
9
  export class UpdateUserInfo implements UpdateUserInfoService {
10
+ /** Stored avatar photos are exactly this: cover-cropped square JPEG. */
11
+ private static readonly AVATAR_SIZE = 512;
12
+ private static readonly AVATAR_JPEG_QUALITY = 85;
13
+ private static readonly MAX_AVATAR_PHOTO_BYTES = 10 * 1024 * 1024;
14
+ private static readonly ACCEPTED_AVATAR_MIME_TYPES = [
15
+ 'image/jpeg',
16
+ 'image/png',
17
+ 'image/webp',
18
+ 'image/heic',
19
+ 'image/heif',
20
+ ];
21
+ /** One emoji (possibly a ZWJ/flag/keycap sequence) — a handful of code units, never a sentence. */
22
+ private static readonly MAX_AVATAR_EMOJI_LENGTH = 16;
23
+ /** Constructed (not a literal) because tsconfig targets es5; runs on Node, which supports `\p`. */
24
+ private static readonly EMOJI_PATTERN = new RegExp('[\\p{Extended_Pictographic}\\p{Regional_Indicator}\\u20E3]', 'u');
25
+
7
26
  public serviceMetadata = {
8
27
  auth: {
9
28
  allUsers: true,
10
29
  },
11
30
  };
12
31
 
13
- async updateName(name: string): Promise<void> {
14
- const db = getDbAsSystem();
15
- const user = new UserRepo().getUser();
16
- await db.update(tables.User, { name }, { id: user.id });
32
+ async updateName(name: string): Promise<UpdatedUser> {
33
+ return await this.saveUserInfo({ name });
17
34
  }
18
35
 
19
36
  async updatePassword(currentPassword: string, newPassword: string): Promise<UpdatePasswordResponse> {
@@ -54,13 +71,7 @@ export class UpdateUserInfo implements UpdateUserInfoService {
54
71
  // hash and store new password
55
72
  try {
56
73
  const hashedNewPassword = sha256(newPassword).toString();
57
- await db.update(
58
- tables.User,
59
- {
60
- password: hashedNewPassword,
61
- },
62
- { id: user.id }
63
- );
74
+ await this.saveUserInfo({ password: hashedNewPassword });
64
75
  } catch (error: any) {
65
76
  return {
66
77
  updated: false,
@@ -72,4 +83,104 @@ export class UpdateUserInfo implements UpdateUserInfoService {
72
83
  updated: true,
73
84
  };
74
85
  }
86
+
87
+ async updateAvatarPhoto(fileData: string, mimeType: string): Promise<UpdatedUser> {
88
+ if (!UpdateUserInfo.ACCEPTED_AVATAR_MIME_TYPES.includes(mimeType)) {
89
+ throw new Error(
90
+ `Unsupported avatar image type '${mimeType}'. Accepted: ${UpdateUserInfo.ACCEPTED_AVATAR_MIME_TYPES.join(', ')}`
91
+ );
92
+ }
93
+
94
+ const imageBytes = Buffer.from(fileData, 'base64');
95
+ if (imageBytes.length > UpdateUserInfo.MAX_AVATAR_PHOTO_BYTES) {
96
+ throw new Error(`Avatar photo is too large (max 10MB)`);
97
+ }
98
+
99
+ const jpeg = await this.toAvatarJpeg(imageBytes, mimeType);
100
+ const file = await new FileStorage().createFile(
101
+ { name: 'avatar.jpg', type: 'image/jpeg', size: jpeg.length },
102
+ jpeg.toString('base64')
103
+ );
104
+ return await this.setAvatar({ avatarEmoji: null, avatarFileId: file.id });
105
+ }
106
+
107
+ async updateAvatarEmoji(emoji: string): Promise<UpdatedUser> {
108
+ const trimmed = emoji.trim();
109
+ if (
110
+ !trimmed ||
111
+ trimmed.length > UpdateUserInfo.MAX_AVATAR_EMOJI_LENGTH ||
112
+ /\s/.test(trimmed) ||
113
+ !UpdateUserInfo.EMOJI_PATTERN.test(trimmed)
114
+ ) {
115
+ throw new Error(`Avatar emoji must be a single short emoji`);
116
+ }
117
+
118
+ return await this.setAvatar({ avatarEmoji: trimmed, avatarFileId: null });
119
+ }
120
+
121
+ async clearAvatar(): Promise<UpdatedUser> {
122
+ return await this.setAvatar({ avatarEmoji: null, avatarFileId: null });
123
+ }
124
+
125
+ /**
126
+ * The single door to the avatar columns — every mutation assigns BOTH, which is what enforces
127
+ * the exactly-one-active invariant (photo OR emoji, never both). Also the previous photo's
128
+ * cleanup point: an avatar file the user row no longer points to is unreachable (the /avatar
129
+ * route serves only the id on the user row), so it is deleted here, after the row moves off it.
130
+ */
131
+ private async setAvatar(avatar: { avatarEmoji: string | null; avatarFileId: string | null }): Promise<UpdatedUser> {
132
+ const userId = new UserRepo().getUser().id;
133
+ const previousAvatarFileId = (await getDbAsSystem().get(tables.User, { id: userId })).avatarFileId;
134
+ const updated = await this.saveUserInfo(avatar);
135
+ if (previousAvatarFileId && previousAvatarFileId !== avatar.avatarFileId) {
136
+ await new FileStorage().deleteFile(previousAvatarFileId);
137
+ }
138
+
139
+ return updated;
140
+ }
141
+
142
+ /**
143
+ * The one path every user-info mutation persists through: writes the changes AND refreshes the
144
+ * server session cache in the same stroke. userCache serializes the user into session data at
145
+ * request start; a bare DB write leaves every read through UserRepo in the same context — and
146
+ * long-lived contexts that keep their session data (sockets) — serving the STALE user until
147
+ * re-login (the rename wart). Fusing persist + refresh here means no future mutation can
148
+ * reintroduce that class of staleness.
149
+ */
150
+ private async saveUserInfo(changes: Partial<User>): Promise<UpdatedUser> {
151
+ const userRepo = new UserRepo();
152
+ const userId = userRepo.getUser().id;
153
+ const db = getDbAsSystem();
154
+ await db.update(tables.User, changes, { id: userId });
155
+ const updated = await db.get(tables.User, { id: userId });
156
+ delete (updated as Partial<User>).password; // the same password-less shape userCache caches
157
+ userRepo.setUser(updated);
158
+ return updated;
159
+ }
160
+
161
+ /**
162
+ * Minimal avatar pipeline: decode, auto-orient from EXIF, cover-crop square, re-encode JPEG.
163
+ * sharp drops metadata (EXIF orientation/GPS) on re-encode by default — the stored avatar
164
+ * carries no camera metadata. HEIC/HEIF (default iPhone camera format) is decoded with WASM
165
+ * libheif (`heic-decode`) because sharp's prebuilt libvips has no HEVC decoder. NOTE: this
166
+ * duplicates the HEIC-normalization approach of chat-server's FileTransformer, which this
167
+ * package cannot import (layering) — future extraction target: a shared image-transform seam.
168
+ */
169
+ private async toAvatarJpeg(imageBytes: Buffer, mimeType: string): Promise<Buffer> {
170
+ const pipeline =
171
+ mimeType === 'image/heic' || mimeType === 'image/heif'
172
+ ? await this.decodeHeic(imageBytes)
173
+ : sharp(imageBytes).rotate();
174
+ return await pipeline
175
+ .resize(UpdateUserInfo.AVATAR_SIZE, UpdateUserInfo.AVATAR_SIZE, { fit: 'cover' })
176
+ .jpeg({ quality: UpdateUserInfo.AVATAR_JPEG_QUALITY })
177
+ .toBuffer();
178
+ }
179
+
180
+ private async decodeHeic(imageBytes: Buffer): Promise<sharp.Sharp> {
181
+ const { width, height, data } = await heicDecode({ buffer: imageBytes });
182
+ return sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
183
+ raw: { width, height, channels: 4 },
184
+ });
185
+ }
75
186
  }
@@ -0,0 +1,159 @@
1
+ import sha256 from 'crypto-js/sha256';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { tables } from '@proteinjs/user';
4
+ import { devLogin } from '../src/routes/devLogin';
5
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
6
+
7
+ const testEnv = new UserServerTestEnvironment();
8
+
9
+ /**
10
+ * `GET /dev/login` multi-user dev bootstrap. The route mints a session for the account named by
11
+ * `?email=` (absent → `DEV_AUTO_LOGIN_EMAIL`), auto-creating a missing account through the normal
12
+ * signup creation path (`Signup.createAccount`) so parallel agent-driven verification can
13
+ * self-serve distinct users. Two rails under test:
14
+ * - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
15
+ * - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
16
+ * must not mint sessions (much less accounts) for arbitrary domains. Others 400.
17
+ */
18
+
19
+ const ENV_EMAIL = 'dev@test.local';
20
+
21
+ type RouteOutcome = {
22
+ loggedInAs?: string;
23
+ sessionSaved: boolean;
24
+ status?: number;
25
+ body?: unknown;
26
+ redirect?: string;
27
+ };
28
+
29
+ const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOutcome> => {
30
+ const outcome: RouteOutcome = { sessionSaved: false };
31
+ const request = {
32
+ query,
33
+ login: (email: string, done: () => void) => {
34
+ outcome.loggedInAs = email;
35
+ done();
36
+ },
37
+ session: {
38
+ save: (done: () => void) => {
39
+ outcome.sessionSaved = true;
40
+ done();
41
+ },
42
+ },
43
+ };
44
+ const response = {
45
+ status(code: number) {
46
+ outcome.status = code;
47
+ return this;
48
+ },
49
+ send(body?: unknown) {
50
+ outcome.body = body;
51
+ },
52
+ redirect(path: string) {
53
+ outcome.redirect = path;
54
+ },
55
+ };
56
+ await devLogin.onRequest(request as never, response as never);
57
+ return outcome;
58
+ };
59
+
60
+ const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
61
+
62
+ describe('devLogin route', () => {
63
+ const originalEnv = {
64
+ DEVELOPMENT: process.env.DEVELOPMENT,
65
+ DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
66
+ };
67
+
68
+ beforeAll(async () => {
69
+ await testEnv.beforeAll();
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await testEnv.afterAll();
74
+ });
75
+
76
+ beforeEach(() => {
77
+ process.env.DEVELOPMENT = 'true';
78
+ process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
79
+ });
80
+
81
+ afterEach(() => {
82
+ for (const [key, value] of Object.entries(originalEnv)) {
83
+ if (value === undefined) {
84
+ delete process.env[key];
85
+ } else {
86
+ process.env[key] = value;
87
+ }
88
+ }
89
+ });
90
+
91
+ it('pins the default path: no ?email → session for DEV_AUTO_LOGIN_EMAIL, redirect to /', async () => {
92
+ await testEnv.createUser({ name: 'Dev Default', email: ENV_EMAIL });
93
+
94
+ const outcome = await invokeDevLogin();
95
+
96
+ expect(outcome.loggedInAs).toBe(ENV_EMAIL);
97
+ expect(outcome.sessionSaved).toBe(true);
98
+ expect(outcome.redirect).toBe('/');
99
+ expect(outcome.status).toBeUndefined();
100
+ });
101
+
102
+ it('?email selects an existing same-domain account for the session', async () => {
103
+ await testEnv.createUser({ name: 'Agent Two', email: 'agent2@test.local' });
104
+
105
+ const outcome = await invokeDevLogin({ email: 'agent2@test.local' });
106
+
107
+ expect(outcome.loggedInAs).toBe('agent2@test.local');
108
+ expect(outcome.sessionSaved).toBe(true);
109
+ expect(outcome.redirect).toBe('/');
110
+ });
111
+
112
+ it('?email for a nonexistent same-domain account creates it as a normal test user, then logs in', async () => {
113
+ expect(await getUserRow('agent3@test.local')).toBeUndefined();
114
+
115
+ const outcome = await invokeDevLogin({ email: 'agent3@test.local' });
116
+
117
+ expect(outcome.loggedInAs).toBe('agent3@test.local');
118
+ expect(outcome.sessionSaved).toBe(true);
119
+ expect(outcome.redirect).toBe('/');
120
+
121
+ // Created through the normal signup creation path: sha256-hashed 'test' password (the seeded
122
+ // test-user convention — interactive login with password 'test' works too), no roles.
123
+ const created = await getUserRow('agent3@test.local');
124
+ expect(created).toBeDefined();
125
+ expect(created!.password).toBe(sha256('test').toString());
126
+ expect(created!.roles).toEqual([]);
127
+ expect(created!.name).toBeTruthy();
128
+ });
129
+
130
+ it('rejects a ?email outside the DEV_AUTO_LOGIN_EMAIL domain with 400 — no session, no account', async () => {
131
+ const outcome = await invokeDevLogin({ email: 'intruder@evil.example' });
132
+
133
+ expect(outcome.status).toBe(400);
134
+ expect(outcome.loggedInAs).toBeUndefined();
135
+ expect(outcome.sessionSaved).toBe(false);
136
+ expect(outcome.redirect).toBeUndefined();
137
+ expect(await getUserRow('intruder@evil.example')).toBeUndefined();
138
+ });
139
+
140
+ it('404s when DEVELOPMENT is unset, even with a ?email', async () => {
141
+ delete process.env.DEVELOPMENT;
142
+
143
+ const outcome = await invokeDevLogin({ email: 'agent4@test.local' });
144
+
145
+ expect(outcome.status).toBe(404);
146
+ expect(outcome.loggedInAs).toBeUndefined();
147
+ expect(await getUserRow('agent4@test.local')).toBeUndefined();
148
+ });
149
+
150
+ it('404s when DEV_AUTO_LOGIN_EMAIL is unset, even with a ?email', async () => {
151
+ delete process.env.DEV_AUTO_LOGIN_EMAIL;
152
+
153
+ const outcome = await invokeDevLogin({ email: 'agent5@test.local' });
154
+
155
+ expect(outcome.status).toBe(404);
156
+ expect(outcome.loggedInAs).toBeUndefined();
157
+ expect(await getUserRow('agent5@test.local')).toBeUndefined();
158
+ });
159
+ });
@@ -0,0 +1,174 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import sharp from 'sharp';
4
+ import { getDbAsSystem } from '@proteinjs/db';
5
+ import { FileStorage, File, tables as fileTables } from '@proteinjs/db-file';
6
+ import { tables, UserRepo, getScopedDbAsSystem } from '@proteinjs/user';
7
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
8
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
9
+
10
+ const testEnv = new UserServerTestEnvironment();
11
+
12
+ /** JPEG magic bytes (SOI + marker prefix). */
13
+ const isJpeg = (buffer: Buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
14
+
15
+ const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
16
+
17
+ /**
18
+ * Avatars are photo OR emoji — exactly one active — stored as 512x512 JPEG, previous photo file
19
+ * deleted on every change, and the server session cache refreshed in the same stroke (the same
20
+ * helper that fixes the updateName rename-staleness wart). All assertions are outcomes: rows,
21
+ * bytes, cache reads.
22
+ */
23
+ describe('UpdateUserInfo avatar mutations', () => {
24
+ const service = new UpdateUserInfo();
25
+
26
+ beforeAll(async () => {
27
+ await testEnv.beforeAll();
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await testEnv.afterAll();
32
+ });
33
+
34
+ it('updateAvatarPhoto stores a 512x512 JPEG file and points the user row at it', async () => {
35
+ const user = await testEnv.createUser({ name: 'Photo user', email: 'photo@test.local' });
36
+ testEnv.actAs(user);
37
+ const png = await sharp({
38
+ create: { width: 800, height: 600, channels: 3, background: { r: 200, g: 80, b: 40 } },
39
+ })
40
+ .png()
41
+ .toBuffer();
42
+
43
+ const updated = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
44
+
45
+ // Returned user: photo active, emoji null, no password hash.
46
+ expect(updated.avatarFileId).toBeTruthy();
47
+ expect(updated.avatarEmoji).toBeFalsy();
48
+ expect((updated as any).password).toBeUndefined();
49
+
50
+ // User row persisted with the exactly-one invariant.
51
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
52
+ expect(row.avatarFileId).toBe(updated.avatarFileId);
53
+ expect(row.avatarEmoji).toBeFalsy();
54
+
55
+ // File row + bytes written: a real 512x512 JPEG.
56
+ const file = await getFileRow(row.avatarFileId!);
57
+ expect(file).toBeTruthy();
58
+ expect(file.type).toBe('image/jpeg');
59
+ const bytes = Buffer.from(await new FileStorage().getFileData(file.id), 'base64');
60
+ expect(isJpeg(bytes)).toBe(true);
61
+ expect(file.size).toBe(bytes.length);
62
+ const metadata = await sharp(bytes).metadata();
63
+ expect(metadata.width).toBe(512);
64
+ expect(metadata.height).toBe(512);
65
+
66
+ // Session cache refreshed in the same stroke.
67
+ expect(new UserRepo().getUser().avatarFileId).toBe(row.avatarFileId);
68
+ });
69
+
70
+ it('a second updateAvatarPhoto deletes the previous avatar file', async () => {
71
+ const user = await testEnv.createUser({ name: 'Replacer', email: 'replace@test.local' });
72
+ testEnv.actAs(user);
73
+ const png = await sharp({ create: { width: 300, height: 300, channels: 3, background: { r: 0, g: 120, b: 200 } } })
74
+ .png()
75
+ .toBuffer();
76
+
77
+ const first = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
78
+ const second = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
79
+ expect(second.avatarFileId).not.toBe(first.avatarFileId);
80
+
81
+ // The prior file is gone — row and bytes.
82
+ expect(await getFileRow(first.avatarFileId!)).toBeFalsy();
83
+ expect(await new FileStorage().getFileData(first.avatarFileId!)).toBe('');
84
+ // The new one is intact.
85
+ expect(await getFileRow(second.avatarFileId!)).toBeTruthy();
86
+ });
87
+
88
+ it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG', async () => {
89
+ const user = await testEnv.createUser({ name: 'iPhone user', email: 'heic@test.local' });
90
+ testEnv.actAs(user);
91
+ const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
92
+
93
+ const updated = await service.updateAvatarPhoto(heic.toString('base64'), 'image/heic');
94
+
95
+ const bytes = Buffer.from(await new FileStorage().getFileData(updated.avatarFileId!), 'base64');
96
+ expect(isJpeg(bytes)).toBe(true);
97
+ const metadata = await sharp(bytes).metadata();
98
+ expect(metadata.width).toBe(512);
99
+ expect(metadata.height).toBe(512);
100
+ });
101
+
102
+ it('updateAvatarEmoji sets the emoji, nulls the photo, and deletes the previous photo file', async () => {
103
+ const user = await testEnv.createUser({ name: 'Emoji user', email: 'emoji@test.local' });
104
+ testEnv.actAs(user);
105
+ const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 9, g: 9, b: 9 } } })
106
+ .png()
107
+ .toBuffer();
108
+ const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
109
+
110
+ const updated = await service.updateAvatarEmoji('🦞');
111
+
112
+ // Exactly-one invariant, in the row and the returned user.
113
+ expect(updated.avatarEmoji).toBe('🦞');
114
+ expect(updated.avatarFileId).toBeFalsy();
115
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
116
+ expect(row.avatarEmoji).toBe('🦞');
117
+ expect(row.avatarFileId).toBeFalsy();
118
+
119
+ // Previous photo file deleted; session cache refreshed.
120
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
121
+ expect(new UserRepo().getUser().avatarEmoji).toBe('🦞');
122
+ });
123
+
124
+ it('accepts multi-codepoint emoji (ZWJ sequences)', async () => {
125
+ const user = await testEnv.createUser({ name: 'Astronaut', email: 'zwj@test.local' });
126
+ testEnv.actAs(user);
127
+ const updated = await service.updateAvatarEmoji('👩‍🚀');
128
+ expect(updated.avatarEmoji).toBe('👩‍🚀');
129
+ });
130
+
131
+ it('clearAvatar removes both representations and the stored photo file', async () => {
132
+ const user = await testEnv.createUser({ name: 'Clearer', email: 'clear@test.local' });
133
+ testEnv.actAs(user);
134
+ const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 1, g: 2, b: 3 } } })
135
+ .png()
136
+ .toBuffer();
137
+ const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
138
+
139
+ const updated = await service.clearAvatar();
140
+
141
+ expect(updated.avatarEmoji).toBeFalsy();
142
+ expect(updated.avatarFileId).toBeFalsy();
143
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
144
+ expect(row.avatarEmoji).toBeFalsy();
145
+ expect(row.avatarFileId).toBeFalsy();
146
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
147
+ expect(new UserRepo().getUser().avatarFileId).toBeFalsy();
148
+ });
149
+
150
+ it('rejects oversized photos before transforming, and unsupported types', async () => {
151
+ const user = await testEnv.createUser({ name: 'Rejected', email: 'reject@test.local' });
152
+ testEnv.actAs(user);
153
+
154
+ const oversized = Buffer.alloc(10 * 1024 * 1024 + 1);
155
+ await expect(service.updateAvatarPhoto(oversized.toString('base64'), 'image/png')).rejects.toThrow(/too large/);
156
+ await expect(service.updateAvatarPhoto(Buffer.alloc(10).toString('base64'), 'image/gif')).rejects.toThrow(
157
+ /Unsupported avatar image type/
158
+ );
159
+
160
+ // Neither rejection touched the user row.
161
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
162
+ expect(row.avatarFileId).toBeFalsy();
163
+ expect(row.avatarEmoji).toBeFalsy();
164
+ });
165
+
166
+ it('rejects non-emoji strings as avatar emoji', async () => {
167
+ const user = await testEnv.createUser({ name: 'Wordy', email: 'wordy@test.local' });
168
+ testEnv.actAs(user);
169
+
170
+ await expect(service.updateAvatarEmoji('hello')).rejects.toThrow(/single short emoji/);
171
+ await expect(service.updateAvatarEmoji('')).rejects.toThrow(/single short emoji/);
172
+ await expect(service.updateAvatarEmoji('🦞🦞🦞🦞🦞🦞🦞🦞🦞')).rejects.toThrow(/single short emoji/);
173
+ });
174
+ });