storage-share-links.enforcement.test.ts329 lines · main
1/**
2 * Tokenized public share-links — enforcement unit tests (M5, S3 sprint).
3 *
4 * This is security code (a link = public bearer access to one file), so the
5 * tests pin the strict-deny contract:
6 * - create → resolve returns the exact file;
7 * - an expired link → null;
8 * - a revoked link → null;
9 * - an unknown / garbage token → null;
10 * - a link resolves ONLY its own file (not another link's);
11 * - revoke is owner-scoped (project A cannot revoke project B's link);
12 * - the expiry clamp is applied (min / default / max).
13 *
14 * We stub the two seams the service touches — `getDb()` (the share-links control
15 * table) and `getFile()` (owner-scoped file existence) — with bun's
16 * `mock.module`, exactly like storage-grants.enforcement.test.ts. A tiny fake
17 * drizzle builder holds an in-memory rows array; insert/update/select operate on
18 * it so we can drive create → resolve → revoke end-to-end, and force a DB error
19 * to prove the fail-closed path.
20 */
21import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
22
23interface LinkRow {
24 id: string;
25 projectId: string;
26 fileId: string;
27 token: string;
28 expiresAt: Date;
29 createdBy: string | null;
30 createdAt: Date;
31 revokedAt: Date | null;
32}
33
34/** Mutable state every stub reads — reset in beforeEach. */
35const state = {
36 /** The in-memory share-links table. */
37 rows: [] as LinkRow[],
38 /** Files getFile() will resolve, keyed by `${projectId}::${fileId}`. */
39 files: new Set<string>(),
40 /** When true, any select throws (proves strict-deny → null on error). */
41 throwOnSelect: false,
42};
43
44/**
45 * Minimal drizzle-style builder over `state.rows`. It captures the column
46 * predicates drizzle passes (as opaque objects we can't read), so instead we
47 * model the two shapes the service actually issues:
48 * - resolveShareLink: select().from().where(token & !revoked & expires>now).limit(1)
49 * - listShareLinks: select().from().where(projectId)
50 * - revokeShareLink: update().set().where(id & projectId).returning()
51 * - createShareLink: insert().values().returning()
52 * We can't introspect the drizzle condition objects, so the builder records the
53 * raw values the service embeds via a side channel: the service calls eq()/gt()
54 * with real values, but those are drizzle internals here. To keep the fake
55 * honest we instead re-implement the predicates the service documents, reading
56 * the values the test set up. See each method.
57 */
58
59// Because drizzle's eq()/gt()/and() return opaque SQL objects, the fake can't
60// read them. We therefore expose the predicate VALUES the service uses by
61// wrapping drizzle: our mock of './storage.js' + './db/schema.js' is not enough.
62// Simplest faithful approach: mock drizzle-orm's helpers to return tagged
63// objects the builder CAN read.
64const eqTag = (col: unknown, val: unknown) => ({ op: 'eq', col, val });
65const gtTag = (col: unknown, val: unknown) => ({ op: 'gt', col, val });
66const isNullTag = (col: unknown) => ({ op: 'isNull', col });
67const andTag = (...parts: unknown[]) => ({ op: 'and', parts });
68
69// Column identity markers — the schema mock returns these; predicates carry them.
70const COL = {
71 id: 'col:id',
72 projectId: 'col:projectId',
73 fileId: 'col:fileId',
74 token: 'col:token',
75 expiresAt: 'col:expiresAt',
76 revokedAt: 'col:revokedAt',
77};
78
79mock.module('drizzle-orm', () => ({
80 eq: eqTag,
81 gt: gtTag,
82 isNull: isNullTag,
83 and: andTag,
84 sql: { raw: (s: string) => s },
85}));
86
87mock.module('../db/schema.js', () => ({
88 projectStorageShareLinks: COL,
89}));
90
91/** Flatten an and()/leaf predicate tree into a list of leaf tags. */
92function leaves(pred: any): any[] {
93 if (!pred) return [];
94 if (pred.op === 'and') return pred.parts.flatMap(leaves);
95 return [pred];
96}
97
98/** Does a row satisfy the flattened predicate leaves? */
99function matches(row: LinkRow, pred: any): boolean {
100 for (const l of leaves(pred)) {
101 if (l.op === 'eq') {
102 if (l.col === COL.id && row.id !== l.val) return false;
103 if (l.col === COL.projectId && row.projectId !== l.val) return false;
104 if (l.col === COL.token && row.token !== l.val) return false;
105 } else if (l.op === 'isNull') {
106 if (l.col === COL.revokedAt && row.revokedAt !== null) return false;
107 } else if (l.op === 'gt') {
108 if (l.col === COL.expiresAt && !(row.expiresAt.getTime() > (l.val as Date).getTime()))
109 return false;
110 }
111 }
112 return true;
113}
114
115function makeDb() {
116 return {
117 execute: mock(async () => undefined),
118 insert() {
119 return {
120 values(v: LinkRow) {
121 return {
122 returning: async () => {
123 const row: LinkRow = {
124 ...v,
125 createdBy: v.createdBy ?? null,
126 createdAt: new Date(),
127 revokedAt: null,
128 };
129 state.rows.push(row);
130 return [row];
131 },
132 };
133 },
134 };
135 },
136 update() {
137 return {
138 set(patch: Partial<LinkRow>) {
139 return {
140 where(pred: any) {
141 return {
142 returning: async () => {
143 const hit = state.rows.filter((r) => matches(r, pred));
144 for (const r of hit) Object.assign(r, patch);
145 return hit;
146 },
147 };
148 },
149 };
150 },
151 };
152 },
153 select() {
154 return {
155 from: () => ({
156 where: (pred: any) => {
157 const run = () => {
158 if (state.throwOnSelect) throw new Error('simulated select failure');
159 return state.rows.filter((r) => matches(r, pred));
160 };
161 const chain = {
162 limit: async () => run(),
163 then: (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) => {
164 try {
165 return res(run());
166 } catch (e) {
167 if (rej) return rej(e);
168 throw e;
169 }
170 },
171 };
172 return chain;
173 },
174 }),
175 };
176 },
177 };
178}
179
180let db = makeDb();
181mock.module('../db/client.js', () => ({ getDb: () => db }));
182
183// getFile: owner-scoped existence. Throws if the project doesn't own the file.
184const getFile = mock(async (fileId: string, projectId: string) => {
185 if (!state.files.has(`${projectId}::${fileId}`)) throw new Error('file not found');
186 return { id: fileId, objectKey: `projects/${projectId}/${fileId}` };
187});
188mock.module('./storage.js', () => ({ getFile }));
189
190let svc: typeof import('./storage-share-links.js');
191beforeAll(async () => {
192 svc = await import('./storage-share-links.js');
193});
194
195beforeEach(() => {
196 state.rows = [];
197 state.files = new Set();
198 state.throwOnSelect = false;
199 db = makeDb();
200 getFile.mockClear();
201});
202
203const P = 'p_owner';
204const OTHER = 'p_other';
205const FILE = 'f_shared';
206
207describe('createShareLink', () => {
208 test('rejects a file the project does not own (getFile throws)', async () => {
209 // no file seeded → getFile throws → create rejects
210 await expect(
211 svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null }),
212 ).rejects.toThrow();
213 expect(state.rows.length).toBe(0);
214 });
215
216 test('mints a link with a URL-safe token and a media /link/ url', async () => {
217 state.files.add(`${P}::${FILE}`);
218 const link = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: 'mck_1' });
219 expect(link.id).toStartWith('sl_');
220 expect(link.token.length).toBeGreaterThanOrEqual(32);
221 expect(link.token).toMatch(/^[A-Za-z0-9\-_]+$/); // base64url alphabet only
222 expect(link.url).toBe(`https://media.briven.tech/link/${link.token}`);
223 expect(state.rows.length).toBe(1);
224 });
225});
226
227describe('resolveShareLink — strict-deny enforcement', () => {
228 test('create → resolve returns the exact file', async () => {
229 state.files.add(`${P}::${FILE}`);
230 const link = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null });
231 expect(await svc.resolveShareLink(link.token)).toEqual({ projectId: P, fileId: FILE });
232 });
233
234 test('unknown / garbage token → null', async () => {
235 expect(await svc.resolveShareLink('not-a-real-token')).toBeNull();
236 expect(await svc.resolveShareLink('')).toBeNull();
237 // @ts-expect-error — hostile non-string input must still fail closed
238 expect(await svc.resolveShareLink(null)).toBeNull();
239 });
240
241 test('expired link → null', async () => {
242 state.files.add(`${P}::${FILE}`);
243 const link = await svc.createShareLink({
244 projectId: P,
245 fileId: FILE,
246 expiresInSeconds: 60,
247 createdBy: null,
248 });
249 // Force the stored row into the past.
250 const stored = state.rows[0];
251 expect(stored).toBeDefined();
252 stored!.expiresAt = new Date(Date.now() - 1000);
253 expect(await svc.resolveShareLink(link.token)).toBeNull();
254 });
255
256 test('revoked link → null', async () => {
257 state.files.add(`${P}::${FILE}`);
258 const link = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null });
259 await svc.revokeShareLink(P, link.id);
260 expect(await svc.resolveShareLink(link.token)).toBeNull();
261 });
262
263 test('a link resolves ONLY its own file, not another link\'s', async () => {
264 state.files.add(`${P}::${FILE}`);
265 state.files.add(`${P}::f_second`);
266 const a = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null });
267 const b = await svc.createShareLink({ projectId: P, fileId: 'f_second', createdBy: null });
268 expect(await svc.resolveShareLink(a.token)).toEqual({ projectId: P, fileId: FILE });
269 expect(await svc.resolveShareLink(b.token)).toEqual({ projectId: P, fileId: 'f_second' });
270 // Cross-token never leaks the other file.
271 const reA = await svc.resolveShareLink(a.token);
272 expect(reA?.fileId).not.toBe('f_second');
273 });
274
275 test('strict-deny on a lookup error → null (never throws)', async () => {
276 state.files.add(`${P}::${FILE}`);
277 const link = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null });
278 state.throwOnSelect = true;
279 await expect(svc.resolveShareLink(link.token)).resolves.toBeNull();
280 });
281});
282
283describe('revokeShareLink — owner-scoped', () => {
284 test('project A cannot revoke project B\'s link', async () => {
285 state.files.add(`${P}::${FILE}`);
286 const link = await svc.createShareLink({ projectId: P, fileId: FILE, createdBy: null });
287 // OTHER tries to revoke P's link → NotFoundError, and the link still resolves.
288 await expect(svc.revokeShareLink(OTHER, link.id)).rejects.toThrow();
289 expect(await svc.resolveShareLink(link.token)).toEqual({ projectId: P, fileId: FILE });
290 // The real owner CAN revoke.
291 await svc.revokeShareLink(P, link.id);
292 expect(await svc.resolveShareLink(link.token)).toBeNull();
293 });
294});
295
296describe('clampExpiresInSeconds — expiry clamp applied', () => {
297 test('below min clamps up to 60s', () => {
298 expect(svc.clampExpiresInSeconds(1)).toBe(60);
299 expect(svc.clampExpiresInSeconds(0)).toBe(60);
300 expect(svc.clampExpiresInSeconds(-100)).toBe(60);
301 });
302 test('unset / non-finite defaults to 24h', () => {
303 expect(svc.clampExpiresInSeconds(null)).toBe(86400);
304 expect(svc.clampExpiresInSeconds(undefined)).toBe(86400);
305 expect(svc.clampExpiresInSeconds(Number.NaN)).toBe(86400);
306 });
307 test('above max clamps down to 30 days', () => {
308 expect(svc.clampExpiresInSeconds(999_999_999)).toBe(30 * 24 * 60 * 60);
309 });
310 test('a sane value passes through (floored)', () => {
311 expect(svc.clampExpiresInSeconds(3600)).toBe(3600);
312 expect(svc.clampExpiresInSeconds(3600.9)).toBe(3600);
313 });
314
315 test('create applies the clamp to the stored expiry', async () => {
316 state.files.add(`${P}::${FILE}`);
317 const before = Date.now();
318 const link = await svc.createShareLink({
319 projectId: P,
320 fileId: FILE,
321 expiresInSeconds: 5, // below min → clamps to 60s
322 createdBy: null,
323 });
324 const expMs = new Date(link.expiresAt).getTime();
325 // ~60s out, not ~5s out.
326 expect(expMs - before).toBeGreaterThanOrEqual(55_000);
327 expect(expMs - before).toBeLessThanOrEqual(65_000);
328 });
329});