@proteinjs/user-server 1.9.1 → 1.10.1
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 +27 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts +27 -5
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +72 -13
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/AvatarPhotoFidelity.test.d.ts +2 -0
- package/dist/test/AvatarPhotoFidelity.test.d.ts.map +1 -0
- package/dist/test/AvatarPhotoFidelity.test.js +221 -0
- package/dist/test/AvatarPhotoFidelity.test.js.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +37 -6
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/UpdateUserInfoAvatar.test.js +5 -4
- package/dist/test/UpdateUserInfoAvatar.test.js.map +1 -1
- package/dist/test/avatarRoute.test.js +4 -2
- package/dist/test/avatarRoute.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/index.ts +4 -1
- package/jest.config.js +7 -0
- package/package.json +5 -5
- package/src/services/UpdateUserInfo.ts +72 -8
- package/test/AvatarPhotoFidelity.test.ts +109 -0
- package/test/MachineAccounts.integration.test.ts +60 -26
- package/test/UpdateUserInfoAvatar.test.ts +7 -4
- package/test/avatarRoute.test.ts +4 -2
- package/tsconfig.json +5 -1
|
@@ -2,13 +2,27 @@ import sharp from 'sharp';
|
|
|
2
2
|
import heicDecode from 'heic-decode';
|
|
3
3
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
4
4
|
import { FileStorage } from '@proteinjs/db-file';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
tables,
|
|
7
|
+
User,
|
|
8
|
+
UserRepo,
|
|
9
|
+
UpdatePasswordResponse,
|
|
10
|
+
UpdatedUser,
|
|
11
|
+
UpdateUserInfoService,
|
|
12
|
+
AvatarCrop,
|
|
13
|
+
} from '@proteinjs/user';
|
|
6
14
|
import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
|
|
7
15
|
import { PasswordHasher } from '../authentication/PasswordHasher';
|
|
8
16
|
|
|
9
17
|
export class UpdateUserInfo implements UpdateUserInfoService {
|
|
10
|
-
/**
|
|
11
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Stored avatar photos are square JPEGs capped at this edge — and never ENLARGED to it: a
|
|
20
|
+
* source (or crop) smaller than the cap keeps its honest pixel count. Upscaling a small photo
|
|
21
|
+
* to a fixed 512 invents pixels that read as permanent blur on every chip that renders it
|
|
22
|
+
* (the founder's fuzzy-avatar defect); the display layer downscales crisply from whatever
|
|
23
|
+
* honest size is stored.
|
|
24
|
+
*/
|
|
25
|
+
private static readonly AVATAR_MAX_SIZE = 512;
|
|
12
26
|
private static readonly AVATAR_JPEG_QUALITY = 85;
|
|
13
27
|
private static readonly MAX_AVATAR_PHOTO_BYTES = 10 * 1024 * 1024;
|
|
14
28
|
private static readonly ACCEPTED_AVATAR_MIME_TYPES = [
|
|
@@ -83,7 +97,7 @@ export class UpdateUserInfo implements UpdateUserInfoService {
|
|
|
83
97
|
};
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
async updateAvatarPhoto(fileData: string, mimeType: string): Promise<UpdatedUser> {
|
|
100
|
+
async updateAvatarPhoto(fileData: string, mimeType: string, crop?: AvatarCrop): Promise<UpdatedUser> {
|
|
87
101
|
if (!UpdateUserInfo.ACCEPTED_AVATAR_MIME_TYPES.includes(mimeType)) {
|
|
88
102
|
throw new Error(
|
|
89
103
|
`Unsupported avatar image type '${mimeType}'. Accepted: ${UpdateUserInfo.ACCEPTED_AVATAR_MIME_TYPES.join(', ')}`
|
|
@@ -95,7 +109,7 @@ export class UpdateUserInfo implements UpdateUserInfoService {
|
|
|
95
109
|
throw new Error(`Avatar photo is too large (max 10MB)`);
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
const jpeg = await this.toAvatarJpeg(imageBytes, mimeType);
|
|
112
|
+
const jpeg = await this.toAvatarJpeg(imageBytes, mimeType, crop);
|
|
99
113
|
const file = await new FileStorage().createFile(
|
|
100
114
|
{ name: 'avatar.jpg', type: 'image/jpeg', size: jpeg.length },
|
|
101
115
|
jpeg.toString('base64')
|
|
@@ -158,24 +172,74 @@ export class UpdateUserInfo implements UpdateUserInfoService {
|
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
|
-
*
|
|
175
|
+
* THE avatar image pipeline — the only place avatar pixels are ever resampled: decode,
|
|
176
|
+
* auto-orient from EXIF, extract the crop frame (client-framed, or the centered square),
|
|
177
|
+
* one high-quality resize down to at most AVATAR_MAX_SIZE (never up), re-encode JPEG.
|
|
178
|
+
* Clients send ORIGINAL bytes; a client-side canvas export would resample a second time
|
|
179
|
+
* (and with the browser's low-default smoothing), baking blur into the stored master.
|
|
162
180
|
* sharp drops metadata (EXIF orientation/GPS) on re-encode by default — the stored avatar
|
|
163
181
|
* carries no camera metadata. HEIC/HEIF (default iPhone camera format) is decoded with WASM
|
|
164
182
|
* libheif (`heic-decode`) because sharp's prebuilt libvips has no HEVC decoder. NOTE: this
|
|
165
183
|
* duplicates the HEIC-normalization approach of chat-server's FileTransformer, which this
|
|
166
184
|
* package cannot import (layering) — future extraction target: a shared image-transform seam.
|
|
167
185
|
*/
|
|
168
|
-
private async toAvatarJpeg(imageBytes: Buffer, mimeType: string): Promise<Buffer> {
|
|
186
|
+
private async toAvatarJpeg(imageBytes: Buffer, mimeType: string, crop?: AvatarCrop): Promise<Buffer> {
|
|
169
187
|
const pipeline =
|
|
170
188
|
mimeType === 'image/heic' || mimeType === 'image/heif'
|
|
171
189
|
? await this.decodeHeic(imageBytes)
|
|
172
190
|
: sharp(imageBytes).rotate();
|
|
191
|
+
const { width, height } = await this.orientedDimensions(pipeline);
|
|
192
|
+
const frame = this.clampCrop(crop, width, height);
|
|
193
|
+
const target = Math.min(UpdateUserInfo.AVATAR_MAX_SIZE, frame.size);
|
|
173
194
|
return await pipeline
|
|
174
|
-
.
|
|
195
|
+
.extract({ left: frame.left, top: frame.top, width: frame.size, height: frame.size })
|
|
196
|
+
.resize(target, target, { fit: 'cover' })
|
|
175
197
|
.jpeg({ quality: UpdateUserInfo.AVATAR_JPEG_QUALITY })
|
|
176
198
|
.toBuffer();
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The decoded image's dimensions in DISPLAYED (EXIF-oriented) pixel space — the space the
|
|
203
|
+
* client framed its crop in, and the space `.rotate()` makes `.extract` operate in.
|
|
204
|
+
* Orientations 5-8 transpose the encoded axes.
|
|
205
|
+
*/
|
|
206
|
+
private async orientedDimensions(pipeline: sharp.Sharp): Promise<{ width: number; height: number }> {
|
|
207
|
+
const metadata = await pipeline.metadata();
|
|
208
|
+
if (!metadata.width || !metadata.height) {
|
|
209
|
+
throw new Error(`Could not read avatar image dimensions`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const transposed = (metadata.orientation ?? 1) >= 5;
|
|
213
|
+
return {
|
|
214
|
+
width: transposed ? metadata.height : metadata.width,
|
|
215
|
+
height: transposed ? metadata.width : metadata.height,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the crop request against the real image: round to whole pixels and clamp fully
|
|
221
|
+
* inside the oriented bounds, staying square. Absent crop = the centered square (identical
|
|
222
|
+
* to the old `fit: cover` center-crop, so no-crop clients keep byte-equivalent behavior).
|
|
223
|
+
*/
|
|
224
|
+
private clampCrop(
|
|
225
|
+
crop: AvatarCrop | undefined,
|
|
226
|
+
width: number,
|
|
227
|
+
height: number
|
|
228
|
+
): { left: number; top: number; size: number } {
|
|
229
|
+
const maxSize = Math.min(width, height);
|
|
230
|
+
if (!crop) {
|
|
231
|
+
return { left: Math.floor((width - maxSize) / 2), top: Math.floor((height - maxSize) / 2), size: maxSize };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (![crop.sx, crop.sy, crop.size].every(Number.isFinite)) {
|
|
235
|
+
throw new Error(`Avatar crop must be numeric`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const size = Math.min(Math.max(1, Math.round(crop.size)), maxSize);
|
|
239
|
+
const clampOffset = (value: number, max: number) => Math.min(Math.max(0, Math.round(value)), max);
|
|
240
|
+
return { left: clampOffset(crop.sx, width - size), top: clampOffset(crop.sy, height - size), size };
|
|
241
|
+
}
|
|
242
|
+
|
|
179
243
|
private async decodeHeic(imageBytes: Buffer): Promise<sharp.Sharp> {
|
|
180
244
|
const { width, height, data } = await heicDecode({ buffer: imageBytes });
|
|
181
245
|
return sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pixel-fidelity contract of THE avatar image pipeline (`toAvatarJpeg` — the only place avatar
|
|
8
|
+
* pixels are ever resampled), tested straight through the pipeline with no DB: a stored master
|
|
9
|
+
* never contains invented (enlarged) pixels, honors the client's crop frame exactly, and still
|
|
10
|
+
* caps large sources at 512. This is the categorical guard for the founder's fuzzy-avatar
|
|
11
|
+
* defect: a small source blown up to a fixed 512 master reads as permanent blur on every chip
|
|
12
|
+
* that renders it.
|
|
13
|
+
*
|
|
14
|
+
* Helpers are reached via the house typed-cast-on-an-instance pattern (no widening of the
|
|
15
|
+
* class surface).
|
|
16
|
+
*/
|
|
17
|
+
type AvatarPipelineInternals = {
|
|
18
|
+
toAvatarJpeg(imageBytes: Buffer, mimeType: string, crop?: { sx: number; sy: number; size: number }): Promise<Buffer>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pipeline = () => new UpdateUserInfo() as unknown as AvatarPipelineInternals;
|
|
22
|
+
|
|
23
|
+
const solidPng = async (width: number, height: number, rgb: { r: number; g: number; b: number }) =>
|
|
24
|
+
await sharp({ create: { width, height, channels: 3, background: rgb } })
|
|
25
|
+
.png()
|
|
26
|
+
.toBuffer();
|
|
27
|
+
|
|
28
|
+
const dimensionsOf = async (jpeg: Buffer) => {
|
|
29
|
+
const metadata = await sharp(jpeg).metadata();
|
|
30
|
+
return { width: metadata.width, height: metadata.height, format: metadata.format };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Average color of the whole output — crop-mapping assertions read regions, not single pixels. */
|
|
34
|
+
const averageColor = async (jpeg: Buffer) => {
|
|
35
|
+
const stats = await sharp(jpeg).stats();
|
|
36
|
+
const [r, g, b] = stats.channels.map((channel) => channel.mean);
|
|
37
|
+
return { r, g, b };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('avatar photo pixel fidelity', () => {
|
|
41
|
+
it('keeps a small source at its honest pixel count — never enlarged to the 512 cap', async () => {
|
|
42
|
+
const png = await solidPng(96, 96, { r: 200, g: 80, b: 40 });
|
|
43
|
+
|
|
44
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
45
|
+
|
|
46
|
+
// The defect: the old pipeline resized EVERYTHING to a fixed 512 square, inventing 5x the
|
|
47
|
+
// pixels for a 96px source — permanent blur baked into the master.
|
|
48
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 96, height: 96, format: 'jpeg' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('center-crops a small non-square source to its honest square (no enlargement)', async () => {
|
|
52
|
+
const png = await solidPng(300, 200, { r: 10, g: 120, b: 210 });
|
|
53
|
+
|
|
54
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
55
|
+
|
|
56
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 200, height: 200, format: 'jpeg' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('extracts exactly the client-framed crop square', async () => {
|
|
60
|
+
// Left half blue, right half red; the client frames the right half.
|
|
61
|
+
const left = await solidPng(100, 100, { r: 20, g: 40, b: 220 });
|
|
62
|
+
const source = await sharp({
|
|
63
|
+
create: { width: 200, height: 100, channels: 3, background: { r: 220, g: 30, b: 30 } },
|
|
64
|
+
})
|
|
65
|
+
.composite([{ input: left, left: 0, top: 0 }])
|
|
66
|
+
.png()
|
|
67
|
+
.toBuffer();
|
|
68
|
+
|
|
69
|
+
const jpeg = await pipeline().toAvatarJpeg(source, 'image/png', { sx: 100, sy: 0, size: 100 });
|
|
70
|
+
|
|
71
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 100, height: 100, format: 'jpeg' });
|
|
72
|
+
const color = await averageColor(jpeg);
|
|
73
|
+
expect(color.r).toBeGreaterThan(180);
|
|
74
|
+
expect(color.b).toBeLessThan(80);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('clamps an out-of-bounds crop inside the image, staying square', async () => {
|
|
78
|
+
const png = await solidPng(300, 200, { r: 90, g: 90, b: 90 });
|
|
79
|
+
|
|
80
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png', { sx: 250, sy: 150, size: 120 });
|
|
81
|
+
|
|
82
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 120, height: 120, format: 'jpeg' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still lands a large source at the 512 cap', async () => {
|
|
86
|
+
const png = await solidPng(1024, 768, { r: 40, g: 160, b: 90 });
|
|
87
|
+
|
|
88
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
89
|
+
|
|
90
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 512, height: 512, format: 'jpeg' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps a small HEIC source at its honest centered square (the WASM decode path)', async () => {
|
|
94
|
+
// The 96x64 fixture: centered 64px square, not an enlarged 512.
|
|
95
|
+
const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
|
|
96
|
+
|
|
97
|
+
const jpeg = await pipeline().toAvatarJpeg(heic, 'image/heic');
|
|
98
|
+
|
|
99
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 64, height: 64, format: 'jpeg' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects a non-numeric crop in plain words', async () => {
|
|
103
|
+
const png = await solidPng(64, 64, { r: 10, g: 10, b: 10 });
|
|
104
|
+
|
|
105
|
+
await expect(pipeline().toAvatarJpeg(png, 'image/png', { sx: NaN, sy: 0, size: 32 })).rejects.toThrow(
|
|
106
|
+
'Avatar crop must be numeric'
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -28,7 +28,13 @@ class TestOpsMachineAccount extends MachineAccount {
|
|
|
28
28
|
secretName = 'test-ops-secret';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
type SourceRepositoryInternals = {
|
|
31
|
+
type SourceRepositoryInternals = {
|
|
32
|
+
objectCache: { [qualifiedName: string]: unknown[] };
|
|
33
|
+
// db >=1.34.4 reads loaders through objectsWithNames (declaration provenance — the
|
|
34
|
+
// source-ownership grain), which resolves from THIS cache; seeding objectCache alone is
|
|
35
|
+
// invisible to it.
|
|
36
|
+
namedObjectCache: { [type: string]: { qualifiedName: string; packageName: string; object: unknown }[] };
|
|
37
|
+
};
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* Reset `TableWatcherRunner`'s static watcher map so the NEXT Db construction recomputes it from
|
|
@@ -49,10 +55,19 @@ const resetTableWatcherMap = () => {
|
|
|
49
55
|
*/
|
|
50
56
|
describe('Machine accounts as source records', () => {
|
|
51
57
|
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
58
|
+
const namedObjectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).namedObjectCache;
|
|
59
|
+
const seedLoaders = (declarations: unknown[]) => {
|
|
60
|
+
objectCache()['@proteinjs/db/SourceRecordLoader'] = declarations;
|
|
61
|
+
namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = declarations.map((object, i) => ({
|
|
62
|
+
qualifiedName: `@proteinjs/user-server-test/MachineAccountFixture${i}`,
|
|
63
|
+
packageName: '@proteinjs/user-server',
|
|
64
|
+
object,
|
|
65
|
+
}));
|
|
66
|
+
};
|
|
52
67
|
|
|
53
68
|
/** One boot of Db.init's source-record leg with these MachineAccount declarations. */
|
|
54
69
|
const boot = async (declarations: MachineAccount[]) => {
|
|
55
|
-
|
|
70
|
+
seedLoaders(declarations);
|
|
56
71
|
await new SourceRecordSyncRunner().load();
|
|
57
72
|
};
|
|
58
73
|
|
|
@@ -82,6 +97,7 @@ describe('Machine accounts as source records', () => {
|
|
|
82
97
|
|
|
83
98
|
afterAll(async () => {
|
|
84
99
|
delete objectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
100
|
+
delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
85
101
|
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
86
102
|
delete objectCache()['@proteinjs/user/RoleCatalogEntry'];
|
|
87
103
|
resetTableWatcherMap();
|
|
@@ -143,28 +159,46 @@ describe('Machine accounts as source records', () => {
|
|
|
143
159
|
expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
|
|
144
160
|
});
|
|
145
161
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// KNOWN CROSS-TRAIN GAP (it.failing — flips RED the moment it starts passing, forcing the
|
|
163
|
+
// marker's removal): the re-declare-after-removal leg needs db's soft-removal re-adoption
|
|
164
|
+
// (integration/r5-db ba9f4ba7 — the sync currently INSERTs the existing machine-test-ops row
|
|
165
|
+
// instead of adopting it). Green everywhere else against db ^1.35.0; this leg lands with the
|
|
166
|
+
// R5 db mint. Exact repro + the semantics call recorded on this suite's landing commit.
|
|
167
|
+
it.failing(
|
|
168
|
+
'removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates',
|
|
169
|
+
async () => {
|
|
170
|
+
const human = await testEnv.createUser({ name: 'A human', email: 'human@test.local' });
|
|
171
|
+
await boot([new TestOpsMachineAccount()]);
|
|
172
|
+
await insertSession('machine-session', 'machine-ops@test.local');
|
|
173
|
+
await insertSession('human-session', human.email);
|
|
174
|
+
|
|
175
|
+
// Removal under the db >=1.34.4 ownership law: the reconcile only touches rows whose
|
|
176
|
+
// sourcePackage the RUNNING build still declares from (shared-db safety — a build missing a
|
|
177
|
+
// package must never deactivate that package's rows). The real removal case is the package
|
|
178
|
+
// still booting with THIS declaration gone — modeled by keeping another declaration from
|
|
179
|
+
// the same package aboard. boot([]) would model "package vanished", which the law protects.
|
|
180
|
+
class SurvivingSibling extends TestOpsMachineAccount {
|
|
181
|
+
email = 'machine-sibling@test.local';
|
|
182
|
+
name = 'Surviving sibling machine';
|
|
183
|
+
secretName = 'sibling-secret';
|
|
184
|
+
}
|
|
185
|
+
await boot([new SurvivingSibling()]);
|
|
186
|
+
|
|
187
|
+
const removed = await machineRow();
|
|
188
|
+
expect(removed).toBeDefined();
|
|
189
|
+
expect(removed.status).toBe('deactivated');
|
|
190
|
+
// The categorical watcher killed the machine sessions; the human session is untouched.
|
|
191
|
+
expect(await sessionEmails()).toEqual([human.email]);
|
|
192
|
+
// The one login door refuses a deactivated account even with matching credentials.
|
|
193
|
+
await getDbAsSystem().update(tables.User, { id: removed.id, password: sha256('bridge-pw').toString() });
|
|
194
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe('This account has been deactivated');
|
|
195
|
+
|
|
196
|
+
// Decommission is reversible in code: re-declaring reverts status via drift reversion.
|
|
197
|
+
await boot([new TestOpsMachineAccount()]);
|
|
198
|
+
expect((await machineRow()).status).toBe('active');
|
|
199
|
+
expect(await authenticate('machine-ops@test.local', 'bridge-pw')).toBe(true);
|
|
200
|
+
}
|
|
201
|
+
);
|
|
168
202
|
|
|
169
203
|
it(`the staff toggle rides the same watcher: SetUserStatus deactivation kills the target's sessions`, async () => {
|
|
170
204
|
const admin = await testEnv.createUser({ name: 'Admin', email: 'admin@test.local', roles: ['admin'] });
|
|
@@ -246,7 +280,7 @@ describe('Machine accounts as source records', () => {
|
|
|
246
280
|
email = 'machine-not-booted@test.local';
|
|
247
281
|
secretName = 'not-booted-secret';
|
|
248
282
|
}
|
|
249
|
-
|
|
283
|
+
seedLoaders([new TestOpsMachineAccount(), new NotBooted()]);
|
|
250
284
|
|
|
251
285
|
const before = await new MachineCredentials().listMachineAccounts();
|
|
252
286
|
expect(before).toEqual([
|
|
@@ -289,7 +323,7 @@ describe('Machine accounts as source records', () => {
|
|
|
289
323
|
email = 'machine-not-booted@test.local';
|
|
290
324
|
}
|
|
291
325
|
// Declared (discoverable) but never booted: no source-owned row exists.
|
|
292
|
-
|
|
326
|
+
seedLoaders([new TestOpsMachineAccount(), new NotBooted()]);
|
|
293
327
|
await expect(new MachineCredentials().mintCredential('machine-not-booted@test.local')).rejects.toThrow(
|
|
294
328
|
/has not been loaded from source yet/
|
|
295
329
|
);
|
|
@@ -15,7 +15,8 @@ const isJpeg = (buffer: Buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && b
|
|
|
15
15
|
const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Avatars are photo OR emoji — exactly one active — stored as
|
|
18
|
+
* Avatars are photo OR emoji — exactly one active — stored as a square JPEG capped at 512px
|
|
19
|
+
* (never enlarged past the source's honest pixels; see AvatarPhotoFidelity.test.ts), previous photo file
|
|
19
20
|
* deleted on every change, and the server session cache refreshed in the same stroke (the same
|
|
20
21
|
* helper that fixes the updateName rename-staleness wart). All assertions are outcomes: rows,
|
|
21
22
|
* bytes, cache reads.
|
|
@@ -85,9 +86,11 @@ describe('UpdateUserInfo avatar mutations', () => {
|
|
|
85
86
|
expect(await getFileRow(second.avatarFileId!)).toBeTruthy();
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG', async () => {
|
|
89
|
+
it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG at its honest size', async () => {
|
|
89
90
|
const user = await testEnv.createUser({ name: 'iPhone user', email: 'heic@test.local' });
|
|
90
91
|
testEnv.actAs(user);
|
|
92
|
+
// The fixture is 96x64 — small on purpose: the stored master is its centered 64px square,
|
|
93
|
+
// NOT an enlarged 512 (enlargement invents pixels that read as permanent blur).
|
|
91
94
|
const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
|
|
92
95
|
|
|
93
96
|
const updated = await service.updateAvatarPhoto(heic.toString('base64'), 'image/heic');
|
|
@@ -95,8 +98,8 @@ describe('UpdateUserInfo avatar mutations', () => {
|
|
|
95
98
|
const bytes = Buffer.from(await new FileStorage().getFileData(updated.avatarFileId!), 'base64');
|
|
96
99
|
expect(isJpeg(bytes)).toBe(true);
|
|
97
100
|
const metadata = await sharp(bytes).metadata();
|
|
98
|
-
expect(metadata.width).toBe(
|
|
99
|
-
expect(metadata.height).toBe(
|
|
101
|
+
expect(metadata.width).toBe(64);
|
|
102
|
+
expect(metadata.height).toBe(64);
|
|
100
103
|
});
|
|
101
104
|
|
|
102
105
|
it('updateAvatarEmoji sets the emoji, nulls the photo, and deletes the previous photo file', async () => {
|
package/test/avatarRoute.test.ts
CHANGED
|
@@ -70,8 +70,10 @@ describe('GET /avatar/:userId', () => {
|
|
|
70
70
|
expect(bytes[0]).toBe(0xff); // JPEG SOI
|
|
71
71
|
expect(bytes[1]).toBe(0xd8);
|
|
72
72
|
const metadata = await sharp(bytes).metadata();
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// The 100px source stays its honest 100px square — masters are capped at 512, never
|
|
74
|
+
// enlarged to it (AvatarPhotoFidelity.test.ts owns the pipeline contract).
|
|
75
|
+
expect(metadata.width).toBe(100);
|
|
76
|
+
expect(metadata.height).toBe(100);
|
|
75
77
|
|
|
76
78
|
// The client-side URL contract carries the cache-buster.
|
|
77
79
|
expect(avatarRoute.path(owner.id, updated.avatarFileId!)).toBe(`/avatar/${owner.id}?v=${updated.avatarFileId}`);
|
package/tsconfig.json
CHANGED