Files
renovate/lib/modules/datasource/hex/index.spec.ts
Sebastian Poxhofer 05e1271376 test: use expect().resolves instead of awaiting inside expect (#45672)
Enables the vitest/prefer-expect-resolves oxlint rule and applies its
autofix across the spec suite.
2026-09-04 10:40:46 +00:00

930 lines
24 KiB
TypeScript

import { generateKeyPair, sign as signPayload } from 'node:crypto';
import { promisify } from 'node:util';
import { gzip as _gzip } from 'node:zlib';
import protobuf from 'protobufjs';
import upath from 'upath';
import { Fixtures } from '~test/fixtures.ts';
import { hostRules } from '~test/host-rules.ts';
import * as httpMock from '~test/http-mock.ts';
import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages.ts';
import * as memCache from '../../../util/cache/memory/index.ts';
import { getPkgReleases } from '../index.ts';
import { HexDatasource } from './index.ts';
import type { Package } from './v2/package.ts';
import { Package as PackageCodec } from './v2/package.ts';
import { Signed as SignedCodec } from './v2/signed.ts';
const certifiResponse = Fixtures.get('certifi.json');
const privatePackageResponse = Fixtures.get('private_package.json');
const baseUrl = 'https://hex.pm/api';
const datasource = HexDatasource.id;
const gzip = promisify(_gzip);
const { privateKey: testPrivateKey, publicKey: testPublicKey } =
await promisify(generateKeyPair)('rsa', {
modulusLength: 2048,
privateKeyEncoding: {
format: 'pem',
type: 'pkcs8',
},
publicKeyEncoding: {
format: 'pem',
type: 'spki',
},
});
interface V2ResponseOptions {
privateKey?: string;
signature?: Buffer;
}
function protobufLoad(file: string): Promise<protobuf.Root> {
const resolvedFile = upath.join(import.meta.dirname, 'v2', file);
return new Promise((resolve, reject) => {
protobuf.load(resolvedFile, (err, root) => {
if (err) {
reject(err);
return;
}
if (!root) {
reject(new Error('Root is empty'));
return;
}
resolve(root);
});
});
}
async function encodePackage(input: unknown): Promise<Buffer> {
const message = PackageCodec.fromJSON(input);
const root = await protobufLoad('package.proto');
const x = root.lookupType('Package').encode(message).finish();
return Buffer.from(x);
}
async function encodeSigned(input: unknown): Promise<Buffer> {
const message = SignedCodec.fromJSON(input);
const root = await protobufLoad('signed.proto');
const x = root.lookupType('Signed').encode(message).finish();
return Buffer.from(x);
}
async function makeV2Response(
pkg: Package,
options: V2ResponseOptions = {},
): Promise<Buffer> {
const payload = await encodePackage(pkg);
const signature =
options.signature ??
(options.privateKey
? signPayload('RSA-SHA512', payload, options.privateKey)
: Buffer.from('fake-sig'));
const signed = await encodeSigned({
payload,
signature,
});
return await gzip(signed);
}
function mockPublicKeyUnavailable(registryUrl: string): void {
httpMock.scope(registryUrl).get('/public_key').reply(404);
}
function mockPublicKey(registryUrl: string, publicKey: string): void {
httpMock.scope(registryUrl).get('/public_key').reply(200, publicKey);
}
describe('modules/datasource/hex/index', () => {
beforeEach(() => {
memCache.init();
});
afterEach(() => {
memCache.reset();
});
describe('getReleases', () => {
it('returns null for empty result', async () => {
httpMock.scope(baseUrl).get('/packages/non_existent_package').reply(200);
await expect(
getPkgReleases({
datasource,
packageName: 'non_existent_package',
}),
).resolves.toBeNull();
});
it('returns null for missing fields', async () => {
httpMock
.scope(baseUrl)
.get('/packages/non_existent_package')
.reply(200, {});
await expect(
getPkgReleases({
datasource,
packageName: 'non_existent_package',
}),
).resolves.toBeNull();
});
it('returns null for 404', async () => {
httpMock.scope(baseUrl).get('/packages/some_package').reply(404);
await expect(
getPkgReleases({ datasource, packageName: 'some_package' }),
).resolves.toBeNull();
});
it('returns null for 401', async () => {
httpMock.scope(baseUrl).get('/packages/some_package').reply(401);
await expect(
getPkgReleases({ datasource, packageName: 'some_package' }),
).resolves.toBeNull();
});
it('throws for 429', async () => {
httpMock.scope(baseUrl).get('/packages/some_crate').reply(429);
await expect(
getPkgReleases({ datasource, packageName: 'some_crate' }),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('throws for 5xx', async () => {
httpMock.scope(baseUrl).get('/packages/some_crate').reply(502);
await expect(
getPkgReleases({ datasource, packageName: 'some_crate' }),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('returns null for unknown error', async () => {
httpMock.scope(baseUrl).get('/packages/some_package').replyWithError('');
await expect(
getPkgReleases({ datasource, packageName: 'some_package' }),
).resolves.toBeNull();
});
it('returns null with wrong auth token', async () => {
httpMock
.scope(baseUrl, {
reqheaders: {
authorization: 'abc',
},
})
.get('/packages/certifi')
.reply(401);
hostRules.add({
authType: 'Token-Only',
token: 'abc',
});
const res = await getPkgReleases({
datasource,
packageName: 'certifi',
});
expect(res).toBeNull();
});
it('processes real data', async () => {
httpMock
.scope(baseUrl)
.get('/packages/certifi')
.reply(200, certifiResponse);
const res = await getPkgReleases({
datasource,
packageName: 'certifi',
});
expect(res).toMatchObject({
releases: [
{
version: '0.1.1',
isDeprecated: true,
releaseTimestamp: '2015-09-10T13:58:55.620Z',
},
{ version: '0.2.0' },
{ version: '0.3.0' },
{ version: '0.4.0' },
{ version: '0.5.0' },
{ version: '0.6.0' },
{ version: '0.7.0' },
{ version: '1.0.0' },
{ version: '1.1.0' },
{ version: '1.2.0' },
{ version: '1.2.1' },
{ version: '2.0.0' },
{ version: '2.1.0' },
{ version: '2.2.0' },
{ version: '2.3.0' },
{ version: '2.3.1' },
{ version: '2.4.1' },
{ version: '2.4.2' },
{ version: '2.5.1' },
{
version: '2.5.2',
releaseTimestamp: '2020-03-04T14:54:16.283Z',
},
],
});
});
it('process public repo without auth', async () => {
httpMock
.scope(baseUrl)
.get('/packages/certifi')
.reply(200, certifiResponse);
const res = await getPkgReleases({
datasource,
packageName: 'certifi',
});
expect(res).toMatchObject({
releases: [
{ version: '0.1.1' },
{ version: '0.2.0' },
{ version: '0.3.0' },
{ version: '0.4.0' },
{ version: '0.5.0' },
{ version: '0.6.0' },
{ version: '0.7.0' },
{ version: '1.0.0' },
{ version: '1.1.0' },
{ version: '1.2.0' },
{ version: '1.2.1' },
{ version: '2.0.0' },
{ version: '2.1.0' },
{ version: '2.2.0' },
{ version: '2.3.0' },
{ version: '2.3.1' },
{ version: '2.4.1' },
{ version: '2.4.2' },
{ version: '2.5.1' },
{ version: '2.5.2' },
],
});
});
it('extracts depreceated info', async () => {
httpMock
.scope(baseUrl)
.get('/packages/certifi')
.reply(200, certifiResponse);
const res = await getPkgReleases({
datasource,
packageName: 'certifi',
});
expect(res?.releases.some((rel) => rel.isDeprecated)).toBeTrue();
});
it('processes a private repo with auth', async () => {
httpMock
.scope(baseUrl, {
reqheaders: {
authorization: 'abc',
},
})
.get('/repos/renovate_test/packages/private_package')
.reply(200, privatePackageResponse);
hostRules.add({
authType: 'Token-Only',
token: 'abc',
});
const result = await getPkgReleases({
datasource,
packageName: 'private_package:renovate_test',
});
expect(result).toEqual({
homepage: 'https://hex.pm/packages/renovate_test/private_package',
sourceUrl: 'https://github.com/renovate_test/private_package',
registryUrl: 'https://hex.pm',
releases: [
{ releaseTimestamp: '2021-08-04T15:26:26.500Z', version: '0.1.0' },
{ releaseTimestamp: '2021-08-04T17:46:00.274Z', version: '0.1.1' },
],
});
});
});
describe('getReleases (V2 protocol)', () => {
const customRegistryUrl = 'https://repo.custom-registry.example.com';
it('extracts versions from V2 response', async () => {
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
{
version: '2.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
{
version: '3.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [
{ version: '1.0.0' },
{ version: '2.0.0' },
{ version: '3.0.0' },
],
});
});
it('marks retired releases as deprecated', async () => {
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
retired: { reason: 3, message: 'use 2.0.0' },
},
{
version: '2.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [
{ version: '1.0.0', isDeprecated: true },
{ version: '2.0.0' },
],
});
});
it('filters releases without versions', async () => {
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'custom',
releases: [
{
version: '',
innerChecksum: new Uint8Array(),
dependencies: [],
},
{
version: '2.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '2.0.0' }],
});
});
it('handles organization packages via V2', async () => {
const v2Body = await makeV2Response({
name: 'private_pkg',
repository: 'my_org',
releases: [
{
version: '0.1.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/repos/my_org/packages/private_pkg')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'private_pkg:my_org',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '0.1.0' }],
});
});
it('returns null for empty releases', async () => {
const v2Body = await makeV2Response({
name: 'empty_package',
repository: 'custom',
releases: [],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/packages/empty_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'empty_package',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('throws for 5xx errors', async () => {
httpMock
.scope(customRegistryUrl)
.get('/packages/some_package')
.reply(502);
await expect(
getPkgReleases({
datasource,
packageName: 'some_package',
registryUrls: [customRegistryUrl],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('throws for 429 errors', async () => {
httpMock
.scope(customRegistryUrl)
.get('/packages/some_package')
.reply(429);
await expect(
getPkgReleases({
datasource,
packageName: 'some_package',
registryUrls: [customRegistryUrl],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('returns null for 404', async () => {
httpMock
.scope(customRegistryUrl)
.get('/packages/some_package')
.reply(404);
await expect(
getPkgReleases({
datasource,
packageName: 'some_package',
registryUrls: [customRegistryUrl],
}),
).resolves.toBeNull();
});
it('returns null for network error', async () => {
httpMock
.scope(customRegistryUrl)
.get('/packages/some_package')
.replyWithError('connection refused');
await expect(
getPkgReleases({
datasource,
packageName: 'some_package',
registryUrls: [customRegistryUrl],
}),
).resolves.toBeNull();
});
it('returns null for malformed gzip', async () => {
httpMock
.scope(customRegistryUrl)
.get('/packages/bad_package')
.reply(200, Buffer.from('not-gzip-data'));
await expect(
getPkgReleases({
datasource,
packageName: 'bad_package',
registryUrls: [customRegistryUrl],
}),
).resolves.toBeNull();
});
it('verifies signature when public key is available', async () => {
const v2Body = await makeV2Response(
{
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
},
{ privateKey: testPrivateKey },
);
mockPublicKey(customRegistryUrl, testPublicKey);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '1.0.0' }],
});
});
it('returns null for invalid signature when public key is available', async () => {
const v2Body = await makeV2Response(
{
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
},
{ signature: Buffer.from('invalid-signature') },
);
mockPublicKey(customRegistryUrl, testPublicKey);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('returns null for missing signature when public key is available', async () => {
const v2Body = await makeV2Response(
{
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
},
{ signature: Buffer.alloc(0) },
);
mockPublicKey(customRegistryUrl, testPublicKey);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('returns null for malformed public key when verification is enabled', async () => {
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKey(customRegistryUrl, 'not-a-valid-public-key');
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('falls back to unsigned payload when public key response is empty', async () => {
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKey(customRegistryUrl, ' ');
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '1.0.0' }],
});
});
it('uses pinned Hex public key for repo.hex.pm', async () => {
const defaultV2RegistryUrl = 'https://repo.hex.pm';
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'hexpm',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
httpMock
.scope(defaultV2RegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [defaultV2RegistryUrl],
});
expect(result).toBeNull();
});
it('maps repo.hex.pm host aliases to hexpm repository checks', async () => {
const hostAliasRegistryUrl = 'https://repo.hex.pm:443';
const v2Body = await makeV2Response({
name: 'my_package',
repository: 'hexpm',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(hostAliasRegistryUrl);
httpMock
.scope(hostAliasRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [hostAliasRegistryUrl],
});
expect(result).toEqual({
registryUrl: hostAliasRegistryUrl,
releases: [{ version: '1.0.0' }],
});
});
it('caches public key responses for subsequent package lookups', async () => {
const v2BodyOne = await makeV2Response(
{
name: 'first_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
},
{ privateKey: testPrivateKey },
);
const v2BodyTwo = await makeV2Response(
{
name: 'second_package',
repository: 'custom',
releases: [
{
version: '2.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
},
{ privateKey: testPrivateKey },
);
mockPublicKey(customRegistryUrl, testPublicKey);
httpMock
.scope(customRegistryUrl)
.get('/packages/first_package')
.reply(200, v2BodyOne);
httpMock
.scope(customRegistryUrl)
.get('/packages/second_package')
.reply(200, v2BodyTwo);
const firstResult = await getPkgReleases({
datasource,
packageName: 'first_package',
registryUrls: [customRegistryUrl],
});
const secondResult = await getPkgReleases({
datasource,
packageName: 'second_package',
registryUrls: [customRegistryUrl],
});
expect(firstResult).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '1.0.0' }],
});
expect(secondResult).toEqual({
registryUrl: customRegistryUrl,
releases: [{ version: '2.0.0' }],
});
});
it('returns null for package name mismatch', async () => {
const v2Body = await makeV2Response({
name: 'different_package',
repository: 'custom',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/packages/my_package')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'my_package',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('returns null for organization repository mismatch', async () => {
const v2Body = await makeV2Response({
name: 'private_pkg',
repository: 'wrong_org',
releases: [
{
version: '1.0.0',
innerChecksum: new Uint8Array(),
dependencies: [],
},
],
});
mockPublicKeyUnavailable(customRegistryUrl);
httpMock
.scope(customRegistryUrl)
.get('/repos/my_org/packages/private_pkg')
.reply(200, v2Body);
const result = await getPkgReleases({
datasource,
packageName: 'private_pkg:my_org',
registryUrls: [customRegistryUrl],
});
expect(result).toBeNull();
});
it('uses JSON API for hex.pm default registry', async () => {
httpMock
.scope(baseUrl)
.get('/packages/certifi')
.reply(200, certifiResponse);
const result = await getPkgReleases({
datasource,
packageName: 'certifi',
});
expect(result).not.toBeNull();
expect(result?.releases.length).toBeGreaterThan(0);
});
});
});