@proteinjs/db-file 1.8.4 → 1.9.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 (97) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/DbFileStorageDriver.d.ts +7 -0
  10. package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
  11. package/dist/src/DbFileStorageDriver.js +69 -41
  12. package/dist/src/DbFileStorageDriver.js.map +1 -1
  13. package/dist/src/FileCopyForOthers.d.ts +52 -0
  14. package/dist/src/FileCopyForOthers.d.ts.map +1 -0
  15. package/dist/src/FileCopyForOthers.js +62 -0
  16. package/dist/src/FileCopyForOthers.js.map +1 -0
  17. package/dist/src/FileStorage.d.ts +145 -11
  18. package/dist/src/FileStorage.d.ts.map +1 -1
  19. package/dist/src/FileStorage.js +537 -41
  20. package/dist/src/FileStorage.js.map +1 -1
  21. package/dist/src/FileStorageDriver.d.ts +11 -0
  22. package/dist/src/FileStorageDriver.d.ts.map +1 -1
  23. package/dist/src/FileStorageError.d.ts +62 -0
  24. package/dist/src/FileStorageError.d.ts.map +1 -0
  25. package/dist/src/FileStorageError.js +118 -0
  26. package/dist/src/FileStorageError.js.map +1 -0
  27. package/dist/src/FileVariantMaker.d.ts +61 -0
  28. package/dist/src/FileVariantMaker.d.ts.map +1 -0
  29. package/dist/src/FileVariantMaker.js +62 -0
  30. package/dist/src/FileVariantMaker.js.map +1 -0
  31. package/dist/src/routes/FileResponder.d.ts +41 -0
  32. package/dist/src/routes/FileResponder.d.ts.map +1 -0
  33. package/dist/src/routes/FileResponder.js +135 -0
  34. package/dist/src/routes/FileResponder.js.map +1 -0
  35. package/dist/src/routes/getFile.d.ts +5 -13
  36. package/dist/src/routes/getFile.d.ts.map +1 -1
  37. package/dist/src/routes/getFile.js +20 -56
  38. package/dist/src/routes/getFile.js.map +1 -1
  39. package/dist/src/routes/getFileVariant.d.ts +20 -0
  40. package/dist/src/routes/getFileVariant.d.ts.map +1 -0
  41. package/dist/src/routes/getFileVariant.js +112 -0
  42. package/dist/src/routes/getFileVariant.js.map +1 -0
  43. package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
  44. package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
  45. package/dist/src/routes/getFileVariantRoute.js +9 -0
  46. package/dist/src/routes/getFileVariantRoute.js.map +1 -0
  47. package/dist/src/tables/FileTable.d.ts +55 -2
  48. package/dist/src/tables/FileTable.d.ts.map +1 -1
  49. package/dist/src/tables/FileTable.js +8 -0
  50. package/dist/src/tables/FileTable.js.map +1 -1
  51. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
  52. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
  53. package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
  54. package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
  55. package/dist/test/FileByteAccess.test.js +6 -3
  56. package/dist/test/FileByteAccess.test.js.map +1 -1
  57. package/dist/test/FileCopyForOthers.test.d.ts +2 -0
  58. package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
  59. package/dist/test/FileCopyForOthers.test.js +1143 -0
  60. package/dist/test/FileCopyForOthers.test.js.map +1 -0
  61. package/dist/test/FileMediaMetadata.test.js +60 -5
  62. package/dist/test/FileMediaMetadata.test.js.map +1 -1
  63. package/dist/test/FileStorageError.test.d.ts +2 -0
  64. package/dist/test/FileStorageError.test.d.ts.map +1 -0
  65. package/dist/test/FileStorageError.test.js +68 -0
  66. package/dist/test/FileStorageError.test.js.map +1 -0
  67. package/dist/test/FileTestEnvironment.d.ts.map +1 -1
  68. package/dist/test/FileTestEnvironment.js +3 -0
  69. package/dist/test/FileTestEnvironment.js.map +1 -1
  70. package/dist/test/FileVariants.test.d.ts +2 -0
  71. package/dist/test/FileVariants.test.d.ts.map +1 -0
  72. package/dist/test/FileVariants.test.js +1008 -0
  73. package/dist/test/FileVariants.test.js.map +1 -0
  74. package/dist/test/GetFileRoute.test.js +29 -0
  75. package/dist/test/GetFileRoute.test.js.map +1 -1
  76. package/generated/index.ts +3 -1
  77. package/index.ts +4 -0
  78. package/package.json +5 -5
  79. package/src/DbFileStorageDriver.ts +32 -21
  80. package/src/FileCopyForOthers.ts +71 -0
  81. package/src/FileStorage.ts +351 -18
  82. package/src/FileStorageDriver.ts +12 -0
  83. package/src/FileStorageError.ts +123 -0
  84. package/src/FileVariantMaker.ts +84 -0
  85. package/src/routes/FileResponder.ts +85 -0
  86. package/src/routes/getFile.ts +9 -44
  87. package/src/routes/getFileVariant.ts +53 -0
  88. package/src/routes/getFileVariantRoute.ts +7 -0
  89. package/src/tables/FileTable.ts +57 -1
  90. package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
  91. package/test/FileByteAccess.test.ts +7 -1
  92. package/test/FileCopyForOthers.test.ts +702 -0
  93. package/test/FileMediaMetadata.test.ts +44 -0
  94. package/test/FileStorageError.test.ts +79 -0
  95. package/test/FileTestEnvironment.ts +3 -0
  96. package/test/FileVariants.test.ts +604 -0
  97. package/test/GetFileRoute.test.ts +19 -0
