@@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => {
410410 } ) ;
411411} ) ;
412412
413+ // ── puter_output_path ─────────────────────────────────────────────
414+
415+ describe ( 'ImageGenerationDriver.generate puter_output_path' , ( ) => {
416+ const TEST_ACTOR : import ( '../../core/actor.js' ) . Actor = {
417+ user : { uuid : 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' , id : 42 , username : 'testuser' } ,
418+ } ;
419+
420+ const withTestUser = < T > ( fn : ( ) => T | Promise < T > ) : Promise < T > =>
421+ Promise . resolve ( runWithContext ( { actor : TEST_ACTOR } , fn ) ) ;
422+
423+ it ( 'throws 400 when puter_output_path resolves to root' , async ( ) => {
424+ await expect (
425+ withTestUser ( ( ) =>
426+ driver . generate ( {
427+ model : 'dall-e-2' ,
428+ prompt : 'hi' ,
429+ puter_output_path : '/' ,
430+ } as never ) ,
431+ ) ,
432+ ) . rejects . toMatchObject ( { statusCode : 400 } ) ;
433+
434+ expect ( openaiImagesGenerateMock ) . not . toHaveBeenCalled ( ) ;
435+ } ) ;
436+
437+ it ( 'throws 400 when puter_output_path parent is root (e.g. /image.png)' , async ( ) => {
438+ await expect (
439+ withTestUser ( ( ) =>
440+ driver . generate ( {
441+ model : 'dall-e-2' ,
442+ prompt : 'hi' ,
443+ puter_output_path : '/image.png' ,
444+ } as never ) ,
445+ ) ,
446+ ) . rejects . toMatchObject ( { statusCode : 400 } ) ;
447+
448+ expect ( openaiImagesGenerateMock ) . not . toHaveBeenCalled ( ) ;
449+ } ) ;
450+
451+ it ( 'throws 403 when ACL denies write access to the destination' , async ( ) => {
452+ const aclCheckSpy = vi . spyOn ( server . services . acl , 'check' ) ;
453+ aclCheckSpy . mockResolvedValueOnce ( false ) ;
454+
455+ await expect (
456+ withTestUser ( ( ) =>
457+ driver . generate ( {
458+ model : 'dall-e-2' ,
459+ prompt : 'hi' ,
460+ puter_output_path : '/testuser/somedir/image.png' ,
461+ } as never ) ,
462+ ) ,
463+ ) . rejects . toMatchObject ( { statusCode : 403 } ) ;
464+
465+ expect ( openaiImagesGenerateMock ) . not . toHaveBeenCalled ( ) ;
466+ } ) ;
467+
468+ it ( 'ACL check runs BEFORE provider.generate so credits are not wasted on a denied path' , async ( ) => {
469+ const callOrder : string [ ] = [ ] ;
470+ const aclCheckSpy = vi . spyOn ( server . services . acl , 'check' ) ;
471+ aclCheckSpy . mockImplementation ( async ( ) => {
472+ callOrder . push ( 'acl' ) ;
473+ return false ;
474+ } ) ;
475+ openaiImagesGenerateMock . mockImplementation ( async ( ) => {
476+ callOrder . push ( 'provider' ) ;
477+ return { data : [ { url : 'https://oai/img.png' } ] } ;
478+ } ) ;
479+
480+ await expect (
481+ withTestUser ( ( ) =>
482+ driver . generate ( {
483+ model : 'dall-e-2' ,
484+ prompt : 'hi' ,
485+ puter_output_path : '/testuser/dir/img.png' ,
486+ } as never ) ,
487+ ) ,
488+ ) . rejects . toMatchObject ( { statusCode : 403 } ) ;
489+
490+ expect ( callOrder ) . toEqual ( [ 'acl' ] ) ;
491+ } ) ;
492+
493+ it ( 'resolves ~ in puter_output_path to /<username>/' , async ( ) => {
494+ const aclCheckSpy = vi . spyOn ( server . services . acl , 'check' ) ;
495+ aclCheckSpy . mockResolvedValueOnce ( true ) ;
496+
497+ const fsWriteSpy = vi . spyOn ( server . services . fs , 'write' ) ;
498+ fsWriteSpy . mockResolvedValueOnce ( undefined as never ) ;
499+
500+ openaiImagesGenerateMock . mockResolvedValueOnce ( {
501+ data : [ { url : 'https://oai/img.png' } ] ,
502+ } ) ;
503+ fetchSpy . mockResolvedValueOnce (
504+ new Response ( Buffer . from ( 'fake-png' ) , {
505+ status : 200 ,
506+ headers : { 'content-type' : 'image/png' } ,
507+ } ) ,
508+ ) ;
509+
510+ await withTestUser ( ( ) =>
511+ driver . generate ( {
512+ model : 'dall-e-2' ,
513+ prompt : 'hi' ,
514+ puter_output_path : '~/images/out.png' ,
515+ } as never ) ,
516+ ) ;
517+
518+ expect ( fsWriteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
519+ const [ , writeArg ] = fsWriteSpy . mock . calls [ 0 ] ! ;
520+ expect (
521+ ( writeArg as { fileMetadata : { path : string } } ) . fileMetadata . path ,
522+ ) . toBe ( '/testuser/images/out.png' ) ;
523+ } ) ;
524+
525+ it ( 'writes the generated image to FS and still returns the result URL' , async ( ) => {
526+ const aclCheckSpy = vi . spyOn ( server . services . acl , 'check' ) ;
527+ aclCheckSpy . mockResolvedValueOnce ( true ) ;
528+
529+ const fsWriteSpy = vi . spyOn ( server . services . fs , 'write' ) ;
530+ fsWriteSpy . mockResolvedValueOnce ( undefined as never ) ;
531+
532+ openaiImagesGenerateMock . mockResolvedValueOnce ( {
533+ data : [ { url : 'https://oai/img.png' } ] ,
534+ } ) ;
535+ fetchSpy . mockResolvedValueOnce (
536+ new Response ( Buffer . from ( 'fake-png' ) , {
537+ status : 200 ,
538+ headers : { 'content-type' : 'image/png' } ,
539+ } ) ,
540+ ) ;
541+
542+ const result = await withTestUser ( ( ) =>
543+ driver . generate ( {
544+ model : 'dall-e-2' ,
545+ prompt : 'hi' ,
546+ puter_output_path : '/testuser/photos/out.png' ,
547+ } as never ) ,
548+ ) ;
549+
550+ expect ( result ) . toBe ( 'https://oai/img.png' ) ;
551+ expect ( fsWriteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
552+ const [ userId , writeArg ] = fsWriteSpy . mock . calls [ 0 ] ! ;
553+ expect ( userId ) . toBe ( 42 ) ;
554+ const meta = (
555+ writeArg as {
556+ fileMetadata : {
557+ path : string ;
558+ contentType : string ;
559+ overwrite : boolean ;
560+ } ;
561+ }
562+ ) . fileMetadata ;
563+ expect ( meta . path ) . toBe ( '/testuser/photos/out.png' ) ;
564+ expect ( meta . contentType ) . toBe ( 'image/png' ) ;
565+ expect ( meta . overwrite ) . toBe ( true ) ;
566+ } ) ;
567+
568+ it ( 'does not forward puter_output_path to the upstream provider call' , async ( ) => {
569+ const aclCheckSpy = vi . spyOn ( server . services . acl , 'check' ) ;
570+ aclCheckSpy . mockResolvedValueOnce ( true ) ;
571+
572+ const fsWriteSpy = vi . spyOn ( server . services . fs , 'write' ) ;
573+ fsWriteSpy . mockResolvedValueOnce ( undefined as never ) ;
574+
575+ openaiImagesGenerateMock . mockResolvedValueOnce ( {
576+ data : [ { url : 'https://oai/img.png' } ] ,
577+ } ) ;
578+ fetchSpy . mockResolvedValueOnce (
579+ new Response ( Buffer . from ( 'fake-png' ) , {
580+ status : 200 ,
581+ headers : { 'content-type' : 'image/png' } ,
582+ } ) ,
583+ ) ;
584+
585+ await withTestUser ( ( ) =>
586+ driver . generate ( {
587+ model : 'dall-e-2' ,
588+ prompt : 'hi' ,
589+ puter_output_path : '/testuser/dir/img.png' ,
590+ } as never ) ,
591+ ) ;
592+
593+ const sent = openaiImagesGenerateMock . mock . calls [ 0 ] ! [ 0 ] ;
594+ expect ( sent . puter_output_path ) . toBeUndefined ( ) ;
595+ } ) ;
596+
597+ it ( 'throws 400 when actor has no user ID but puter_output_path is set' , async ( ) => {
598+ const noIdActor : import ( '../../core/actor.js' ) . Actor = {
599+ user : { uuid : 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f' , username : 'noone' } ,
600+ } ;
601+ await expect (
602+ Promise . resolve (
603+ runWithContext ( { actor : noIdActor } , ( ) =>
604+ driver . generate ( {
605+ model : 'dall-e-2' ,
606+ prompt : 'hi' ,
607+ puter_output_path : '/noone/dir/img.png' ,
608+ } as never ) ,
609+ ) ,
610+ ) ,
611+ ) . rejects . toMatchObject ( { statusCode : 400 } ) ;
612+
613+ expect ( openaiImagesGenerateMock ) . not . toHaveBeenCalled ( ) ;
614+ } ) ;
615+ } ) ;
616+
413617// Avoid coupling the 'unused' XAI export to lint. The catalog reference
414618// is also used implicitly by the routing tests above.
415619void XAI_IMAGE_GENERATION_MODELS ;
0 commit comments