@@ -199,6 +199,31 @@ describe('attachments permission matrix (#2755)', () => {
199199 expect ( allowed . status ) . toBeLessThan ( 300 ) ;
200200 } ) ;
201201
202+ // ── (item 3) edit-on-parent: read is not enough to attach ────────────
203+ it ( '(item 3) a member who can READ but not EDIT the parent cannot attach — yet can still list its attachments' , async ( ) => {
204+ // att_readonly is public_read: every member reads it, only the owner edits.
205+ const ro = await ql . insert ( 'att_readonly' , { name : 'ro' , owner_id : adminId } , { context : { ...SYS } } ) ;
206+
207+ // memberA can READ the record…
208+ const canRead = await stack . apiAs ( memberATok , 'GET' , `/data/att_readonly/${ ro . id } ` ) ;
209+ expect ( canRead . status ) . toBe ( 200 ) ;
210+
211+ // …but attaching requires EDIT (Salesforce parity) → 403.
212+ const file = await uploadFile ( stack , memberATok ) ;
213+ const denied = await attach ( memberATok , 'att_readonly' , ro . id , file ) ;
214+ expect ( denied . status ) . toBe ( 403 ) ;
215+ expect ( ( ( await denied . json ( ) ) as any ) . code ) . toBe ( 'ATTACHMENT_PARENT_ACCESS' ) ;
216+
217+ // The owner (admin) can attach, and memberA — who can read the parent —
218+ // then sees that attachment in the list (read-visibility, item 1).
219+ const adminFile = await uploadFile ( stack , adminTok ) ;
220+ const attached = await attach ( adminTok , 'att_readonly' , ro . id , adminFile ) ;
221+ expect ( attached . status ) . toBeLessThan ( 300 ) ;
222+ const list = await stack . apiAs ( memberATok , 'GET' , '/data/sys_attachment' ) ;
223+ const rows = ( ( await list . json ( ) ) as any ) . records ?? [ ] ;
224+ expect ( rows . some ( ( r : any ) => r . file_id === adminFile ) ) . toBe ( true ) ;
225+ } ) ;
226+
202227 // ── (a) delete gate ──────────────────────────────────────────────────
203228 it ( '(a, DOGFOOD FINDING pin) the everyone baseline carries NO delete bit: an ungranted member cannot delete even their OWN attachment (403 PERMISSION_DENIED)' , async ( ) => {
204229 // ADR-0090 D5 / #2753: `member_default` is the anchor-bound baseline and
@@ -246,34 +271,79 @@ describe('attachments permission matrix (#2755)', () => {
246271 expect ( ownDelete . status ) . toBeLessThan ( 300 ) ;
247272 } ) ;
248273
249- // ── (c) known gap: listing does not inherit parent visibility ──── ────
250- it ( '(c, KNOWN GAP pin ) a member can LIST sys_attachment rows pointing at records they cannot read' , async ( ) => {
274+ // ── (c) attachment LIST inherits parent visibility (#2970 item 1) ────
275+ it ( '(c) a member CANNOT list/read sys_attachment rows whose parent record they cannot read' , async ( ) => {
251276 const adminFile = await uploadFile ( stack , adminTok ) ;
252277 const created = await attach ( adminTok , 'att_secret' , secretId , adminFile ) ;
253278 expect ( created . status ) . toBeLessThan ( 300 ) ;
279+ const secretRow = await ql . findOne ( 'sys_attachment' , { where : { file_id : adminFile } , context : SYS } ) ;
254280
255281 // memberB cannot read the att_secret PARENT…
256282 const parentRead = await stack . apiAs ( memberBTok , 'GET' , `/data/att_secret/${ secretId } ` ) ;
257283 expect ( [ 403 , 404 ] ) . toContain ( parentRead . status ) ;
258284
259- // …but CAN see the join row (file name, size, parent id) through the
260- // generic list path. Attachment read visibility does not yet inherit
261- // parent-record visibility — enforce-or-remove follow-up filed with
262- // #2755; flip this pin to a denial assertion when inheritance lands.
285+ // …and now the join row is filtered out of the generic list too (the
286+ // read-visibility middleware inherits the parent's visibility, and the
287+ // list `total` is filtered identically via count()).
263288 const list = await stack . apiAs ( memberBTok , 'GET' , '/data/sys_attachment' ) ;
264289 expect ( list . status ) . toBe ( 200 ) ;
265- const rows = ( ( await list . json ( ) ) as any ) . records ?? [ ] ;
266- const leaked = rows . some ( ( r : any ) => r . parent_object === 'att_secret' && r . parent_id === secretId ) ;
267- expect ( leaked , 'KNOWN GAP: join rows of invisible parents are listable' ) . toBe ( true ) ;
290+ const body = ( await list . json ( ) ) as any ;
291+ const rows = body . records ?? [ ] ;
292+ expect (
293+ rows . some ( ( r : any ) => r . id === secretRow . id ) ,
294+ 'attachment of an invisible parent must not be listable' ,
295+ ) . toBe ( false ) ;
296+ // total must not leak the hidden row's existence either.
297+ expect ( rows . every ( ( r : any ) => r . parent_object !== 'att_secret' || r . parent_id !== secretId ) ) . toBe ( true ) ;
298+
299+ // A by-id read of the hidden attachment is a 404/403, not a leak.
300+ const byId = await stack . apiAs ( memberBTok , 'GET' , `/data/sys_attachment/${ secretRow . id } ` ) ;
301+ expect ( [ 403 , 404 ] ) . toContain ( byId . status ) ;
302+
303+ // Control: memberB CAN still see attachments on a record they can read.
304+ const okFile = await uploadFile ( stack , memberBTok ) ;
305+ const okAttach = await attach ( memberBTok , 'att_case' , caseAId , okFile ) ;
306+ expect ( okAttach . status ) . toBeLessThan ( 300 ) ;
307+ const okList = await stack . apiAs ( memberBTok , 'GET' , '/data/sys_attachment' ) ;
308+ const okRows = ( ( await okList . json ( ) ) as any ) . records ?? [ ] ;
309+ expect ( okRows . some ( ( r : any ) => r . file_id === okFile ) ) . toBe ( true ) ;
268310 } ) ;
269311
270- // ── (e-read) known gap: anonymous downloads ──────────────────────────
271- it ( '(e-read, KNOWN GAP pin) anonymous download of a committed file id succeeds (capability URL)' , async ( ) => {
272- const fileId = await uploadFile ( stack , memberATok ) ;
273- const res = await stack . api ( `/storage/files/${ fileId } /url` ) ;
274- // Anyone holding a fileId can mint a download URL without a session.
275- // Authenticated downloads / signed-link gating is a filed follow-up.
276- expect ( res . status ) . toBe ( 200 ) ;
312+ // ── (e-read) attachment downloads inherit parent visibility (#2970 item 2) ──
313+ it ( '(e-read) attachments download requires auth AND read access to a parent record' , async ( ) => {
314+ // A file attached to the admin-owned, private att_secret record.
315+ const adminFile = await uploadFile ( stack , adminTok ) ;
316+ const linked = await attach ( adminTok , 'att_secret' , secretId , adminFile ) ;
317+ expect ( linked . status ) . toBeLessThan ( 300 ) ;
318+
319+ // Anonymous → 401 (was a 200 capability URL before #2970).
320+ const anon = await stack . api ( `/storage/files/${ adminFile } /url` ) ;
321+ expect ( anon . status ) . toBe ( 401 ) ;
322+ expect ( ( ( await anon . json ( ) ) as any ) . code ) . toBe ( 'AUTH_REQUIRED' ) ;
323+
324+ // memberB is authenticated but cannot read att_secret and is not the
325+ // owner → 403.
326+ const denied = await stack . apiAs ( memberBTok , 'GET' , `/storage/files/${ adminFile } /url` ) ;
327+ expect ( denied . status ) . toBe ( 403 ) ;
328+ expect ( ( ( await denied . json ( ) ) as any ) . code ) . toBe ( 'ATTACHMENT_DOWNLOAD_DENIED' ) ;
329+
330+ // The owner (admin) → 200 with a signed URL.
331+ const owner = await stack . apiAs ( adminTok , 'GET' , `/storage/files/${ adminFile } /url` ) ;
332+ expect ( owner . status ) . toBe ( 200 ) ;
333+ expect ( ( ( await owner . json ( ) ) as any ) . url ) . toBeTruthy ( ) ;
334+
335+ // Parent-inherited read: a file on the PUBLIC att_case record is
336+ // downloadable by any member who can read that record — even a
337+ // non-uploader (memberB downloads memberA's attachment).
338+ const caseFile = await uploadFile ( stack , memberATok ) ;
339+ const caseLink = await attach ( memberATok , 'att_case' , caseAId , caseFile ) ;
340+ expect ( caseLink . status ) . toBeLessThan ( 300 ) ;
341+ const inherited = await stack . apiAs ( memberBTok , 'GET' , `/storage/files/${ caseFile } /url` ) ;
342+ expect ( inherited . status ) . toBe ( 200 ) ;
343+
344+ // The stable 302 endpoint is gated the same way (anon → 401).
345+ const anon302 = await stack . api ( `/storage/files/${ adminFile } ` ) ;
346+ expect ( anon302 . status ) . toBe ( 401 ) ;
277347 } ) ;
278348
279349 // ── Part 1: sys_file orphan lifecycle, end to end ─────────────────────
@@ -405,6 +475,31 @@ describe('attachments permission matrix (#2755)', () => {
405475 expect ( report . errors , JSON . stringify ( report . errors ) ) . toEqual ( [ ] ) ;
406476 expect ( await ql . findOne ( 'sys_file' , { where : { id : data . fileId } , context : SYS } ) ) . toBeNull ( ) ;
407477 } ) ;
478+
479+ it ( '(item 4) abandoned sys_upload_session rows are reaped past their expiry window' , async ( ) => {
480+ // Initiate a chunked upload (creates a sys_upload_session) but never
481+ // complete it.
482+ const init = await stack . api ( '/storage/upload/chunked' , {
483+ method : 'POST' ,
484+ headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${ memberATok } ` } ,
485+ body : JSON . stringify ( { filename : 'big.bin' , mimeType : 'application/octet-stream' , totalSize : 10_485_760 } ) ,
486+ } ) ;
487+ expect ( init . status ) . toBe ( 200 ) ;
488+ const { uploadId } = ( ( await init . json ( ) ) as any ) . data ;
489+ const session = await ql . findOne ( 'sys_upload_session' , { where : { id : uploadId } , context : SYS } ) ;
490+ expect ( session ?. id , 'session row created' ) . toBeTruthy ( ) ;
491+
492+ // Backdate expires_at past the 1d TTL grace.
493+ await ql . update (
494+ 'sys_upload_session' ,
495+ { id : uploadId , expires_at : new Date ( Date . now ( ) - 2 * DAY_MS ) } ,
496+ { context : { ...SYS } } ,
497+ ) ;
498+
499+ const report = await lifecycle . sweep ( ) ;
500+ expect ( report . errors , JSON . stringify ( report . errors ) ) . toEqual ( [ ] ) ;
501+ expect ( await ql . findOne ( 'sys_upload_session' , { where : { id : uploadId } , context : SYS } ) ) . toBeNull ( ) ;
502+ } ) ;
408503 } ) ;
409504} ) ;
410505
0 commit comments