@@ -0,0 +1,702 @@
1
+ import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
2
+ import { DefaultLogWriter, Log, Logger } from '@proteinjs/logger';
3
+ import { Interface, Method, SourceRepository, TypeAliasDeclaration } from '@proteinjs/reflection';
4
+ import { Serializer } from '@proteinjs/serializer';
5
+ import { UserAuth, UserRepo, User } from '@proteinjs/user';
6
+ import { File } from '../src/tables/FileTable';
7
+ import { tables } from '../src/tables/tables';
8
+ import { FileStorage } from '../src/FileStorage';
9
+ import { ServiceRefusal } from '@proteinjs/service';
10
+ // The router and executor every browser-facing service call runs through (ServiceRouter →
11
+ // ServiceExecutor), not part of the package's entry: what they log and answer for this door — the
12
+ // status and the body the browser reads — is the outcome under test.
13
+ import { ServiceExecutor } from '@proteinjs/service/dist/src/ServiceExecutor';
14
+ import { ServiceRouter } from '@proteinjs/service/dist/src/ServiceRouter';
15
+ import { FileStorageDriver } from '../src/FileStorageDriver';
16
+ import { getFile } from '../src/routes/getFile';
17
+ import { FileTestEnvironment } from './FileTestEnvironment';
18
+
19
+ /**
20
+ * A file's bytes are the owner's, kept as they arrived; what anyone ELSE is served is a copy the
21
+ * registered maker derives — made once, stored as its own File in the owner's scope, named on the
22
+ * original's row, dropped when the bytes change, deleted with the original. This suite proves the
23
+ * seam contract with a stub maker (the real one — a picture without its location — lives in the
24
+ * package that knows media): who gets which bytes, through the service door and both shapes of
25
+ * the route; that the copy is cached and its cache never crosses to the owner; how it dies.
26
+ *
27
+ * The stub maker marks the bytes it makes: the original carries `LOCATION-MARKER` (what a phone
28
+ * photo's GPS block is to the real maker), the copy does not.
29
+ */
30
+
31
+ const LOCATION_MARKER = Buffer.from('LOCATION-MARKER');
32
+ /** Every byte value once, then the marker — a payload the copy must be seen to differ from. */
33
+ const originalBytes = Buffer.concat([Buffer.from(Array.from({ length: 256 }, (_, i) => i)), LOCATION_MARKER]);
34
+ const copyOf = (bytes: Buffer): Buffer => Buffer.from(bytes.subarray(0, bytes.length - LOCATION_MARKER.length));
35
+
36
+ /** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
37
+ class ProxyDriver implements FileStorageDriver {
38
+ readonly store = new Map<string, Buffer>();
39
+
40
+ async createFile(file: File, fileData: string): Promise<void> {
41
+ this.store.set(file.id, Buffer.from(fileData, 'base64'));
42
+ await atBarrier(); // the racing case: every copy's bytes are stored before any row is asked its word
43
+ }
44
+
45
+ async getFileData(fileId: string): Promise<string> {
46
+ const data = this.store.get(fileId);
47
+ if (data === undefined) {
48
+ throw new Error(`No such object: ${fileId}`);
49
+ }
50
+ return data.toString('base64');
51
+ }
52
+
53
+ async updateFileData(fileId: string, data: string): Promise<void> {
54
+ this.store.set(fileId, Buffer.from(data, 'base64'));
55
+ }
56
+
57
+ async deleteFile(fileId: string): Promise<void> {
58
+ this.store.delete(fileId);
59
+ }
60
+ }
61
+
62
+ /** Proxy driver plus a signed-URL mint that names the object it signs — the GCS serving shape. */
63
+ class SignedUrlDriver extends ProxyDriver {
64
+ async getSignedUrl(fileId: string): Promise<string> {
65
+ return `https://signed.test/blob/${fileId}?sig=test`;
66
+ }
67
+ }
68
+
69
+ class ResponseRecorder {
70
+ statusCode?: number;
71
+ body?: unknown;
72
+ redirectUrl?: string;
73
+ headers: Record<string, string> = {};
74
+
75
+ status(code: number): ResponseRecorder {
76
+ this.statusCode = code;
77
+ return this;
78
+ }
79
+
80
+ send(body: unknown): ResponseRecorder {
81
+ this.body = body;
82
+ return this;
83
+ }
84
+
85
+ setHeader(name: string, value: string): void {
86
+ this.headers[name.toLowerCase()] = value;
87
+ }
88
+
89
+ redirect(status: number, url: string): void {
90
+ this.statusCode = status;
91
+ this.redirectUrl = url;
92
+ }
93
+ }
94
+
95
+ type RouteRequest = Parameters<typeof getFile.onRequest>[0];
96
+ type RouteResponse = Parameters<typeof getFile.onRequest>[1];
97
+
98
+ const invokeRoute = async (fileId: string): Promise<ResponseRecorder> => {
99
+ const response = new ResponseRecorder();
100
+ await getFile.onRequest(
101
+ { params: { id: fileId }, headers: {} } as unknown as RouteRequest,
102
+ response as unknown as RouteResponse
103
+ );
104
+ return response;
105
+ };
106
+
107
+ /** A logger that writes into `entries` — what a door logged, read back as the outcome. */
108
+ const loggerInto = (name: string, entries: Log[]): Logger =>
109
+ new Logger({ name, logWriter: { write: (log: Log) => entries.push(log) } as unknown as DefaultLogWriter });
110
+
111
+ const SERVICE_PATH = '/service/@proteinjs/db-file/FileStorageService/getFileData';
112
+
113
+ /**
114
+ * One call of the browser's door — `FileStorageService.getFileData` — through the REAL service
115
+ * router (its executor seeded by path, the way the router builds it from the source graph): the
116
+ * status and the body the browser reads, every entry the executor logged (`entries`) and every
117
+ * entry the file storage itself logged (`storageEntries`).
118
+ */
119
+ const invokeServiceDoor = async (
120
+ fileId: string
121
+ ): Promise<{ status: number; body: unknown; entries: Log[]; storageEntries: Log[] }> => {
122
+ const returnType = { name: 'Promise<string>' } as unknown as TypeAliasDeclaration;
123
+ const method = new Method('getFileData', returnType, true, false, false, false, 'public', []);
124
+ const storage = new FileStorage();
125
+ const storageEntries: Log[] = [];
126
+ (storage as unknown as { logger: Logger }).logger = loggerInto('FileStorage', storageEntries);
127
+ const executor = new ServiceExecutor(
128
+ storage,
129
+ new Interface('@proteinjs/db-file', 'FileStorageService', [], [method]),
130
+ method
131
+ );
132
+ const entries: Log[] = [];
133
+ (executor as unknown as { logger: Logger }).logger = loggerInto('FileStorageService.getFileData', entries);
134
+ const router = new ServiceRouter();
135
+ (router as unknown as { serviceExecutorMap: Record<string, ServiceExecutor> }).serviceExecutorMap = {
136
+ [SERVICE_PATH]: executor,
137
+ };
138
+ const response = new ResponseRecorder();
139
+ await router.onRequest({ path: SERVICE_PATH, body: Serializer.serialize([fileId]) }, response);
140
+ return { status: response.statusCode ?? 200, body: response.body, entries, storageEntries };
141
+ };
142
+
143
+ /** The status the service router answers a thrown error with: a refusal's own, else 400. */
144
+ const routerStatus = (thrown: unknown): number => (ServiceRefusal.is(thrown) ? thrown.status : 400);
145
+
146
+ const testEnv = new FileTestEnvironment();
147
+ type UserAuthInternals = { userRepo?: unknown };
148
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
149
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
150
+ /** The row's seat for the copy — read by name so the suite states the contract, not the type. */
151
+ type FileWithCopy = File & { copyForOthers?: { _id: string | null } | null };
152
+
153
+ let owner: User;
154
+ let recipient: User;
155
+ const reachableFileIds = new Set<string>();
156
+ /** Every call the maker took: which file, and the bytes it was handed. */
157
+ let makerCalls: Array<{ fileId: string; bytes: Buffer }> = [];
158
+ /** What the stub maker applies to — the suite's own rule (the real maker's is "pictures and clips"). */
159
+ let makerAppliesTo = (file: File): boolean => file.type.startsWith('image/');
160
+ let makerFails = false;
161
+ /** What the stub maker throws when it fails — its own internal words, the server's to keep. */
162
+ const MAKER_REASON = 'no copy can be made of this file';
163
+ /**
164
+ * A barrier for the racing case: with `makerBarrier = n`, the stub maker holds every call until n
165
+ * calls are inside it, then answers them all on the same tick — so the reads that follow (the
166
+ * copy's row, its bytes, the row's word) run in lockstep, the tightest race the seam can meet.
167
+ */
168
+ let makerBarrier = 0;
169
+ let makerWaiting: Array<() => void> = [];
170
+ /** Hold until `makerBarrier` callers are here, then release them all on one tick (no-op when 0). */
171
+ const atBarrier = async (): Promise<void> => {
172
+ if (makerBarrier <= 0) {
173
+ return;
174
+ }
175
+ await new Promise<void>((resolve) => {
176
+ makerWaiting.push(resolve);
177
+ if (makerWaiting.length >= makerBarrier) {
178
+ const release = makerWaiting;
179
+ makerWaiting = [];
180
+ release.forEach((go) => go());
181
+ }
182
+ });
183
+ };
184
+
185
+ const registerMaker = () => {
186
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
187
+ {
188
+ appliesTo: (file: File) => makerAppliesTo(file),
189
+ make: async (file: File, bytes: Buffer) => {
190
+ makerCalls.push({ fileId: file.id, bytes: Buffer.from(bytes) });
191
+ await atBarrier();
192
+ if (makerFails) {
193
+ throw new Error(MAKER_REASON);
194
+ }
195
+ return copyOf(bytes);
196
+ },
197
+ },
198
+ ];
199
+ };
200
+
201
+ beforeAll(async () => {
202
+ await testEnv.beforeAll();
203
+ objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
204
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
205
+ objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
206
+ { canReadViaReference: async (fileId: string) => reachableFileIds.has(fileId) },
207
+ ];
208
+ registerMaker();
209
+ owner = await testEnv.createUser({ name: 'File owner', email: 'copy-owner@test.local' });
210
+ recipient = await testEnv.createUser({ name: 'Share recipient', email: 'copy-recipient@test.local' });
211
+ testEnv.actAs(owner);
212
+ });
213
+
214
+ afterAll(async () => {
215
+ delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
216
+ delete objectCache()['@proteinjs/db-file/FileCopyForOthers'];
217
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
218
+ await testEnv.afterAll();
219
+ });
220
+
221
+ beforeEach(() => {
222
+ reachableFileIds.clear();
223
+ makerCalls = [];
224
+ makerAppliesTo = (file: File) => file.type.startsWith('image/');
225
+ makerFails = false;
226
+ makerBarrier = 0;
227
+ makerWaiting = [];
228
+ registerMaker();
229
+ testEnv.actAs(owner);
230
+ });
231
+
232
+ const createOwnerFile = async (name: string, type: string, bytes = originalBytes): Promise<File> =>
233
+ await new FileStorage().createFile({ name, type, size: bytes.length } as File, bytes.toString('base64'));
234
+
235
+ const rowAsSystem = async (fileId: string): Promise<FileWithCopy | undefined> =>
236
+ (await getDbAsSystem().get(tables.File, { id: fileId })) as FileWithCopy | undefined;
237
+
238
+ const bytesAs = async (user: User, fileId: string): Promise<Buffer> => {
239
+ testEnv.actAs(user);
240
+ return Buffer.from(await new FileStorage().getFileData(fileId), 'base64');
241
+ };
242
+
243
+ const fileRowsInScope = async (scope: string): Promise<File[]> =>
244
+ await getDbAsSystem().query(
245
+ tables.File,
246
+ new QueryBuilderFactory().getQueryBuilder(tables.File).condition({ field: 'scope', operator: '=', value: scope })
247
+ );
248
+
249
+ describe('the copy for others — the service door (proxy serving)', () => {
250
+ const driver = new ProxyDriver();
251
+
252
+ beforeAll(() => {
253
+ testEnv.setDriver(driver);
254
+ });
255
+
256
+ it('the owner is served the original, and no copy is made for them', async () => {
257
+ const file = await createOwnerFile('mine.jpg', 'image/jpeg');
258
+
259
+ const served = await bytesAs(owner, file.id);
260
+
261
+ expect(served.equals(originalBytes)).toBe(true);
262
+ expect(makerCalls).toEqual([]);
263
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
264
+ });
265
+
266
+ it("a recipient the content reaches is served the maker's copy — the original's bytes never leave to them", async () => {
267
+ const file = await createOwnerFile('shared.jpg', 'image/jpeg');
268
+ reachableFileIds.add(file.id);
269
+
270
+ const served = await bytesAs(recipient, file.id);
271
+
272
+ expect(served.includes(LOCATION_MARKER)).toBe(false);
273
+ expect(served.equals(copyOf(originalBytes))).toBe(true);
274
+ expect(makerCalls.map((call) => call.fileId)).toEqual([file.id]);
275
+ expect(makerCalls[0].bytes.equals(originalBytes)).toBe(true);
276
+ });
277
+
278
+ it("the copy is a File of the OWNER's, named on the original's row, with the original's name, type and dimensions", async () => {
279
+ const file = await createOwnerFile('framed.jpg', 'image/jpeg');
280
+ await getDbAsSystem().update(tables.File, { id: file.id, width: 640, height: 480 });
281
+ reachableFileIds.add(file.id);
282
+
283
+ await bytesAs(recipient, file.id);
284
+
285
+ const row = (await rowAsSystem(file.id))!;
286
+ const copyId = row.copyForOthers?._id;
287
+ expect(copyId).toBeTruthy();
288
+ const copy = (await rowAsSystem(copyId!))!;
289
+ expect(copy.scope).toEqual(owner.id);
290
+ expect([copy.name, copy.type, copy.size, copy.width, copy.height]).toEqual([
291
+ 'framed.jpg',
292
+ 'image/jpeg',
293
+ copyOf(originalBytes).length,
294
+ 640,
295
+ 480,
296
+ ]);
297
+ expect(driver.store.get(copyId!)!.equals(copyOf(originalBytes))).toBe(true);
298
+ // The original is untouched: its bytes, its size.
299
+ expect(driver.store.get(file.id)!.equals(originalBytes)).toBe(true);
300
+ expect(row.size).toEqual(originalBytes.length);
301
+ });
302
+
303
+ it('the copy is made ONCE: every later non-owner read serves the same copy without the maker', async () => {
304
+ const file = await createOwnerFile('popular.jpg', 'image/jpeg');
305
+ reachableFileIds.add(file.id);
306
+
307
+ await bytesAs(recipient, file.id);
308
+ const copyId = (await rowAsSystem(file.id))!.copyForOthers!._id;
309
+ const again = await bytesAs(recipient, file.id);
310
+ const onceMore = await bytesAs(recipient, file.id);
311
+
312
+ expect(again.equals(copyOf(originalBytes))).toBe(true);
313
+ expect(onceMore.equals(copyOf(originalBytes))).toBe(true);
314
+ expect(makerCalls).toHaveLength(1);
315
+ expect((await rowAsSystem(file.id))!.copyForOthers!._id).toEqual(copyId);
316
+ });
317
+
318
+ it("TWO NON-OWNERS RACING: both are served the copy, ONE copy row survives in the owner's scope, the row names it, the loser's object is gone", async () => {
319
+ const file = await createOwnerFile('raced.jpg', 'image/jpeg');
320
+ reachableFileIds.add(file.id);
321
+ const readers = 4;
322
+ makerBarrier = readers;
323
+ // Warm the driver's session pool so the readers' round trips really overlap (a cold pool serializes them).
324
+ await Promise.all(Array.from({ length: readers * 2 }, () => rowAsSystem(file.id)));
325
+
326
+ testEnv.actAs(recipient);
327
+ const served = await Promise.all(Array.from({ length: readers }, () => new FileStorage().getFileData(file.id)));
328
+
329
+ for (const bytes of served) {
330
+ expect(Buffer.from(bytes, 'base64').equals(copyOf(originalBytes))).toBe(true);
331
+ }
332
+ expect(makerCalls).toHaveLength(readers); // all raced into the maker; the contract is about what SURVIVES
333
+ const named = (await rowAsSystem(file.id))!.copyForOthers!._id!;
334
+ const copies = (await fileRowsInScope(owner.id)).filter((row) => row.id !== file.id && row.name === 'raced.jpg');
335
+ expect(copies.map((row) => row.id)).toEqual([named]);
336
+ // The loser's object is gone: every object in the store belongs to a row that still exists.
337
+ const liveIds = new Set(
338
+ (await getDbAsSystem().query(tables.File, new QueryBuilderFactory().getQueryBuilder(tables.File))).map(
339
+ (row) => row.id
340
+ )
341
+ );
342
+ expect(Array.from(driver.store.keys()).filter((id) => !liveIds.has(id))).toEqual([]);
343
+ expect(await fileRowsInScope(recipient.id)).toEqual([]);
344
+
345
+ makerBarrier = 0;
346
+ const again = await bytesAs(recipient, file.id);
347
+ expect(again.equals(copyOf(originalBytes))).toBe(true);
348
+ expect(makerCalls).toHaveLength(readers);
349
+ });
350
+
351
+ it("THE CACHE NEVER CROSSES TO THE OWNER: once a copy exists, the owner's read still serves the original", async () => {
352
+ const file = await createOwnerFile('still-mine.jpg', 'image/jpeg');
353
+ reachableFileIds.add(file.id);
354
+ await bytesAs(recipient, file.id);
355
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id).toBeTruthy();
356
+
357
+ const served = await bytesAs(owner, file.id);
358
+
359
+ expect(served.equals(originalBytes)).toBe(true);
360
+ expect(served.includes(LOCATION_MARKER)).toBe(true);
361
+ });
362
+
363
+ it('a file the maker does not apply to serves as itself to a recipient — no bytes read, no copy made', async () => {
364
+ const file = await createOwnerFile('notes.pdf', 'application/pdf');
365
+ reachableFileIds.add(file.id);
366
+ const rowsBefore = (await fileRowsInScope(owner.id)).length;
367
+
368
+ const served = await bytesAs(recipient, file.id);
369
+
370
+ expect(served.equals(originalBytes)).toBe(true);
371
+ expect(makerCalls).toEqual([]);
372
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
373
+ expect((await fileRowsInScope(owner.id)).length).toEqual(rowsBefore);
374
+ });
375
+
376
+ it("the copy's own id is the owner's alone: a recipient cannot reach it, the owner can", async () => {
377
+ const file = await createOwnerFile('guarded.jpg', 'image/jpeg');
378
+ reachableFileIds.add(file.id);
379
+ await bytesAs(recipient, file.id);
380
+ const copyId = (await rowAsSystem(file.id))!.copyForOthers!._id!;
381
+
382
+ testEnv.actAs(recipient);
383
+ await expect(new FileStorage().getFileData(copyId)).rejects.toThrow('File not found');
384
+ testEnv.actAs(owner);
385
+ expect(Buffer.from(await new FileStorage().getFileData(copyId), 'base64').equals(copyOf(originalBytes))).toBe(true);
386
+ });
387
+
388
+ it('when no copy can be made, the recipient is served NOTHING — never the original', async () => {
389
+ const file = await createOwnerFile('unrewritable.jpg', 'image/jpeg');
390
+ reachableFileIds.add(file.id);
391
+ makerFails = true;
392
+
393
+ testEnv.actAs(recipient);
394
+ const refusal = await new FileStorage().getFileData(file.id).then(
395
+ () => undefined,
396
+ (error: unknown) => error
397
+ );
398
+ // Not there for this caller — a 404 refusal in the words a missing file gets, the same at every door.
399
+ expect(ServiceRefusal.is(refusal)).toBe(true);
400
+ expect((refusal as ServiceRefusal).status).toBe(404);
401
+ expect((refusal as ServiceRefusal).message).toEqual('File not found');
402
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
403
+ expect(Array.from(driver.store.keys())).toContain(file.id);
404
+ expect(
405
+ (await fileRowsInScope(owner.id)).find((row) => row.id !== file.id && row.name === 'unrewritable.jpg')
406
+ ).toBeUndefined();
407
+ });
408
+
409
+ it('with no maker registered at all, a recipient is served the original (the seam is opt-in)', async () => {
410
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [];
411
+ const file = await createOwnerFile('unguarded.jpg', 'image/jpeg');
412
+ reachableFileIds.add(file.id);
413
+
414
+ const served = await bytesAs(recipient, file.id);
415
+
416
+ expect(served.equals(originalBytes)).toBe(true);
417
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
418
+ });
419
+
420
+ it("NEW BYTES DROP THE COPY: after the owner rewrites the file, the recipient's next read is a fresh copy of the new bytes, the old copy's row and bytes gone", async () => {
421
+ const file = await createOwnerFile('edited.jpg', 'image/jpeg');
422
+ reachableFileIds.add(file.id);
423
+ await bytesAs(recipient, file.id);
424
+ const staleCopyId = (await rowAsSystem(file.id))!.copyForOthers!._id!;
425
+ const newBytes = Buffer.concat([Buffer.from('v2-'), originalBytes]);
426
+
427
+ testEnv.actAs(owner);
428
+ await new FileStorage().updateFileData(file.id, newBytes.toString('base64'));
429
+
430
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
431
+ expect(await rowAsSystem(staleCopyId)).toBeUndefined();
432
+ expect(driver.store.has(staleCopyId)).toBe(false);
433
+ const served = await bytesAs(recipient, file.id);
434
+ expect(served.equals(copyOf(newBytes))).toBe(true);
435
+ expect(makerCalls).toHaveLength(2);
436
+ expect(makerCalls[1].bytes.equals(newBytes)).toBe(true);
437
+ });
438
+
439
+ it("DELETING THE ORIGINAL DELETES THE COPY: the copy's row and bytes go with the file", async () => {
440
+ const file = await createOwnerFile('gone.jpg', 'image/jpeg');
441
+ reachableFileIds.add(file.id);
442
+ await bytesAs(recipient, file.id);
443
+ const copyId = (await rowAsSystem(file.id))!.copyForOthers!._id!;
444
+
445
+ testEnv.actAs(owner);
446
+ await new FileStorage().deleteFile(file.id);
447
+
448
+ expect(await rowAsSystem(file.id)).toBeUndefined();
449
+ expect(await rowAsSystem(copyId)).toBeUndefined();
450
+ expect(driver.store.has(file.id)).toBe(false);
451
+ expect(driver.store.has(copyId)).toBe(false);
452
+ });
453
+ });
454
+
455
+ describe('the copy for others — the route, both serving shapes', () => {
456
+ it("proxy shape: a recipient's GET /file/:id serves the copy's bytes under the ORIGINAL's name and type; the owner's serves the original", async () => {
457
+ const driver = new ProxyDriver();
458
+ testEnv.setDriver(driver);
459
+ const file = await createOwnerFile('route-shot.jpg', 'image/jpeg');
460
+ reachableFileIds.add(file.id);
461
+
462
+ testEnv.actAs(recipient);
463
+ const theirs = await invokeRoute(file.id);
464
+ testEnv.actAs(owner);
465
+ const mine = await invokeRoute(file.id);
466
+
467
+ expect(theirs.statusCode ?? 200).toEqual(200);
468
+ expect(theirs.headers['content-type']).toEqual('image/jpeg');
469
+ expect(theirs.headers['content-disposition']).toEqual('inline; filename="route-shot.jpg"');
470
+ expect((theirs.body as Buffer).equals(copyOf(originalBytes))).toBe(true);
471
+ expect((mine.body as Buffer).equals(originalBytes)).toBe(true);
472
+ });
473
+
474
+ it("signed-URL shape (the production store): a recipient is redirected to the COPY's object, the owner to the original's", async () => {
475
+ const driver = new SignedUrlDriver();
476
+ testEnv.setDriver(driver);
477
+ const file = await createOwnerFile('route-clip-poster.jpg', 'image/jpeg');
478
+ reachableFileIds.add(file.id);
479
+
480
+ testEnv.actAs(recipient);
481
+ const theirs = await invokeRoute(file.id);
482
+ const copyId = (await rowAsSystem(file.id))!.copyForOthers!._id!;
483
+ testEnv.actAs(owner);
484
+ const mine = await invokeRoute(file.id);
485
+
486
+ expect(theirs.statusCode).toEqual(302);
487
+ expect(theirs.redirectUrl).toEqual(`https://signed.test/blob/${copyId}?sig=test`);
488
+ expect(copyId).not.toEqual(file.id);
489
+ expect(mine.statusCode).toEqual(302);
490
+ expect(mine.redirectUrl).toEqual(`https://signed.test/blob/${file.id}?sig=test`);
491
+ });
492
+
493
+ it.each([
494
+ ['signed-URL', () => new SignedUrlDriver()],
495
+ ['proxy', () => new ProxyDriver()],
496
+ ])(
497
+ "a file no copy can be made of (%s shape): a recipient's GET /file/:id is a quiet 404 File not found — not a 500, no error printed; the owner's still serves",
498
+ async (_shape, makeDriver) => {
499
+ const driver = makeDriver();
500
+ testEnv.setDriver(driver);
501
+ const file = await createOwnerFile('unrewritable-route.jpg', 'image/jpeg');
502
+ reachableFileIds.add(file.id);
503
+ makerFails = true;
504
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
505
+ try {
506
+ testEnv.actAs(recipient);
507
+ const theirs = await invokeRoute(file.id);
508
+ testEnv.actAs(owner);
509
+ const mine = await invokeRoute(file.id);
510
+
511
+ expect([theirs.statusCode, theirs.body]).toEqual([404, 'File not found']);
512
+ expect(theirs.redirectUrl).toBeUndefined();
513
+ expect(consoleError).not.toHaveBeenCalled();
514
+ if (driver instanceof SignedUrlDriver) {
515
+ expect(mine.statusCode).toEqual(302);
516
+ expect(mine.redirectUrl).toEqual(`https://signed.test/blob/${file.id}?sig=test`);
517
+ } else {
518
+ expect((mine.body as Buffer).equals(originalBytes)).toBe(true);
519
+ }
520
+ } finally {
521
+ consoleError.mockRestore();
522
+ }
523
+ }
524
+ );
525
+
526
+ it("a signed URL for a file the maker does not apply to still names the original's object", async () => {
527
+ const driver = new SignedUrlDriver();
528
+ testEnv.setDriver(driver);
529
+ const file = await createOwnerFile('route-notes.pdf', 'application/pdf');
530
+ reachableFileIds.add(file.id);
531
+
532
+ testEnv.actAs(recipient);
533
+ const theirs = await invokeRoute(file.id);
534
+
535
+ expect(theirs.redirectUrl).toEqual(`https://signed.test/blob/${file.id}?sig=test`);
536
+ });
537
+ });
538
+
539
+ describe('the copy for others — a server-side door that made its own access decision', () => {
540
+ const driver = new ProxyDriver();
541
+ /** What such a door does: reads the row as system (its own rule opened it), then asks which bytes this caller is served. */
542
+ const throughTheDoor = async (user: User, fileId: string): Promise<Buffer> => {
543
+ const row = (await rowAsSystem(fileId))!;
544
+ testEnv.actAs(user);
545
+ return Buffer.from(await new FileStorage().getAuthorizedFileData(row), 'base64');
546
+ };
547
+
548
+ beforeAll(() => {
549
+ testEnv.setDriver(driver);
550
+ });
551
+
552
+ it("a caller neither scope nor a resolver opens, whom the door vouches for, is served the maker's copy — the same copy the row names for every other non-owner", async () => {
553
+ const file = await createOwnerFile('attached.jpg', 'image/jpeg');
554
+
555
+ const served = await throughTheDoor(recipient, file.id);
556
+
557
+ expect(served.includes(LOCATION_MARKER)).toBe(false);
558
+ expect(served.equals(copyOf(originalBytes))).toBe(true);
559
+ const copyId = (await rowAsSystem(file.id))!.copyForOthers?._id;
560
+ expect(copyId).toBeTruthy();
561
+ expect(driver.store.get(copyId!)!.equals(served)).toBe(true);
562
+ // A share recipient later reaching the same file is served that same copy, never a second one.
563
+ reachableFileIds.add(file.id);
564
+ expect((await bytesAs(recipient, file.id)).equals(served)).toBe(true);
565
+ expect(makerCalls.map((call) => call.fileId)).toEqual([file.id]);
566
+ });
567
+
568
+ it('the owner through the same door is served the original, byte for byte, and no copy is made', async () => {
569
+ const file = await createOwnerFile('own-attachment.jpg', 'image/jpeg');
570
+
571
+ const served = await throughTheDoor(owner, file.id);
572
+
573
+ expect(served.equals(originalBytes)).toBe(true);
574
+ expect(makerCalls).toEqual([]);
575
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
576
+ });
577
+
578
+ it("when no copy can be made, the door's caller is refused as NOT FOUND — a 404 ServiceRefusal in a missing file's words, a refusal and never a failure — and never served the original", async () => {
579
+ const file = await createOwnerFile('raw-attachment.jpg', 'image/jpeg');
580
+ makerFails = true;
581
+
582
+ const refusal = await throughTheDoor(recipient, file.id).then(
583
+ () => undefined,
584
+ (error: unknown) => error
585
+ );
586
+
587
+ // The status the service router answers with and the executor logs at WARN: 404 leaks nothing
588
+ // about the file's existence to a caller who may not read it.
589
+ expect(ServiceRefusal.is(refusal)).toBe(true);
590
+ expect((refusal as ServiceRefusal).status).toBe(404);
591
+ expect((refusal as ServiceRefusal).message).toEqual('File not found');
592
+ expect((await rowAsSystem(file.id))!.copyForOthers?._id ?? null).toBeNull();
593
+ });
594
+ });
595
+
596
+ describe('ONE REFUSAL AT EVERY DOOR — a file no copy can be made of is not found for anyone but its owner (404), whichever door asks', () => {
597
+ /** A file the recipient reaches whose copy the maker refuses — both serving shapes hold its bytes. */
598
+ const refusedFile = async (name: string) => {
599
+ const proxy = new ProxyDriver();
600
+ const signed = new SignedUrlDriver();
601
+ testEnv.setDriver(proxy);
602
+ const file = await createOwnerFile(name, 'image/jpeg');
603
+ signed.store.set(file.id, proxy.store.get(file.id)!);
604
+ reachableFileIds.add(file.id);
605
+ makerFails = true;
606
+ return { file, proxy, signed };
607
+ };
608
+
609
+ /** Every door a recipient can ask, in one order: the browser service, the route (proxy, then signed-URL), the server-side door. */
610
+ const askEveryDoor = async (file: File, signed: SignedUrlDriver) => {
611
+ testEnv.actAs(recipient);
612
+ const service = await invokeServiceDoor(file.id);
613
+ const proxyRoute = await invokeRoute(file.id);
614
+ const serverSide = await new FileStorage().getAuthorizedFileData((await rowAsSystem(file.id))!).then(
615
+ () => undefined,
616
+ (error: unknown) => error
617
+ );
618
+ testEnv.setDriver(signed);
619
+ const signedRoute = await invokeRoute(file.id);
620
+ return { service, proxyRoute, serverSide, signedRoute };
621
+ };
622
+
623
+ it('the browser service (through the router), GET /file/:id in both shapes and the server-side door answer one status, 404 — the browser door logs one WARN with its status and no ERROR', async () => {
624
+ const { file, signed } = await refusedFile('one-answer.jpg');
625
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
626
+ try {
627
+ const { service, proxyRoute, serverSide, signedRoute } = await askEveryDoor(file, signed);
628
+
629
+ expect([service.status, signedRoute.statusCode, proxyRoute.statusCode, routerStatus(serverSide)]).toEqual([
630
+ 404, 404, 404, 404,
631
+ ]);
632
+ // The browser door's log: one WARN naming the status and the operation — a refusal, never a failure.
633
+ expect(service.entries.filter((entry) => entry.logLevel === 'error')).toEqual([]);
634
+ const warnings = service.entries.filter((entry) => entry.logLevel === 'warn');
635
+ expect(warnings).toHaveLength(1);
636
+ expect(warnings[0].obj).toMatchObject({ status: 404, functionName: 'FileStorageService.getFileData' });
637
+ expect(consoleError).not.toHaveBeenCalled();
638
+ // And never the original, at any door.
639
+ expect([signedRoute.redirectUrl, proxyRoute.body]).toEqual([undefined, 'File not found']);
640
+ } finally {
641
+ consoleError.mockRestore();
642
+ }
643
+ });
644
+
645
+ it("ONE BODY TOO: every door answers the words a missing file gets — \"File not found\" — never the maker's reason, the file's id or its owner; the reason stays on the server, in the storage's one WARN", async () => {
646
+ const { file, signed } = await refusedFile('one-body.jpg');
647
+
648
+ const { service, proxyRoute, serverSide, signedRoute } = await askEveryDoor(file, signed);
649
+
650
+ // What each door's caller reads: the router's body, the route's in both shapes, the refusal's message.
651
+ const read = [service.body, signedRoute.body, proxyRoute.body, (serverSide as Error | undefined)?.message];
652
+ expect(read).toEqual([{ error: 'File not found' }, 'File not found', 'File not found', 'File not found']);
653
+ for (const words of read.map((answer) => JSON.stringify(answer))) {
654
+ expect(words).not.toContain(MAKER_REASON);
655
+ expect(words).not.toContain(file.id);
656
+ expect(words).not.toContain('owner');
657
+ }
658
+ // The server keeps the reason: the storage logs one WARN with the file and the maker's words; no ERROR anywhere.
659
+ const storageWarnings = service.storageEntries.filter((entry) => entry.logLevel === 'warn');
660
+ expect(storageWarnings).toHaveLength(1);
661
+ expect(storageWarnings[0].obj).toEqual({ fileId: file.id, reason: MAKER_REASON });
662
+ expect([...service.entries, ...service.storageEntries].filter((entry) => entry.logLevel === 'error')).toEqual([]);
663
+ });
664
+
665
+ it("A MISS ANSWERS WHAT A REFUSAL ANSWERS: a file that is not there, and one the caller cannot reach, get the refused copy's status and body at every door — the browser service, the route in both shapes, the signed-URL mint — never the id", async () => {
666
+ const { file: refused, proxy, signed } = await refusedFile('refused-beside-a-miss.jpg');
667
+ testEnv.actAs(owner);
668
+ const unreachable = await createOwnerFile('not-theirs.jpg', 'image/jpeg');
669
+ signed.store.set(unreachable.id, proxy.store.get(unreachable.id)!);
670
+ /** What a recipient reads for `fileId` at each door, and the WARN / ERROR levels the browser door logged. */
671
+ const notRoutine = (level: string) => level === 'warn' || level === 'error';
672
+ const answersFor = async (fileId: string) => {
673
+ testEnv.actAs(recipient);
674
+ testEnv.setDriver(proxy);
675
+ const service = await invokeServiceDoor(fileId);
676
+ const proxyRoute = await invokeRoute(fileId);
677
+ testEnv.setDriver(signed);
678
+ const signedRoute = await invokeRoute(fileId);
679
+ const mint = await new FileStorage().getSignedUrl(fileId).then(
680
+ () => undefined,
681
+ (error: unknown) => error
682
+ );
683
+ return [
684
+ [service.status, service.body, service.entries.map((entry) => entry.logLevel).filter(notRoutine)],
685
+ [proxyRoute.statusCode, proxyRoute.body, proxyRoute.redirectUrl],
686
+ [signedRoute.statusCode, signedRoute.body, signedRoute.redirectUrl],
687
+ [ServiceRefusal.is(mint) ? mint.status : 'not a refusal', (mint as Error | undefined)?.message],
688
+ ];
689
+ };
690
+
691
+ const refusal = await answersFor(refused.id);
692
+
693
+ expect(refusal).toEqual([
694
+ [404, { error: 'File not found' }, ['warn']],
695
+ [404, 'File not found', undefined],
696
+ [404, 'File not found', undefined],
697
+ [404, 'File not found'],
698
+ ]);
699
+ expect(await answersFor(unreachable.id)).toEqual(refusal);
700
+ expect(await answersFor('no-such-file')).toEqual(refusal);
701
+ });
702
+ });