11/**
22 * @vitest -environment jsdom
33 */
4+ import { Blob as NodeBlob } from 'node:buffer'
45import { act } from 'react'
56import { createRoot } from 'react-dom/client'
6- import { afterEach , describe , expect , it , vi } from 'vitest'
7- import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download'
7+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
8+ import {
9+ ChatFileDownload ,
10+ ChatFileDownloadAll ,
11+ } from '@/app/(interfaces)/chat/components/message/components/file-download'
812import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
913
1014const imageFile : ChatFile = {
@@ -17,20 +21,47 @@ const imageFile: ChatFile = {
1721 base64 : 'YWJj' ,
1822}
1923
24+ const fetchMock = vi . fn < typeof fetch > ( )
25+ const createObjectURL = vi . fn ( ( _blob : Blob ) => 'blob:download' )
26+ const downloadedNames : string [ ] = [ ]
27+
28+ beforeEach ( ( ) => {
29+ vi . clearAllMocks ( )
30+ downloadedNames . length = 0
31+ vi . stubGlobal ( 'fetch' , fetchMock )
32+ vi . stubGlobal ( 'Blob' , NodeBlob )
33+ vi . stubGlobal (
34+ 'URL' ,
35+ class extends URL {
36+ static createObjectURL = createObjectURL
37+ static revokeObjectURL = vi . fn ( )
38+ }
39+ )
40+ vi . spyOn ( HTMLAnchorElement . prototype , 'click' ) . mockImplementation ( function ( ) {
41+ downloadedNames . push ( this . download )
42+ } )
43+ vi . spyOn ( window , 'open' ) . mockImplementation ( ( ) => null )
44+ } )
45+
2046const mounts : Array < ( ) => void > = [ ]
2147
22- function renderFile ( file : ChatFile ) : HTMLDivElement {
48+ function renderFile ( file : ChatFile | ChatFile [ ] ) : HTMLDivElement {
2349 ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
2450 const container = document . createElement ( 'div' )
2551 const root = createRoot ( container )
26- act ( ( ) => root . render ( < ChatFileDownload file = { file } /> ) )
52+ act ( ( ) =>
53+ root . render (
54+ Array . isArray ( file ) ? < ChatFileDownloadAll files = { file } /> : < ChatFileDownload file = { file } />
55+ )
56+ )
2757 mounts . push ( ( ) => act ( ( ) => root . unmount ( ) ) )
2858 return container
2959}
3060
3161afterEach ( ( ) => {
3262 while ( mounts . length ) mounts . pop ( ) ?.( )
3363 vi . restoreAllMocks ( )
64+ vi . unstubAllGlobals ( )
3465} )
3566
3667describe ( 'ChatFileDownload' , ( ) => {
@@ -67,3 +98,102 @@ describe('ChatFileDownload', () => {
6798 expect ( container . querySelector ( 'button' ) ?. textContent ) . toContain ( 'report.pdf' )
6899 } )
69100} )
101+
102+ async function clickDownload ( container : HTMLDivElement ) : Promise < void > {
103+ await act ( async ( ) => container . querySelector ( 'button' ) ! . click ( ) )
104+ }
105+
106+ describe ( 'chat file downloads' , ( ) => {
107+ it ( 'downloads exact inline bytes without fetching a data URL or needing a session' , async ( ) => {
108+ fetchMock . mockRejectedValue ( new TypeError ( 'Blocked by connect-src' ) )
109+ const container = renderFile ( { ...imageFile , base64 : 'AP9/gAE=' } )
110+ await clickDownload ( container )
111+ expect ( fetchMock ) . not . toHaveBeenCalled ( )
112+ const blob = createObjectURL . mock . calls [ 0 ] ! [ 0 ]
113+ expect ( [ ...new Uint8Array ( await blob . arrayBuffer ( ) ) ] ) . toEqual ( [ 0 , 255 , 127 , 128 , 1 ] )
114+ expect ( blob . type ) . toBe ( 'image/png' )
115+ expect ( downloadedNames ) . toEqual ( [ 'generated.png' ] )
116+ expect ( window . open ) . not . toHaveBeenCalled ( )
117+ } )
118+
119+ it . each ( [ 's3' , 'blob' , 'gcs' , 'local' ] ) (
120+ 'downloads stored %s files through the logs serve route instead of stale URLs' ,
121+ async ( provider ) => {
122+ fetchMock . mockResolvedValue ( new Response ( 'current stored bytes' ) )
123+ const file = {
124+ ...imageFile ,
125+ base64 : undefined ,
126+ key : `execution/workspace/workflow/run/${ provider } .png` ,
127+ url : 'https://files.example.com/expired?X-Amz-Expires=300' ,
128+ }
129+ await clickDownload ( renderFile ( file ) )
130+ expect ( fetchMock ) . toHaveBeenCalledExactlyOnceWith (
131+ `/api/files/serve/${ encodeURIComponent ( file . key ) } ?context=execution` ,
132+ { cache : 'no-store' }
133+ )
134+ expect ( await createObjectURL . mock . calls [ 0 ] ! [ 0 ] . text ( ) ) . toBe ( 'current stored bytes' )
135+ expect ( downloadedNames ) . toEqual ( [ 'generated.png' ] )
136+ }
137+ )
138+
139+ it ( 'keeps external URL files on their existing URL path' , async ( ) => {
140+ fetchMock . mockResolvedValue ( new Response ( 'external bytes' ) )
141+ await clickDownload ( renderFile ( { ...imageFile , base64 : undefined , key : 'url/external' } ) )
142+ expect ( fetchMock ) . toHaveBeenCalledExactlyOnceWith ( imageFile . url , { cache : 'no-store' } )
143+ } )
144+
145+ it ( 'preserves delivered signed access for public visitors without a workspace session' , async ( ) => {
146+ fetchMock
147+ . mockResolvedValueOnce ( new Response ( null , { status : 401 } ) )
148+ . mockResolvedValueOnce ( new Response ( 'publicly delivered bytes' ) )
149+ await clickDownload ( renderFile ( { ...imageFile , base64 : undefined } ) )
150+ expect ( fetchMock ) . toHaveBeenNthCalledWith ( 2 , imageFile . url , { cache : 'no-store' } )
151+ expect ( downloadedNames ) . toEqual ( [ 'generated.png' ] )
152+ } )
153+
154+ it ( 'shows download errors without opening an expired storage error page' , async ( ) => {
155+ fetchMock
156+ . mockResolvedValueOnce ( new Response ( null , { status : 401 } ) )
157+ . mockResolvedValueOnce ( new Response ( '<Error>Request has expired</Error>' , { status : 403 } ) )
158+ const container = renderFile ( { ...imageFile , base64 : undefined } )
159+ await clickDownload ( container )
160+ expect ( container . querySelector ( '[role="alert"]' ) ?. textContent ) . toContain ( 'Unable to download' )
161+ expect ( downloadedNames ) . toEqual ( [ ] )
162+ expect ( window . open ) . not . toHaveBeenCalled ( )
163+ expect ( container . querySelector ( 'button' ) ?. disabled ) . toBe ( false )
164+ } )
165+
166+ it . each ( [ 403 , 404 ] ) (
167+ 'does not retry denied or deleted stored files through their old URLs (%s)' ,
168+ async ( status ) => {
169+ fetchMock . mockResolvedValue ( new Response ( null , { status } ) )
170+ await clickDownload ( renderFile ( { ...imageFile , base64 : undefined } ) )
171+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 )
172+ expect ( downloadedNames ) . toEqual ( [ ] )
173+ }
174+ )
175+
176+ it ( 'uses the same inline and storage handling for download all' , async ( ) => {
177+ fetchMock . mockResolvedValue ( new Response ( 'stored bytes' ) )
178+ const stored = { ...imageFile , id : 'stored' , name : 'stored.png' , base64 : undefined }
179+ const container = renderFile ( [ imageFile , stored ] )
180+ await act ( async ( ) => {
181+ container . querySelector ( 'button' ) ! . click ( )
182+ await vi . waitFor ( ( ) => expect ( downloadedNames ) . toEqual ( [ 'generated.png' , 'stored.png' ] ) )
183+ } )
184+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 )
185+ } )
186+ } )
187+
188+ it ( 'refuses unsafe external file URLs' , async ( ) => {
189+ const container = renderFile ( {
190+ ...imageFile ,
191+ base64 : undefined ,
192+ key : 'url/external' ,
193+ url : 'javascript:alert(1)' ,
194+ } )
195+ await clickDownload ( container )
196+ expect ( fetchMock ) . not . toHaveBeenCalled ( )
197+ expect ( window . open ) . not . toHaveBeenCalled ( )
198+ expect ( downloadedNames ) . toEqual ( [ ] )
199+ } )
0 commit comments