fix: correct mirror warnings, auth scoping, and integration coverage

- only warn about PyPy/GraalPy mirror when a custom mirror is set; the
  action.yml default made the warning fire on every run
- accept the refs/heads/{branch} raw URL form so it routes via the REST
  API instead of tripping the slash-branch warning
- scope mirror-token to the full mirror origin (scheme+host+port) so it
  can't leak to a same-host http download_url
- make an invalid mirror fatal on the auth path, matching getManifestUrl
- fix warning/docs that wrongly claimed the raw fallback is anonymous
- force a manifest fetch in the mirror integration job (check-latest) so
  it actually contacts the mirror instead of using the preinstalled cache
This commit is contained in:
Ludovic Henry 2026-08-31 18:28:02 +02:00
parent 0d1135ac81
commit 990142f1ec
No known key found for this signature in database
6 changed files with 221 additions and 53 deletions

View file

@ -86,6 +86,7 @@ const {
getManifestFromRepo,
getManifestFromURL,
resolveRepoCoords,
isMirrorCustomized,
installCpythonFromRelease
} = await import('../src/install-python.js');
@ -152,6 +153,55 @@ describe('getManifestUrl', () => {
// find-python.ts calls this while building the "version not found" message.
expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/);
});
it('treats an invalid mirror as fatal on the auth path too', async () => {
// getManifestUrl() throws on a bad mirror; the auth resolution must agree
// rather than swallow the error and quietly skip the mirror-token branch.
setInputs({'mirror-token': 'MTOK', mirror: 'not a url'});
(tc.downloadTool as jest.Mock<any>).mockResolvedValue('/tmp/py.tgz');
(tc.extractTar as jest.Mock<any>).mockResolvedValue('/tmp/extracted');
const release = {
version: '3.12.0',
stable: true,
files: [
{
filename: 'python-3.12.0-linux-x64.tar.gz',
platform: 'linux',
arch: 'x64',
download_url: 'https://cdn.example/py.tar.gz'
}
]
} as any;
await expect(installCpythonFromRelease(release)).rejects.toThrow(
/Invalid 'mirror' URL/
);
});
});
describe('isMirrorCustomized', () => {
it('is false when the mirror input is empty', () => {
expect(isMirrorCustomized()).toBe(false);
});
it('is false when the mirror input equals the default', () => {
// action.yml gives `mirror` this exact default, so getInput() returns it on
// every run where the user did not set one. The PyPy/GraalPy warning must
// not fire in that case.
setInputs({mirror: DEFAULT_MIRROR});
expect(isMirrorCustomized()).toBe(false);
});
it('is false when the mirror input is the default with trailing slashes', () => {
setInputs({mirror: `${DEFAULT_MIRROR}///`});
expect(isMirrorCustomized()).toBe(false);
});
it('is true when the mirror input is a custom URL', () => {
setInputs({mirror: 'https://mirror.example/py'});
expect(isMirrorCustomized()).toBe(true);
});
});
describe('resolveRepoCoords', () => {
@ -166,6 +216,22 @@ describe('resolveRepoCoords', () => {
);
});
it('parses the refs/heads/{branch} form to the bare branch without warning', () => {
setInputs({
mirror:
'https://raw.githubusercontent.com/actions/python-versions/refs/heads/main'
});
expect(resolveRepoCoords()).toEqual({
owner: 'actions',
repo: 'python-versions',
branch: 'main'
});
// refs/heads/main is a valid single branch, so it must route through the
// API path and never hit the slash-branch warning.
expect(core.warning).not.toHaveBeenCalled();
});
it('does not warn for a non-GitHub mirror', () => {
setInputs({mirror: 'https://mirror.example/py'});
@ -223,6 +289,25 @@ describe('getManifestFromRepo mirror resolution', () => {
);
});
it('resolves the refs/heads/{branch} form to the bare branch for the API', async () => {
setInputs({
token: 'TKN',
mirror: 'https://raw.githubusercontent.com/foo/bar/refs/heads/main'
});
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
await getManifestFromRepo();
// The GitHub tree API takes a bare branch, so the refs/heads/ prefix must
// be stripped rather than passed through as part of the branch name.
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
'foo',
'bar',
'token TKN',
'main'
);
});
it('returns null for a non-GitHub mirror so the caller uses the raw URL', () => {
setInputs({mirror: 'https://mirror.example/py'});
expect(resolveRepoCoords()).toBeNull();
@ -296,9 +381,13 @@ describe('getManifestFromURL mirror resolution', () => {
});
it('sends token as a prefixed header for a GitHub-hosted raw manifest', async () => {
// A slash-branch URL genuinely falls to the direct-URL path (it does not
// match the {owner}/{repo}/{branch} shape). raw.githubusercontent.com is a
// GitHub host, so the token is still attached here — contradicting any
// claim that the fallback fetch is anonymous.
setInputs({
token: 'TKN',
mirror: 'https://raw.githubusercontent.com/foo/bar/refs/heads/main'
mirror: 'https://raw.githubusercontent.com/foo/bar/feature/riscv'
});
const getJson = jest.fn(async () => ({result: mockManifest}));
(httpm.HttpClient as jest.Mock<any>).mockImplementation(() => ({getJson}));
@ -306,7 +395,7 @@ describe('getManifestFromURL mirror resolution', () => {
await getManifestFromURL();
expect(getJson).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/foo/bar/refs/heads/main/versions-manifest.json',
'https://raw.githubusercontent.com/foo/bar/feature/riscv/versions-manifest.json',
{authorization: 'token TKN'}
);
});
@ -426,6 +515,41 @@ describe('installCpythonFromRelease auth gating', () => {
).resolves.toBe('Basic dXNlcjpwYXNz');
});
it('withholds mirror-token from a same-host download URL on a different scheme', async () => {
setInputs({
'mirror-token': 'Bearer MTOK',
mirror: 'https://cdn.example'
});
// The mirror is https, but the manifest points a download_url at http on
// the same host. Matching on origin (scheme + host + port) rather than host
// alone keeps the token from going out in cleartext.
await expect(
downloadAuthFor('http://cdn.example/py.tar.gz')
).resolves.toBeUndefined();
});
it('withholds mirror-token from a same-host download URL on a different port', async () => {
setInputs({
'mirror-token': 'Bearer MTOK',
mirror: 'https://cdn.example'
});
// Different port is a different origin, so the nominated credential must
// not follow.
await expect(
downloadAuthFor('https://cdn.example:8443/py.tar.gz')
).resolves.toBeUndefined();
});
it('sends mirror-token to a same-origin download URL on an explicit port', async () => {
setInputs({
'mirror-token': 'Bearer MTOK',
mirror: 'https://cdn.example:8443'
});
await expect(
downloadAuthFor('https://cdn.example:8443/py.tar.gz')
).resolves.toBe('Bearer MTOK');
});
it('withholds mirror-token from an incidental GitHub host and uses token there', async () => {
setInputs({
token: 'TKN',