@proteinjs/db-file 1.7.3 → 1.7.5
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 +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/FileReachabilityResolver.d.ts +27 -0
- package/dist/src/FileReachabilityResolver.d.ts.map +1 -0
- package/dist/src/FileReachabilityResolver.js +9 -0
- package/dist/src/FileReachabilityResolver.js.map +1 -0
- package/dist/src/FileStorage.d.ts +28 -3
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +72 -11
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/routes/getFile.d.ts +4 -1
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +4 -1
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/test/FileByteAccess.test.d.ts +2 -0
- package/dist/test/FileByteAccess.test.d.ts.map +1 -0
- package/dist/test/FileByteAccess.test.js +252 -0
- package/dist/test/FileByteAccess.test.js.map +1 -0
- package/dist/test/FileSharedReachability.test.d.ts +2 -0
- package/dist/test/FileSharedReachability.test.d.ts.map +1 -0
- package/dist/test/FileSharedReachability.test.js +358 -0
- package/dist/test/FileSharedReachability.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -1
- package/dist/test/FileTestEnvironment.js +4 -0
- package/dist/test/FileTestEnvironment.js.map +1 -1
- package/generated/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/FileReachabilityResolver.ts +29 -0
- package/src/FileStorage.ts +53 -4
- package/src/routes/getFile.ts +5 -2
- package/test/FileByteAccess.test.ts +143 -0
- package/test/FileSharedReachability.test.ts +229 -0
- package/test/FileTestEnvironment.ts +4 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { UserAuth, UserRepo, User } from '@proteinjs/user';
|
|
3
|
+
import { File } from '../src/tables/FileTable';
|
|
4
|
+
import { FileStorage } from '../src/FileStorage';
|
|
5
|
+
import { FileStorageDriver } from '../src/FileStorageDriver';
|
|
6
|
+
import { getFile } from '../src/routes/getFile';
|
|
7
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
8
|
+
|
|
9
|
+
/** Every byte value once — a payload that corrupts under any text/base64 mix-up. */
|
|
10
|
+
const rawBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shared-content file leg (SHARING_EXPANSION §0 found defect / §1 fix shape): `/file/:id`
|
|
14
|
+
* used the caller's SCOPED row read as its whole access check, so a file embedded in content
|
|
15
|
+
* shared WITH the caller — a shared thought's media — 404'd for every share recipient. The fix
|
|
16
|
+
* is the FileReachabilityResolver seam: after the scoped read misses, content packages answer
|
|
17
|
+
* "can the current user read a row that references this file?" through their own grant-filtered
|
|
18
|
+
* reads, and only then is the row served via system read.
|
|
19
|
+
*
|
|
20
|
+
* This suite proves the SEAM CONTRACT at the route with a stub resolver (content packages sit
|
|
21
|
+
* above db-file — the real thought-media resolver is integration-tested in thought-server):
|
|
22
|
+
* a reachable file serves byte-identical to a non-owner, an unreachable one still 404s (the fix
|
|
23
|
+
* must not over-open), the owner's scoped leg never consults resolvers, and writes stay
|
|
24
|
+
* owner-scoped.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
|
|
28
|
+
class ProxyDriver implements FileStorageDriver {
|
|
29
|
+
readonly store = new Map<string, Buffer>();
|
|
30
|
+
|
|
31
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
32
|
+
this.store.set(file.id, Buffer.from(fileData, 'base64'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getFileData(fileId: string): Promise<string> {
|
|
36
|
+
const data = this.store.get(fileId);
|
|
37
|
+
if (data === undefined) {
|
|
38
|
+
throw new Error(`No such object: ${fileId}`);
|
|
39
|
+
}
|
|
40
|
+
return data.toString('base64');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
44
|
+
this.store.set(fileId, Buffer.from(data, 'base64'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
48
|
+
this.store.delete(fileId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Proxy driver plus a counting signed-URL mint — the GCS serving shape, minting observable. */
|
|
53
|
+
class SignedUrlDriver extends ProxyDriver {
|
|
54
|
+
mintCount = 0;
|
|
55
|
+
|
|
56
|
+
async getSignedUrl(fileId: string): Promise<string> {
|
|
57
|
+
this.mintCount++;
|
|
58
|
+
return `https://signed.test/blob/${fileId}?sig=test`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Minimal express Response recorder — the route only touches these members. */
|
|
63
|
+
class ResponseRecorder {
|
|
64
|
+
statusCode?: number;
|
|
65
|
+
body?: unknown;
|
|
66
|
+
redirectUrl?: string;
|
|
67
|
+
headers: Record<string, string> = {};
|
|
68
|
+
|
|
69
|
+
status(code: number): ResponseRecorder {
|
|
70
|
+
this.statusCode = code;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
send(body: unknown): ResponseRecorder {
|
|
75
|
+
this.body = body;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setHeader(name: string, value: string): void {
|
|
80
|
+
this.headers[name.toLowerCase()] = value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
redirect(status: number, url: string): void {
|
|
84
|
+
this.statusCode = status;
|
|
85
|
+
this.redirectUrl = url;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type RouteRequest = Parameters<typeof getFile.onRequest>[0];
|
|
90
|
+
type RouteResponse = Parameters<typeof getFile.onRequest>[1];
|
|
91
|
+
|
|
92
|
+
const invokeRoute = async (fileId: string): Promise<ResponseRecorder> => {
|
|
93
|
+
const response = new ResponseRecorder();
|
|
94
|
+
await getFile.onRequest(
|
|
95
|
+
{ params: { id: fileId }, headers: {} } as unknown as RouteRequest,
|
|
96
|
+
response as unknown as RouteResponse
|
|
97
|
+
);
|
|
98
|
+
return response;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const testEnv = new FileTestEnvironment();
|
|
102
|
+
type UserAuthInternals = { userRepo?: unknown };
|
|
103
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
104
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
105
|
+
|
|
106
|
+
let owner: User;
|
|
107
|
+
let recipient: User;
|
|
108
|
+
/** File ids the stub resolver reports as reachable for the current user. */
|
|
109
|
+
const reachableFileIds = new Set<string>();
|
|
110
|
+
/** How many times the seam was consulted — the owner's scoped leg must never pay for it. */
|
|
111
|
+
let resolverConsultations = 0;
|
|
112
|
+
|
|
113
|
+
beforeAll(async () => {
|
|
114
|
+
await testEnv.beforeAll();
|
|
115
|
+
// The route gates on UserAuth.isLoggedIn(), which resolves its user repo from the source
|
|
116
|
+
// repository — seed it (tests don't load the generated source graph) and reset the static cache.
|
|
117
|
+
objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
118
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
119
|
+
// The seam under test, stubbed (the real thought-media resolver lives above this package).
|
|
120
|
+
objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
|
|
121
|
+
{
|
|
122
|
+
canReadViaReference: async (fileId: string) => {
|
|
123
|
+
resolverConsultations++;
|
|
124
|
+
return reachableFileIds.has(fileId);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
owner = await testEnv.createUser({ name: 'File owner', email: 'reachability-owner@test.local' });
|
|
129
|
+
recipient = await testEnv.createUser({ name: 'Share recipient', email: 'reachability-recipient@test.local' });
|
|
130
|
+
testEnv.actAs(owner);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterAll(async () => {
|
|
134
|
+
delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
|
|
135
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
136
|
+
await testEnv.afterAll();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
reachableFileIds.clear();
|
|
141
|
+
resolverConsultations = 0;
|
|
142
|
+
testEnv.actAs(owner);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const createOwnerFile = async (name: string, type: string): Promise<File> =>
|
|
146
|
+
await new FileStorage().createFile({ name, type, size: rawBytes.length } as File, rawBytes.toString('base64'));
|
|
147
|
+
|
|
148
|
+
describe('shared-content reachability — proxy serving', () => {
|
|
149
|
+
const driver = new ProxyDriver();
|
|
150
|
+
|
|
151
|
+
beforeAll(() => {
|
|
152
|
+
testEnv.setDriver(driver);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("THE DEFECT: a non-owner whom a resolver vouches for is served the owner's file byte-identical", async () => {
|
|
156
|
+
const file = await createOwnerFile('shared-shot.png', 'image/png');
|
|
157
|
+
reachableFileIds.add(file.id);
|
|
158
|
+
|
|
159
|
+
testEnv.actAs(recipient);
|
|
160
|
+
const response = await invokeRoute(file.id);
|
|
161
|
+
|
|
162
|
+
expect(response.statusCode).not.toEqual(404);
|
|
163
|
+
expect(response.headers['content-type']).toEqual('image/png');
|
|
164
|
+
expect(Buffer.isBuffer(response.body)).toBe(true);
|
|
165
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('THE OTHER DIRECTION: a non-owner with no reachability still 404s — the fix must not over-open', async () => {
|
|
169
|
+
const file = await createOwnerFile('private-shot.png', 'image/png');
|
|
170
|
+
|
|
171
|
+
testEnv.actAs(recipient);
|
|
172
|
+
const response = await invokeRoute(file.id);
|
|
173
|
+
|
|
174
|
+
expect(response.statusCode).toEqual(404);
|
|
175
|
+
expect(response.body).toEqual('File not found');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('the owner is served by their scoped read alone — resolvers are never consulted', async () => {
|
|
179
|
+
const file = await createOwnerFile('own-shot.png', 'image/png');
|
|
180
|
+
|
|
181
|
+
const response = await invokeRoute(file.id);
|
|
182
|
+
|
|
183
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
184
|
+
expect(resolverConsultations).toEqual(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reachability widens READS only: a vouched-for non-owner's delete leaves the owner's file intact", async () => {
|
|
188
|
+
const file = await createOwnerFile('undeletable.png', 'image/png');
|
|
189
|
+
reachableFileIds.add(file.id);
|
|
190
|
+
|
|
191
|
+
testEnv.actAs(recipient);
|
|
192
|
+
await new FileStorage().deleteFile(file.id);
|
|
193
|
+
|
|
194
|
+
testEnv.actAs(owner);
|
|
195
|
+
const response = await invokeRoute(file.id);
|
|
196
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('shared-content reachability — signed-URL serving', () => {
|
|
201
|
+
const driver = new SignedUrlDriver();
|
|
202
|
+
|
|
203
|
+
beforeAll(() => {
|
|
204
|
+
testEnv.setDriver(driver);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('302-redirects a vouched-for non-owner to a signed URL (the GCS prod shape)', async () => {
|
|
208
|
+
const file = await createOwnerFile('shared-clip.mp4', 'video/mp4');
|
|
209
|
+
reachableFileIds.add(file.id);
|
|
210
|
+
|
|
211
|
+
testEnv.actAs(recipient);
|
|
212
|
+
const response = await invokeRoute(file.id);
|
|
213
|
+
|
|
214
|
+
expect(response.statusCode).toEqual(302);
|
|
215
|
+
expect(response.redirectUrl).toEqual(`https://signed.test/blob/${file.id}?sig=test`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('404s an unreachable non-owner WITHOUT minting a signed URL (a signed URL is a bearer capability)', async () => {
|
|
219
|
+
const file = await createOwnerFile('private-clip.mp4', 'video/mp4');
|
|
220
|
+
const mintsBefore = driver.mintCount;
|
|
221
|
+
|
|
222
|
+
testEnv.actAs(recipient);
|
|
223
|
+
const response = await invokeRoute(file.id);
|
|
224
|
+
|
|
225
|
+
expect(response.statusCode).toEqual(404);
|
|
226
|
+
expect(response.redirectUrl).toBeUndefined();
|
|
227
|
+
expect(driver.mintCount).toEqual(mintsBefore);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -58,6 +58,10 @@ export class FileTestEnvironment {
|
|
|
58
58
|
objectCache['@proteinjs/db/Table'] = [...Object.values(userTables), ...Object.values(fileTables)] as unknown[];
|
|
59
59
|
// The byte-deletion seam under test: prod loads it from the generated source graph.
|
|
60
60
|
objectCache['@proteinjs/db/TableWatcher'] = [new FileStorageTableWatcher()];
|
|
61
|
+
// The shared-content reachability seam getFile consults on a scoped-read miss (prod loads
|
|
62
|
+
// implementations from the generated source graph; a graph-less harness lookup THROWS).
|
|
63
|
+
// Default: no registered resolvers. Suites exercising the seam overwrite this entry.
|
|
64
|
+
objectCache['@proteinjs/db-file/FileReachabilityResolver'] = [];
|
|
61
65
|
Session.setData({
|
|
62
66
|
sessionId: 'test-session',
|
|
63
67
|
user: 'guest',
|