renovate/lib/config/validation.spec.ts

1986 lines
56 KiB
TypeScript

import { configFileNames } from './app-strings';
import { GlobalConfig } from './global';
import type { RenovateConfig } from './types';
import * as configValidation from './validation';
describe('config/validation', () => {
describe('getParentName()', () => {
it('ignores encrypted in root', () => {
expect(configValidation.getParentName('encrypted')).toBeEmptyString();
});
it('handles array types', () => {
expect(configValidation.getParentName('hostRules[1]')).toBe('hostRules');
});
it('handles encrypted within array types', () => {
expect(configValidation.getParentName('hostRules[0].encrypted')).toBe(
'hostRules',
);
});
});
describe('validateConfig(config)', () => {
it('returns deprecation warnings', async () => {
const config = {
prTitle: 'something',
};
const { warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(1);
expect(warnings).toMatchSnapshot();
});
it('catches global options in repo config', async () => {
const config = {
binarySource: 'something',
username: 'user',
};
const { warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(2);
expect(warnings).toMatchObject([
{
message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
{
message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
]);
});
it('catches global options in inherit config', async () => {
const config = {
binarySource: 'something',
username: 'user',
};
const { warnings } = await configValidation.validateConfig(
'inherit',
config,
);
expect(warnings).toHaveLength(2);
expect(warnings).toMatchObject([
{
message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
{
message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
]);
});
it('only warns for actual globals in repo config', async () => {
const config = {
hostRules: [
{
username: 'user',
token: 'token',
password: 'pass',
},
],
};
const { warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
});
it('does not warn for valid inheritConfig', async () => {
const config = {
onboarding: false,
};
const { warnings } = await configValidation.validateConfig(
'inherit',
config,
);
expect(warnings).toHaveLength(0);
});
it('does not warn for valid platformConfig', async () => {
const config = {
platformConfig: 'auto',
};
const { warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
});
it('warns for invalid platformConfig', async () => {
const config = {
platformConfig: 'invalid',
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(1);
});
it('catches invalid templates', async () => {
const config = {
commitMessage: '{{{something}}',
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(1);
expect(errors).toMatchSnapshot();
});
it('catches invalid allowedVersions regex', async () => {
const config = {
packageRules: [
{
matchPackageNames: ['foo'],
allowedVersions: '/^2/',
},
{
matchPackageNames: ['bar'],
allowedVersions: '/***$}{]][/',
},
{
matchPackageNames: ['baz'],
allowedVersions: '!/^2/',
},
{
matchPackageNames: ['quack'],
allowedVersions: '!/***$}{]][/',
},
{
matchPackageNames: ['quack'],
allowedVersions: '/quaCk/i',
},
],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(2);
expect(errors).toMatchSnapshot();
});
it('catches invalid matchCurrentValue', async () => {
const config = {
packageRules: [
{
matchPackageNames: ['foo'],
matchCurrentValue: '/^2/',
enabled: true,
},
{
matchPackageNames: ['bar'],
matchCurrentValue: '^1',
enabled: true,
},
{
matchPackageNames: ['quack'],
matchCurrentValue: '<1.0.0',
enabled: true,
},
{
matchPackageNames: ['foo'],
matchCurrentValue: '/^2/i',
enabled: true,
},
{
matchPackageNames: ['bad'],
matchNewValue: '/^2(/',
enabled: true,
},
],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(1);
});
it('catches invalid matchNewValue', async () => {
const config = {
packageRules: [
{
matchPackageNames: ['foo'],
matchNewValue: '/^2/',
enabled: true,
},
{
matchPackageNames: ['bar'],
matchNewValue: '^1',
enabled: true,
},
{
matchPackageNames: ['quack'],
matchNewValue: '<1.0.0',
enabled: true,
},
{
matchPackageNames: ['foo'],
matchNewValue: '/^2/i',
enabled: true,
},
{
matchPackageNames: ['bad'],
matchNewValue: '/^2(/',
enabled: true,
},
],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(1);
});
it('validates matchBaseBranches', async () => {
const config = {
baseBranches: ['foo'],
packageRules: [
{
matchBaseBranches: ['foo'],
addLabels: ['foo'],
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toHaveLength(0);
expect(warnings).toHaveLength(0);
});
it('catches invalid matchBaseBranches when baseBranches is not defined', async () => {
const config = {
packageRules: [
{
matchBaseBranches: ['foo'],
addLabels: ['foo'],
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toHaveLength(0);
expect(warnings).toHaveLength(1);
});
it('catches invalid matchCurrentVersion regex', async () => {
const config = {
packageRules: [
{
matchPackageNames: ['foo'],
matchCurrentVersion: '/^2/',
enabled: true,
},
{
matchPackageNames: ['bar'],
matchCurrentVersion: '/***$}{]][/',
enabled: true,
},
{
matchPackageNames: ['baz'],
matchCurrentVersion: '!/^2/',
enabled: true,
},
{
matchPackageNames: ['quack'],
matchCurrentVersion: '!/***$}{]][/',
enabled: true,
},
{
matchPackageNames: ['foo'],
matchCurrentVersion: '/^2/i',
enabled: true,
},
],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toHaveLength(2);
expect(errors).toMatchSnapshot();
});
it('catches invalid customDatasources content', async () => {
const config = {
customDatasources: {
foo: {
description: 3,
randomKey: '',
defaultRegistryUrlTemplate: [],
transformTemplates: [{}],
},
},
} as any;
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toMatchObject([
{
message:
'Invalid `customDatasources.defaultRegistryUrlTemplate` configuration: is a string',
},
{
message:
'Invalid `customDatasources.description` configuration: is not an array of strings',
},
{
message:
'Invalid `customDatasources.randomKey` configuration: key is not allowed',
},
{
message:
'Invalid `customDatasources.transformTemplates` configuration: is not an array of string',
},
]);
});
it('validates invalid statusCheckNames', async () => {
const config = {
statusCheckNames: {
randomKey: '',
mergeConfidence: 10,
configValidation: '',
artifactError: null,
},
};
// @ts-expect-error invalid options
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toMatchObject([
{
message:
'Invalid `statusCheckNames.mergeConfidence` configuration: status check is not a string.',
},
{
message:
'Invalid `statusCheckNames.statusCheckNames.randomKey` configuration: key is not allowed.',
},
]);
expect(errors).toHaveLength(2);
});
it('catches invalid customDatasources record type', async () => {
const config = {
customDatasources: {
randomKey: '',
},
} as any;
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toMatchObject([
{
message:
'Invalid `customDatasources.randomKey` configuration: customDatasource is not an object',
},
]);
});
it('catches invalid baseBranches regex', async () => {
const config = {
baseBranches: ['/***$}{]][/', '/branch/i'],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toEqual([
{
topic: 'Configuration Error',
message: 'Invalid regExp for baseBranches: `/***$}{]][/`',
},
]);
});
it('returns nested errors', async () => {
const config: RenovateConfig = {
foo: 1,
schedule: ['after 5pm'],
timezone: 'Asia/Singapore',
packageRules: [
{
matchPackageNames: [
'*',
'/abc ([a-z]+) ([a-z]+))/',
'!/abc ([a-z]+) ([a-z]+))/',
],
enabled: true,
},
],
lockFileMaintenance: {
bar: 2,
},
major: null,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(4);
expect(errors).toMatchSnapshot();
});
it('included unsupported manager', async () => {
const config = {
packageRules: [
{
matchManagers: ['foo'],
enabled: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('ansible');
});
it('included managers of the wrong type', async () => {
const config = {
packageRules: [
{
matchManagers: 'string not an array',
enabled: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(2);
expect(errors).toMatchSnapshot();
});
it.each([
['empty configuration', {}],
['configuration with enabledManagers empty', { enabledManagers: [] }],
['single enabled manager', { enabledManagers: ['npm'] }],
[
'multiple enabled managers',
{
enabledManagers: ['npm', 'gradle', 'maven', 'custom.regex'],
},
],
])('validates enabled managers for %s', async (_case, config) => {
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it.each([
['single not supported manager', { enabledManagers: ['foo'] }],
['multiple not supported managers', { enabledManagers: ['foo', 'bar'] }],
[
'combined supported and not supported managers',
{ enabledManagers: ['foo', 'npm', 'gradle', 'maven'] },
],
])(
'errors if included not supported enabled managers for %s',
async (_case, config) => {
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchSnapshot();
},
);
it('errors for all types', async () => {
const config: RenovateConfig = {
allowedVersions: 'foo',
enabled: 1 as any,
enabledManagers: ['npm'],
schedule: ['every 15 mins every weekday'],
timezone: 'Asia',
labels: 5 as any,
azureWorkItemId: false as any,
semanticCommitType: 7 as any,
lockFileMaintenance: false as any,
extends: [':timezone(Europe/Brussel)'],
packageRules: [
{
foo: 1,
},
'what?' as any,
{
matchPackageNames: '/abc ([a-z]+) ([a-z]+))/',
matchDepNames: ['abc ([a-z]+) ([a-z]+))'],
enabled: false,
},
],
major: null,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(1);
expect(errors).toMatchSnapshot();
expect(errors).toHaveLength(12);
});
it('selectors outside packageRules array trigger errors', async () => {
const config = {
matchDepNames: ['angular'],
matchPackageNames: ['angular'],
meteor: {
packageRules: [
{
matchDepNames: ['meteor'],
matchPackageNames: ['meteor'],
enabled: true,
},
],
},
ansible: {
minor: {
matchDepNames: ['meteor'],
matchPackageNames: ['testPackage'],
},
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(4);
expect(errors).toMatchSnapshot();
expect(errors).toHaveLength(4);
});
it('ignore packageRule nesting validation for presets', async () => {
const config = {
description: ['All angular.js packages'],
matchPackageNames: [
'angular',
'angular-animate',
'angular-scroll',
'angular-sanitize',
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchSnapshot();
expect(errors).toHaveLength(0);
});
it('errors for unsafe fileMatches', async () => {
const config = {
npm: {
fileMatch: ['abc ([a-z]+) ([a-z]+))'],
},
dockerfile: {
fileMatch: ['x?+'],
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(2);
expect(errors).toMatchSnapshot();
});
it('validates regEx for each fileMatch', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
fileMatch: ['js', '***$}{]]['],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchSnapshot();
});
it('errors if customManager has empty fileMatch', async () => {
const config = {
customManagers: [
{
customType: 'regex',
fileMatch: [],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Each Custom Manager must contain a non-empty fileMatch array",
"topic": "Configuration Error",
},
]
`);
});
it('errors if no customManager customType', async () => {
const config = {
customManagers: [
{
fileMatch: ['some-file'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Each Custom Manager must contain a non-empty customType string",
"topic": "Configuration Error",
},
]
`);
});
it('errors if invalid customManager customType', async () => {
const config = {
customManagers: [
{
customType: 'unknown',
fileMatch: ['some-file'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Invalid customType: unknown. Key is not a custom manager",
"topic": "Configuration Error",
},
]
`);
});
it('errors if empty customManager matchStrings', async () => {
const config = {
customManagers: [
{
customType: 'regex',
fileMatch: ['foo'],
matchStrings: [],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
{
customType: 'regex',
fileMatch: ['foo'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as RenovateConfig,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(2);
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Each Custom Manager must contain a non-empty matchStrings array",
"topic": "Configuration Error",
},
{
"message": "Each Custom Manager must contain a non-empty matchStrings array",
"topic": "Configuration Error",
},
]
`);
});
it('errors if no customManager fileMatch', async () => {
const config = {
customManagers: [
{
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
it('validates regEx for each matchStrings', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['***$}{]]['],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
// testing if we get all errors at once or not (possible), this does not include customType or fileMatch
// since they are common to all custom managers
it('validates all possible regex manager options', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['***$}{]]['], // invalid matchStrings regex, no depName, datasource and currentValue
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(4);
});
it('passes if customManager fields are present', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
registryUrlTemplate: 'foobar',
extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
depTypeTemplate: 'apple',
},
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
packageNameTemplate: 'foo',
datasourceTemplate: 'bar',
registryUrlTemplate: 'foobar',
extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
depTypeTemplate: 'apple',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('errors if extra customManager fields are present', async () => {
const config = {
customManagers: [
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
depTypeTemplate: 'apple',
automerge: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
it('errors if customManager fields are missing', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['ENV (.*?)\\s'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchSnapshot();
expect(errors).toHaveLength(1);
});
it('ignore keys', async () => {
const config = {
$schema: 'renovate.json',
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('validates timezone preset', async () => {
const config = {
extends: [':timezone', ':timezone(Europe/Berlin)'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('does not validate constraints children', async () => {
const config = {
constraints: { packageRules: [{}] },
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as never, // TODO: #15963
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('validates object with ignored children', async () => {
const config = {
prBodyDefinitions: {},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('validates valid registryAlias objects', async () => {
const config = {
registryAliases: {
example1: 'http://www.example.com',
example2: 'https://www.example2.com/example',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('errors if registryAliases depth is more than 1', async () => {
const config = {
registryAliases: {
sample: {
example1: 'http://www.example.com',
} as unknown as string, // intentional incorrect config to check error message
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
'Invalid `registryAliases.registryAliases.sample` configuration: value is not a string',
topic: 'Configuration Error',
},
]);
});
it('errors if registryAliases have invalid value', async () => {
const config = {
registryAliases: {
example1: 123 as never,
example2: 'http://www.example.com',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
'Invalid `registryAliases.registryAliases.example1` configuration: value is not a string',
topic: 'Configuration Error',
},
]);
});
it('errors if fileMatch has wrong parent', async () => {
const config: RenovateConfig = {
fileMatch: ['foo'],
npm: {
fileMatch: ['package\\.json'],
minor: {
fileMatch: ['bar'],
},
},
customManagers: [
{
customType: 'regex',
fileMatch: ['build.gradle'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toHaveLength(1);
expect(warnings).toHaveLength(1);
expect(errors).toMatchSnapshot();
expect(warnings).toMatchSnapshot();
});
it('errors if manager objects are nested', async () => {
const config = {
pyenv: {
enabled: false,
},
maven: {
gradle: {
enabled: false,
},
},
} as never;
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toHaveLength(1);
expect(warnings).toHaveLength(0);
expect(errors).toMatchSnapshot();
});
it('warns if hostType has the wrong parent', async () => {
const config = {
hostType: 'npm',
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings).toMatchSnapshot();
});
it('validates preset values', async () => {
const config = {
extends: ['foo', 'bar', 42] as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
it('warns if only selectors in packageRules', async () => {
const config = {
packageRules: [{ matchDepTypes: ['foo'], matchPackageNames: ['bar'] }],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(1);
expect(warnings).toMatchSnapshot();
expect(errors).toHaveLength(0);
});
it('errors if invalid combinations in packageRules', async () => {
const config = {
packageRules: [
{
matchUpdateTypes: ['major'],
registryUrls: ['https://registry.npmjs.org'],
},
],
} as any;
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors).toMatchSnapshot();
});
it('warns on nested group packageRules', async () => {
const config = {
extends: ['group:fortawesome'],
packageRules: [
{
automerge: true,
extends: ['group:fortawesome'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(errors).toHaveLength(0);
expect(warnings).toHaveLength(1);
});
// adding this test explicitly because we used to validate the customEnvVariables inside repo config previously
it('warns if customEnvVariables are found in repo config', async () => {
const config = {
customEnvVariables: {
example1: 'abc',
example2: 123,
},
};
const { warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toMatchObject([
{
topic: 'Configuration Error',
message: `The "customEnvVariables" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
]);
});
it('errors if schedule is cron and has no * minutes', async () => {
const config = {
schedule: ['30 5 * * *'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
'Invalid schedule: `Invalid schedule: "30 5 * * *" has cron syntax, but doesn\'t have * as minutes`',
topic: 'Configuration Error',
},
]);
});
it('errors if invalid matchHost values in hostRules', async () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
const config = {
hostRules: [
{
matchHost: '://',
token: 'token',
},
{
matchHost: '',
token: 'token',
},
{
matchHost: undefined,
token: 'token',
},
{
hostType: 'github',
token: 'token',
},
],
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
'Configuration option `hostRules[2].matchHost` should be a string',
},
{
topic: 'Configuration Error',
message:
'Invalid value for hostRules matchHost. It cannot be an empty string.',
},
{
topic: 'Configuration Error',
message: 'hostRules matchHost `://` is not a valid URL.',
},
]);
});
it('errors if forbidden header in hostRules', async () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 'token',
unallowedHeader: 'token',
},
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
"hostRules header `unallowedHeader` is not allowed by this bot's `allowedHeaders`.",
topic: 'Configuration Error',
},
]);
});
it('errors if headers values are not string', async () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 10,
} as unknown as Record<string, string>,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
'Invalid hostRules headers value configuration: header must be a string.',
topic: 'Configuration Error',
},
]);
});
it('errors if allowedHeaders is empty or not defined', async () => {
GlobalConfig.set({});
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 'token',
},
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
message:
"hostRules header `X-Auth-Token` is not allowed by this bot's `allowedHeaders`.",
topic: 'Configuration Error',
},
]);
});
it('catches invalid variable name in env config option', async () => {
GlobalConfig.set({ allowedEnv: ['SOME*'] });
const config = {
env: {
randomKey: '',
SOME_VAR: 'some_value',
SOME_OTHER_VAR: 10,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: testing invalid values in env object
config,
);
expect(errors).toMatchObject([
{
message:
"Env variable name `randomKey` is not allowed by this bot's `allowedEnv`.",
},
{
message:
'Invalid env variable value: `env.SOME_OTHER_VAR` must be a string.',
},
]);
expect(errors).toHaveLength(2);
expect(warnings).toHaveLength(0);
});
it('catches env config option if configured inside a parent', async () => {
GlobalConfig.set({ allowedEnv: ['SOME*'] });
const config = {
npm: {
env: {
SOME_VAR: 'some_value',
},
},
packageRules: [
{
matchManagers: ['regex'],
env: {
SOME_VAR: 'some_value',
},
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'The "env" object can only be configured at the top level of a config but was found inside "npm"',
topic: 'Configuration Error',
},
{
message:
'The "env" object can only be configured at the top level of a config but was found inside "packageRules[0]"',
topic: 'Configuration Error',
},
]);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(2);
});
it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => {
const config = {
packageRules: [
{
matchRepositories: ['groupA/**', 'groupB/**'], // valid
enabled: false,
},
{
matchRepositories: ['*', 'repo'], // invalid
enabled: true,
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'packageRules[1].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
topic: 'Configuration Error',
},
]);
expect(errors).toHaveLength(1);
expect(warnings).toHaveLength(0);
});
it('catches when negative number is used for integer type', async () => {
const config = {
azureWorkItemId: -2,
};
const { errors } = await configValidation.validateConfig('repo', config);
expect(errors).toMatchObject([
{
message:
'Configuration option `azureWorkItemId` should be a positive integer. Found negative value instead.',
topic: 'Configuration Error',
},
]);
});
it('validates prPriority', async () => {
const config = {
packageRules: [
{
matchDepNames: ['somedep'],
prPriority: -2,
},
{
matchDepNames: ['some-other-dep'],
prPriority: 2,
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
});
describe('validateConfig() -> globaOnly options', () => {
it('returns errors for invalid options', async () => {
const config = {
logFile: 'something',
logFileLevel: 'DEBUG',
};
const { errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toMatchObject([
{
message: 'Invalid configuration option: logFile',
topic: 'Configuration Error',
},
{
message: 'Invalid configuration option: logFileLevel',
topic: 'Configuration Error',
},
]);
});
it('validates hostRules.headers', async () => {
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 'token',
},
},
],
allowedHeaders: ['X-Auth-Token'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('errors if hostRules.headers is defined but allowedHeaders is not', async () => {
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 'token',
},
},
],
};
const { errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toMatchObject([
{
message:
"hostRules header `X-Auth-Token` is not allowed by this bot's `allowedHeaders`.",
topic: 'Configuration Error',
},
]);
});
it('validates env', async () => {
const config = {
env: {
SOME_VAR: 'SOME_VALUE',
},
allowedEnv: ['SOME*'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('errors if env object is defined but allowedEnv is empty or undefined', async () => {
const config = {
env: {
SOME_VAR: 'SOME_VALUE',
},
};
const { errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toMatchObject([
{
message:
"Env variable name `SOME_VAR` is not allowed by this bot's `allowedEnv`.",
topic: 'Configuration Error',
},
]);
});
it('validates options with different type but defaultValue=null', async () => {
const config = {
minimumReleaseAge: null,
groupName: null,
groupSlug: null,
dependencyDashboardLabels: null,
defaultRegistryUrls: null,
registryUrls: null,
hostRules: [
{
artifactAuth: null,
concurrentRequestLimit: null,
httpsCertificate: null,
httpsPrivateKey: null,
httpsCertificateAuthority: null,
},
],
encrypted: null,
milestone: null,
branchConcurrentLimit: null,
hashedBranchLength: null,
assigneesSampleSize: null,
reviewersSampleSize: null,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
});
describe('validate globalOptions()', () => {
it('binarySource', async () => {
const config = {
binarySource: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `binarySource`. The allowed values are docker, global, install, hermit.',
topic: 'Configuration Error',
},
]);
});
describe('validates string type options', () => {
it('binarySource', async () => {
const config = {
binarySource: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `binarySource`. The allowed values are docker, global, install, hermit.',
topic: 'Configuration Error',
},
]);
});
it('baseDir', async () => {
const config = {
baseDir: false as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message: 'Configuration option `baseDir` should be a string.',
topic: 'Configuration Error',
},
]);
});
it('requireConfig', async () => {
const config = {
requireConfig: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `requireConfig`. The allowed values are required, optional, ignored.',
topic: 'Configuration Error',
},
]);
});
it('dryRun', async () => {
const config = {
dryRun: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `dryRun`. The allowed values are extract, lookup, full.',
topic: 'Configuration Error',
},
]);
});
it('repositoryCache', async () => {
const config = {
repositoryCache: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `repositoryCache`. The allowed values are enabled, disabled, reset.',
topic: 'Configuration Error',
},
]);
});
it('onboardingConfigFileName', async () => {
const config = {
onboardingConfigFileName: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message: `Invalid value \`invalid\` for \`onboardingConfigFileName\`. The allowed values are ${configFileNames.join(', ')}.`,
topic: 'Configuration Error',
},
]);
});
it('onboardingConfig', async () => {
const config = {
onboardingConfig: {
extends: ['config:recommended'],
binarySource: 'global', // should not allow globalOnly options inside onboardingConfig
fileMatch: ['somefile'], // invalid at top level
},
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'"fileMatch" may not be defined at the top level of a config and must instead be within a manager block',
topic: 'Config error',
},
{
topic: 'Configuration Error',
message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
},
]);
});
it('force', async () => {
const config = {
force: {
extends: ['config:recommended'],
binarySource: 'global',
fileMatch: ['somefile'], // invalid at top level
constraints: {
python: '2.7',
},
},
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'"fileMatch" may not be defined at the top level of a config and must instead be within a manager block',
topic: 'Config error',
},
]);
});
it('gitUrl', async () => {
const config = {
gitUrl: 'invalid' as never,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `gitUrl`. The allowed values are default, ssh, endpoint.',
topic: 'Configuration Error',
},
]);
});
});
it('validates boolean type options', async () => {
const config = {
unicodeEmoji: false,
detectGlobalManagerConfig: 'invalid-type',
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message: `Configuration option \`detectGlobalManagerConfig\` should be a boolean. Found: ${JSON.stringify(
'invalid-type',
)} (string).`,
topic: 'Configuration Error',
},
]);
});
it('validates integer type options', async () => {
const config = {
prCommitsPerRunLimit: 2,
gitTimeout: 'invalid-type',
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message: `Configuration option \`gitTimeout\` should be an integer. Found: ${JSON.stringify(
'invalid-type',
)} (string).`,
topic: 'Configuration Error',
},
]);
});
it('validates array type options', async () => {
const config = {
allowedPostUpgradeCommands: ['cmd'],
checkedBranches: 'invalid-type',
gitNoVerify: ['invalid'],
mergeConfidenceDatasources: [1],
};
const { warnings } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toMatchObject([
{
message:
'Configuration option `checkedBranches` should be a list (Array).',
topic: 'Configuration Error',
},
{
topic: 'Configuration Error',
message:
'Invalid value `1` for `mergeConfidenceDatasources`. The allowed values are go, maven, npm, nuget, packagist, pypi, rubygems.',
},
{
message:
'Invalid value for `gitNoVerify`. The allowed values are commit, push.',
topic: 'Configuration Error',
},
]);
});
it('validates object type options', async () => {
const config = {
productLinks: {
documentation: 'https://docs.renovatebot.com/',
help: 'https://github.com/renovatebot/renovate/discussions',
homepage: 'https://github.com/renovatebot/renovate',
},
secrets: 'invalid-type',
cacheTtlOverride: {
someField: false,
},
};
const { warnings } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toMatchObject([
{
topic: 'Configuration Error',
message:
'Configuration option `cacheTtlOverride.someField` should be an integer. Found: false (boolean).',
},
{
message: 'Configuration option `secrets` should be a JSON object.',
topic: 'Configuration Error',
},
]);
});
it('warns if negative number is used for integer type', async () => {
const config = {
prCommitsPerRunLimit: -2,
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message:
'Configuration option `prCommitsPerRunLimit` should be a positive integer. Found negative value instead.',
topic: 'Configuration Error',
},
]);
});
it('warns on invalid customEnvVariables objects', async () => {
const config = {
customEnvVariables: {
example1: 'abc',
example2: 123,
},
};
const { warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message:
'Invalid `customEnvVariables.example2` configuration: value must be a string.',
topic: 'Configuration Error',
},
]);
});
it('validates valid customEnvVariables objects', async () => {
const config = {
customEnvVariables: {
example1: 'abc',
example2: 'https://www.example2.com/example',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('validates options with different type but defaultValue=null', async () => {
const config = {
onboardingCommitMessage: null,
dryRun: null,
logContext: null,
endpoint: null,
skipInstalls: null,
autodiscoverFilter: null,
autodiscoverNamespaces: null,
autodiscoverTopics: null,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('fails for missing reportPath if reportType is "s3"', async () => {
const config: RenovateConfig = {
reportType: 's3',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
it('validates reportPath if reportType is "s3"', async () => {
const config: RenovateConfig = {
reportType: 's3',
reportPath: 's3://bucket-name/key-name',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('fails for missing reportPath if reportType is "file"', async () => {
const config: RenovateConfig = {
reportType: 'file',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
});
it('validates reportPath if reportType is "file"', async () => {
const config: RenovateConfig = {
reportType: 'file',
reportPath: './report.json',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => {
const config = {
packageRules: [
{
matchRepositories: ['*', 'repo'], // invalid
enabled: false,
},
],
allowedHeaders: ['*', '**'], // invalid
autodiscoverProjects: ['**', 'project'], // invalid
allowedEnv: ['env_var'], // valid
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message:
'allowedHeaders: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
topic: 'Configuration Error',
},
{
message:
'autodiscoverProjects: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
topic: 'Configuration Error',
},
]);
expect(errors).toMatchObject([
{
message:
'packageRules[0].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
topic: 'Configuration Error',
},
]);
expect(warnings).toHaveLength(2);
expect(errors).toHaveLength(1);
});
});
});