@@ -13,13 +13,19 @@ const {
1313 mockDownloadFile,
1414 mockExtractEmbeddedImageIds,
1515 mockRenderMarkdownPdf,
16+ mockEnforceUserRateLimit,
17+ mockRecordAudit,
18+ mockCaptureServerEvent,
1619} = vi . hoisted ( ( ) => ( {
1720 mockCheckAuth : vi . fn ( ) ,
1821 mockGetFileMetadataById : vi . fn ( ) ,
1922 mockVerifyFileAccess : vi . fn ( ) ,
2023 mockDownloadFile : vi . fn ( ) ,
2124 mockExtractEmbeddedImageIds : vi . fn ( ) ,
2225 mockRenderMarkdownPdf : vi . fn ( ) ,
26+ mockEnforceUserRateLimit : vi . fn ( ) ,
27+ mockRecordAudit : vi . fn ( ) ,
28+ mockCaptureServerEvent : vi . fn ( ) ,
2329} ) )
2430
2531vi . mock ( '@/lib/auth/hybrid' , ( ) => ( { checkSessionOrInternalAuth : mockCheckAuth } ) )
@@ -34,12 +40,15 @@ vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
3440vi . mock ( '@/app/api/files/export/[id]/markdown-pdf' , ( ) => ( {
3541 renderMarkdownPdf : mockRenderMarkdownPdf ,
3642} ) )
43+ vi . mock ( '@/lib/core/rate-limiter/route-helpers' , ( ) => ( {
44+ enforceUserRateLimit : mockEnforceUserRateLimit ,
45+ } ) )
3746vi . mock ( '@sim/audit' , ( ) => ( {
38- recordAudit : vi . fn ( ) ,
47+ recordAudit : mockRecordAudit ,
3948 AuditAction : { FILE_DOWNLOADED : 'file.downloaded' } ,
4049 AuditResourceType : { FILE : 'file' } ,
4150} ) )
42- vi . mock ( '@/lib/posthog/server' , ( ) => ( { captureServerEvent : vi . fn ( ) } ) )
51+ vi . mock ( '@/lib/posthog/server' , ( ) => ( { captureServerEvent : mockCaptureServerEvent } ) )
4352
4453import { GET } from '@/app/api/files/export/[id]/route'
4554
@@ -90,6 +99,7 @@ describe('markdown export bundling', () => {
9099 mockDownloadFile . mockResolvedValue ( Buffer . from ( '# Doc\n' ) )
91100 mockExtractEmbeddedImageIds . mockReturnValue ( [ ] )
92101 mockRenderMarkdownPdf . mockResolvedValue ( Buffer . from ( '%PDF-generated' ) )
102+ mockEnforceUserRateLimit . mockResolvedValue ( null )
93103 } )
94104
95105 it ( 'returns the stored Markdown unchanged when no format is requested' , async ( ) => {
@@ -100,6 +110,7 @@ describe('markdown export bundling', () => {
100110 expect ( response . headers . get ( 'Content-Disposition' ) ) . toContain ( 'doc.md' )
101111 expect ( Buffer . from ( await response . arrayBuffer ( ) ) . toString ( ) ) . toBe ( '# Doc\n' )
102112 expect ( mockRenderMarkdownPdf ) . not . toHaveBeenCalled ( )
113+ expect ( mockEnforceUserRateLimit ) . not . toHaveBeenCalled ( )
103114 } )
104115
105116 it ( 'renders Markdown as a directly downloadable PDF' , async ( ) => {
@@ -115,6 +126,23 @@ describe('markdown export bundling', () => {
115126 images : expect . any ( Map ) ,
116127 } )
117128 expect ( mockRenderMarkdownPdf . mock . calls [ 0 ] [ 0 ] . images . size ) . toBe ( 0 )
129+ expect ( mockEnforceUserRateLimit ) . toHaveBeenCalledWith ( 'markdown-pdf-export' , 'user-1' , {
130+ maxTokens : 3 ,
131+ refillRate : 3 ,
132+ refillIntervalMs : 60_000 ,
133+ } )
134+ } )
135+
136+ it ( 'stops a rate-limited PDF export before reading the document' , async ( ) => {
137+ mockEnforceUserRateLimit . mockResolvedValue (
138+ new Response ( JSON . stringify ( { error : 'Rate limit exceeded' } ) , { status : 429 } )
139+ )
140+
141+ const response = await GET ( request ( 'pdf' ) , context )
142+
143+ expect ( response . status ) . toBe ( 429 )
144+ expect ( mockDownloadFile ) . not . toHaveBeenCalled ( )
145+ expect ( mockRenderMarkdownPdf ) . not . toHaveBeenCalled ( )
118146 } )
119147
120148 it ( 'passes only authorized, readable embedded images to the PDF renderer' , async ( ) => {
@@ -134,6 +162,37 @@ describe('markdown export bundling', () => {
134162 expect ( images . get ( 'good' ) ) . toEqual ( Buffer . from ( 'png-bytes' ) )
135163 } )
136164
165+ it ( 'records an image-containing PDF as one downloaded file' , async ( ) => {
166+ mockExtractEmbeddedImageIds . mockReturnValue ( [ 'image-1' ] )
167+
168+ await GET ( request ( 'pdf' ) , context )
169+
170+ expect ( mockRecordAudit ) . toHaveBeenCalledWith (
171+ expect . objectContaining ( {
172+ metadata : expect . objectContaining ( { assetCount : 1 , format : 'pdf' } ) ,
173+ } )
174+ )
175+ expect ( mockCaptureServerEvent ) . toHaveBeenCalledWith (
176+ 'user-1' ,
177+ 'file_downloaded' ,
178+ expect . objectContaining ( { file_count : 1 , is_bulk : false } ) ,
179+ { groups : { workspace : 'ws-1' } }
180+ )
181+ } )
182+
183+ it ( 'keeps image-containing ZIP telemetry bulk' , async ( ) => {
184+ mockExtractEmbeddedImageIds . mockReturnValue ( [ 'image-1' ] )
185+
186+ await GET ( request ( ) , context )
187+
188+ expect ( mockCaptureServerEvent ) . toHaveBeenCalledWith (
189+ 'user-1' ,
190+ 'file_downloaded' ,
191+ expect . objectContaining ( { file_count : 2 , is_bulk : true } ) ,
192+ { groups : { workspace : 'ws-1' } }
193+ )
194+ } )
195+
137196 it ( 'rejects PDF format for a non-Markdown file' , async ( ) => {
138197 mockGetFileMetadataById . mockResolvedValue ( {
139198 id : DOC_ID ,
@@ -197,6 +256,25 @@ describe('markdown export bundling', () => {
197256 expect ( bodyCall ?. [ 0 ] . maxBytes ) . toBe ( 250 * MB )
198257 } )
199258
259+ it ( 'uses a smaller document limit for PDF rendering' , async ( ) => {
260+ await GET ( request ( 'pdf' ) , context )
261+
262+ const bodyCall = mockDownloadFile . mock . calls . find ( ( [ options ] ) => options . key . endsWith ( 'doc.md' ) )
263+ expect ( bodyCall ?. [ 0 ] . maxBytes ) . toBe ( 256 * 1024 )
264+ } )
265+
266+ it ( 'reports an oversized PDF body with the PDF-specific limit' , async ( ) => {
267+ mockDownloadFile . mockRejectedValue (
268+ new PayloadSizeLimitError ( { label : 'storage file download' , maxBytes : 1 } )
269+ )
270+
271+ const response = await GET ( request ( 'pdf' ) , context )
272+
273+ expect ( response . status ) . toBe ( 400 )
274+ expect ( ( await response . json ( ) ) . error ) . toContain ( '256 KB PDF export limit' )
275+ expect ( mockRenderMarkdownPdf ) . not . toHaveBeenCalled ( )
276+ } )
277+
200278 it ( 'reports an oversized body as a size rejection, not a server error' , async ( ) => {
201279 mockExtractEmbeddedImageIds . mockReturnValue ( [ ] )
202280 mockDownloadFile . mockRejectedValue (
@@ -221,6 +299,41 @@ describe('markdown export bundling', () => {
221299 expect ( assetCall ?. [ 0 ] . maxBytes ) . toBe ( 25 * MB )
222300 } )
223301
302+ it ( 'uses a smaller per-asset limit for PDF rendering' , async ( ) => {
303+ mockExtractEmbeddedImageIds . mockReturnValue ( [ 'a' ] )
304+
305+ await GET ( request ( 'pdf' ) , context )
306+
307+ const assetCall = mockDownloadFile . mock . calls . find (
308+ ( [ options ] ) => options . key === 'workspace/ws-1/a'
309+ )
310+ expect ( assetCall ?. [ 0 ] . maxBytes ) . toBe ( 10 * MB )
311+ } )
312+
313+ it ( 'rejects PDF source material above its aggregate input limit' , async ( ) => {
314+ mockExtractEmbeddedImageIds . mockReturnValue ( [ 'a' , 'b' ] )
315+ mockGetFileMetadataById . mockImplementation ( async ( id : string ) =>
316+ id === DOC_ID
317+ ? {
318+ id : DOC_ID ,
319+ key : 'workspace/ws-1/doc.md' ,
320+ originalName : 'doc.md' ,
321+ contentType : 'text/markdown' ,
322+ context : 'workspace' ,
323+ size : 1024 ,
324+ workspaceId : 'ws-1' ,
325+ }
326+ : assetRecord ( id , 30 * MB )
327+ )
328+
329+ const response = await GET ( request ( 'pdf' ) , context )
330+
331+ expect ( response . status ) . toBe ( 400 )
332+ expect ( ( await response . json ( ) ) . error ) . toContain ( '50 MB PDF export limit' )
333+ expect ( mockDownloadFile ) . toHaveBeenCalledTimes ( 1 )
334+ expect ( mockRenderMarkdownPdf ) . not . toHaveBeenCalled ( )
335+ } )
336+
224337 it ( 'drops an unreadable asset instead of failing the whole export' , async ( ) => {
225338 mockExtractEmbeddedImageIds . mockReturnValue ( [ 'good' , 'bad' ] )
226339 mockDownloadFile . mockImplementation ( async ( { key } : { key : string } ) => {
0 commit comments