@@ -198,4 +198,73 @@ describe('attachment access — beforeDelete (uploader or parent editor)', () =>
198198 await expect ( beforeDelete ( deleteCtx ( { id : 'a1' } , { isSystem : true , userId : 'x' } ) ) ) . resolves . toBeUndefined ( ) ;
199199 await expect ( beforeDelete ( deleteCtx ( { id : 'missing' } , { userId : 'x' } ) ) ) . resolves . toBeUndefined ( ) ;
200200 } ) ;
201+
202+ // #4757 — no id AND no `where` is not "nothing matched", it is "nothing was
203+ // ever queried": the engine seeds `{ object }` as the delete AST and hands
204+ // it to `driver.deleteMany`, which empties the table. The gate must refuse
205+ // rather than fall through the empty-`rows` short-circuit.
206+ describe ( 'unscoped multi-delete (no id, no where) — #4757' , ( ) => {
207+ const unscopedShapes : Array < [ string , any ] > = [
208+ [ 'options.multi with no where' , { options : { multi : true } } ] ,
209+ [ 'no id and no options at all' , { } ] ,
210+ [ 'an explicitly null where' , { options : { multi : true , where : null } } ] ,
211+ [ 'an explicitly undefined where' , { options : { multi : true , where : undefined } } ] ,
212+ ] ;
213+
214+ for ( const [ label , input ] of unscopedShapes ) {
215+ it ( `refuses ${ label } (403 ATTACHMENT_DELETE_DENIED)` , async ( ) => {
216+ const { beforeDelete } = install ( { attachments : [ row ] , sharing : { canEdit : async ( ) => true } } ) ;
217+ await expect ( beforeDelete ( deleteCtx ( input , { userId : 'uploader' } ) ) ) . rejects . toMatchObject ( {
218+ code : 'ATTACHMENT_DELETE_DENIED' ,
219+ status : 403 ,
220+ } ) ;
221+ } ) ;
222+ }
223+
224+ it ( 'refuses even the uploader of every matched row — the AST is unscoped, not row-scoped' , async ( ) => {
225+ // The uploader shortcut is per RESOLVED row; with nothing resolved there
226+ // is no row whose ownership could license emptying the table.
227+ const canEdit = vi . fn ( async ( ) => true ) ;
228+ const { beforeDelete } = install ( {
229+ attachments : [ { ...row , uploaded_by : 'uploader' } ] ,
230+ sharing : { canEdit } ,
231+ } ) ;
232+ await expect (
233+ beforeDelete ( deleteCtx ( { options : { multi : true } } , { userId : 'uploader' } ) ) ,
234+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_DELETE_DENIED' } ) ;
235+ expect ( canEdit ) . not . toHaveBeenCalled ( ) ;
236+ } ) ;
237+
238+ it ( 'still bypasses for system context and context-less calls' , async ( ) => {
239+ const { beforeDelete } = install ( { attachments : [ row ] , sharing : { canEdit : async ( ) => false } } ) ;
240+ await expect (
241+ beforeDelete ( deleteCtx ( { options : { multi : true } } , { isSystem : true , userId : 'x' } ) ) ,
242+ ) . resolves . toBeUndefined ( ) ;
243+ await expect ( beforeDelete ( deleteCtx ( { options : { multi : true } } , { } ) ) ) . resolves . toBeUndefined ( ) ;
244+ } ) ;
245+
246+ // The scoped paths must be untouched by the fix: an id-bound delete and a
247+ // `where`-bound one still authorize row-by-row and still ALLOW when they
248+ // pass. `where: {}` matches every row but is a real query — every matched
249+ // row is authorized, so it stays on the authorize path, not the refuse one.
250+ it ( 'leaves the legitimate scoped paths alone' , async ( ) => {
251+ const { beforeDelete } = install ( { attachments : [ row ] , sharing : { canEdit : async ( ) => true } } ) ;
252+ await expect ( beforeDelete ( deleteCtx ( { id : 'a1' } , { userId : 'stranger' } ) ) ) . resolves . toBeUndefined ( ) ;
253+ await expect (
254+ beforeDelete (
255+ deleteCtx ( { options : { where : { parent_object : 'att_secret' } , multi : true } } , { userId : 'stranger' } ) ,
256+ ) ,
257+ ) . resolves . toBeUndefined ( ) ;
258+ await expect (
259+ beforeDelete ( deleteCtx ( { options : { where : { } , multi : true } } , { userId : 'stranger' } ) ) ,
260+ ) . resolves . toBeUndefined ( ) ;
261+ } ) ;
262+
263+ it ( 'an empty `where` still authorizes every matched row (one failing row denies)' , async ( ) => {
264+ const { beforeDelete } = install ( { attachments : [ row ] , sharing : { canEdit : async ( ) => false } } ) ;
265+ await expect (
266+ beforeDelete ( deleteCtx ( { options : { where : { } , multi : true } } , { userId : 'stranger' } ) ) ,
267+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_DELETE_DENIED' } ) ;
268+ } ) ;
269+ } ) ;
201270} ) ;
0 commit comments