Files
renovate/lib/config/presets/index.spec.ts
Jamie Tanna acb46bc706 fix(config): surface blocked hosts more clearly when resolving config
With the new hardening on blocking internal hosts with outbound HTTP
calls, we should build on top of this to ensure that the UX is better
when a preset is blocked.

We can do this by making sure that when `HOST_BLOCKED`, we report a more
targeted config validation error.

We can also make sure to pass around our `internalGrants`, similar to
how we currently allow an admin's `headers` are handled.

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

1728 lines
54 KiB
TypeScript

import { mockDeep } from 'vitest-mock-extended';
import { Fixtures } from '~test/fixtures.ts';
import { logger } from '~test/util.ts';
import {
CONFIG_VALIDATION,
HOST_BLOCKED,
PLATFORM_RATE_LIMIT_EXCEEDED,
} 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 { setCustomEnv } from '../../util/env.ts';
import { GlobalConfig } from '../global.ts';
import type { AllConfig } from '../types.ts';
import * as _forgejo from './forgejo/index.ts';
import * as _gitea from './gitea/index.ts';
import * as _github from './github/index.ts';
import * as _gitlab from './gitlab/index.ts';
import * as _http from './http/index.ts';
import * as presets from './index.ts';
import * as _local from './local/index.ts';
import * as _npm from './npm/index.ts';
import {
PRESET_DEP_NOT_FOUND,
PRESET_INVALID_JSON,
PRESET_NOT_FOUND,
PRESET_RENOVATE_CONFIG_NOT_FOUND,
} from './util.ts';
vi.mock('./forgejo/index.ts');
vi.mock('./gitea/index.ts');
vi.mock('./http/index.ts');
vi.mock('./npm/index.ts');
vi.mock('./github/index.ts');
vi.mock('./gitlab/index.ts');
vi.mock('./local/index.ts');
vi.mock('../../util/cache/package/index.ts', () => mockDeep());
const forgejo = vi.mocked(_forgejo);
const gitea = vi.mocked(_gitea);
const http = vi.mocked(_http);
const npm = vi.mocked(_npm);
const local = vi.mocked(_local);
const gitHub = vi.mocked(_github);
const gitLab = vi.mocked(_gitlab);
const packageCache = vi.mocked(_packageCache);
const presetIkatyang = Fixtures.getJson('renovate-config-ikatyang.json');
describe('config/presets/index', () => {
describe('resolveConfigPresets', () => {
let config: AllConfig;
beforeEach(() => {
config = {};
GlobalConfig.reset();
memCache.init();
packageCache.get.mockImplementation(
<T>(namespace: string, key: string): Promise<T> =>
Promise.resolve(memCache.get(`${namespace}-${key}`)),
);
packageCache.set.mockImplementation(
(
namespace: string,
key: string,
value: unknown,
_minutes: number,
): Promise<void> => {
memCache.set(`${namespace}-${key}`, value);
return Promise.resolve();
},
);
npm.getPreset.mockImplementation(({ repo, presetName }) => {
if (repo === 'renovate-config-ikatyang') {
return presetIkatyang.versions[presetIkatyang['dist-tags'].latest][
'renovate-config'
][presetName!];
}
if (repo === 'renovate-config-notfound') {
throw new Error(PRESET_DEP_NOT_FOUND);
}
if (repo === 'renovate-config-noconfig') {
throw new Error(PRESET_RENOVATE_CONFIG_NOT_FOUND);
}
if (repo === 'renovate-config-throw') {
throw new Error('whoops');
}
if (repo === 'renovate-config-wrongpreset') {
throw new Error(PRESET_NOT_FOUND);
}
return null;
});
});
it('returns same if no presets', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = [];
const { config: res } = await presets.resolveConfigPresets(config);
expect(config).toMatchObject(res);
expect(res).toEqual({ foo: 1 });
});
it('skips duplicate resolves', async () => {
config.extends = ['local>some/repo:a', 'local>some/repo:b'];
local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] });
local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] });
local.getPreset.mockResolvedValueOnce({ foo: 1 });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
foo: 1,
});
expect(local.getPreset).toHaveBeenCalledTimes(3);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Already seen preset local>some/repo:c in [local>some/repo:a, local>some/repo:c]',
);
});
it('throws if invalid preset file', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['local>some/repo'];
local.getPreset.mockResolvedValueOnce({ extends: ['notfound'] });
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
"Cannot find preset's package (notfound)." +
' Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.',
);
expect(e!.validationMessage).toBeUndefined();
});
it('throws if the preset host is blocked', async () => {
config.extends = ['http://10.1.2.3/preset.json'];
http.getPreset.mockRejectedValueOnce(new Error(HOST_BLOCKED));
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationError).toBe(
'Preset host is blocked by this Renovate instance (http://10.1.2.3/preset.json). If this is intended, ask your Renovate administrator to permit it with a `hostRules` entry setting `allowInternal=true`, scoped either by `hostType` (for example `preset` or `npm`) or by a URL-prefix `matchHost`',
);
expect(logger.logger.warn).toHaveBeenCalledWith(
{
preset: 'http://10.1.2.3/preset.json',
documentationUrl: expect.stringContaining(
'self-hosted-configuration/#hostrulesallowinternal',
),
},
'Preset host is blocked by this Renovate instance',
);
});
it('throws if invalid preset', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['wrongpreset:invalid-preset'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
'Preset name not found within published preset config (wrongpreset:invalid-preset)',
);
expect(e!.validationMessage).toBeUndefined();
});
it('throws if path + invalid syntax', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['github>user/repo//'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe('Preset is invalid (github>user/repo//)');
expect(e!.validationMessage).toBeUndefined();
});
it('throws if path + sub-preset', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['github>user/repo//path:subpreset'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
'Sub-presets cannot be combined with a custom path (github>user/repo//path:subpreset)',
);
expect(e!.validationMessage).toBeUndefined();
});
it('throws if invalid preset json', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['org/repo'];
let e: Error | undefined;
local.getPreset.mockRejectedValueOnce(new Error(PRESET_INVALID_JSON));
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe('Preset is invalid JSON (org/repo)');
expect(e!.validationMessage).toBeUndefined();
});
it('throws noconfig', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['noconfig:recommended'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
'Preset package is missing a renovate-config entry (noconfig:recommended)',
);
expect(e!.validationMessage).toBeUndefined();
});
it('throws throw', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['throw:base'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
'Preset caused unexpected error (throw:base)',
);
expect(e!.validationMessage).toBeUndefined();
});
it('works with valid', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = [':pinVersions'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
description: [
'Use version pinning (maintain a single version only and not SemVer ranges).',
],
foo: 1,
rangeStrategy: 'pin',
});
expect(res.rangeStrategy).toBe('pin');
});
it('replaces preset descriptions with overrideDescription', async () => {
config.overrideDescription = ['Pin everything.'];
config.extends = [':pinVersions'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
description: ['Pin everything.'],
rangeStrategy: 'pin',
});
});
it('ignores empty overrideDescription', async () => {
config.overrideDescription = [];
config.extends = [':pinVersions'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
description: [
'Use version pinning (maintain a single version only and not SemVer ranges).',
],
rangeStrategy: 'pin',
});
});
it('throws if valid and invalid', async () => {
// @ts-expect-error -- invalid config
config.foo = 1;
config.extends = ['wrongpreset:invalid-preset', ':pinVersions'];
let e: Error | undefined;
try {
await presets.resolveConfigPresets(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBe(
'Preset name not found within published preset config (wrongpreset:invalid-preset)',
);
expect(e!.validationMessage).toBeUndefined();
});
it('resolves packageRule', async () => {
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
packageRules: [
{
groupName: 'eslint',
matchPackageNames: [
'*/eslint-plugin',
'@babel/eslint-parser',
'@eslint/**',
'@eslint-community/**',
'@stylistic/eslint-plugin**',
'@types/eslint',
'@types/eslint__**',
'@typescript-eslint/**',
'babel-eslint',
'eslint**',
'typescript-eslint',
],
},
],
});
});
it('resolves pin GitHub Action digests to SemVer', async () => {
config.extends = ['helpers:pinGitHubActionDigestsToSemver'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res.packageRules).toEqual([
{
matchDepTypes: ['action', 'workflow'],
pinDigests: true,
},
{
matchDepTypes: ['action', 'workflow'],
extractVersion: '^(?<version>v?\\d+\\.\\d+\\.\\d+)$',
versioning:
'regex:^v?(?<major>\\d+)(\\.(?<minor>\\d+)\\.(?<patch>\\d+))?$',
},
]);
});
it('resolves eslint', async () => {
config.extends = ['packages:eslint'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
matchPackageNames: [
'*/eslint-plugin',
'@babel/eslint-parser',
'@eslint/**',
'@eslint-community/**',
'@stylistic/eslint-plugin**',
'@types/eslint',
'@types/eslint__**',
'@typescript-eslint/**',
'babel-eslint',
'eslint**',
'typescript-eslint',
],
});
});
it('resolves linters', async () => {
config.extends = ['packages:linters'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
description: ['All lint-related packages.'],
matchPackageNames: [
'ember-template-lint**',
'*/eslint-plugin',
'@babel/eslint-parser',
'@eslint/**',
'@eslint-community/**',
'@stylistic/eslint-plugin**',
'@types/eslint',
'@types/eslint__**',
'@typescript-eslint/**',
'babel-eslint',
'eslint**',
'typescript-eslint',
'friendsofphp/php-cs-fixer',
'squizlabs/php_codesniffer',
'symplify/easy-coding-standard',
'stylelint**',
'codelyzer',
'/\\btslint\\b/',
'@oxlint/**',
'oxlint',
'prettier',
'remark-lint',
'standard',
],
});
});
it('resolves nested groups', async () => {
config.extends = [':automergeLinters'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res.packageRules).toHaveLength(1);
const rule = res.packageRules![0];
expect(rule.automerge).toBeTrue();
expect(rule.matchPackageNames).toHaveLength(23);
});
it('migrates automerge in presets', async () => {
config.extends = ['ikatyang:library'];
const { config: res } = await presets.resolveConfigPresets(config);
expect(res.automerge).toBeUndefined();
expect(res).toMatchObject({
major: { automerge: false },
minor: { automerge: true },
});
});
it('ignores presets', async () => {
config.extends = ['config:recommended'];
const { config: res } = await presets.resolveConfigPresets(config, {}, [
'config:recommended',
]);
expect(config).toMatchObject(res);
expect(res).toBeEmptyObject();
});
it('resolves self-hosted presets without baseConfig', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
});
const { config: res } = await presets.resolveConfigPresets(config);
expect(local.getPreset.mock.calls).toHaveLength(1);
expect(res).toEqual({ labels: ['self-hosted resolved'] });
});
it('returns the presets which have been merged into the resulting config', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
labels: ['self-hosted resolved'],
});
const {
visitedPresets: { merged },
} = await presets.resolveConfigPresets(config);
expect(merged).toEqual([
'local>username/preset-repo',
'security:openssf-scorecard',
]);
});
it('de-duplicates the presets which have been meregd into the resulting config', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
'security:openssf-scorecard',
];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
extends: ['security:openssf-scorecard'],
packageRules: [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
],
});
const {
visitedPresets: { merged },
} = await presets.resolveConfigPresets(config);
expect(merged).toEqual(
// NOTE that we're not expecting to have a strict or stable ordering
expect.arrayContaining([
'local>username/preset-repo',
'security:openssf-scorecard',
'packages:eslint',
]),
);
});
it('resolves self-hosted preset with templating', async () => {
setCustomEnv({ GIT_REF: 'abc123' });
config.extends = ['local>username/preset-repo#{{ env.GIT_REF }}'];
local.getPreset.mockImplementationOnce(({ tag }) =>
tag === 'abc123'
? Promise.resolve({ labels: ['self-hosted with template resolved'] })
: Promise.reject(new Error('Failed to resolve self-hosted preset')),
);
const { config: res } = await presets.resolveConfigPresets(config);
expect(res.labels).toEqual(['self-hosted with template resolved']);
expect(local.getPreset).toHaveBeenCalledExactlyOnceWith({
presetName: 'default',
presetPath: undefined,
repo: 'username/preset-repo',
tag: 'abc123',
});
});
it('resolves self-hosted transitive presets without baseConfig', async () => {
config.platform = 'gitlab';
config.endpoint = 'https://dummy.example.com/api/v4';
config.extends = ['local>username/preset-repo'];
local.getPreset
.mockResolvedValueOnce({
extends: ['local>username/preset-repo//subpreset'],
})
.mockResolvedValueOnce({ labels: ['self-hosted resolved'] });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
platform: 'gitlab',
endpoint: 'https://dummy.example.com/api/v4',
labels: ['self-hosted resolved'],
});
});
it('resolves http presets', async () => {
config.extends = ['https://example.com/preset.json'];
http.getPreset.mockResolvedValueOnce({ labels: ['http'] });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ labels: ['http'] });
});
it('resolves forgejo presets', async () => {
config.extends = ['forgejo>user/repo'];
forgejo.getPreset.mockResolvedValueOnce({ labels: ['forgejo'] });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ labels: ['forgejo'] });
});
it('resolves gitea presets', async () => {
config.extends = ['gitea>user/repo'];
gitea.getPreset.mockResolvedValueOnce({ labels: ['gitea'] });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ labels: ['gitea'] });
});
it('resolves gitlab presets', async () => {
config.extends = ['gitlab>user/repo'];
gitLab.getPreset.mockResolvedValueOnce({ labels: ['gitlab'] });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ labels: ['gitlab'] });
});
it('gets preset value from cache when it has been seen', async () => {
config.extends = ['github>username/preset-repo'];
config.packageRules = [
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
];
gitHub.getPreset.mockResolvedValueOnce({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
],
});
await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined();
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
],
});
});
it('default packageCache TTL should be 15 minutes', async () => {
GlobalConfig.set({
presetCachePersistence: true,
});
config.extends = ['github>username/preset-repo'];
config.packageRules = [
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
];
gitHub.getPreset.mockResolvedValueOnce({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
],
});
await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined();
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
],
});
expect(packageCache.set.mock.calls[0][3]).toBe(15);
});
it('use packageCache when presetCachePersistence is set', async () => {
GlobalConfig.set({
presetCachePersistence: true,
});
config.extends = ['github>username/preset-repo'];
config.packageRules = [
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
];
gitHub.getPreset.mockResolvedValueOnce({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
],
});
await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined();
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
packageRules: [
{
matchDatasources: ['docker'],
matchPackageNames: ['ubi'],
versioning: 'regex',
},
{
matchManagers: ['github-actions'],
groupName: 'github-actions dependencies',
},
],
});
expect(packageCache.set.mock.calls[0][3]).toBe(15);
});
it('throws', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockRejectedValueOnce(
new ExternalHostError(new Error('whoops')),
);
await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
ExternalHostError,
);
local.getPreset.mockRejectedValueOnce(
new Error(PLATFORM_RATE_LIMIT_EXCEEDED),
);
await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
PLATFORM_RATE_LIMIT_EXCEEDED,
);
});
describe('relative presets', () => {
it('resolves a relative preset against its parent', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['./system/registries'],
});
gitHub.getPreset.mockResolvedValueOnce({ prCreation: 'not-pending' });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ prCreation: 'not-pending' });
expect(gitHub.getPreset).toHaveBeenCalledWith({
repo: 'some/repo',
presetPath: 'system',
presetName: 'registries',
tag: 'v1.0.0',
});
});
it('terminates on self-referencing relative presets', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockResolvedValue({
extends: ['./default'],
prCreation: 'not-pending',
});
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ prCreation: 'not-pending' });
expect(gitHub.getPreset).toHaveBeenCalledTimes(1);
});
it('fetches the root default preset only once', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockImplementation(({ presetPath, presetName }) => {
if (presetPath === 'group' && presetName === 'a') {
return Promise.resolve({ extends: ['/default'] });
}
return Promise.resolve({
extends: ['./group/a'],
packageRules: [{ matchManagers: ['npm'], automerge: true }],
});
});
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({
packageRules: [{ matchManagers: ['npm'], automerge: true }],
});
expect(gitHub.getPreset).toHaveBeenCalledTimes(2);
});
it('resolves relative presets which use params', async () => {
config.extends = ['github>some/repo#v1.0.0(argA)'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['./{{arg0}}'],
});
gitHub.getPreset.mockResolvedValueOnce({ prCreation: 'not-pending' });
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ prCreation: 'not-pending' });
expect(gitHub.getPreset).toHaveBeenCalledWith({
repo: 'some/repo',
presetPath: undefined,
presetName: 'argA',
tag: 'v1.0.0',
});
});
it('throws if a relative preset has no parent preset', async () => {
config.extends = ['./x'];
await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
expect.objectContaining({
message: CONFIG_VALIDATION,
validationError:
'Relative preset reference cannot be resolved (./x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends)',
}),
);
});
it('throws if a relative preset resolves outside of its repository', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['github>other/repo#v2.0.0'],
});
gitHub.getPreset.mockResolvedValueOnce({ extends: ['../x'] });
await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
expect.objectContaining({
message: CONFIG_VALIDATION,
validationError:
'Relative preset reference cannot be resolved (../x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends). Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.',
}),
);
});
it('throws if a relative preset is used by an unsupported preset source', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['somepackage:webapp'],
});
npm.getPreset.mockResolvedValueOnce({ extends: ['./x'] });
await expect(presets.resolveConfigPresets(config)).rejects.toThrow(
expect.objectContaining({
message: CONFIG_VALIDATION,
validationError:
'Relative preset reference cannot be resolved (./x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends). Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.',
}),
);
});
it('resolves relative ignorePresets of a preset', async () => {
config.extends = ['github>some/repo#v1.0.0'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['./optional'],
ignorePresets: ['./optional'],
prCreation: 'not-pending',
});
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ prCreation: 'not-pending' });
expect(gitHub.getPreset).toHaveBeenCalledTimes(1);
});
it('allows ignorePresets to neutralize a broken relative preset', async () => {
config.extends = ['github>some/repo#v1.0.0'];
config.ignorePresets = ['../oops'];
gitHub.getPreset.mockResolvedValueOnce({
extends: ['../oops'],
prCreation: 'not-pending',
});
const { config: res } = await presets.resolveConfigPresets(config);
expect(res).toEqual({ prCreation: 'not-pending' });
});
});
describe('when using mergeInternalPresets=true', () => {
describe('when resolving an internal preset', () => {
it('merges `extends`', async () => {
config.extends = ['security:openssf-scorecard'];
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(res).toEqual({
description: ['Show OpenSSF badge on pull requests.'],
packageRules: [
{
matchSourceUrls: ['https://github.com/**'],
prBodyColumns: [
'Package',
'Type',
'Update',
'Change',
'Pending',
'OpenSSF',
],
prBodyDefinitions: {
OpenSSF:
'[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})',
},
},
{
groupName: 'eslint',
matchPackageNames: [
'*/eslint-plugin',
'@babel/eslint-parser',
'@eslint/**',
'@eslint-community/**',
'@stylistic/eslint-plugin**',
'@types/eslint',
'@types/eslint__**',
'@typescript-eslint/**',
'babel-eslint',
'eslint**',
'typescript-eslint',
],
},
],
});
});
it('does not return any unmerged presets', async () => {
config.extends = ['security:openssf-scorecard'];
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(unmerged).toBeEmptyArray();
});
});
// NOTE that this is a **??**
describe('when resolving an internal preset which includes many other internal presets', () => {
it('merges `extends`, recursively', async () => {
config.extends = [':assignAndReview(jamietanna)'];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(res).toEqual({
assignees: ['jamietanna'],
description: [
'Assign PRs to `jamietanna`.',
'Add `jamietanna` as reviewer for PRs.',
],
reviewers: ['jamietanna'],
});
});
it('does not return any unmerged presets', async () => {
config.extends = [':assignAndReview(jamietanna)'];
const {
visitedPresets: { merged, unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(merged).toEqual([
':assignAndReview(jamietanna)',
':assignee(jamietanna)',
':reviewer(jamietanna)',
]);
expect(unmerged).toEqual([]);
});
});
describe('when resolving an external preset which references an internal preset', () => {
it('merges internal `extends`', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
labels: ['self-hosted resolved'],
});
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(res).toEqual({
description: ['Show OpenSSF badge on pull requests.'],
labels: ['self-hosted resolved'],
packageRules: [
{
matchSourceUrls: ['https://github.com/**'],
prBodyColumns: [
'Package',
'Type',
'Update',
'Change',
'Pending',
'OpenSSF',
],
prBodyDefinitions: {
OpenSSF:
'[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})',
},
},
],
});
});
it('does not return any unmerged presets', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
labels: ['self-hosted resolved'],
});
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(unmerged).toBeEmptyArray();
});
});
describe('when resolving mixed internal and external presets', () => {
it('merges internal `extends`', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
});
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(res).toEqual({
description: ['Show OpenSSF badge on pull requests.'],
labels: ['self-hosted resolved'],
packageRules: [
{
matchSourceUrls: ['https://github.com/**'],
prBodyColumns: [
'Package',
'Type',
'Update',
'Change',
'Pending',
'OpenSSF',
],
prBodyDefinitions: {
OpenSSF:
'[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})',
},
},
{
groupName: 'eslint',
matchPackageNames: [
'*/eslint-plugin',
'@babel/eslint-parser',
'@eslint/**',
'@eslint-community/**',
'@stylistic/eslint-plugin**',
'@types/eslint',
'@types/eslint__**',
'@typescript-eslint/**',
'babel-eslint',
'eslint**',
'typescript-eslint',
],
},
],
});
});
it('does not return any unmerged presets', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
});
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
true,
);
expect(unmerged).toBeEmptyArray();
});
});
});
describe('when using mergeInternalPresets=false', () => {
describe('when resolving an internal preset', () => {
it('does not merge `extends`', async () => {
config.extends = ['security:openssf-scorecard'];
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(res).toEqual({
packageRules: [
{
groupName: 'eslint',
},
],
});
});
it('returns the presets in the unmerged array', async () => {
config.extends = ['security:openssf-scorecard'];
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual([
'security:openssf-scorecard',
'packages:eslint',
]);
});
});
describe('when resolving an internal, parameterised preset', () => {
it('does not merge `extends`', async () => {
config.extends = [':assignee(renovate-tests)'];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(res).toEqual({});
});
it('returns the preset in the unmerged array', async () => {
config.extends = [':assignee(renovate-tests)'];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual([':assignee(renovate-tests)']);
});
});
describe('when resolving an internal preset which includes many other internal presets', () => {
it('does not merge `extends`', async () => {
config.extends = ['config:recommended'];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(res).toEqual({});
});
it('returns the unmerged internal presets', async () => {
config.extends = ['config:recommended'];
const {
visitedPresets: { merged, unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(merged).toEqual([]);
expect(unmerged).toEqual(['config:recommended']);
});
});
describe('when resolving an external preset which references an internal preset', () => {
it('does not merge `extends`', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
labels: ['self-hosted resolved'],
});
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(res).toEqual({
labels: ['self-hosted resolved'],
});
});
it('returns the unmerged internal presets', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
labels: ['self-hosted resolved'],
});
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual(['security:openssf-scorecard']);
});
});
describe('when resolving mixed internal and external presets', () => {
it('does not expand internal `extends`', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
});
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const { config: res } = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(res).toEqual({
labels: ['self-hosted resolved'],
packageRules: [
{
groupName: 'eslint',
},
],
});
});
it('returns the unmerged internal presets', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
];
local.getPreset.mockResolvedValueOnce({
labels: ['self-hosted resolved'],
});
config.packageRules = [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual([
'security:openssf-scorecard',
'packages:eslint',
]);
});
});
describe('when resolving an internal preset inside a nested object config value', () => {
it('returns the unmerged internal presets from a datasource', async () => {
config.extends = ['local>username/preset-repo'];
local.getPreset.mockResolvedValueOnce({
artifactory: {
extends: ['security:openssf-scorecard'],
enabled: true,
},
});
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual(['security:openssf-scorecard']);
});
});
describe('when duplicate internal presets are found', () => {
it('they are de-duplicated when returned as unmerged', async () => {
config.extends = [
'security:openssf-scorecard',
'local>username/preset-repo',
];
local.getPreset.mockResolvedValueOnce({
extends: ['security:openssf-scorecard'],
});
config.packageRules = [
{
extends: ['security:openssf-scorecard'],
},
];
const {
visitedPresets: { unmerged },
} = await presets.resolveConfigPresets(
config,
undefined,
undefined,
undefined,
false,
);
expect(unmerged).toEqual(['security:openssf-scorecard']);
});
});
});
});
describe('replaceArgs', () => {
const argMappings = {
arg0: 'a',
arg1: 'b',
arg2: 'c',
};
it('replaces args in strings', () => {
const str = '{{arg2}} foo {{arg0}}{{arg1}}';
const res = presets.replaceArgs(str, argMappings);
expect(res).toBe('c foo ab');
});
it('replaces args twice in same string', () => {
const str = '{{arg2}}{{arg0}} foo {{arg0}}{{arg1}}';
const res = presets.replaceArgs(str, argMappings);
expect(res).toBe('ca foo ab');
});
it('replaces objects', () => {
const obj = {
foo: 'ha {{arg0}}',
bar: {
baz: '{{arg1}} boo',
aaa: {
bbb: 'woo {{arg2}}',
},
},
};
const res = presets.replaceArgs(obj, argMappings);
expect(res).toEqual({
bar: { aaa: { bbb: 'woo c' }, baz: 'b boo' },
foo: 'ha a',
});
});
it('replaces arrays', () => {
const obj = {
foo: ['{{arg0}}', { bar: '{{arg1}}', baz: 5 }],
};
const res = presets.replaceArgs(obj, argMappings);
expect(res).toEqual({
foo: ['a', { bar: 'b', baz: 5 }],
});
});
});
describe('getPreset', () => {
beforeEach(() => {
memCache.init();
});
it('does not use cache for internal presets', async () => {
const memCacheGetSpy = vi.spyOn(memCache, 'get');
await expect(
presets.getPreset(':dependencyDashboard', {}),
).resolves.toBeDefined();
expect(memCacheGetSpy).not.toHaveBeenCalled();
expect(packageCache.get).not.toHaveBeenCalled();
});
it('handles removed presets with a migration', async () => {
const res = await presets.getPreset(':base', {});
expect(res).toEqual({
extends: [
':dependencyDashboard',
':semanticPrefixFixDepsChoreOthers',
':ignoreModulesAndTests',
'group:monorepos',
'group:recommended',
'mergeConfidence:age-confidence-badges',
'replacements:all',
'workarounds:all',
'helpers:forgejoDigestChangelogs',
'helpers:giteaDigestChangelogs',
'helpers:githubDigestChangelogs',
'helpers:gitlabDigestChangelogs',
'helpers:goXPackagesChangelogLink',
'helpers:goXPackagesNameLink',
'helpers:renovateChangelog',
],
});
});
it('handles removed presets with no migration', async () => {
const res = await presets.getPreset('helpers:oddIsUnstable', {});
expect(res).toEqual({});
});
it('handles renamed monorepos', async () => {
const res = await presets.getPreset('monorepo:opentelemetry', {});
expect(res).toMatchInlineSnapshot(`
{
"description": [
"opentelemetry-js monorepo",
],
"matchSourceUrls": [
"https://github.com/open-telemetry/opentelemetry-js",
],
}
`);
});
it('handles renamed monorepo groups', async () => {
const res = await presets.getPreset('group:opentelemetryMonorepo', {});
expect(res).toMatchInlineSnapshot(`
{
"packageRules": [
{
"description": [
"Group packages from opentelemetry-js monorepo together.",
],
"extends": [
"monorepo:opentelemetry-js",
],
"groupName": "opentelemetry-js monorepo",
"matchUpdateTypes": [
"digest",
"patch",
"minor",
"major",
],
},
],
}
`);
});
it('handles renamed regexManagers presets', async () => {
const res = await presets.getPreset(
'regexManagers:dockerfileVersions',
{},
);
expect(res.customManagers).toHaveLength(1);
});
it('gets linters', async () => {
const res = await presets.getPreset('packages:linters', {});
expect(res).toEqual({
description: ['All lint-related packages.'],
extends: [
'packages:emberTemplateLint',
'packages:eslint',
'packages:phpLinters',
'packages:stylelint',
'packages:tslint',
],
matchPackageNames: [
'@oxlint/**',
'oxlint',
'prettier',
'remark-lint',
'standard',
],
});
});
it('gets parameterised configs', async () => {
const res = await presets.getPreset(
':group(packages:eslint, eslint)',
{},
);
expect(res).toEqual({
description: ['Group `eslint` packages into same branch/PR.'],
packageRules: [
{
extends: ['packages:eslint'],
groupName: 'eslint',
},
],
});
});
it('handles missing params', async () => {
const res = await presets.getPreset(':group()', {});
expect(res).toEqual({
description: ['Group `{{arg1}}` packages into same branch/PR.'],
packageRules: [
{
extends: [],
groupName: '{{arg1}}',
},
],
});
});
it('ignores irrelevant params', async () => {
const res = await presets.getPreset(':pinVersions(foo, bar)', {});
expect(res).toEqual({
description: [
'Use version pinning (maintain a single version only and not SemVer ranges).',
],
rangeStrategy: 'pin',
});
});
it('substitutes {{args}}', async () => {
local.getPreset.mockResolvedValueOnce({
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['{{args}}'],
matchStrings: ['# renovate: ...'],
},
],
});
const res = await presets.getPreset(
'local>customManager(**/{*.py, *.yaml})',
{},
);
expect(res).toEqual({
customManagers: [
{
customType: 'regex',
// The space after comma is obviously incorrect here.
// But the test must ensure that spaces aren't removed.
managerFilePatterns: ['**/{*.py, *.yaml}'],
matchStrings: ['# renovate: ...'],
},
],
});
});
it('handles 404 packages', async () => {
let e: Error | undefined;
try {
await presets.getPreset('notfound:foo', {});
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBeUndefined();
expect(e!.validationMessage).toBeUndefined();
});
it('handles no config', async () => {
let e: Error | undefined;
try {
await presets.getPreset('noconfig:foo', {});
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBeUndefined();
expect(e!.validationMessage).toBeUndefined();
});
it('handles throw errors', async () => {
let e: Error | undefined;
try {
await presets.getPreset('throw:foo', {});
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBeUndefined();
expect(e!.validationMessage).toBeUndefined();
});
it('canonicalizes relative presets', async () => {
gitHub.getPreset.mockResolvedValueOnce({
extends: ['./system/registries', 'github>other/repo'],
packageRules: [{ extends: ['./rules/docker'] }],
});
const res = await presets.getPreset('github>some/repo#v1.0.0', {});
expect(res).toEqual({
extends: [
'github>some/repo//system/registries#v1.0.0',
'github>other/repo',
],
packageRules: [{ extends: ['github>some/repo//rules/docker#v1.0.0'] }],
});
});
it('leaves relative presets of npm presets unchanged', async () => {
npm.getPreset.mockResolvedValueOnce({
extends: ['./system/registries'],
ignorePresets: ['./optional'],
});
const res = await presets.getPreset('somepackage:webapp', {});
expect(res).toEqual({
extends: ['./system/registries'],
ignorePresets: ['./optional'],
});
});
it('handles preset not found', async () => {
let e: Error | undefined;
try {
await presets.getPreset('wrongpreset:foo', {});
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e!.validationSource).toBeUndefined();
expect(e!.validationError).toBeUndefined();
expect(e!.validationMessage).toBeUndefined();
});
});
});