Files
renovate/lib/modules/datasource/index.spec.ts
Jamie Tanna 29a7b9c5f5 fix(util/http): allow blocking requests to internal hosts
To harden Renovate from being able to access internal hosts, we can add
a level of enforcement on all outgoing requests.

We can split this into two types of traffic:

- where the response becomes configuration (i.e. `extends`)
- where the response does not become configuration (i.e.
  `customDatasources`)

To harden this, we enforce on every outbound HTTP request:

- check the resolved URL before anything, including cache lookups, can
  proceed
  - catching IP-literal hosts in every spelling the WHATWG URL parser normalizes
  - blocking known Cloud metadata IPs, too
- a `dnsLookup` wrapper validates every address a hostname resolves to,
  on every new connection, which also defeats DNS rebinding
- a `beforeRedirect` hook re-runs the URL check on each redirect
  target, with the new host getting its own grant lookup rather than
  inheriting the original host's verdict

We default this enforcement to `warn`, to not break existing deployments
and instead give them visibility into required configuration before a
future major version (major+1) enables `block` by default.

We'll also remove allow in a future major version (major+2), to give
time for admins to migrate over.

When a request is blocked, this degrades like an unreachable registry,
so a Renovate run isn't aborted by a blocked request.

As part of this, we can also provide a worked example of the different
cases that do/do not need allowing via `hostRules`.

Requests through an admin-configured proxy resolve DNS at the proxy, so
`dnsLookup` never runs for them, so we can resolve the hostname locally
and validate every address before the request instead.

This doesn't catch everything, especially as the proxy may resolve the
name to a different result, and if we can't resolve the name - possible
in locked-down corporate environments - we fail open.

Child processes aren't currently able to route traffic through
Renovate's HTTP layer, so are unrestricted.

Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
2026-09-10 14:52:01 +01:00

1130 lines
34 KiB
TypeScript

