@@ -312,4 +312,173 @@ describe('confirm-uploads handler', () => {
312312 const body = JSON . parse ( result . body ) ;
313313 expect ( body . error . code ) . toBe ( 'ATTACHMENT_BLOCKED' ) ;
314314 } ) ;
315+
316+ test ( 'skips S3 cleanup when failTaskOnScreening loses the race (ConditionalCheckFailedException)' , async ( ) => {
317+ const { screenImage, AttachmentScreeningError } = jest . requireMock ( '../../src/handlers/shared/attachment-screening' ) ;
318+
319+ ddbSend . mockResolvedValueOnce ( { Item : PENDING_TASK } ) ;
320+ s3Send
321+ . mockResolvedValueOnce ( { VersionId : 'v1' , ContentLength : 1024 } )
322+ . mockResolvedValueOnce ( { VersionId : 'v2' , ContentLength : 512 } ) ;
323+
324+ // Pre-check passes
325+ ddbSend . mockResolvedValueOnce ( { Item : { active_count : 0 } } ) ;
326+
327+ // GetObject for first attachment
328+ const pngContent = Buffer . alloc ( 1024 ) ;
329+ s3Send . mockResolvedValueOnce ( { Body : { transformToByteArray : ( ) => pngContent } } ) ;
330+
331+ // Screening blocks the image
332+ screenImage . mockRejectedValueOnce ( new AttachmentScreeningError ( 'Inappropriate content detected' ) ) ;
333+
334+ // failTaskOnScreening conditional write fails — another caller already transitioned
335+ const condErr = new Error ( 'The conditional request failed' ) ;
336+ condErr . name = 'ConditionalCheckFailedException' ;
337+ ddbSend . mockRejectedValueOnce ( condErr ) ;
338+
339+ const result = await handler ( makeEvent ( 'task-1' ) , makeContext ( 180_000 ) ) ;
340+ expect ( result . statusCode ) . toBe ( 400 ) ;
341+
342+ // S3 DeleteObjectsCommand should NOT have been called (only Head + Get calls)
343+ const s3DeleteCalls = s3Send . mock . calls . filter (
344+ ( call : any [ ] ) => call [ 0 ] ?. _type === 'S3Delete' ,
345+ ) ;
346+ expect ( s3DeleteCalls ) . toHaveLength ( 0 ) ;
347+ } ) ;
348+
349+ test ( 'does not re-upload content to S3 after screening passes (no redundant PUT)' , async ( ) => {
350+ const { screenImage, screenTextFile } = jest . requireMock ( '../../src/handlers/shared/attachment-screening' ) ;
351+
352+ const pngContent = Buffer . alloc ( 1024 ) ;
353+ pngContent . set ( [ 0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ] ) ;
354+ const textContent = Buffer . alloc ( 512 ) ;
355+ textContent . write ( 'hello world' ) ;
356+
357+ screenImage . mockResolvedValue ( {
358+ content : pngContent ,
359+ contentType : 'image/png' ,
360+ checksum : 'abc123' ,
361+ screening : { status : 'passed' } ,
362+ } ) ;
363+ screenTextFile . mockResolvedValue ( {
364+ content : textContent ,
365+ contentType : 'text/plain' ,
366+ checksum : 'def456' ,
367+ screening : { status : 'passed' } ,
368+ } ) ;
369+
370+ let ddbCallCount = 0 ;
371+ ddbSend . mockImplementation ( ( ) => {
372+ ddbCallCount ++ ;
373+ switch ( ddbCallCount ) {
374+ case 1 : return Promise . resolve ( { Item : PENDING_TASK } ) ;
375+ case 2 : return Promise . resolve ( { Item : { active_count : 1 } } ) ;
376+ case 3 : return Promise . resolve ( { } ) ;
377+ case 4 : return Promise . resolve ( { } ) ;
378+ case 5 : return Promise . resolve ( { } ) ;
379+ default : return Promise . resolve ( { } ) ;
380+ }
381+ } ) ;
382+
383+ s3Send . mockImplementation ( ( cmd : any ) => {
384+ if ( cmd . _type === 'S3Head' ) {
385+ const isAtt1 = cmd . input . Key ?. includes ( 'att-1' ) ;
386+ return Promise . resolve ( {
387+ VersionId : isAtt1 ? 'v1' : 'v2' ,
388+ ContentLength : isAtt1 ? 1024 : 512 ,
389+ } ) ;
390+ }
391+ if ( cmd . _type === 'S3Get' ) {
392+ const isAtt1 = cmd . input . Key ?. includes ( 'att-1' ) ;
393+ return Promise . resolve ( {
394+ Body : { transformToByteArray : ( ) => ( isAtt1 ? pngContent : textContent ) } ,
395+ } ) ;
396+ }
397+ if ( cmd . _type === 'S3Put' ) {
398+ return Promise . resolve ( { VersionId : 'v-screened' } ) ;
399+ }
400+ return Promise . resolve ( { } ) ;
401+ } ) ;
402+
403+ lambdaSend . mockResolvedValueOnce ( { } ) ;
404+
405+ const result = await handler ( makeEvent ( 'task-1' ) , makeContext ( 180_000 ) ) ;
406+ expect ( result . statusCode ) . toBe ( 200 ) ;
407+
408+ // Verify NO S3 PutObject calls were made
409+ const s3PutCalls = s3Send . mock . calls . filter (
410+ ( call : any [ ] ) => call [ 0 ] ?. _type === 'S3Put' ,
411+ ) ;
412+ expect ( s3PutCalls ) . toHaveLength ( 0 ) ;
413+ } ) ;
414+
415+ test ( 'uses original versionId and size from HeadObject in attachment record after screening' , async ( ) => {
416+ const { screenImage, screenTextFile } = jest . requireMock ( '../../src/handlers/shared/attachment-screening' ) ;
417+
418+ const pngContent = Buffer . alloc ( 1024 ) ;
419+ pngContent . set ( [ 0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ] ) ;
420+ const textContent = Buffer . alloc ( 512 ) ;
421+ textContent . write ( 'hello world' ) ;
422+
423+ screenImage . mockResolvedValue ( {
424+ content : pngContent ,
425+ contentType : 'image/png' ,
426+ checksum : 'abc123' ,
427+ screening : { status : 'passed' } ,
428+ } ) ;
429+ screenTextFile . mockResolvedValue ( {
430+ content : textContent ,
431+ contentType : 'text/plain' ,
432+ checksum : 'def456' ,
433+ screening : { status : 'passed' } ,
434+ } ) ;
435+
436+ let ddbCallCount = 0 ;
437+ ddbSend . mockImplementation ( ( ) => {
438+ ddbCallCount ++ ;
439+ switch ( ddbCallCount ) {
440+ case 1 : return Promise . resolve ( { Item : PENDING_TASK } ) ;
441+ case 2 : return Promise . resolve ( { Item : { active_count : 0 } } ) ;
442+ case 3 : return Promise . resolve ( { } ) ;
443+ case 4 : return Promise . resolve ( { } ) ;
444+ case 5 : return Promise . resolve ( { } ) ;
445+ default : return Promise . resolve ( { } ) ;
446+ }
447+ } ) ;
448+
449+ s3Send . mockImplementation ( ( cmd : any ) => {
450+ if ( cmd . _type === 'S3Head' ) {
451+ const isAtt1 = cmd . input . Key ?. includes ( 'att-1' ) ;
452+ return Promise . resolve ( {
453+ VersionId : isAtt1 ? 'original-v1' : 'original-v2' ,
454+ ContentLength : isAtt1 ? 1024 : 512 ,
455+ } ) ;
456+ }
457+ if ( cmd . _type === 'S3Get' ) {
458+ const isAtt1 = cmd . input . Key ?. includes ( 'att-1' ) ;
459+ return Promise . resolve ( {
460+ Body : { transformToByteArray : ( ) => ( isAtt1 ? pngContent : textContent ) } ,
461+ } ) ;
462+ }
463+ return Promise . resolve ( { } ) ;
464+ } ) ;
465+
466+ lambdaSend . mockResolvedValueOnce ( { } ) ;
467+
468+ const result = await handler ( makeEvent ( 'task-1' ) , makeContext ( 180_000 ) ) ;
469+ expect ( result . statusCode ) . toBe ( 200 ) ;
470+
471+ // Check the DDB UpdateCommand (transition to SUBMITTED) includes original versionIds
472+ const updateCall = ddbSend . mock . calls . find (
473+ ( call : any [ ] ) => call [ 0 ] ?. input ?. UpdateExpression ?. includes ( 'attachments' ) ,
474+ ) ;
475+ expect ( updateCall ) . toBeDefined ( ) ;
476+ const attachments = updateCall ! [ 0 ] . input . ExpressionAttributeValues [ ':atts' ] ;
477+ const att1 = attachments . find ( ( a : any ) => a . attachment_id === 'att-1' ) ;
478+ const att2 = attachments . find ( ( a : any ) => a . attachment_id === 'att-2' ) ;
479+ expect ( att1 . s3_version_id ) . toBe ( 'original-v1' ) ;
480+ expect ( att1 . size_bytes ) . toBe ( 1024 ) ;
481+ expect ( att2 . s3_version_id ) . toBe ( 'original-v2' ) ;
482+ expect ( att2 . size_bytes ) . toBe ( 512 ) ;
483+ } ) ;
315484} ) ;
0 commit comments