diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 48eeb18..d779375 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -220,19 +220,37 @@ export const ApiGateway = { artifactsPath: string = './artifacts.zip', ) { try { - const res = await fetch(`${baseUrl}/results/${uploadId}/download`, { - body: JSON.stringify({ results }), + // Ask for signed-URL delivery (dcd#1124): a current API responds with + // JSON `{ url }` pointing at object storage so the bundle bypasses the + // API; an older API ignores the extra field and streams the ZIP + // inline. The content-type distinguishes the two. + let res = await fetch(`${baseUrl}/results/${uploadId}/download`, { + body: JSON.stringify({ results, delivery: 'url' }), headers: { 'content-type': 'application/json', ...auth.headers, }, method: 'POST', }); + if (res.status === 501) { + // The API understands url delivery but its object storage isn't + // configured — fall back to inline streaming. + res = await fetch(`${baseUrl}/results/${uploadId}/download`, { + body: JSON.stringify({ results }), + headers: { + 'content-type': 'application/json', + ...auth.headers, + }, + method: 'POST', + }); + } + if (!res.ok) { await this.handleApiError(res, 'Failed to download artifacts'); } - await this.streamResponseToFile(res, artifactsPath, 'Failed to download artifacts'); + const download = await this.followSignedUrlDelivery(res, 'Failed to download artifacts'); + await this.streamResponseToFile(download, artifactsPath, 'Failed to download artifacts'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}/download`); @@ -242,6 +260,26 @@ export const ApiGateway = { } }, + /** + * Resolves a download response that may be signed-URL delivery: a JSON + * body `{ url }` is followed (pre-signed, so no auth headers) and the + * storage response returned; any other content-type is the file itself. + */ + async followSignedUrlDelivery(res: Response, errorPrefix: string): Promise { + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + return res; + } + + const { url } = await parseJsonResponse<{ url: string }>(res, errorPrefix); + const download = await fetch(url); + if (!download.ok) { + throw new Error(`${errorPrefix}: signed URL responded with ${download.status}`); + } + + return download; + }, + async finaliseUpload(config: { auth: AuthContext; backblazeSuccess: boolean; @@ -678,13 +716,25 @@ export const ApiGateway = { const url = `${baseUrl}${endpoint}`; try { - // Make the download request - const res = await fetch(url, { + // The HTML report ZIP supports signed-URL delivery (dcd#1124); older + // APIs ignore the query param and stream inline. Other report types + // are small and stay inline. + let res = await fetch(reportType === 'html' ? `${url}?delivery=url` : url, { headers: { ...auth.headers, }, method: 'GET', }); + if (reportType === 'html' && res.status === 501) { + // The API understands url delivery but its object storage isn't + // configured — fall back to inline streaming. + res = await fetch(url, { + headers: { + ...auth.headers, + }, + method: 'GET', + }); + } if (!res.ok) { const errorText = await res.text(); @@ -695,7 +745,9 @@ export const ApiGateway = { throw new Error(`${errorPrefix}: ${res.status} ${errorText}`); } - await this.streamResponseToFile(res, finalReportPath, errorPrefix); + const download = + reportType === 'html' ? await this.followSignedUrlDelivery(res, errorPrefix) : res; + await this.streamResponseToFile(download, finalReportPath, errorPrefix); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, url); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 2ed8566..0bc05b0 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -67,6 +67,48 @@ function restoreFetch() { captured = null; } +/** + * Replace global.fetch with a mock that serves the given responses in call + * order (the last one repeats) and records every request. Used for the + * signed-URL delivery flows, which make more than one request. + * @param responses Status/body/content-type per sequential call + * @returns The list of captured requests, appended to live + */ +function mockFetchSequence( + responses: Array<{ body: string; contentType?: string; status: number }>, +): CapturedRequest[] { + const encoder = new TextEncoder(); + const calls: CapturedRequest[] = []; + (global as any).fetch = async ( + input: URL | string, + init?: RequestInit, + ): Promise => { + calls.push({ + body: init?.body ? String(init.body) : null, + headers: Object.fromEntries( + Object.entries((init?.headers as Record) ?? {}), + ), + method: (init?.method ?? 'GET').toUpperCase(), + url: input.toString(), + }); + + const next = responses[Math.min(calls.length - 1, responses.length - 1)]; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(next.body)); + controller.close(); + }, + }); + + return new Response(stream, { + headers: next.contentType ? { 'content-type': next.contentType } : {}, + status: next.status, + }); + }; + + return calls; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -124,7 +166,7 @@ describe('ReportDownloadService', () => { expect(captured!.method).to.equal('POST'); expect(captured!.headers['x-app-api-key']).to.equal('test-key'); expect(captured!.headers['content-type']).to.equal('application/json'); - expect(JSON.parse(captured!.body!)).to.deep.equal({ results: 'FAILED' }); + expect(JSON.parse(captured!.body!)).to.deep.equal({ delivery: 'url', results: 'FAILED' }); }); it('calls POST /results/{uploadId}/download with body results: ALL', async () => { @@ -137,7 +179,7 @@ describe('ReportDownloadService', () => { downloadType: 'ALL', }); - expect(JSON.parse(captured!.body!)).to.deep.equal({ results: 'ALL' }); + expect(JSON.parse(captured!.body!)).to.deep.equal({ delivery: 'url', results: 'ALL' }); }); it('writes the response body to the specified file path', async () => { @@ -200,6 +242,48 @@ describe('ReportDownloadService', () => { expect(logs.join(' ')).to.include(outPath); }); + + it('follows signed-URL delivery when the API responds with JSON', async () => { + const outPath = path.join(tempDir, 'signed-url.zip'); + const calls = mockFetchSequence([ + { + body: JSON.stringify({ url: 'https://storage.example.com/bundle.zip?sig=abc' }), + contentType: 'application/json', + status: 200, + }, + { body: 'zip-from-storage', status: 200 }, + ]); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(calls).to.have.length(2); + expect(calls[1].url).to.equal('https://storage.example.com/bundle.zip?sig=abc'); + // The URL is pre-signed — auth headers must not leak to storage. + expect(calls[1].headers).to.not.have.property('x-app-api-key'); + expect(fs.readFileSync(outPath, 'utf8')).to.equal('zip-from-storage'); + }); + + it('falls back to inline delivery when the API returns 501', async () => { + const outPath = path.join(tempDir, 'fallback.zip'); + const calls = mockFetchSequence([ + { body: JSON.stringify({ message: 'unavailable' }), contentType: 'application/json', status: 501 }, + { body: 'inline-zip', status: 200 }, + ]); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'FAILED', + }); + + expect(calls).to.have.length(2); + expect(JSON.parse(calls[1].body!)).to.deep.equal({ results: 'FAILED' }); + expect(fs.readFileSync(outPath, 'utf8')).to.equal('inline-zip'); + }); }); // ------------------------------------------------------------------------- @@ -257,7 +341,7 @@ describe('ReportDownloadService', () => { }); expect(captured!.url).to.equal( - 'https://api.example.com/results/upload-xyz-456/html-report', + 'https://api.example.com/results/upload-xyz-456/html-report?delivery=url', ); });