@proteinjs/db-file 1.8.4 → 1.9.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 +41 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DbFileStorageDriver.d.ts +7 -0
- package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
- package/dist/src/DbFileStorageDriver.js +69 -41
- package/dist/src/DbFileStorageDriver.js.map +1 -1
- package/dist/src/FileCopyForOthers.d.ts +52 -0
- package/dist/src/FileCopyForOthers.d.ts.map +1 -0
- package/dist/src/FileCopyForOthers.js +62 -0
- package/dist/src/FileCopyForOthers.js.map +1 -0
- package/dist/src/FileStorage.d.ts +158 -11
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +562 -41
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/FileStorageDriver.d.ts +11 -0
- package/dist/src/FileStorageDriver.d.ts.map +1 -1
- package/dist/src/FileStorageError.d.ts +62 -0
- package/dist/src/FileStorageError.d.ts.map +1 -0
- package/dist/src/FileStorageError.js +118 -0
- package/dist/src/FileStorageError.js.map +1 -0
- package/dist/src/FileVariantMaker.d.ts +66 -0
- package/dist/src/FileVariantMaker.d.ts.map +1 -0
- package/dist/src/FileVariantMaker.js +62 -0
- package/dist/src/FileVariantMaker.js.map +1 -0
- package/dist/src/routes/FileResponder.d.ts +41 -0
- package/dist/src/routes/FileResponder.d.ts.map +1 -0
- package/dist/src/routes/FileResponder.js +135 -0
- package/dist/src/routes/FileResponder.js.map +1 -0
- package/dist/src/routes/getFile.d.ts +5 -13
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +20 -56
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/src/routes/getFileVariant.d.ts +20 -0
- package/dist/src/routes/getFileVariant.d.ts.map +1 -0
- package/dist/src/routes/getFileVariant.js +112 -0
- package/dist/src/routes/getFileVariant.js.map +1 -0
- package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
- package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
- package/dist/src/routes/getFileVariantRoute.js +9 -0
- package/dist/src/routes/getFileVariantRoute.js.map +1 -0
- package/dist/src/tables/FileTable.d.ts +70 -2
- package/dist/src/tables/FileTable.d.ts.map +1 -1
- package/dist/src/tables/FileTable.js +30 -0
- package/dist/src/tables/FileTable.js.map +1 -1
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
- package/dist/test/FileByteAccess.test.js +6 -3
- package/dist/test/FileByteAccess.test.js.map +1 -1
- package/dist/test/FileCopyForOthers.test.d.ts +2 -0
- package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
- package/dist/test/FileCopyForOthers.test.js +1143 -0
- package/dist/test/FileCopyForOthers.test.js.map +1 -0
- package/dist/test/FileDerivedProvenance.test.d.ts +2 -0
- package/dist/test/FileDerivedProvenance.test.d.ts.map +1 -0
- package/dist/test/FileDerivedProvenance.test.js +420 -0
- package/dist/test/FileDerivedProvenance.test.js.map +1 -0
- package/dist/test/FileMediaMetadata.test.js +60 -5
- package/dist/test/FileMediaMetadata.test.js.map +1 -1
- package/dist/test/FileStorageError.test.d.ts +2 -0
- package/dist/test/FileStorageError.test.d.ts.map +1 -0
- package/dist/test/FileStorageError.test.js +68 -0
- package/dist/test/FileStorageError.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -1
- package/dist/test/FileTestEnvironment.js +3 -0
- package/dist/test/FileTestEnvironment.js.map +1 -1
- package/dist/test/FileVariants.test.d.ts +2 -0
- package/dist/test/FileVariants.test.d.ts.map +1 -0
- package/dist/test/FileVariants.test.js +1008 -0
- package/dist/test/FileVariants.test.js.map +1 -0
- package/dist/test/GetFileRoute.test.js +29 -0
- package/dist/test/GetFileRoute.test.js.map +1 -1
- package/generated/index.ts +3 -1
- package/index.ts +4 -0
- package/package.json +5 -5
- package/src/DbFileStorageDriver.ts +32 -21
- package/src/FileCopyForOthers.ts +71 -0
- package/src/FileStorage.ts +377 -19
- package/src/FileStorageDriver.ts +12 -0
- package/src/FileStorageError.ts +123 -0
- package/src/FileVariantMaker.ts +89 -0
- package/src/routes/FileResponder.ts +85 -0
- package/src/routes/getFile.ts +9 -44
- package/src/routes/getFileVariant.ts +53 -0
- package/src/routes/getFileVariantRoute.ts +7 -0
- package/src/tables/FileTable.ts +82 -1
- package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
- package/test/FileByteAccess.test.ts +7 -1
- package/test/FileCopyForOthers.test.ts +702 -0
- package/test/FileDerivedProvenance.test.ts +248 -0
- package/test/FileMediaMetadata.test.ts +44 -0
- package/test/FileStorageError.test.ts +79 -0
- package/test/FileTestEnvironment.ts +3 -0
- package/test/FileVariants.test.ts +604 -0
- package/test/GetFileRoute.test.ts +19 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
3
|
+
import { UserAuth, UserRepo, User } from '@proteinjs/user';
|
|
4
|
+
import { File, FileTable } from '../src/tables/FileTable';
|
|
5
|
+
import { tables } from '../src/tables/tables';
|
|
6
|
+
import { FileStorage } from '../src/FileStorage';
|
|
7
|
+
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
8
|
+
import { FileVariantKind } from '../src/FileVariantMaker';
|
|
9
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A derived File — a variant the seam makes (at ingest with the bytes in hand, or on the read
|
|
13
|
+
* path's first request) and the copy for others — carries its ORIGINAL's provenance: who made the
|
|
14
|
+
* bytes (`origin`, `originModel`), where they came from (`sourceUrl`, `sourcePageUrl`,
|
|
15
|
+
* `retrievedAt`) and the rights that ride with them (`license`, `licenseUrl`, `attribution`) —
|
|
16
|
+
* the columns the table itself names as provenance (`FileTable.provenanceColumns`). A rendition's
|
|
17
|
+
* provenance is its original's: a consumer that labels a thumbnail with what made it, or refuses a
|
|
18
|
+
* non-capture picture as evidence, reads the row it draws, without a join. The library that makes
|
|
19
|
+
* the row writes it, whichever door made the row; a row with no provenance derives rows with none
|
|
20
|
+
* (nothing is invented); a row derived from a derived row carries what that row carries — its
|
|
21
|
+
* original's. The facts of a row's OWN bytes (`size`, `contentHash`, the dimensions) stay the
|
|
22
|
+
* maker's answer, never the original's.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const originalBytes = Buffer.concat([Buffer.from('ORIGINAL:'), Buffer.from(Array.from({ length: 64 }, (_, i) => i))]);
|
|
26
|
+
/** What the copy-for-others maker takes off a copy (the location marker of the real one). */
|
|
27
|
+
const COPY_MARK = Buffer.from('-FOR-OTHERS');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every provenance column populated at once — whatever the story, each is seen to travel. The
|
|
31
|
+
* fixture is tied to the table's list below: a provenance column added to the table must be added
|
|
32
|
+
* here, and is then proven to travel like the rest.
|
|
33
|
+
*/
|
|
34
|
+
const PROVENANCE = {
|
|
35
|
+
origin: 'generation',
|
|
36
|
+
originModel: 'vendor/picture-model-1',
|
|
37
|
+
sourceUrl: 'https://source.test/pictures/1.jpg',
|
|
38
|
+
sourcePageUrl: 'https://source.test/pages/1',
|
|
39
|
+
retrievedAt: new Date('2026-09-25T10:00:00.000Z'),
|
|
40
|
+
license: 'CC BY-SA 4.0',
|
|
41
|
+
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
|
|
42
|
+
attribution: 'Photo by Someone, CC BY-SA 4.0',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
|
|
46
|
+
class ProxyDriver implements FileStorageDriver {
|
|
47
|
+
readonly store = new Map<string, Buffer>();
|
|
48
|
+
|
|
49
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
50
|
+
this.store.set(file.id, Buffer.from(fileData, 'base64'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getFileData(fileId: string): Promise<string> {
|
|
54
|
+
const data = this.store.get(fileId);
|
|
55
|
+
if (data === undefined) {
|
|
56
|
+
throw new Error(`No such object: ${fileId}`);
|
|
57
|
+
}
|
|
58
|
+
return data.toString('base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
62
|
+
this.store.set(fileId, Buffer.from(data, 'base64'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
66
|
+
this.store.delete(fileId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const testEnv = new FileTestEnvironment();
|
|
71
|
+
type UserAuthInternals = { userRepo?: unknown };
|
|
72
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
73
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
74
|
+
|
|
75
|
+
let owner: User;
|
|
76
|
+
let recipient: User;
|
|
77
|
+
const reachableFileIds = new Set<string>();
|
|
78
|
+
|
|
79
|
+
/** The stub variant maker: a picture's `preview` and `stage` — bytes marked with the kind, a type, dimensions. Keeps the copy mark on (the real one keeps a picture's tags for its owner). */
|
|
80
|
+
const registerVariantMaker = () => {
|
|
81
|
+
objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
|
|
82
|
+
{
|
|
83
|
+
appliesTo: (file: File) => file.type.startsWith('image/'),
|
|
84
|
+
make: async (file: File, bytes: Buffer, kind: FileVariantKind) => ({
|
|
85
|
+
bytes: Buffer.concat([Buffer.from(`${kind.toUpperCase()}:`), bytes.subarray(0, 8), COPY_MARK]),
|
|
86
|
+
type: 'image/webp',
|
|
87
|
+
width: kind === 'stage' ? 1600 : 512,
|
|
88
|
+
height: kind === 'stage' ? 800 : 256,
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The stub copy maker: a picture's copy is its bytes with the mark taken off. */
|
|
95
|
+
const registerCopyMaker = () => {
|
|
96
|
+
objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
|
|
97
|
+
{
|
|
98
|
+
appliesTo: (file: File) => file.type.startsWith('image/'),
|
|
99
|
+
make: async (file: File, bytes: Buffer) => bytes.subarray(0, bytes.length - COPY_MARK.length),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
await testEnv.beforeAll();
|
|
106
|
+
objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
107
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
108
|
+
objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
|
|
109
|
+
// The content leg vouches for the recipient alone (a share they accepted).
|
|
110
|
+
{
|
|
111
|
+
canReadViaReference: async (fileId: string) =>
|
|
112
|
+
reachableFileIds.has(fileId) && new UserRepo().getUser().id === recipient.id,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
owner = await testEnv.createUser({ name: 'File owner', email: 'provenance-owner@test.local' });
|
|
116
|
+
recipient = await testEnv.createUser({ name: 'Share recipient', email: 'provenance-recipient@test.local' });
|
|
117
|
+
testEnv.actAs(owner);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterAll(async () => {
|
|
121
|
+
delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
|
|
122
|
+
delete objectCache()['@proteinjs/db-file/FileVariantMaker'];
|
|
123
|
+
delete objectCache()['@proteinjs/db-file/FileCopyForOthers'];
|
|
124
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
125
|
+
await testEnv.afterAll();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const driver = new ProxyDriver();
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
reachableFileIds.clear();
|
|
132
|
+
registerVariantMaker();
|
|
133
|
+
registerCopyMaker();
|
|
134
|
+
testEnv.setDriver(driver);
|
|
135
|
+
testEnv.actAs(owner);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** A picture of the owner's, with (or without) its provenance, stored with no variant. */
|
|
139
|
+
const createOwnerPicture = async (name: string, provenance: Partial<File> = {}): Promise<File> =>
|
|
140
|
+
await new FileStorage().createFile(
|
|
141
|
+
{
|
|
142
|
+
name,
|
|
143
|
+
type: 'image/jpeg',
|
|
144
|
+
size: originalBytes.length,
|
|
145
|
+
width: 4032,
|
|
146
|
+
height: 3024,
|
|
147
|
+
contentHash: 'a'.repeat(64),
|
|
148
|
+
...provenance,
|
|
149
|
+
} as File,
|
|
150
|
+
Buffer.concat([originalBytes, COPY_MARK]).toString('base64')
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const rowAsSystem = async (fileId: string): Promise<File> => (await getDbAsSystem().get(tables.File, { id: fileId }))!;
|
|
154
|
+
|
|
155
|
+
/** The provenance a row carries, column for column as the table names them — `null` for a column it does not hold. */
|
|
156
|
+
const provenanceOf = (row: File): Record<string, unknown> =>
|
|
157
|
+
FileTable.provenanceColumns.reduce<Record<string, unknown>>(
|
|
158
|
+
(carried, column) => ({ ...carried, [column]: row[column] ?? null }),
|
|
159
|
+
{}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const NO_PROVENANCE = provenanceOf({} as File);
|
|
163
|
+
|
|
164
|
+
/** The copy for others of a file, made by a recipient's read of its bytes. */
|
|
165
|
+
const copyForOthersOf = async (file: File): Promise<File> => {
|
|
166
|
+
reachableFileIds.add(file.id);
|
|
167
|
+
testEnv.actAs(recipient);
|
|
168
|
+
await new FileStorage().getFileData(file.id);
|
|
169
|
+
testEnv.actAs(owner);
|
|
170
|
+
const copyId = (await rowAsSystem(file.id)).copyForOthers?._id;
|
|
171
|
+
expect(copyId).toBeTruthy();
|
|
172
|
+
return await rowAsSystem(copyId!);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
describe('the list is the table’s own', () => {
|
|
176
|
+
it('names the producer, the web source and the rights record — and the fixture covers every column it names, so a column added there is proven to travel', () => {
|
|
177
|
+
expect([...FileTable.provenanceColumns].sort()).toEqual(Object.keys(PROVENANCE).sort());
|
|
178
|
+
expect(FileTable.provenanceColumns).toEqual(expect.arrayContaining(['origin', 'originModel']));
|
|
179
|
+
// The facts of a row's own bytes are not provenance.
|
|
180
|
+
expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['contentHash']));
|
|
181
|
+
expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['size']));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('a derived row carries its original’s provenance', () => {
|
|
186
|
+
it("the ingest door: every variant deriveVariants makes carries the original's provenance, column for column; the maker's own answer (bytes, type, dimensions) stays its own", async () => {
|
|
187
|
+
const file = await createOwnerPicture('made.jpg', PROVENANCE);
|
|
188
|
+
|
|
189
|
+
const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
|
|
190
|
+
|
|
191
|
+
expect(Object.keys(variants).sort()).toEqual(['preview', 'stage']);
|
|
192
|
+
for (const kind of ['preview', 'stage'] as const) {
|
|
193
|
+
const variant = await rowAsSystem(variants[kind]!.id);
|
|
194
|
+
expect(provenanceOf(variant)).toEqual(PROVENANCE);
|
|
195
|
+
expect([variant.type, variant.width, variant.height]).toEqual([
|
|
196
|
+
'image/webp',
|
|
197
|
+
kind === 'stage' ? 1600 : 512,
|
|
198
|
+
kind === 'stage' ? 800 : 256,
|
|
199
|
+
]);
|
|
200
|
+
expect(variant.size).toEqual(driver.store.get(variant.id)!.length);
|
|
201
|
+
// The hash is of THESE bytes, which the maker did not answer: never the original's.
|
|
202
|
+
expect(variant.contentHash ?? null).toBeNull();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("the read path's door: the variant getVariant derives on the first request carries the original's provenance", async () => {
|
|
207
|
+
const file = await createOwnerPicture('existing.jpg', PROVENANCE);
|
|
208
|
+
expect((await rowAsSystem(file.id)).stage?._id ?? null).toBeNull();
|
|
209
|
+
|
|
210
|
+
const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
211
|
+
|
|
212
|
+
expect(stage.variantOf?._id).toEqual(file.id);
|
|
213
|
+
expect(provenanceOf(await rowAsSystem(stage.id))).toEqual(PROVENANCE);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("the copy for others carries the original's provenance", async () => {
|
|
217
|
+
const file = await createOwnerPicture('shared.jpg', PROVENANCE);
|
|
218
|
+
|
|
219
|
+
const copy = await copyForOthersOf(file);
|
|
220
|
+
|
|
221
|
+
expect(copy.scope).toEqual(owner.id);
|
|
222
|
+
expect(provenanceOf(copy)).toEqual(PROVENANCE);
|
|
223
|
+
expect(copy.contentHash ?? null).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('a row derived from a derived row carries what that row carries — its original’s: the copy for others of a stage variant', async () => {
|
|
227
|
+
const file = await createOwnerPicture('chain.jpg', PROVENANCE);
|
|
228
|
+
const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
|
|
229
|
+
reachableFileIds.add(file.id); // a variant's reachability is its original's: the grant names the original
|
|
230
|
+
|
|
231
|
+
const copyOfStage = await copyForOthersOf(await rowAsSystem(stage.id));
|
|
232
|
+
|
|
233
|
+
expect(copyOfStage.variantOf?._id ?? null).toBeNull(); // a copy's own id stays nobody's but the owner's
|
|
234
|
+
expect(provenanceOf(copyOfStage)).toEqual(PROVENANCE);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('a row with NO provenance derives rows with none — nothing is invented, at any door', async () => {
|
|
238
|
+
const file = await createOwnerPicture('plain.jpg');
|
|
239
|
+
expect(provenanceOf(await rowAsSystem(file.id))).toEqual(NO_PROVENANCE);
|
|
240
|
+
|
|
241
|
+
const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
|
|
242
|
+
const copy = await copyForOthersOf(file);
|
|
243
|
+
|
|
244
|
+
expect(provenanceOf(await rowAsSystem(variants.preview!.id))).toEqual(NO_PROVENANCE);
|
|
245
|
+
expect(provenanceOf(await rowAsSystem(variants.stage!.id))).toEqual(NO_PROVENANCE);
|
|
246
|
+
expect(provenanceOf(copy)).toEqual(NO_PROVENANCE);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -60,6 +60,49 @@ describe('File media metadata columns', () => {
|
|
|
60
60
|
expect(row.durationMs).toEqual(24_500);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('round-trips the producer attribution — how the bytes came to exist (`origin`)', async () => {
|
|
64
|
+
const file = await new FileStorage().createFile(
|
|
65
|
+
{ name: 'frame.png', type: 'image/png', size: 4, width: 1440, height: 900, origin: 'mockup' } as File,
|
|
66
|
+
Buffer.from('png!').toString('base64')
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const row = await getDbAsSystem().get(tables.File, { id: file.id });
|
|
70
|
+
expect(row.origin).toEqual('mockup');
|
|
71
|
+
// Absent for everything that does not state its producer (a NULL column reads back empty).
|
|
72
|
+
const plain = await new FileStorage().createFile(
|
|
73
|
+
{ name: 'note.txt', type: 'text/plain', size: 2 } as File,
|
|
74
|
+
Buffer.from('hi').toString('base64')
|
|
75
|
+
);
|
|
76
|
+
expect((await getDbAsSystem().get(tables.File, { id: plain.id })).origin).toBeFalsy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('round-trips the model beside the producer — which model made the bytes (`originModel`), only where one did', async () => {
|
|
80
|
+
const made = await new FileStorage().createFile(
|
|
81
|
+
{
|
|
82
|
+
name: 'mark.png',
|
|
83
|
+
type: 'image/png',
|
|
84
|
+
size: 4,
|
|
85
|
+
width: 1024,
|
|
86
|
+
height: 1024,
|
|
87
|
+
origin: 'generation',
|
|
88
|
+
originModel: 'example-image-model-1',
|
|
89
|
+
} as File,
|
|
90
|
+
Buffer.from('mark').toString('base64')
|
|
91
|
+
);
|
|
92
|
+
const madeRow = await getDbAsSystem().get(tables.File, { id: made.id });
|
|
93
|
+
expect(madeRow.origin).toEqual('generation');
|
|
94
|
+
expect(madeRow.originModel).toEqual('example-image-model-1');
|
|
95
|
+
|
|
96
|
+
// A producer that is not a model states its kind and no model: the two columns are independent.
|
|
97
|
+
const uploaded = await new FileStorage().createFile(
|
|
98
|
+
{ name: 'photo.jpg', type: 'image/jpeg', size: 5, origin: 'upload' } as File,
|
|
99
|
+
Buffer.from('photo').toString('base64')
|
|
100
|
+
);
|
|
101
|
+
const uploadedRow = await getDbAsSystem().get(tables.File, { id: uploaded.id });
|
|
102
|
+
expect(uploadedRow.origin).toEqual('upload');
|
|
103
|
+
expect(uploadedRow.originModel).toBeFalsy();
|
|
104
|
+
});
|
|
105
|
+
|
|
63
106
|
it('round-trips the rights record on a web-saved copy — licence name, deed URL and the credit sentence', async () => {
|
|
64
107
|
const file = await new FileStorage().createFile(
|
|
65
108
|
{
|
|
@@ -97,5 +140,6 @@ describe('File media metadata columns', () => {
|
|
|
97
140
|
expect(row.durationMs ?? undefined).toBeUndefined();
|
|
98
141
|
expect(row.license ?? undefined).toBeUndefined();
|
|
99
142
|
expect(row.attribution ?? undefined).toBeUndefined();
|
|
143
|
+
expect(row.originModel ?? undefined).toBeUndefined();
|
|
100
144
|
});
|
|
101
145
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import util from 'util';
|
|
2
|
+
import { FileStorageError } from '../src/FileStorageError';
|
|
3
|
+
|
|
4
|
+
/** An obviously fake credential — what a store client's error carries in its request headers. */
|
|
5
|
+
const MARKER = 'FAKE-TOKEN-MARKER';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a driver's error carries is the driver's to own: a code, a plain message, the HTTP status.
|
|
9
|
+
* Whatever a caller does with one — print it, serialise it, inspect it — reads those three facts.
|
|
10
|
+
*/
|
|
11
|
+
describe('FileStorageError', () => {
|
|
12
|
+
it('carries the code, a plain message and the status — and nothing else', () => {
|
|
13
|
+
const error = new FileStorageError('not-found', 'getFileData failed for file file-1', {
|
|
14
|
+
status: 404,
|
|
15
|
+
detail: 'No such object: a-bucket/file-1',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(error).toBeInstanceOf(Error);
|
|
19
|
+
expect(error.code).toEqual('not-found');
|
|
20
|
+
expect(error.status).toEqual(404);
|
|
21
|
+
expect(error.message).toEqual(
|
|
22
|
+
'getFileData failed for file file-1: not-found (HTTP 404) — No such object: a-bucket/file-1'
|
|
23
|
+
);
|
|
24
|
+
expect(Object.keys(error).sort()).toEqual(['code', 'name', 'status']);
|
|
25
|
+
expect(Object.getOwnPropertyNames(error).sort()).toEqual(['code', 'message', 'name', 'stack', 'status']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('never reads an object into its message — only text is kept as the store’s detail', () => {
|
|
29
|
+
const vendorError = { message: 'boom', config: { headers: { Authorization: `Bearer ${MARKER}` } } };
|
|
30
|
+
|
|
31
|
+
const error = new FileStorageError('unknown', 'createFile failed for file file-2', { detail: vendorError });
|
|
32
|
+
|
|
33
|
+
expect(error.message).toEqual('createFile failed for file file-2: unknown');
|
|
34
|
+
expect(util.inspect(error, { depth: null, showHidden: true })).not.toContain(MARKER);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps a store’s text plain: one line, bounded, no URL query string, no bearer credential', () => {
|
|
38
|
+
const detail =
|
|
39
|
+
`Request to https://storage.example/b/a-bucket/o/file-3?X-Goog-Signature=${MARKER}&X-Goog-Expires=900 failed\n` +
|
|
40
|
+
`with headers Authorization: Bearer ${MARKER} ` +
|
|
41
|
+
'x'.repeat(1000);
|
|
42
|
+
|
|
43
|
+
const error = new FileStorageError('unavailable', 'getSignedUrl failed for file file-3', { status: 503, detail });
|
|
44
|
+
|
|
45
|
+
expect(error.message).not.toContain(MARKER);
|
|
46
|
+
expect(error.message).not.toContain('\n');
|
|
47
|
+
expect(error.message).toContain('https://storage.example/b/a-bucket/o/file-3 failed');
|
|
48
|
+
expect(error.message).toContain('Bearer [masked]');
|
|
49
|
+
expect(error.message.length).toBeLessThan(400);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('is recognised by its shape, so a copy from a duplicate package still reads as not-found', () => {
|
|
53
|
+
const fromAnotherCopy = Object.assign(new Error('getFileData failed'), {
|
|
54
|
+
name: 'FileStorageError',
|
|
55
|
+
code: 'not-found',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(FileStorageError.is(fromAnotherCopy)).toBe(true);
|
|
59
|
+
expect(FileStorageError.isNotFound(fromAnotherCopy)).toBe(true);
|
|
60
|
+
expect(FileStorageError.isNotFound(new FileStorageError('forbidden', 'getFileData failed'))).toBe(false);
|
|
61
|
+
expect(FileStorageError.is(Object.assign(new Error('a vendor error'), { code: 404 }))).toBe(false);
|
|
62
|
+
expect(FileStorageError.is(Object.assign(new Error('x'), { name: 'FileStorageError', code: 'made-up' }))).toBe(
|
|
63
|
+
false
|
|
64
|
+
);
|
|
65
|
+
expect(FileStorageError.is(undefined)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('reads one HTTP status one way', () => {
|
|
69
|
+
expect(FileStorageError.codeForStatus(404)).toEqual('not-found');
|
|
70
|
+
expect(FileStorageError.codeForStatus(412)).toEqual('precondition-failed');
|
|
71
|
+
expect(FileStorageError.codeForStatus(401)).toEqual('forbidden');
|
|
72
|
+
expect(FileStorageError.codeForStatus(403)).toEqual('forbidden');
|
|
73
|
+
expect(FileStorageError.codeForStatus(408)).toEqual('unavailable');
|
|
74
|
+
expect(FileStorageError.codeForStatus(429)).toEqual('unavailable');
|
|
75
|
+
expect(FileStorageError.codeForStatus(503)).toEqual('unavailable');
|
|
76
|
+
expect(FileStorageError.codeForStatus(400)).toEqual('unknown');
|
|
77
|
+
expect(FileStorageError.codeForStatus(undefined)).toEqual('unknown');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -62,6 +62,9 @@ export class FileTestEnvironment {
|
|
|
62
62
|
// implementations from the generated source graph; a graph-less harness lookup THROWS).
|
|
63
63
|
// Default: no registered resolvers. Suites exercising the seam overwrite this entry.
|
|
64
64
|
objectCache['@proteinjs/db-file/FileReachabilityResolver'] = [];
|
|
65
|
+
// The copy-for-others seam a non-owner read consults (same graph-less rule). Default: no
|
|
66
|
+
// maker — everyone is served the original. Suites exercising the seam overwrite this entry.
|
|
67
|
+
objectCache['@proteinjs/db-file/FileCopyForOthers'] = [];
|
|
65
68
|
Session.setData({
|
|
66
69
|
sessionId: 'test-session',
|
|
67
70
|
user: 'guest',
|