@@ -30,6 +30,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
3030
3131vi . mock ( '../../src/services/files' , ( ) => ( {
3232 getFile : vi . fn ( ) ,
33+ getFilePreviewStat : vi . fn ( ) ,
3334 getRawFileContent : vi . fn ( ) ,
3435 getFileRange : vi . fn ( ) ,
3536 uploadFile : vi . fn ( ) ,
@@ -47,6 +48,7 @@ vi.mock('../../src/services/archive', () => ({
4748} ) )
4849
4950const getFile = fileService . getFile as MockedFunction < typeof fileService . getFile >
51+ const getFilePreviewStat = fileService . getFilePreviewStat as MockedFunction < typeof fileService . getFilePreviewStat >
5052const getRawFileContent = fileService . getRawFileContent as MockedFunction < typeof fileService . getRawFileContent >
5153const getFileRange = fileService . getFileRange as MockedFunction < typeof fileService . getFileRange >
5254const uploadFile = fileService . uploadFile as MockedFunction < typeof fileService . uploadFile >
@@ -71,18 +73,18 @@ describe('File Routes', () => {
7173 } )
7274
7375 describe ( 'GET /preview/* - HTML Preview Assets' , ( ) => {
74- const htmlFileInfo : FileInfo = {
76+ const lastModified = new Date ( '2024-01-01T00:00:00.000Z' )
77+
78+ const htmlStat = {
7579 name : 'index.html' ,
76- path : 'test-repo/index.html' ,
7780 isDirectory : false ,
7881 size : 31 ,
79- mimeType : 'text/html' ,
80- content : '' ,
81- lastModified : new Date ( ) ,
82+ mimeType : 'text/html' as const ,
83+ lastModified,
8284 }
8385
84- it ( 'should serve HTML files with sandbox CSP headers' , async ( ) => {
85- getFile . mockResolvedValue ( htmlFileInfo )
86+ it ( 'should serve HTML files with sandbox CSP and caching headers' , async ( ) => {
87+ getFilePreviewStat . mockResolvedValue ( htmlStat )
8688 getRawFileContent . mockResolvedValue ( Buffer . from ( '<html><body>Hello</body></html>' ) )
8789
8890 const response = await app . request ( '/api/files/preview/test-repo/index.html' )
@@ -94,54 +96,70 @@ describe('File Routes', () => {
9496 expect ( response . headers . get ( 'Content-Disposition' ) ) . toContain ( 'inline' )
9597 expect ( response . headers . get ( 'X-Content-Type-Options' ) ) . toBe ( 'nosniff' )
9698 expect ( response . headers . get ( 'Referrer-Policy' ) ) . toBe ( 'no-referrer' )
97- expect ( response . headers . get ( 'Content-Security-Policy' ) ) . toContain ( 'sandbox allow-scripts' )
99+ expect ( response . headers . get ( 'Content-Security-Policy' ) ) . toContain ( 'sandbox allow-scripts allow-same-origin ' )
98100 expect ( response . headers . get ( 'Content-Security-Policy' ) ) . toContain ( 'https:' )
99- expect ( getFile ) . toHaveBeenCalledWith ( 'test-repo/index.html' )
101+ expect ( response . headers . get ( 'Cache-Control' ) ) . toBe ( 'private, must-revalidate, max-age=0' )
102+ expect ( response . headers . get ( 'ETag' ) ) . toBeTruthy ( )
103+ expect ( response . headers . get ( 'Last-Modified' ) ) . toBe ( lastModified . toUTCString ( ) )
104+ expect ( getFilePreviewStat ) . toHaveBeenCalledWith ( 'test-repo/index.html' )
100105 expect ( getRawFileContent ) . toHaveBeenCalledWith ( 'test-repo/index.html' )
101106 } )
102107
108+ it ( 'should return 304 when ETag matches If-None-Match without reading the file' , async ( ) => {
109+ getFilePreviewStat . mockResolvedValue ( htmlStat )
110+ getRawFileContent . mockResolvedValue ( Buffer . from ( '<html><body>Hello</body></html>' ) )
111+
112+ const first = await app . request ( '/api/files/preview/test-repo/index.html' )
113+ const etag = first . headers . get ( 'ETag' ) as string
114+ await first . text ( )
115+ getRawFileContent . mockClear ( )
116+
117+ const response = await app . request ( '/api/files/preview/test-repo/index.html' , {
118+ headers : { 'If-None-Match' : etag } ,
119+ } )
120+
121+ expect ( response . status ) . toBe ( 304 )
122+ expect ( getRawFileContent ) . not . toHaveBeenCalled ( )
123+ } )
124+
103125 it ( 'should serve CSS files with text/css Content-Type' , async ( ) => {
104- const cssFileInfo : FileInfo = {
126+ getFilePreviewStat . mockResolvedValue ( {
105127 name : 'styles.css' ,
106- path : 'test-repo/styles/app.css' ,
107128 isDirectory : false ,
108129 size : 50 ,
109130 mimeType : 'text/css' ,
110- content : '' ,
111- lastModified : new Date ( ) ,
112- }
113- getFile . mockResolvedValue ( cssFileInfo )
131+ lastModified,
132+ } )
114133 getRawFileContent . mockResolvedValue ( Buffer . from ( 'body { color: red }' ) )
115134
116135 const response = await app . request ( '/api/files/preview/test-repo/styles/app.css' )
117136
118137 expect ( response . status ) . toBe ( 200 )
119138 expect ( response . headers . get ( 'Content-Type' ) ) . toContain ( 'text/css' )
120139 expect ( response . headers . get ( 'Content-Security-Policy' ) ) . toBeNull ( )
121- expect ( getFile ) . toHaveBeenCalledWith ( 'test-repo/styles/app.css' )
140+ expect ( getFilePreviewStat ) . toHaveBeenCalledWith ( 'test-repo/styles/app.css' )
122141 } )
123142
124143 it ( 'should support query-based path parameter' , async ( ) => {
125- getFile . mockResolvedValue ( htmlFileInfo )
144+ getFilePreviewStat . mockResolvedValue ( htmlStat )
126145 getRawFileContent . mockResolvedValue ( Buffer . from ( '<html><body>Hello</body></html>' ) )
127146
128147 const response = await app . request ( '/api/files/preview?path=test-repo/index.html' )
129148 const body = await response . text ( )
130149
131150 expect ( response . status ) . toBe ( 200 )
132151 expect ( body ) . toBe ( '<html><body>Hello</body></html>' )
133- expect ( getFile ) . toHaveBeenCalledWith ( 'test-repo/index.html' )
152+ expect ( getFilePreviewStat ) . toHaveBeenCalledWith ( 'test-repo/index.html' )
134153 } )
135154
136155 it ( 'should return 400 for directory results' , async ( ) => {
137- const dirInfo : FileInfo = {
156+ getFilePreviewStat . mockResolvedValue ( {
138157 name : 'test-repo' ,
139- path : 'test-repo' ,
140158 isDirectory : true ,
141159 size : 0 ,
142- lastModified : new Date ( ) ,
143- }
144- getFile . mockResolvedValue ( dirInfo )
160+ mimeType : 'text/plain' ,
161+ lastModified ,
162+ } )
145163
146164 const response = await app . request ( '/api/files/preview/test-repo' )
147165
@@ -151,22 +169,20 @@ describe('File Routes', () => {
151169 } )
152170
153171 it ( 'should return 415 for non-previewable MIME types' , async ( ) => {
154- const pdfFileInfo : FileInfo = {
172+ getFilePreviewStat . mockResolvedValue ( {
155173 name : 'doc.pdf' ,
156- path : 'test-repo/doc.pdf' ,
157174 isDirectory : false ,
158175 size : 100 ,
159- mimeType : 'application/pdf' ,
160- content : '' ,
161- lastModified : new Date ( ) ,
162- }
163- getFile . mockResolvedValue ( pdfFileInfo )
176+ mimeType : 'application/pdf' as never ,
177+ lastModified,
178+ } )
164179
165180 const response = await app . request ( '/api/files/preview/test-repo/doc.pdf' )
166181
167182 expect ( response . status ) . toBe ( 415 )
168183 const body = await response . json ( ) as { error : string }
169184 expect ( body . error ) . toContain ( 'File type cannot be previewed' )
185+ expect ( getRawFileContent ) . not . toHaveBeenCalled ( )
170186 } )
171187
172188 it ( 'should return 400 when no path is provided' , async ( ) => {
@@ -179,7 +195,7 @@ describe('File Routes', () => {
179195
180196 it ( 'should return 403 for path traversal attempts' , async ( ) => {
181197 const error = { message : 'Path traversal detected' , statusCode : 403 }
182- getFile . mockRejectedValue ( error )
198+ getFilePreviewStat . mockRejectedValue ( error )
183199
184200 const response = await app . request ( '/api/files/preview/test-repo/../outside' )
185201
0 commit comments