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 | */ |
| 21 | import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; |
| 22 | |
| 23 | interface 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. */ |
| 35 | const 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. |
| 64 | const eqTag = (col: unknown, val: unknown) => ({ op: 'eq', col, val }); |
| 65 | const gtTag = (col: unknown, val: unknown) => ({ op: 'gt', col, val }); |
| 66 | const isNullTag = (col: unknown) => ({ op: 'isNull', col }); |
| 67 | const andTag = (...parts: unknown[]) => ({ op: 'and', parts }); |
| 68 | |
| 69 | // Column identity markers — the schema mock returns these; predicates carry them. |
| 70 | const 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 | |
| 79 | mock.module('drizzle-orm', () => ({ |
| 80 | eq: eqTag, |
| 81 | gt: gtTag, |
| 82 | isNull: isNullTag, |
| 83 | and: andTag, |
| 84 | sql: { raw: (s: string) => s }, |
| 85 | })); |
| 86 | |
| 87 | mock.module('../db/schema.js', () => ({ |
| 88 | projectStorageShareLinks: COL, |
| 89 | })); |
| 90 | |
| 91 | /** Flatten an and()/leaf predicate tree into a list of leaf tags. */ |
| 92 | function 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? */ |
| 99 | function 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 | |
| 115 | function 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 | |
| 180 | let db = makeDb(); |
| 181 | mock.module('../db/client.js', () => ({ getDb: () => db })); |
| 182 | |
| 183 | // getFile: owner-scoped existence. Throws if the project doesn't own the file. |
| 184 | const 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 | }); |
| 188 | mock.module('./storage.js', () => ({ getFile })); |
| 189 | |
| 190 | let svc: typeof import('./storage-share-links.js'); |
| 191 | beforeAll(async () => { |
| 192 | svc = await import('./storage-share-links.js'); |
| 193 | }); |
| 194 | |
| 195 | beforeEach(() => { |
| 196 | state.rows = []; |
| 197 | state.files = new Set(); |
| 198 | state.throwOnSelect = false; |
| 199 | db = makeDb(); |
| 200 | getFile.mockClear(); |
| 201 | }); |
| 202 | |
| 203 | const P = 'p_owner'; |
| 204 | const OTHER = 'p_other'; |
| 205 | const FILE = 'f_shared'; |
| 206 | |
| 207 | describe('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 | |
| 227 | describe('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 | |
| 283 | describe('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 | |
| 296 | describe('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 | }); |