setup-java/__tests__/distributors/jetbrains-installer.test.ts
Bruno Borges 27f2c62824
Verify JDK downloads with vendor checksums (#1167)
* Verify JDK downloads with vendor checksums

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Handle missing vendor checksum values

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Preserve checksum error during cleanup failure

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Validate checksum metadata value types

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Clarify checksum documentation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Expand vendor checksum verification

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Accept SHA-256 or SHA-512 for JetBrains checksum sibling

JetBrains publishes a single, generically-named ".checksum" sibling
whose digest algorithm isn't disclosed by the filename. Older JBR 11
builds (e.g. jbrsdk_nomod-11_0_16-*-b2043.64.tar.gz) publish a SHA-256
digest there, while newer builds publish SHA-512. The JetBrains
installer previously assumed SHA-512 unconditionally, so verification
failed with "Malformed sha512 checksum metadata ... expected a
128-character hexadecimal digest" for those older builds, breaking the
jetbrains 11 e2e job on macOS and Windows.

fetchChecksum now accepts a list of candidate algorithms and infers
the actual algorithm from the returned digest's length, preferring the
strongest match. The JetBrains installer passes ['sha512', 'sha256'];
all other callers are unaffected since they already pass a single,
vendor-disclosed algorithm.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

* Use SapMachine archive checksum files

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d
2026-07-29 04:43:56 -04:00

324 lines
9.9 KiB
TypeScript

import {
jest,
describe,
it,
expect,
beforeEach,
afterEach,
beforeAll,
afterAll
} from '@jest/globals';
import https from 'https';
import {HttpClient, HttpClientResponse} from '@actions/http-client';
import type {IncomingMessage} from 'http';
import {Readable} from 'stream';
import manifestData from '../data/jetbrains.json' with {type: 'json'};
import os from 'os';
// Mock @actions/core before importing source modules that depend on it
jest.unstable_mockModule('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
setFailed: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(),
getBooleanInput: jest.fn(),
getMultilineInput: jest.fn(),
addPath: jest.fn(),
exportVariable: jest.fn(),
saveState: jest.fn(),
getState: jest.fn(),
setSecret: jest.fn(),
isDebug: jest.fn(() => false),
startGroup: jest.fn(),
endGroup: jest.fn(),
group: jest.fn((_name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}));
// Dynamic imports after mocking
const core = await import('@actions/core');
const {JetBrainsDistribution} =
await import('../../src/distributions/jetbrains/installer.js');
const {RetryingHttpClient} = await import('../../src/retrying-http-client.js');
function response(
statusCode: number,
body = '',
headers: IncomingMessage['headers'] = {}
): HttpClientResponse {
const message = Readable.from([Buffer.from(body)]) as IncomingMessage;
message.statusCode = statusCode;
message.headers = headers;
return new HttpClientResponse(message);
}
describe('getAvailableVersions', () => {
let spyHttpClient: any;
let spyCoreError: any;
beforeEach(() => {
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient.mockReturnValue({
statusCode: 200,
headers: {},
result: []
});
// Mock core.error to suppress error logs
spyCoreError = core.error as jest.Mock;
spyCoreError.mockImplementation(() => {});
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('load available versions', async () => {
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient
.mockReturnValueOnce({
statusCode: 200,
headers: {},
result: manifestData as any
})
.mockReturnValue({
statusCode: 200,
headers: {},
result: []
});
const distribution = new JetBrainsDistribution({
version: '17',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
const availableVersions = await distribution['getAvailableVersions']();
expect(availableVersions).not.toBeNull();
const length =
os.platform() === 'win32' ? manifestData.length : manifestData.length + 2;
expect(availableVersions.length).toBe(length);
}, 10_000);
it('retries a GitHub rate limit using Retry-After', async () => {
spyHttpClient.mockRestore();
const sleep = jest.fn(async () => undefined);
const requestRaw = jest
.spyOn(HttpClient.prototype, 'requestRaw')
.mockResolvedValueOnce(response(429, '', {'retry-after': '2'}))
.mockResolvedValueOnce(response(200, '[]'))
.mockResolvedValueOnce(response(200))
.mockResolvedValueOnce(response(200));
const distribution = new JetBrainsDistribution({
version: '17',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['http'] = new RetryingHttpClient('test', {
sleep,
random: () => 0
});
const availableVersions = await distribution['getAvailableVersions']();
expect(availableVersions).toHaveLength(2);
expect(requestRaw).toHaveBeenCalledTimes(4);
expect(requestRaw.mock.calls[0][0].options.path).toBe(
requestRaw.mock.calls[1][0].options.path
);
expect(requestRaw.mock.calls[0][0].options.path).toContain(
'/repos/JetBrains/JetBrainsRuntime/releases'
);
expect(sleep).toHaveBeenCalledWith(2000);
expect(core.info).toHaveBeenCalledWith(
'Request attempt 1 of 4 failed (HTTP 429); retrying in 2000 ms'
);
});
});
describe('findPackageForDownload', () => {
let spyHttpClientGet: any;
const JETBRAINS_CHECKSUM = 'c'.repeat(128);
beforeEach(() => {
// Every resolved release fetches `${url}.checksum` (sha512, GNU
// `<hex> <filename>` format); stub it so tests never reach the real
// network, except the dedicated 'version %s can be downloaded' test
// below which intentionally exercises real HTTPS HEAD requests.
spyHttpClientGet = jest
.spyOn(HttpClient.prototype, 'get')
.mockResolvedValue({
message: {statusCode: 200},
readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk.tar.gz\n`
} as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
['17', '17.0.11+1207.24'],
['11.0', '11.0.16+2043.64'],
['11.0.11', '11.0.11+1542.1'],
['21.0.2', '21.0.2+375.1'],
['21', '21.0.3+465.3'],
['x', '21.0.3+465.3']
])('version is resolved correctly %s -> %s', async (input, expected) => {
const distribution = new JetBrainsDistribution({
version: input,
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
const resolvedVersion = await distribution['findPackageForDownload'](input);
expect(resolvedVersion.version).toBe(expected);
});
it.each(['17', '11.0', '11.0.11', '21.0.2', '21'])(
'version %s can be downloaded',
async input => {
const distribution = new JetBrainsDistribution({
version: input,
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
const resolvedVersion =
await distribution['findPackageForDownload'](input);
const url = resolvedVersion.url;
const options = {method: 'HEAD'};
await new Promise<void>((resolve, reject) => {
const request = https.request(url, options, res => {
let assertionError: unknown;
try {
// JetBrains uses 403 for non-existent packages
expect(res.statusCode).not.toBe(403);
} catch (error) {
assertionError = error;
}
res.resume();
res.once('error', reject);
res.once('end', () =>
assertionError ? reject(assertionError as Error) : resolve()
);
});
request.on('error', reject);
request.end();
});
}
);
it('version is not found', async () => {
const distribution = new JetBrainsDistribution({
version: '8.0.452',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
await expect(distribution['findPackageForDownload']('8.x')).rejects.toThrow(
/No matching version found for SemVer */
);
});
it('version list is empty', async () => {
const distribution = new JetBrainsDistribution({
version: '8',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => [];
await expect(distribution['findPackageForDownload']('8')).rejects.toThrow(
/No matching version found for SemVer */
);
});
it('fetches the authoritative sha512 checksum only for the resolved version', async () => {
const distribution = new JetBrainsDistribution({
version: '21',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
const result = await distribution['findPackageForDownload']('21');
expect(result.checksum).toEqual({
algorithm: 'sha512',
value: JETBRAINS_CHECKSUM,
source: `${result.url}.checksum`
});
// Only the single resolved/winning version's checksum is requested,
// not one per candidate considered during version resolution.
expect(spyHttpClientGet).toHaveBeenCalledWith(`${result.url}.checksum`);
expect(spyHttpClientGet).toHaveBeenCalledTimes(1);
});
it('parses only the first whitespace-delimited token from the GNU checksum payload', async () => {
spyHttpClientGet.mockResolvedValue({
message: {statusCode: 200},
readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk-21.tar.gz\n`
} as any);
const distribution = new JetBrainsDistribution({
version: '21',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
const result = await distribution['findPackageForDownload']('21');
expect(result.checksum?.value).toBe(JETBRAINS_CHECKSUM);
});
it('falls back to a sha256 checksum for older JBR builds that only publish one', async () => {
// Older JBR 11 builds (e.g. jbrsdk_nomod-11_0_16-*-b2043.64.tar.gz) publish
// a SHA-256 digest at the generic `.checksum` sibling instead of SHA-512.
const sha256Checksum = 'a'.repeat(64);
spyHttpClientGet.mockResolvedValue({
message: {statusCode: 200},
readBody: async () =>
`${sha256Checksum} jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz\n`
} as any);
const distribution = new JetBrainsDistribution({
version: '21',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
distribution['getAvailableVersions'] = async () => manifestData as any;
const result = await distribution['findPackageForDownload']('21');
expect(result.checksum).toEqual({
algorithm: 'sha256',
value: sha256Checksum,
source: `${result.url}.checksum`
});
});
});