import { isFunction } from '@sindresorhus/is';
import fs from 'fs-extra';
import { logger } from '~test/util.ts';
import { GlobalConfig } from '../../config/global.ts';
import {
EXTERNAL_HOST_ERROR,
HOST_BLOCKED,
HOST_DISABLED,
} from '../../constants/error-messages.ts';
import { ExternalHostError } from '../../types/errors/external-host-error.ts';
import * as memCache from '../../util/cache/memory/index.ts';
import * as _packageCache from '../../util/cache/package/index.ts';
import { loadModules } from '../../util/modules.ts';
import datasources from './api.ts';
import { getDefaultVersioning } from './common.ts';
import { Datasource } from './datasource.ts';
import {
getDatasourceList,
getDatasources,
getDigest,
getPkgReleases,
supportsDigests,
} from './index.ts';
import type {
DatasourceApi,
DigestConfig,
GetPkgReleasesConfig,
GetReleasesConfig,
ReleaseResult,
} from './types.ts';
const datasource = 'dummy';
const packageName = 'package';
type RegistriesMock = Record<
string,
ReleaseResult | (() => ReleaseResult) | null
>;
const defaultRegistriesMock: RegistriesMock = {
'https://reg1.com': { releases: [{ version: '1.2.3' }] },
};
class DummyDatasource extends Datasource {
override defaultVersioning = 'python';
override defaultRegistryUrls = ['https://reg1.com'];
private registriesMock: RegistriesMock;
constructor(registriesMock: RegistriesMock = defaultRegistriesMock) {
super(datasource);
this.registriesMock = registriesMock;
}
override getReleases({
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const fn = this.registriesMock[registryUrl!];
if (isFunction(fn)) {
return Promise.resolve(fn());
}
return Promise.resolve(fn ?? null);
}
}
class DummyDatasource2 extends Datasource {
override defaultRegistryUrls = function () {
return ['https://reg1.com'];
};
private registriesMock: RegistriesMock;
constructor(registriesMock: RegistriesMock = defaultRegistriesMock) {
super(datasource);
this.registriesMock = registriesMock;
}
override getReleases({
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const fn = this.registriesMock[registryUrl!];
if (isFunction(fn)) {
return Promise.resolve(fn());
}
return Promise.resolve(fn ?? null);
}
}
class DummyDatasource3 extends Datasource {
override customRegistrySupport = false;
override defaultRegistryUrls = function () {
return ['https://reg1.com'];
};
private registriesMock: RegistriesMock;
constructor(registriesMock: RegistriesMock = defaultRegistriesMock) {
super(datasource);
this.registriesMock = registriesMock;
}
override getReleases({
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const fn = this.registriesMock[registryUrl!];
if (isFunction(fn)) {
return Promise.resolve(fn());
}
return Promise.resolve(fn ?? null);
}
}
class DummyDatasource4 extends DummyDatasource3 {
override defaultRegistryUrls = undefined as never;
}
class DummyDatasource5 extends Datasource {
override registryStrategy = undefined as never;
private registriesMock: RegistriesMock;
constructor(registriesMock: RegistriesMock = defaultRegistriesMock) {
super(datasource);
this.registriesMock = registriesMock;
}
override getReleases({
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const fn = this.registriesMock[registryUrl!];
if (isFunction(fn)) {
return Promise.resolve(fn());
}
return Promise.resolve(fn ?? null);
}
}
vi.mock('./metadata-manual.ts', () => ({
manualChangelogUrls: {
dummy: {
package: 'https://foo.bar/package/CHANGELOG.md',
},
},
manualSourceUrls: {
dummy: {
package: 'https://foo.bar/package',
},
},
}));
vi.mock('../../util/cache/package/index.ts');
const packageCache = vi.mocked(_packageCache);
describe('modules/datasource/index', () => {
afterEach(() => {
datasources.delete(datasource);
});
describe('getDefaultVersioning()', () => {
it('returns semver if undefined', () => {
expect(getDefaultVersioning(undefined)).toBe('semver-coerced');
});
});
describe('Validations', () => {
it('returns datasources', async () => {
expect(getDatasources()).toBeDefined();
const managerList = (
await fs.readdir(import.meta.dirname, { withFileTypes: true })
)
.filter(
(dirent) => dirent.isDirectory() && !dirent.name.startsWith('_'),
)
.map((dirent) => dirent.name)
.sort();
expect(getDatasourceList()).toEqual(managerList);
});
it('validates datasource', async () => {
function validateDatasource(
module: DatasourceApi,
name: string,
): boolean {
if (!module.getReleases) {
return false;
}
return module.id === name;
}
function filterClassBasedDatasources(name: string): boolean {
return !(getDatasources().get(name) instanceof Datasource);
}
const dss = new Map(getDatasources());
for (const ds of dss.values()) {
if (ds instanceof Datasource) {
dss.delete(ds.id);
}
}
const loadedDs = await loadModules(
import.meta.dirname,
validateDatasource,
filterClassBasedDatasources,
);
expect(Array.from(dss.keys())).toEqual(Object.keys(loadedDs));
for (const dsName of dss.keys()) {
const ds = dss.get(dsName)!;
expect(validateDatasource(ds, dsName)).toBeTrue();
}
});
it('returns null for null datasource', async () => {
await expect(
getPkgReleases({
datasource: null as never, // #22198
packageName: 'some/dep',
}),
).resolves.toBeNull();
});
it('returns null for no packageName', async () => {
datasources.set(datasource, new DummyDatasource());
await expect(
getPkgReleases({
datasource,
packageName: null as never, // #22198
}),
).resolves.toBeNull();
});
it('returns null for unknown datasource', async () => {
await expect(
getPkgReleases({
datasource: 'some-unknown-datasource',
packageName: 'some/dep',
}),
).resolves.toBeNull();
});
it('ignores and warns for disabled custom registryUrls', async () => {
class TestDatasource extends DummyDatasource {
override readonly customRegistrySupport = false;
}
datasources.set(datasource, new TestDatasource());
const registryUrls = ['https://foo.bar'];
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{ datasource: 'dummy', registryUrls, defaultRegistryUrls: undefined },
'Custom registries are not allowed for this datasource and will be ignored',
);
expect(res).toMatchObject({ releases: [{ version: '1.2.3' }] });
});
});
describe('Digest', () => {
it('returns if digests are supported', () => {
datasources.set(datasource, new DummyDatasource());
expect(supportsDigests(datasource)).toBeFalse();
});
it('returns value if defined', async () => {
class TestDatasource extends DummyDatasource {
override getDigest(): Promise<string> {
return Promise.resolve('123');
}
}
datasources.set(datasource, new TestDatasource());
expect(supportsDigests(datasource)).toBeTrue();
await expect(getDigest({ datasource, packageName })).resolves.toBe('123');
});
it('returns replacementName if defined', async () => {
class TestDatasource extends DummyDatasource {
override getDigest(
config: DigestConfig,
newValue?: string,
): Promise<string> {
return Promise.resolve(config.packageName);
}
}
datasources.set(datasource, new TestDatasource());
await expect(
getDigest({
datasource,
packageName: 'pkgName',
replacementName: 'replacement',
}),
).resolves.toBe('replacement');
});
});
describe('Metadata', () => {
beforeEach(() => {
datasources.set(datasource, new DummyDatasource());
});
it('adds changelogUrl', async () => {
await expect(
getPkgReleases({ datasource, packageName }),
).resolves.toMatchObject({
changelogUrl: 'https://foo.bar/package/CHANGELOG.md',
});
});
it('adds sourceUrl', async () => {
await expect(
getPkgReleases({ datasource, packageName }),
).resolves.toMatchObject({
sourceUrl: 'https://foo.bar/package',
});
});
});
describe('Packages', () => {
describe('registry caching', () => {
beforeEach(() => memCache.init());
afterEach(() => memCache.reset());
it.each(['defaultRegistryUrls', 'additionalRegistryUrls'])(
'keeps releases separate for different %s',
async (registryOption) => {
const firstRegistry = vi.fn(() => ({
releases: [{ version: '1.0.0' }],
}));
const secondRegistry = vi.fn(() => ({
releases: [{ version: '2.0.0' }],
}));
datasources.set(
datasource,
new DummyDatasource({
'https://reg2.com': firstRegistry,
'https://reg3.com': secondRegistry,
}),
);
const firstConfig = {
datasource,
packageName,
registryStrategy: 'merge',
[registryOption]: ['https://reg2.com'],
} satisfies GetPkgReleasesConfig;
const secondConfig = {
...firstConfig,
[registryOption]: ['https://reg3.com'],
};
const [first, second, repeated] = await Promise.all([
getPkgReleases(firstConfig),
getPkgReleases(secondConfig),
getPkgReleases(secondConfig),
]);
expect(first).toMatchObject({
releases: [{ version: '1.0.0' }],
registryUrl: 'https://reg2.com',
});
expect(second).toMatchObject({
releases: [{ version: '2.0.0' }],
registryUrl: 'https://reg3.com',
});
expect(repeated).toEqual(second);
expect(firstRegistry).toHaveBeenCalledTimes(1);
expect(secondRegistry).toHaveBeenCalledTimes(1);
},
);
});
it('supports defaultRegistryUrls parameter', async () => {
const registries: RegistriesMock = {
'https://foo.bar': { releases: [{ version: '0.0.1' }] },
};
datasources.set(datasource, new DummyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
defaultRegistryUrls: ['https://foo.bar'],
});
expect(res).toMatchObject({ releases: [{ version: '0.0.1' }] });
});
it('defaultRegistryUrls function works', async () => {
datasources.set(datasource, new DummyDatasource2());
const res = await getPkgReleases({
datasource,
packageName,
});
expect(res).toMatchObject({
releases: [{ version: '1.2.3' }],
registryUrl: 'https://reg1.com',
});
});
it('defaultRegistryUrls function with customRegistrySupport works', async () => {
datasources.set(datasource, new DummyDatasource3());
const res = await getPkgReleases({
datasource,
packageName,
});
expect(res).toMatchObject({
releases: [{ version: '1.2.3' }],
registryUrl: 'https://reg1.com',
});
});
// for coverage
it('undefined defaultRegistryUrls with customRegistrySupport works', async () => {
datasources.set(datasource, new DummyDatasource4());
const res = await getPkgReleases({
datasource,
packageName,
});
expect(res).toBeNull();
});
it('applies extractVersion', async () => {
const registries: RegistriesMock = {
'https://reg1.com': {
releases: [{ version: 'v4.3.143' }, { version: 'rc4.3.143' }],
},
};
datasources.set(datasource, new DummyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
extractVersion: '^(?<version>v\\d+\\.\\d+)',
versioning: 'loose',
});
expect(res).toMatchObject({ releases: [{ version: 'v4.3' }] });
});
it('trims sourceUrl', async () => {
datasources.set(
datasource,
new DummyDatasource({
'https://reg1.com': {
sourceUrl: ' https://abc.com ',
releases: [{ version: '1.0.0' }],
},
}),
);
const res = await getPkgReleases({
datasource,
packageName: 'foobar',
});
expect(res).toMatchObject({ sourceUrl: 'https://abc.com' });
});
it('massages sourceUrl', async () => {
datasources.set(
datasource,
new DummyDatasource({
'https://reg1.com': {
sourceUrl: 'scm:git@github.com:Jasig/cas.git',
releases: [{ version: '1.0.0' }],
},
}),
);
const res = await getPkgReleases({
datasource,
packageName: 'foobar',
});
expect(res).toMatchObject({ sourceUrl: 'https://github.com/Jasig/cas' });
});
it('applies replacements', async () => {
datasources.set(datasource, new DummyDatasource());
const res = await getPkgReleases({
datasource,
packageName,
replacementName: 'def',
replacementVersion: '2.0.0',
});
expect(res).toMatchObject({
replacementName: 'def',
replacementVersion: '2.0.0',
});
});
describe('Registry strategies', () => {
describe('first', () => {
class FirstRegistryDatasource extends DummyDatasource {
override readonly registryStrategy = 'first';
}
it('returns value from single registry', async () => {
datasources.set(datasource, new FirstRegistryDatasource());
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com'],
});
expect(res).toMatchObject({
releases: [{ version: '1.2.3' }],
registryUrl: 'https://reg1.com',
});
expect(logger.logger.warn).not.toHaveBeenCalled();
});
it('warns and returns first result', async () => {
const registries: RegistriesMock = {
'https://reg1.com': { releases: [{ version: '1.0.0' }] },
'https://reg2.com': { releases: [{ version: '2.0.0' }] },
'https://reg3.com': null,
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new FirstRegistryDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toMatchObject({
releases: [{ version: '1.0.0' }],
registryUrl: 'https://reg1.com',
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{
datasource: 'dummy',
packageName: 'package',
registryUrls,
},
'Excess registryUrls found for datasource lookup - using first configured only',
);
});
it('warns and returns first null', async () => {
const registries: RegistriesMock = {
'https://reg1.com': null,
'https://reg2.com': { releases: [{ version: '1.2.3' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new FirstRegistryDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toBeNull();
expect(logger.logger.warn).toHaveBeenCalledWith(
{ datasource, packageName, registryUrls },
'Excess registryUrls found for datasource lookup - using first configured only',
);
});
});
describe('merge', () => {
class MergeRegistriesDatasource extends DummyDatasource {
override readonly registryStrategy = 'merge';
override caching = true;
override readonly defaultRegistryUrls = [
'https://reg1.com',
'https://reg2.com',
];
}
const registries: RegistriesMock = {
'https://reg1.com': () => ({
releases: [{ version: '1.0.0' }],
tags: { release: '2.0.0' },
}),
'https://reg2.com': () => ({
releases: [{ version: '1.1.0' }],
tags: { latest: '1.1.0', release: '1.1.0' },
}),
'https://reg3.com': () => {
throw new ExternalHostError(new Error());
},
'https://reg4.com': () => {
throw new Error('a');
},
'https://reg5.com': () => {
throw new Error('b');
},
// for coverage
'https://reg6.com': null,
// has the same result as reg1 url, to test de-deplication of releases
'https://reg7.com': () => ({
releases: [{ version: '1.0.0' }],
tags: { latest: '1.2.0.0', release: '2.1.0' },
}),
};
beforeEach(() => {
datasources.set(
datasource,
new MergeRegistriesDatasource(registries),
);
});
it('merges custom defaultRegistryUrls and returns success', async () => {
const res = await getPkgReleases({ datasource, packageName });
expect(res).toMatchObject({
releases: [
{ registryUrl: 'https://reg1.com', version: '1.0.0' },
{ registryUrl: 'https://reg2.com', version: '1.1.0' },
],
tags: {
latest: '1.1.0',
release: '2.0.0',
},
});
});
it('ignores custom defaultRegistryUrls if registryUrls are set', async () => {
const res = await getPkgReleases({
datasource,
packageName,
defaultRegistryUrls: ['https://reg3.com'],
registryUrls: ['https://reg1.com', 'https://reg2.com'],
});
expect(res).toMatchObject({
releases: [
{ registryUrl: 'https://reg1.com', version: '1.0.0' },
{ registryUrl: 'https://reg2.com', version: '1.1.0' },
],
});
});
it('merges registries and returns success', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com', 'https://reg2.com'],
});
expect(res).toMatchObject({
releases: [
{ registryUrl: 'https://reg1.com', version: '1.0.0' },
{ registryUrl: 'https://reg2.com', version: '1.1.0' },
],
});
});
it('filters out duplicate releases', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com', 'https://reg7.com'],
});
expect(res).toMatchObject({
releases: [
{ registryUrl: 'https://reg1.com', version: '1.0.0' },
// { registryUrl: 'https://reg2.com', version: '1.0.0' },
],
tags: {
latest: '1.2.0.0',
release: '2.1.0',
},
});
});
describe('Cache', () => {
afterAll(() => {
GlobalConfig.reset();
});
class CachingDatasource extends DummyDatasource {
override caching = true;
}
it('returns cached result on cache hit', async () => {
const cachedResult: ReleaseResult = {
releases: [{ version: '0.0.1' }],
};
packageCache.get.mockResolvedValue(cachedResult);
datasources.set(datasource, new CachingDatasource());
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com'],
});
expect(res).toMatchObject({ releases: [{ version: '0.0.1' }] });
expect(packageCache.set).not.toHaveBeenCalled();
});
it('caches by default', async () => {
const registries = {
'https://reg1.com': {
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
},
} satisfies RegistriesMock;
datasources.set(datasource, new CachingDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
});
expect(packageCache.set).toHaveBeenCalledExactlyOnceWith(
'datasource-releases-dummy',
'https://reg1.com:package',
{
changelogUrl: 'https://foo.bar/package/CHANGELOG.md',
registryUrl: 'https://reg1.com',
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
sourceUrl: 'https://foo.bar/package',
},
15,
);
});
it('skips cache when isPrivate=true', async () => {
const registries = {
'https://reg1.com': {
isPrivate: true,
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
},
} satisfies RegistriesMock;
datasources.set(datasource, new CachingDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
});
expect(packageCache.set).not.toHaveBeenCalledExactlyOnceWith();
});
it('forces cache via GlobalConfig', async () => {
GlobalConfig.set({ cachePrivatePackages: true });
const registries = {
'https://reg1.com': {
isPrivate: true,
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
},
} satisfies RegistriesMock;
datasources.set(datasource, new CachingDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg1.com'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
});
expect(packageCache.set).toHaveBeenCalledExactlyOnceWith(
'datasource-releases-dummy',
expect.any(String),
expect.any(Object),
expect.any(Number),
);
});
});
it('keeps merged results when a registry throws ExternalHostError', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: [
'https://reg1.com',
'https://reg2.com',
'https://reg3.com',
],
});
expect(res).toMatchObject({
releases: [
{ registryUrl: 'https://reg1.com', version: '1.0.0' },
{ registryUrl: 'https://reg2.com', version: '1.1.0' },
],
});
});
it('returns results found after an ExternalHostError', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg3.com', 'https://reg1.com'],
});
expect(res).toMatchObject({
registryUrl: 'https://reg1.com',
releases: [{ version: '1.0.0' }],
});
});
it('aborts on ExternalHostError when no registry returned releases', async () => {
await expect(
getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg3.com'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('merges registries and returns null for error', async () => {
await expect(
getPkgReleases({
datasource,
packageName,
registryUrls: ['https://reg4.com', 'https://reg5.com'],
}),
).resolves.toBeNull();
});
});
describe('hunt', () => {
class HuntRegistriyDatasource extends DummyDatasource {
override readonly registryStrategy = 'hunt';
}
it('returns first successful result', async () => {
const registries: RegistriesMock = {
'https://reg1.com': null,
'https://reg2.com': () => {
throw new Error('unknown');
},
'https://reg3.com': { releases: [{ version: '1.0.0' }] },
'https://reg4.com': { releases: [{ version: '2.0.0' }] },
'https://reg5.com': { releases: [{ version: '3.0.0' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new HuntRegistriyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toMatchObject({
registryUrl: 'https://reg3.com',
releases: [{ version: '1.0.0' }],
});
});
it('returns null for HOST_DISABLED', async () => {
const registries: RegistriesMock = {
'https://reg1.com': () => {
throw new ExternalHostError(new Error(HOST_DISABLED));
},
'https://reg2.com': { releases: [{ version: '1.0.0' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new HuntRegistriyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toBeNull();
});
it('returns null for HOST_BLOCKED', async () => {
const registries: RegistriesMock = {
'https://reg1.com': () => {
throw new ExternalHostError(new Error(HOST_BLOCKED));
},
'https://reg2.com': { releases: [{ version: '1.0.0' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new HuntRegistriyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toBeNull();
});
it('aborts on ExternalHostError', async () => {
const registries: RegistriesMock = {
'https://reg1.com': () => {
throw new ExternalHostError(new Error('something unknown'));
},
'https://reg2.com': { releases: [{ version: '1.0.0' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new HuntRegistriyDatasource(registries));
await expect(
getPkgReleases({ datasource, packageName, registryUrls }),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('returns null if no releases are found', async () => {
const registries: RegistriesMock = {
'https://reg1.com': () => {
throw Object.assign(new Error(), { statusCode: '404' });
},
'https://reg2.com': () => {
throw Object.assign(new Error(), { statusCode: '401' });
},
'https://reg3.com': () => {
throw Object.assign(new Error(), { statusCode: '403' });
},
'https://reg4.com': () => {
throw new Error('b');
},
'https://reg5.com': () => {
throw Object.assign(new Error(), { code: '403' });
},
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new HuntRegistriyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toBeNull();
});
it('defaults to hunt strategy', async () => {
const registries: RegistriesMock = {
'https://reg1.com': null,
'https://reg2.com': () => {
throw new Error('unknown');
},
'https://reg3.com': { releases: [{ version: '1.0.0' }] },
'https://reg4.com': { releases: [{ version: '2.0.0' }] },
'https://reg5.com': { releases: [{ version: '3.0.0' }] },
};
const registryUrls = Object.keys(registries);
datasources.set(datasource, new DummyDatasource5(registries));
const res = await getPkgReleases({
datasource,
packageName,
registryUrls,
});
expect(res).toMatchObject({
registryUrl: 'https://reg3.com',
releases: [{ version: '1.0.0' }],
});
});
});
describe('relaseConstraintFiltering', () => {
it('keeps all releases by default', async () => {
const registries = {
'https://foo.bar': {
releases: [
{
version: '0.0.1',
constraints: {
python: ['2.7'],
},
},
{
version: '0.0.2',
},
],
},
} satisfies RegistriesMock;
datasources.set(datasource, new DummyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
defaultRegistryUrls: ['https://foo.bar'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
});
});
it('keeps all releases if constraints is set but no value defined for constraintsFiltering', async () => {
const registries = {
'https://foo.bar': {
releases: [
{
version: '0.0.1',
constraints: {
python: ['2.7'],
},
},
{
version: '0.0.2',
},
],
},
} satisfies RegistriesMock;
datasources.set(datasource, new DummyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
defaultRegistryUrls: ['https://foo.bar'],
constraints: {
python: '2.7.0',
},
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }, { version: '0.0.2' }],
});
});
it('filters releases if value is strict', async () => {
const registries = {
'https://foo.bar': {
releases: [
{
version: '0.0.5',
constraints: {
python: ['>= 3.0.0, < 4.0'],
},
},
{
version: '0.0.4',
constraints: {
python: ['>= 2.7, < 4.0'],
},
},
{
version: '0.0.3',
constraints: {
python: ['>= 2.7, < 3.0'],
},
},
{
version: '0.0.2',
constraints: {
python: ['2.7'],
},
},
{
version: '0.0.1',
constraints: {
python: ['1.0'],
},
},
],
},
} satisfies RegistriesMock;
datasources.set(datasource, new DummyDatasource(registries));
const res = await getPkgReleases({
datasource,
packageName,
defaultRegistryUrls: ['https://foo.bar'],
constraints: { python: '>= 2.7, < 3.0' },
constraintsFiltering: 'strict',
});
expect(res).toMatchObject({
releases: [
{ version: '0.0.2' },
{ version: '0.0.3' },
{ version: '0.0.4' },
],
});
});
});
describe('overruled by package config', () => {
beforeEach(() => {
datasources.set(
datasource,
new DummyDatasource({
'https://reg1.com': { releases: [{ version: '0.0.1' }] },
'https://reg2.com': { releases: [{ version: '0.0.2' }] },
'https://reg3.com': { releases: [{ version: '0.0.3' }] },
}),
);
});
it('first', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryStrategy: 'first',
defaultRegistryUrls: ['https://reg1.com', 'https://reg2.com'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }],
});
});
it('hunt', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryStrategy: 'hunt',
defaultRegistryUrls: ['https://foo.bar', 'https://reg1.com'],
});
expect(res).toMatchObject({
releases: [{ version: '0.0.1' }],
});
});
it('merge', async () => {
const res = await getPkgReleases({
datasource,
packageName,
registryStrategy: 'merge',
defaultRegistryUrls: [
'https://reg1.com',
'https://reg2.com',
'https://reg3.com',
],
});
expect(res).toMatchObject({
releases: [
{ version: '0.0.1' },
{ version: '0.0.2' },
{ version: '0.0.3' },
],
});
});
});
});
});
});