Files
backnotprop__plannotator/packages/shared/pr-artifact-document.test.ts
Sun f7068ce31e fix(review): GitLab upload artifact fetching via authenticated API with hardened rewrite (#1228)
* fix(pr-artifacts): read GitLab uploads through the token-readable API

GitLab serves `/uploads/<secret>/<file>` from a Rails web route that only
honors session cookies. A `PRIVATE-TOKEN` request is redirected to the
sign-in page, so every GitLab upload attachment referenced by an MR was
unreadable — and because the sign-in page is HTML served with HTTP 200,
it was rendered as the artifact instead of the file.

Route upload links through `GET /projects/:id/uploads/:secret/:filename`,
which serves the same bytes for a personal access token. Both the bare
`/uploads/...` and project-scoped `/<path>/uploads/...` forms are rewritten.

Also stop following redirects into a provider sign-in path: fail with 401
and the matching `gh`/`glab auth login` hint so an auth gap is legible
instead of surfacing as a corrupt artifact.

The uploads API answers every file with `application/octet-stream`, and the
content route serves provider types with `nosniff`, so refine an opaque type
from the file extension to keep images and video rendering.

Verified end-to-end against a self-hosted GitLab 18.8.0-ee instance.

* fix(pr-artifacts): rewrite only real GitLab upload paths

The upload rewrite matched `/uploads/<anything>/<anything>`, so a link in an
MR body could name a path GitLab never minted and still be rewritten into
`/api/v4/projects/:id/uploads/...` with the PRIVATE-TOKEN attached. Because
the remainder allowed slashes, a crafted link could append an attacker-chosen
path to that credentialed GET, leaving the safety of the request to GitLab's
router rather than to this allowlist.

Pin the shape GitLab actually mints: a 32 lowercase hex secret and a single
filename segment. A non-conforming path is no longer rewritten, so it is
fetched verbatim as the ordinary web route exactly as before this feature.

Test fixtures move to a real 32-hex secret, and a new case pins that a short
secret, an uppercase secret, and a multi-segment remainder are all left alone.

* fix(pr-artifacts): diagnose a direct provider refusal as a missing login

The sign-in guard only fired on a redirect, but GitLab's `/api/v4` routes
refuse a bad or absent token directly with a 401/403 JSON body. Those fell
through to `Artifact host returned HTTP 401` at status 502, which reads as a
broken artifact rather than the thing the reader can fix.

Hoist the sign-in messaging into `providerAuthRequiredError` and reuse it for a
direct refusal, so both paths produce the same actionable error at status 401.

GitHub's 403 stays a transport status on purpose: it also covers rate limiting
and SSO enforcement, where a `gh auth login` hint points at the wrong problem.
A GitHub 401 is unambiguous and is diagnosed.

The message text moves to a colon instead of a dash to match house style.

* fix(pr-artifacts): fall back to the upload web route when the API route is absent

`GET /projects/:id/uploads/:secret/:filename` landed in GitLab 17.4. On an
older self-hosted instance the rewrite turns an upload that used to load, on a
public project where the web route reads anonymously, into a 404 surfaced as a
502. That is a regression the rewrite introduced.

When the rewritten uploads API URL answers 404, retry the original web route
once before failing. The retry is deliberately narrow: only for the upload
rewrite, only on 404, and only when the 404 came from the rewritten URL itself,
with no version probing anywhere. It reuses the loop's redirect budget, so the
total number of requests stays bounded, and it is same-origin, so credentials
are attached on exactly the existing `shouldSendProviderAuth` terms.

A sign-in redirect from that retry still produces the actionable 401, so a
private project on an old instance reports a missing login rather than a 404.

* fix(pr-artifacts): drop the active content types from the extension map

`html` and `text/javascript` bought nothing. An HTML artifact is read through
`/api/pr-artifact-document`, which always answers `text/plain; charset=utf-8`,
so nothing on that path ever consulted this map. Keeping them meant the media
route could label an upload as active content, leaving safety resting on the
`Content-Security-Policy: sandbox` header staying in place forever.

`css` stays because `shouldRewriteCss` keys on `text/css` to rewrite provider
references, and the image and video types stay because `nosniff` means an
unrefined octet-stream simply does not render.

`svg` stays too, on the evidence of how svg uploads actually reach the screen.
`.svg` is not in the review editor's IMAGE_EXTENSIONS, so an svg upload arrives
either as an authored markdown image, rendered through `<img src>`, which is a
non-scripting context by spec, or as a resource referenced from an HTML
artifact, which renders inside an `<iframe sandbox="">`. Both are served by the
media route with `Content-Security-Policy: sandbox` and `X-Content-Type-Options:
nosniff`, so even a direct navigation to the proxy URL lands in a sandboxed,
opaque-origin document that cannot run script. Dropping it would give up real
rendering for no reduction in reachable capability.

Adds a regression test pinning that an .html or .js upload stays opaque.

* test(pr-artifacts): pin that the upload rewrite does not widen token reach

The rewrite sends PRIVATE-TOKEN to a different path on the provider origin, so
the invariant worth guarding is that it did not also change where that token can
travel. GitLab object storage answers an upload with a 302 to a signed URL on an
unrelated host, which is exactly the hop a credential must not follow.

Verified the test bites: forcing shouldSendProviderAuth to return true for
gitlab fails it on the second request's header.

The module-global auth cache has a 5 minute TTL and no reset seam, so the test
resolves the same `test-token` every other gitlab case in this file resolves,
which makes it correct whether the cache is cold or warm rather than dependent
on test order.

---------

Co-authored-by: Sun Neoh <yuensun.neoh@stashaway.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-08-17 07:39:27 -07:00

718 lines
25 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import type { GithubPRMetadata, GitlabMRMetadata, PRContext, PRRuntime } from './pr-types';
import {
fetchPRArtifactContent,
fetchPRArtifactDocument,
isPRArtifactDocumentUrlAllowed,
} from './pr-artifact-document';
const context: PRContext = {
body: [
'[explainer](https://github.com/user-attachments/files/123/explainer.html)',
'[source](https://raw.githubusercontent.com/acme/widgets/main/review.md)',
'[committed](https://github.com/acme/widgets/blob/main/docs/review.html)',
'![media](https://github.com/acme/widgets/blob/main/assets/demo.png)',
].join('\n'),
state: 'OPEN',
isDraft: false,
labels: [],
reviewDecision: '',
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
comments: [],
reviews: [],
reviewThreads: [],
checks: [],
linkedIssues: [],
};
const github: GithubPRMetadata = {
platform: 'github',
host: 'github.com',
owner: 'acme',
repo: 'widgets',
number: 1,
title: 'Artifacts',
author: 'reviewer',
baseBranch: 'main',
headBranch: 'feature',
baseSha: 'base',
headSha: 'head',
url: 'https://github.com/acme/widgets/pull/1',
};
const gitlabMetadata: GitlabMRMetadata = {
platform: 'gitlab',
host: 'gitlab.example.com',
projectPath: 'acme/widgets',
iid: 7,
title: 'Artifacts',
author: 'reviewer',
baseBranch: 'main',
headBranch: 'feature',
baseSha: 'base',
headSha: 'head',
url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/7',
};
/** GitLab mints upload secrets as 32 lowercase hex characters; the rewrite requires that shape. */
const uploadSecret = '0123456789abcdef0123456789abcdef';
describe('isPRArtifactDocumentUrlAllowed', () => {
test('allows referenced GitHub uploads and raw files from the active repository', () => {
expect(isPRArtifactDocumentUrlAllowed(
'https://github.com/user-attachments/files/123/explainer.html',
github,
context,
)).toBe(true);
expect(isPRArtifactDocumentUrlAllowed(
'https://github.com/acme/widgets/blob/main/docs/review.html',
github,
context,
)).toBe(true);
expect(isPRArtifactDocumentUrlAllowed(
'https://raw.githubusercontent.com/acme/widgets/main/review.md',
github,
context,
)).toBe(true);
});
test('matches GitHub owner and repository casing without weakening the origin check', () => {
const mixedCaseUrl = 'https://github.com/ACME/Widgets/blob/main/docs/review.html';
expect(isPRArtifactDocumentUrlAllowed(
mixedCaseUrl,
github,
{ ...context, body: `[review](${mixedCaseUrl})` },
)).toBe(true);
expect(isPRArtifactDocumentUrlAllowed(
'https://github.com:8443/ACME/Widgets/blob/main/docs/review.html',
github,
{
...context,
body: '[review](https://github.com:8443/ACME/Widgets/blob/main/docs/review.html)',
},
)).toBe(false);
});
test('rejects unreferenced URLs and raw files from a different repository', () => {
expect(isPRArtifactDocumentUrlAllowed(
'https://github.com/user-attachments/files/999/private.html',
github,
context,
)).toBe(false);
expect(isPRArtifactDocumentUrlAllowed(
'https://raw.githubusercontent.com/other/widgets/main/review.md',
github,
{
...context,
body: '[source](https://raw.githubusercontent.com/other/widgets/main/review.md)',
},
)).toBe(false);
expect(isPRArtifactDocumentUrlAllowed(
'http://github.com/user-attachments/files/123/explainer.html',
github,
{
...context,
body: '[explainer](http://github.com/user-attachments/files/123/explainer.html)',
},
)).toBe(false);
});
test('allows a relative GitLab upload only when the active MR references it', () => {
const gitlab: GitlabMRMetadata = {
platform: 'gitlab',
host: 'gitlab.example.com',
projectPath: 'acme/widgets',
iid: 7,
title: 'Artifacts',
author: 'reviewer',
baseBranch: 'main',
headBranch: 'feature',
baseSha: 'base',
headSha: 'head',
url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/7',
};
expect(isPRArtifactDocumentUrlAllowed(
'https://gitlab.example.com/uploads/hash/explainer.html',
gitlab,
{ ...context, body: '[explainer](/uploads/hash/explainer.html)' },
)).toBe(true);
});
});
describe('fetchPRArtifactDocument', () => {
test('fetches a GitHub blob link from its raw-content URL', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let requestedUrl = '';
globalThis.fetch = async (input) => {
requestedUrl = String(input);
return new Response('<main>Review</main>', { headers: { 'content-type': 'text/html' } });
};
try {
const result = await fetchPRArtifactDocument(
runtime,
github,
context,
'https://github.com/acme/widgets/blob/main/docs/review.html',
);
expect(result.content).toBe('<main>Review</main>');
expect(requestedUrl).toBe('https://raw.githubusercontent.com/acme/widgets/main/docs/review.html');
} finally {
globalThis.fetch = originalFetch;
}
});
test('supports an exact self-hosted provider origin including its port', async () => {
const enterprise: GithubPRMetadata = {
...github,
host: 'github.example.com:8443',
url: 'https://github.example.com:8443/acme/widgets/pull/1',
};
const artifactUrl = 'https://github.example.com:8443/acme/widgets/blob/main/review.md';
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'enterprise-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let receivedToken = '';
let requestedUrl = '';
globalThis.fetch = async (input, init) => {
requestedUrl = String(input);
receivedToken = new Headers(init?.headers).get('authorization') ?? '';
return new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
try {
const result = await fetchPRArtifactDocument(
runtime,
enterprise,
{ ...context, body: `[review](${artifactUrl})` },
artifactUrl,
);
expect(result.content).toBe('# Review');
expect(requestedUrl).toBe(
'https://github.example.com:8443/acme/widgets/raw/main/review.md',
);
expect(receivedToken).toBe('Bearer enterprise-token');
} finally {
globalThis.fetch = originalFetch;
}
});
test('does not forward provider credentials to a redirect on another port', async () => {
const artifactUrl = 'https://github.com/user-attachments/files/123/explainer.html';
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
const authorizations: string[] = [];
globalThis.fetch = async (_input, init) => {
authorizations.push(new Headers(init?.headers).get('authorization') ?? '');
if (authorizations.length === 1) {
return new Response(null, {
status: 302,
headers: { location: 'https://github.com:8443/download/explainer.html' },
});
}
return new Response('<main>Review</main>', { headers: { 'content-type': 'text/html' } });
};
try {
const result = await fetchPRArtifactDocument(runtime, github, context, artifactUrl);
expect(result.content).toBe('<main>Review</main>');
expect(authorizations).toEqual(['Bearer test-token', '']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('reads GitLab credentials with the supported per-host config command', async () => {
const gitlab: GitlabMRMetadata = {
platform: 'gitlab',
host: 'gitlab.example.com',
projectPath: 'acme/widgets',
iid: 7,
title: 'Artifacts',
author: 'reviewer',
baseBranch: 'main',
headBranch: 'feature',
baseSha: 'base',
headSha: 'head',
url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/7',
};
const commands: string[] = [];
const runtime: PRRuntime = {
async runCommand(command, args) {
commands.push([command, ...args].join(' '));
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let receivedToken = '';
globalThis.fetch = async (_input, init) => {
receivedToken = new Headers(init?.headers).get('PRIVATE-TOKEN') ?? '';
return new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
try {
const result = await fetchPRArtifactDocument(
runtime,
gitlab,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
expect(result.content).toBe('# Review');
expect(receivedToken).toBe('test-token');
expect(commands).toEqual(['glab config get token --host gitlab.example.com']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fetches GitLab uploads through the token-readable uploads API', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
const requestedUrls: string[] = [];
globalThis.fetch = async (input) => {
requestedUrls.push(String(input));
return new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
try {
for (const rawUrl of [
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
`https://gitlab.example.com/acme/widgets/uploads/${uploadSecret}/review.md`,
]) {
const result = await fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{
...context,
body: [
`[a](/uploads/${uploadSecret}/review.md)`,
`[b](/acme/widgets/uploads/${uploadSecret}/review.md)`,
].join('\n'),
},
rawUrl,
);
expect(result.content).toBe('# Review');
}
expect(requestedUrls).toEqual([
`https://gitlab.example.com/api/v4/projects/acme%2Fwidgets/uploads/${uploadSecret}/review.md`,
`https://gitlab.example.com/api/v4/projects/acme%2Fwidgets/uploads/${uploadSecret}/review.md`,
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('rewrites only real upload paths, so a crafted MR link cannot steer the credentialed GET', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
const requestedUrls: string[] = [];
globalThis.fetch = async (input) => {
requestedUrls.push(String(input));
return new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
const crafted = [
// Secrets GitLab could never have minted: too short, and not lowercase hex.
'https://gitlab.example.com/uploads/hash/review.md',
`https://gitlab.example.com/uploads/${uploadSecret.toUpperCase()}/review.md`,
// A multi-segment remainder would otherwise append an attacker-chosen path
// to the /api/v4 URL the token is sent to.
`https://gitlab.example.com/uploads/${uploadSecret}/nested/review.md`,
];
try {
for (const rawUrl of crafted) {
await fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](${rawUrl})` },
rawUrl,
);
}
// Left alone: fetched verbatim as the ordinary web route, never rewritten.
expect(requestedUrls).toEqual(crafted);
} finally {
globalThis.fetch = originalFetch;
}
});
test('refines the opaque content type the GitLab uploads API returns', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(Uint8Array.from([137, 80, 78, 71]), {
headers: { 'content-type': 'application/octet-stream' },
});
try {
const result = await fetchPRArtifactContent(
runtime,
gitlabMetadata,
{ ...context, body: `![shot](/uploads/${uploadSecret}/screenshot.png)` },
`https://gitlab.example.com/uploads/${uploadSecret}/screenshot.png`,
);
expect(result.contentType).toBe('image/png');
} finally {
globalThis.fetch = originalFetch;
}
});
test('never refines an opaque upload into an active content type', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('<script>alert(1)</script>', {
headers: { 'content-type': 'application/octet-stream' },
});
try {
// An HTML artifact is read through the document route, which always serves
// text/plain, so refining these here would only put an active type on the media
// route and leave its safety resting on a CSP header.
for (const filename of ['payload.html', 'payload.js']) {
const result = await fetchPRArtifactContent(
runtime,
gitlabMetadata,
{ ...context, body: `[x](/uploads/${uploadSecret}/${filename})` },
`https://gitlab.example.com/uploads/${uploadSecret}/${filename}`,
);
expect(result.contentType).toBe('application/octet-stream');
}
} finally {
globalThis.fetch = originalFetch;
}
});
test('retries the original upload route once when the uploads API route is absent', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
const requestedUrls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
requestedUrls.push(url);
// Pre-17.4 self-hosted GitLab: the uploads API route does not exist, while the
// original web route serves a public project without a session.
return url.includes('/api/v4/')
? new Response('{"error":"404 Not Found"}', { status: 404 })
: new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
try {
const result = await fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
expect(result.content).toBe('# Review');
expect(requestedUrls).toEqual([
`https://gitlab.example.com/api/v4/projects/acme%2Fwidgets/uploads/${uploadSecret}/review.md`,
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('reports the transport status when the fallback route also 404s, without retrying again', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let requests = 0;
globalThis.fetch = async () => {
requests += 1;
return new Response('missing', { status: 404 });
};
try {
const promise = fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
await expect(promise).rejects.toMatchObject({ status: 502 });
expect(requests).toBe(2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('diagnoses a direct 401 or 403 from the GitLab API as a missing login', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: '', stderr: '', exitCode: 1 };
},
};
const originalFetch = globalThis.fetch;
try {
for (const status of [401, 403]) {
globalThis.fetch = async () => new Response('{"message":"401 Unauthorized"}', {
status,
headers: { 'content-type': 'application/json' },
});
const promise = fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
// The actionable outcome: the reader is told to log in, not that HTTP 401 happened.
await expect(promise).rejects.toMatchObject({ status: 401 });
await expect(promise).rejects.toThrow(/glab auth login --hostname gitlab\.example\.com/);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test('leaves a GitHub 403 as a transport status, since it also covers rate limits', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('rate limited', { status: 403 });
try {
const promise = fetchPRArtifactDocument(
runtime,
github,
context,
'https://github.com/acme/widgets/blob/main/docs/review.html',
);
await expect(promise).rejects.toMatchObject({ status: 502 });
} finally {
globalThis.fetch = originalFetch;
}
});
test('does not forward the GitLab token to a cross-origin redirect after the upload rewrite', async () => {
// The module-global auth cache is keyed by platform and host with a 5 minute TTL, so
// this deliberately resolves the same `test-token` every other gitlab test in this file
// does: whether the cache is cold or warm, request one carries that exact token.
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
const requestedUrls: string[] = [];
const tokens: string[] = [];
const signedUrl = 'https://objects.example.net/gitlab/review.md?signature=abc';
globalThis.fetch = async (input, init) => {
requestedUrls.push(String(input));
tokens.push(new Headers(init?.headers).get('private-token') ?? '');
// Object storage hands an upload off to a signed URL on an unrelated host.
return requestedUrls.length === 1
? new Response(null, { status: 302, headers: { location: signedUrl } })
: new Response('# Review', { headers: { 'content-type': 'text/markdown' } });
};
try {
const result = await fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
expect(result.content).toBe('# Review');
expect(requestedUrls[0]).toContain('/api/v4/projects/acme%2Fwidgets/uploads/');
expect(requestedUrls[1]).toBe(signedUrl);
// The invariant: routing uploads through the API must not widen where the
// credential travels. The token stops at the provider's own origin.
expect(tokens).toEqual(['test-token', '']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fails loudly instead of rendering a provider sign-in page', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: '', stderr: '', exitCode: 1 };
},
};
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
return String(input).includes('/api/v4/')
? new Response(null, {
status: 302,
headers: { location: 'https://gitlab.example.com/users/auth/saml' },
})
: new Response('<form action="/users/auth/saml">', {
headers: { 'content-type': 'text/html' },
});
};
try {
const promise = fetchPRArtifactDocument(
runtime,
gitlabMetadata,
{ ...context, body: `[review](/uploads/${uploadSecret}/review.md)` },
`https://gitlab.example.com/uploads/${uploadSecret}/review.md`,
);
await expect(promise).rejects.toThrow(/requires authentication/);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('fetchPRArtifactContent', () => {
test('normalizes provider blob media, preserves bytes, and forwards a valid range', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let requestedUrl = '';
let receivedRange = '';
globalThis.fetch = async (input, init) => {
requestedUrl = String(input);
receivedRange = new Headers(init?.headers).get('range') ?? '';
return new Response(Uint8Array.from([0, 1, 2, 255]), {
status: 206,
headers: {
'content-type': 'image/png',
'content-range': 'bytes 0-3/4',
'accept-ranges': 'bytes',
},
});
};
try {
const result = await fetchPRArtifactContent(
runtime,
github,
context,
'https://github.com/acme/widgets/blob/main/assets/demo.png',
{ range: 'bytes=0-3' },
);
expect(requestedUrl).toBe('https://raw.githubusercontent.com/acme/widgets/main/assets/demo.png');
expect(receivedRange).toBe('bytes=0-3');
expect([...result.content]).toEqual([0, 1, 2, 255]);
expect(result).toMatchObject({
status: 206,
contentType: 'image/png',
contentRange: 'bytes 0-3/4',
acceptRanges: 'bytes',
});
} finally {
globalThis.fetch = originalFetch;
}
});
test('allows a provider resource derived from a referenced document and rewrites CSS assets', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(
[
'.hero { background: url(../images/hero.png); }',
'.icon { background: url(https://cdn.example.com/icon.svg); }',
].join('\n'),
{ headers: { 'content-type': 'text/css' } },
);
try {
const result = await fetchPRArtifactContent(
runtime,
github,
context,
'https://raw.githubusercontent.com/acme/widgets/main/styles/review.css',
{ sourceUrl: 'https://github.com/acme/widgets/blob/main/docs/review.html' },
);
const css = new TextDecoder().decode(result.content);
expect(css).toContain('/api/pr-artifact-content?');
expect(css).toContain('hero.png');
expect(css).toContain('source=');
expect(css).toContain('url(https://cdn.example.com/icon.svg)');
expect(css).not.toContain('url=https%3A%2F%2Fcdn.example.com');
} finally {
globalThis.fetch = originalFetch;
}
});
test('buffers the complete bounded response before returning it', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: 'test-token\n', stderr: '', exitCode: 0 };
},
};
const originalFetch = globalThis.fetch;
let finishBody: (() => void) | undefined;
let markBodyStarted: (() => void) | undefined;
const bodyStarted = new Promise<void>((resolve) => {
markBodyStarted = resolve;
});
globalThis.fetch = async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Uint8Array.from([1, 2]));
finishBody = () => {
controller.enqueue(Uint8Array.from([3, 4]));
controller.close();
};
markBodyStarted?.();
},
}), { headers: { 'content-type': 'video/mp4' } });
try {
let settled = false;
const pending = fetchPRArtifactContent(
runtime,
github,
context,
'https://github.com/acme/widgets/blob/main/assets/demo.png',
).then((result) => {
settled = true;
return result;
});
await bodyStarted;
expect(settled).toBe(false);
if (finishBody === undefined) throw new Error('Expected response body controller');
finishBody();
const result = await pending;
expect([...result.content]).toEqual([1, 2, 3, 4]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('rejects an unreferenced provider resource without a referenced source document', async () => {
const runtime: PRRuntime = {
async runCommand() {
return { stdout: '', stderr: '', exitCode: 1 };
},
};
await expect(fetchPRArtifactContent(
runtime,
github,
context,
'https://raw.githubusercontent.com/acme/widgets/main/private/secret.png',
)).rejects.toMatchObject({ status: 403 });
});
});