Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions src/gateways/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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<Response> {
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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
90 changes: 87 additions & 3 deletions test/unit/report-download.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> => {
calls.push({
body: init?.body ? String(init.body) : null,
headers: Object.fromEntries(
Object.entries((init?.headers as Record<string, string>) ?? {}),
),
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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',
);
});

Expand Down
Loading