Files
renovate/lib/config/validation.spec.ts
Jamie Tanna 238e15a498 feat(config): add inheritConfigTrusted to trust inherited-config hostRules
Renovate deployments that are deployed across multiple organisations may
take advantage of Inherited Config as a means to decentralise some
decisions and/or preferences that their organisations would like to use
for some self-hosted configuration.

Similarly, the decision of which hosts to allowlist may be something
that a self-hosted administrator /may/ want to allow their organisation
admins to decide.

We can make this an opt-in capability to indicate that the self-hosted
administrator trusts the Inherited Config moreso, and allows someone who
can write to it the ability to allowlist access to internal hosts that
may not be intended.

If a self-hosted administrator sets `allowInternal: false`, this takes
precedence, allowing a "veto", and a self-hosted admin's `headers` are
still preferred, too.

It's not possible for Inherited Config to set grants for `extends`, as
config resolution has already occurred, so this still needs to be
performed at the self-hosted config level.

This trust is transitive: `hostRules` defined in presets the
inherited config `extends` are registered at the same inherited
tier once resolved, so this option must only be enabled when those
preset sources are trusted too.

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

3904 lines
115 KiB
TypeScript

import { partial } from '~test/util.ts';
import type { HostRule } from '../types/index.ts';
import { getConfigFileNames } from './app-strings.ts';
import { GlobalConfig } from './global.ts';
import type { AllConfig, RenovateConfig } from './types.ts';
import * as configValidation from './validation.ts';
describe('config/validation', () => {
describe('validateConfig(config)', () => {
it.each([
[
'branchName',
`Direct editing of branchName is now deprecated. Please edit branchPrefix, additionalBranchPrefix, or branchTopic instead`,
],
[
'commitMessage',
`Direct editing of commitMessage is now deprecated. Please edit commitMessage's subcomponents instead.`,
],
[
'prTitle',
`Direct editing of prTitle is now deprecated. Please edit commitMessage subcomponents instead as they will be passed through to prTitle.`,
],
])(
`returns custom deprecation warnings for %s`,
async (option, message) => {
const config = {
[option]: 'something',
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toMatchObject([
{
topic: 'Deprecation Warning',
message,
},
]);
expect(errors).toBeEmptyArray();
},
);
// NOTE that this should always refer to a deprecated option, but at some point, we may have removed them all, so we'll need to think about how to handle that, at that point
it('returns the deprecationMsg for `dnsCache` as a warning', async () => {
const config: RenovateConfig = {
hostRules: [
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid/removed HostRule property
{
dnsCache: true,
} as HostRule,
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
topic: 'Deprecation Warning',
message: `The 'dnsCache' option is deprecated: This option is deprecated and will be removed in a future release.`,
},
]);
});
it('allow enabled field in vulnerabilityAlerts', async () => {
const config = {
vulnerabilityAlerts: {
enabled: false,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('catches global options in repo config', async () => {
const config = {
binarySource: 'something',
username: 'user',
ignorePrAuthor: true,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error -- invalid config
config,
);
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 "ignorePrAuthor" 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.`,
},
]);
expect(errors).toBeEmptyArray();
});
it('catches global options in inherit config', async () => {
const config = {
binarySource: 'something',
username: 'user',
};
const { warnings, errors } = await configValidation.validateConfig(
'inherit',
// @ts-expect-error -- invalid config
config,
);
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.`,
},
]);
expect(errors).toBeEmptyArray();
});
it('only warns for actual globals in repo config', async () => {
const config = {
hostRules: [
{
username: 'user',
token: 'token',
password: 'pass',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('does not warn for valid inheritConfig', async () => {
const config = {
onboarding: false,
};
const { warnings, errors } = await configValidation.validateConfig(
'inherit',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('catches invalid templates', async () => {
const config = {
commitMessage: '{{{something}}',
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message: 'Invalid template in config path: commitMessage',
},
]);
expect(warnings).toMatchObject([
{
message: `Direct editing of commitMessage is now deprecated. Please edit commitMessage's subcomponents instead.`,
},
]);
});
it('accepts templates referencing runtime-only fields', async () => {
const config = {
packageRules: [
{
matchPackageNames: ['rabbitmq'],
allowedVersions: '<{{add major 1}}',
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('catches invalid jsonata expressions', async () => {
const config = {
packageRules: [
{
matchJsonata: ['packageName = "foo"', '{{{something wrong}'],
enabled: true,
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message: expect.stringContaining('Invalid JSONata expression'),
},
]);
expect(warnings).toBeEmptyArray();
});
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, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'Invalid regExp for packageRules[1].allowedVersions: `/***$}{]][/`',
},
{
message:
'Invalid regExp for packageRules[3].allowedVersions: `!/***$}{]][/`',
},
]);
expect(warnings).toBeEmptyArray();
});
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, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message: 'Invalid regExp for packageRules[4].matchNewValue: `/^2(/`',
},
]);
expect(warnings).toBeEmptyArray();
});
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, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message: 'Invalid regExp for packageRules[4].matchNewValue: `/^2(/`',
},
]);
expect(warnings).toBeEmptyArray();
});
it('validates matchBaseBranches', async () => {
const config = {
baseBranchPatterns: ['foo'],
packageRules: [
{
matchBaseBranches: ['foo'],
addLabels: ['foo'],
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('catches invalid matchBaseBranches when baseBranchPatterns is not defined', async () => {
const config = {
packageRules: [
{
matchBaseBranches: ['foo'],
addLabels: ['foo'],
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
topic: 'Configuration Error',
message:
'packageRules[0]: You must configure baseBranchPatterns in order to use them inside matchBaseBranches.',
},
]);
});
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, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'Invalid regExp for packageRules[1].matchCurrentVersion: `/***$}{]][/`',
},
{
message:
'Invalid regExp for packageRules[3].matchCurrentVersion: `!/***$}{]][/`',
},
]);
expect(warnings).toBeEmptyArray();
});
it('catches invalid customDatasources content', async () => {
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid customDatasources content
const config = {
customDatasources: {
foo: {
description: 3,
randomKey: '',
defaultRegistryUrlTemplate: [],
transformTemplates: [{}],
},
bar: {
description: 'foo',
defaultRegistryUrlTemplate: 'bar',
transformTemplates: ['foo = "bar"', 'bar[0'],
},
},
} as any;
const { errors, warnings } = 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',
},
]);
expect(warnings).toBeEmptyArray();
});
it('validates invalid statusCheckNames', async () => {
const config = {
statusCheckNames: {
randomKey: '',
mergeConfidence: 10,
configValidation: '',
artifactError: null,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
// @ts-expect-error invalid options
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(warnings).toBeEmptyArray();
});
it('validates invalid statusCheckWhen', async () => {
const config = {
statusCheckWhen: {
randomKey: 'always',
mergeConfidence: 'invalid',
artifactError: 'always',
},
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
// @ts-expect-error invalid options
config,
);
expect(errors).toMatchObject([
{
message:
'Invalid `statusCheckWhen.mergeConfidence` configuration: value must be one of "always", "never", or "failed".',
},
{
message:
'Invalid `statusCheckWhen.statusCheckWhen.randomKey` configuration: key is not allowed.',
},
]);
expect(warnings).toBeEmptyArray();
});
it('catches invalid customDatasources record type', async () => {
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid customDatasources record type
const config = {
customDatasources: {
randomKey: '',
},
} as any;
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'Invalid `customDatasources.randomKey` configuration: customDatasource is not an object',
},
]);
expect(warnings).toBeEmptyArray();
});
it('catches invalid baseBranchPatterns regex', async () => {
const config = {
baseBranchPatterns: ['/***$}{]][/', '/branch/i'],
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Failed to parse regex pattern for baseBranchPatterns: /***$}{]][/',
},
{
topic: 'Configuration Error',
message: 'Invalid regExp for baseBranchPatterns: `/***$}{]][/`',
},
]);
expect(warnings).toBeEmptyArray();
});
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: {
// @ts-expect-error -- testing
bar: 2,
},
major: null,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Failed to parse regex pattern for packageRules[0].matchPackageNames: !/abc ([a-z]+) ([a-z]+))/',
},
{
message:
'Failed to parse regex pattern for packageRules[0].matchPackageNames: /abc ([a-z]+) ([a-z]+))/',
},
{
message: 'Invalid configuration option: foo',
},
{
message: 'Invalid configuration option: lockFileMaintenance.bar',
},
{
message:
'Invalid regExp for packageRules[0].matchPackageNames: `!/abc ([a-z]+) ([a-z]+))/`',
},
{
message:
'Invalid regExp for packageRules[0].matchPackageNames: `/abc ([a-z]+) ([a-z]+))/`',
},
{
message:
'packageRules[0].matchPackageNames: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.',
},
]);
});
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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Configuration option `packageRules[0].matchManagers` should be a list (Array)',
},
]);
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it.each([
[
'single not supported manager',
{ enabledManagers: ['foo'] },
'The following managers configured in enabledManagers are not supported: "foo"',
],
[
'multiple not supported managers',
{ enabledManagers: ['foo', 'bar'] },
'The following managers configured in enabledManagers are not supported: "foo, bar"',
],
[
'combined supported and not supported managers',
{ enabledManagers: ['foo', 'npm', 'gradle', 'maven'] },
'The following managers configured in enabledManagers are not supported: "foo"',
],
])(
'errors if included not supported enabled managers for %s',
async (_case, config, message) => {
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([{ message }]);
},
);
it('errors for all types', async () => {
const config: RenovateConfig = {
// @ts-expect-error - only allowed in package rules
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).toMatchObject([
{
topic: 'allowedVersions',
message:
'"allowedVersions" can\'t be used in ".". Allowed objects: packageRules.',
},
]);
expect(errors).toMatchObject([
{
message:
'Configuration option `azureWorkItemId` should be an integer. Found: false (boolean).',
},
{
message:
'Configuration option `enabled` should be boolean. Found: 1 (number)',
},
{
message: 'Configuration option `labels` should be a list (Array)',
},
{
message:
'Configuration option `lockFileMaintenance` should be a json object',
},
{
message:
'Configuration option `packageRules[2].matchPackageNames` should be a list (Array)',
},
{
message:
'Configuration option `semanticCommitType` should be a string',
},
{
message: 'Invalid configuration option: packageRules[0].foo',
},
{
message:
'Invalid schedule: `Invalid schedule: "every 15 mins every weekday" should not specify minutes`',
},
{
message:
'extends: Invalid schedule: Unsupported timezone Europe/Brussel',
},
{
message: 'packageRules must contain JSON objects',
},
{
message:
'packageRules[0]: Each packageRule must contain at least one match* or exclude* selector. Rule: {"foo":1}',
},
{
message: 'timezone: Invalid schedule: Unsupported timezone Asia',
},
]);
});
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',
// @ts-expect-error -- TODO: managers, datasources and versionings are not defined on RenovateConfig
config,
);
expect(warnings).toMatchObject([
{
topic: 'ansible.minor.matchDepNames',
message:
'"matchDepNames" can\'t be used in "minor". Allowed objects: packageRules.',
},
{
topic: 'ansible.minor.matchPackageNames',
message:
'"matchPackageNames" can\'t be used in "minor". Allowed objects: packageRules.',
},
{
topic: 'matchDepNames',
message:
'"matchDepNames" can\'t be used in ".". Allowed objects: packageRules.',
},
{
topic: 'matchPackageNames',
message:
'"matchPackageNames" can\'t be used in ".". Allowed objects: packageRules.',
},
]);
expect(errors).toMatchObject([
{
message:
'ansible.minor.matchDepNames: matchDepNames should be inside a `packageRule` only',
},
{
message:
'ansible.minor.matchPackageNames: matchPackageNames should be inside a `packageRule` only',
},
{
message:
'matchDepNames: matchDepNames should be inside a `packageRule` only',
},
{
message:
'matchPackageNames: matchPackageNames should be inside a `packageRule` only',
},
]);
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors for unsafe managerFilePatterns', async () => {
const config = {
npm: {
managerFilePatterns: ['/abc ([a-z]+) ([a-z]+))/'],
},
dockerfile: {
managerFilePatterns: ['/x?+/'],
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error -- TODO: managers, datasources and versionings are not defined on RenovateConfig
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Failed to parse regex pattern for dockerfile.managerFilePatterns: /x?+/',
},
{
message:
'Failed to parse regex pattern for npm.managerFilePatterns: /abc ([a-z]+) ([a-z]+))/',
},
]);
});
it('validates regEx for each managerFilePatterns of format regex', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['/js/', '/***$}{]][/'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Failed to parse regex pattern for customManagers[0].managerFilePatterns: /***$}{]][/',
},
]);
});
it('errors if customManager has empty managerFilePatterns', async () => {
const config = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: [],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Each Custom Manager must contain a non-empty managerFilePatterns array",
"topic": "Configuration Error",
},
]
`);
});
it('errors if no customManager customType', async () => {
const config = {
customManagers: [
{
managerFilePatterns: ['some-file'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toBeEmptyArray();
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',
managerFilePatterns: ['some-file'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as any,
true,
);
expect(warnings).toBeEmptyArray();
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',
managerFilePatterns: ['foo'],
matchStrings: [],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
{
customType: 'jsonata',
fileFormat: 'json',
managerFilePatterns: ['foo'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config as RenovateConfig,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchInlineSnapshot(`
[
{
"message": "Each Custom Manager \`matchStrings\` array must have at least one item.",
"topic": "Configuration Error",
},
{
"message": "Each Custom Manager must contain a non-empty matchStrings array",
"topic": "Configuration Error",
},
]
`);
});
it('errors if no customManager managerFilePatterns', 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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Each Custom Manager must contain a non-empty customType string',
},
]);
});
it('validates regEx for each matchStrings', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['Dockerfile'],
matchStrings: ['***$}{]]['],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: 'Invalid regExp for customManagers: `***$}{]][`',
},
]);
});
it('error if no fileFormat in custom JSONata manager', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
managerFilePatterns: ['package.json'],
matchStrings: [
'packages.{"depName": name, "currentValue": version, "datasource": "npm"}',
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message: 'Each JSONata manager must contain a fileFormat field.',
},
]);
});
it('validates JSONata query for each matchStrings', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
fileFormat: 'json',
managerFilePatterns: ['package.json'],
matchStrings: ['packages.{'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message: `Invalid JSONata query for customManagers: \`packages.{\``,
},
]);
});
// testing if we get all errors at once or not (possible), this does not include customType or managerFilePatterns
// since they are common to all custom managers
it('validates all possible regex manager options', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['Dockerfile'],
matchStrings: ['***$}{]]['], // invalid matchStrings regex, no depName, datasource and currentValue
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: 'Invalid regExp for customManagers: `***$}{]][`',
},
{
message:
'Regex Managers must contain currentValue or currentDigest, their template variants (currentValueTemplate or currentDigestTemplate) or regex groups named after configuration fields',
},
{
message:
'Regex Managers must contain datasourceTemplate configuration or regex group named datasource',
},
{
message:
'Regex Managers must contain depName or packageName regex groups or templates',
},
]);
});
it('passes if customManager fields are present', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['Dockerfile'],
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
registryUrlTemplate: 'foobar',
extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
depTypeTemplate: 'apple',
},
{
customType: 'jsonata',
fileFormat: 'json',
managerFilePatterns: ['package.json'],
matchStrings: [
'packages.{"depName": depName, "currentValue": version, "datasource": "npm"}',
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors if extra customManager fields are present', async () => {
const config = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: 'Custom Manager contains disallowed fields: automerge',
},
]);
});
it('errors if customManager fields are missing', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['Dockerfile'],
matchStrings: ['ENV (.*?)\\s'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Regex Managers must contain currentValue or currentDigest, their template variants (currentValueTemplate or currentDigestTemplate) or regex groups named after configuration fields',
},
]);
});
// via https://github.com/renovatebot/renovate/discussions/45665
it('does not error if a customManager only uses `currentDigest`', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: [
'/\\.gitlab-ci\\.yml$/',
'/\\.gitlab\\/pipelines\\/.*\\.yaml$/',
],
matchStrings: [
'.*@(?<currentDigest>sha256:[^ ]+) *# renovate: image=(?<depName>[^ \\n]+)\\n',
],
datasourceTemplate: 'docker',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('does not error if a customManager only uses `currentValue`', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'regex',
managerFilePatterns: [
'/\\.gitlab-ci\\.yml$/',
'/\\.gitlab\\/pipelines\\/.*\\.yaml$/',
],
matchStrings: ['ENV YARN_VERSION=(?<currentValue>.*?)\\n'],
depNameTemplate: 'foo',
datasourceTemplate: 'docker',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors if customManager fields are missing: JSONataManager', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
fileFormat: 'json',
managerFilePatterns: ['package.json'],
matchStrings: ['packages'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
'JSONata Managers must contain currentValue or currentDigest in the query or their templates',
},
{
topic: 'Configuration Error',
message: `JSONata Managers must contain datasourceTemplate configuration or datasource in the query `,
},
{
topic: 'Configuration Error',
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
},
]);
});
it('ignore keys', async () => {
const config = {
$schema: 'renovate.json',
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('validates timezone preset', async () => {
const config = {
extends: [':timezone', ':timezone(Europe/Berlin)'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
describe('constraints', () => {
it('can contain a valid tool name for Containerbase', async () => {
const config: RenovateConfig = {
constraints: {
golang: '1.26.0',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('can contain a constraint for a non-Containerbase tool', async () => {
const config: RenovateConfig = {
constraints: {
gomodMod: 'latest',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('warns if an unsupported constraint is specified', async () => {
const config = {
constraints: {
'not-supported': '4.5.6',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: contains invalid values
config,
true,
);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraints.not-supported`: `not-supported` is not a supported constraint name',
},
]);
expect(errors).toBeEmptyArray();
});
it('warns if a constraint is not valid', async () => {
const config: RenovateConfig = {
constraints: {
node: '1.2.3foo',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraints.node=1.2.3foo` is not a valid tool version constraint, according to `node` versioning',
},
]);
expect(errors).toBeEmptyArray();
});
it('errors if constraints is a malformed object', async () => {
const config = {
constraints: { packageRules: [{}] },
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: contains invalid values
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraints.packageRules` should be an object of key-value pairs of constraints and their value',
},
]);
});
it('errors if constraints is a malformed array', async () => {
const config = {
constraints: [1, 2, 3],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: contains invalid values
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraints` should be a json object',
},
]);
});
});
describe('constraintsVersioning', () => {
it('cannot contain a valid tool name for Containerbase', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
// @ts-expect-error: not an AdditionalConstraintName
golang: 'semver',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraintsVersioning.golang` is not a valid additional constraint name, as `golang` is a tool name, and `constraintsVersioning` can only override the versioning for a non-tool constraint',
},
]);
});
it('can contain a constraint for a non-Containerbase tool', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
gomodMod: 'semver',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('cannot contain an additional constraint name with an invalid versioning scheme', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
gomodMod: 'not-supported-versioning',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraintsVersioning.gomodMod=not-supported-versioning`: `not-supported-versioning` is not a valid versioning scheme',
},
]);
});
it('can contain an additional constraint name with a regex versioning scheme', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
gomodMod:
'regex:^(?<major>\\d+?)\\.(?<minor>\\d+?)(\\.(?<patch>\\d+))?$',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('cannot contain an unsupported constraint', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
// @ts-expect-error: contains invalid values
'not-supported': '4.5.6',
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraintsVersioning.not-supported`: `not-supported` is not a known additional constraint name',
},
]);
});
it('errors if constraintsVersioning is a malformed object', async () => {
const config: RenovateConfig = {
constraintsVersioning: {
// @ts-expect-error: contains invalid values
packageRules: [{}],
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraintsVersioning.packageRules` should be an object of key-value pairs of additional constraint names and their versioning',
},
]);
});
it('errors if constraintsVersioning is a malformed array', async () => {
const config: RenovateConfig = {
// @ts-expect-error: contains invalid values
constraintsVersioning: [1, 2, 3],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'Configuration option `constraintsVersioning` should be a json object',
},
]);
});
});
it('validates object with ignored children', async () => {
const config = {
prBodyDefinitions: {},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors if registryAliases depth is more than 1', async () => {
const config = {
registryAliases: {
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentional incorrect config to check error message
sample: {
example1: 'http://www.example.com',
} as unknown as string,
},
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Invalid `registryAliases.registryAliases.example1` configuration: value is not a string',
topic: 'Configuration Error',
},
]);
});
it('errors if managerFilePatterns has wrong parent', async () => {
const config: RenovateConfig = {
managerFilePatterns: ['foo'],
// @ts-expect-error: -- TODO: managers, datasources and versionings are not defined on RenovateConfig
npm: {
managerFilePatterns: ['package\\.json'],
minor: {
managerFilePatterns: ['bar'],
},
},
customManagers: [
{
customType: 'regex',
managerFilePatterns: ['build.gradle'],
matchStrings: ['^(?<depName>foo)(?<currentValue>bar)$'],
datasourceTemplate: 'maven',
versioningTemplate: 'gradle',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toEqual([
{
topic: 'managerFilePatterns',
message: expect.toStartWith(
`"managerFilePatterns" can't be used in ".". Allowed objects: `,
),
},
{
topic: 'npm.minor.managerFilePatterns',
message: expect.toStartWith(
`"managerFilePatterns" can't be used in "minor". Allowed objects: `,
),
},
]);
});
it('errors if manager objects are nested', async () => {
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid nested manager config
const config = {
pyenv: {
enabled: false,
},
maven: {
gradle: {
enabled: false,
},
},
} as never;
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'The "gradle" object can only be configured at the top level of a config but was found inside "maven"',
},
]);
});
it('warns if hostType has the wrong parent', async () => {
const config = {
hostType: 'npm',
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
// @ts-expect-error: contains invalid values
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
topic: 'hostType',
message:
'"hostType" can\'t be used in ".". Allowed objects: hostRules.',
},
]);
});
it('validates preset values', async () => {
const config = {
extends: ['foo', 'bar', 42] as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: 'extends: preset value is not a string',
},
]);
});
it('errors on invalid preset syntax', async () => {
const config = {
extends: [
'github>owner/repo//path@commitHash',
'github>owner/repo//path#commitHash',
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: expect.stringContaining(
'github>owner/repo//path@commitHash',
),
},
]);
});
it('accepts relative preset references', async () => {
const config = {
extends: ['./foo', '../foo/bar', '/foo'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([]);
});
it('accepts relative preset references inside packageRules', async () => {
const config = {
packageRules: [
{
matchManagers: ['npm'],
extends: ['./rules/npm'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([]);
});
it('accepts relative preset references nested inside packageRules', async () => {
const config = {
packageRules: [
{
matchManagers: ['npm'],
minor: {
extends: ['./minor-rules'],
},
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([]);
});
it('accepts selectors-only packageRules which contain a relative preset', async () => {
const config = {
packageRules: [
{
matchManagers: ['npm'],
extends: ['./x'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([]);
});
it('warns about selectors-only packageRules which contain a non-relative preset', async () => {
const config = {
packageRules: [
{
extends: ['packages:eslint'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'packageRules[0]: Each packageRule must contain at least one non-match* or non-exclude* field. Rule: {"extends":["packages:eslint"]}',
},
]);
expect(errors).toEqual([]);
});
it('warns about packageRules whose selectors may come from a relative preset', async () => {
const config = {
packageRules: [
{
extends: ['./selectors'],
automerge: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'packageRules[0]: this rule extends a relative preset that cannot be resolved during validation, so its selectors could not be checked. Rule: {"extends":["./selectors"],"automerge":true}',
},
]);
expect(errors).toEqual([]);
});
it('warns instead of erroring on packageRules which only contain a relative preset', async () => {
const config = {
packageRules: [
{
extends: ['./automerge'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'packageRules[0]: this rule extends a relative preset that cannot be resolved during validation, so its selectors could not be checked. Rule: {"extends":["./automerge"]}',
},
]);
expect(errors).toEqual([]);
});
it('errors on packageRules without selectors and without relative presets', async () => {
const config = {
packageRules: [
{
automerge: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([
{
topic: 'Configuration Error',
message:
'packageRules[0]: Each packageRule must contain at least one match* or exclude* selector. Rule: {"automerge":true}',
},
]);
});
it('resolves absolute preset references inside packageRules which also contain relative ones', async () => {
const config = {
packageRules: [
{
matchManagers: ['npm'],
extends: ['./rel', ':pinVersions'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([]);
});
it('errors on invalid relative preset references', async () => {
const config = {
extends: ['./foo#v1', './foo:bar', './a//b'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toEqual([]);
expect(errors).toEqual([
{
topic: 'Configuration Error',
message: 'extends: preset "./a//b" is not valid',
},
{
topic: 'Configuration Error',
message: 'extends: preset "./foo#v1" is not valid',
},
{
topic: 'Configuration Error',
message: 'extends: preset "./foo:bar" is not valid',
},
]);
});
it('skips preset syntax validation for templates', async () => {
const config = {
extends: ['local>{{ env.PRESET_REPO }}:python-312'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('validates valid commitTrailers', async () => {
const config = {
commitTrailers: [
'Signed-off-by: {{{gitAuthor}}}',
'Co-authored-by: First Contributor <first@example.com>',
'Co-authored-by: Second Contributor <second@example.com>',
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors on invalid commitTrailers', async () => {
const config = {
commitTrailers: [
'no colon',
'Bad key: value',
'Key:no-space',
'Key: multi\nline',
42,
] as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
'Invalid commit trailer: `"Bad key: value"`. Must be a single-line string in the form `Key: value`, where the key contains only letters, digits and `-`.',
},
{
topic: 'Configuration Error',
message:
'Invalid commit trailer: `"Key: multi\\nline"`. Must be a single-line string in the form `Key: value`, where the key contains only letters, digits and `-`.',
},
{
topic: 'Configuration Error',
message:
'Invalid commit trailer: `"Key:no-space"`. Must be a single-line string in the form `Key: value`, where the key contains only letters, digits and `-`.',
},
{
topic: 'Configuration Error',
message:
'Invalid commit trailer: `"no colon"`. Must be a single-line string in the form `Key: value`, where the key contains only letters, digits and `-`.',
},
{
topic: 'Configuration Error',
message:
'Invalid commit trailer: `42`. Must be a single-line string in the form `Key: value`, where the key contains only letters, digits and `-`.',
},
]);
});
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(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
message:
'packageRules[0]: Each packageRule must contain at least one non-match* or non-exclude* field. Rule: {"matchDepTypes":["foo"],"matchPackageNames":["bar"]}',
},
]);
});
it('errors if invalid combinations in packageRules', async () => {
const config = partial<AllConfig>({
packageRules: [
{
matchUpdateTypes: ['major'],
registryUrls: ['https://registry.npmjs.org'],
},
],
});
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'packageRules[0]: packageRules cannot combine both matchUpdateTypes and registryUrls. Rule: {"matchUpdateTypes":["major"],"registryUrls":["https://registry.npmjs.org"]}',
},
]);
});
it('warns when registryUrls is set at the top level of repo config', async () => {
const config = {
registryUrls: ['https://registry.npmjs.org'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
message: expect.stringContaining(
'Setting `registryUrls` at the top level of your config will apply it to all managers',
),
},
]);
});
it('warns when defaultRegistryUrls is set at the top level of repo config', async () => {
const config = {
defaultRegistryUrls: ['https://registry.npmjs.org'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
message: expect.stringContaining(
'Setting `defaultRegistryUrls` at the top level of your config will apply it to all managers',
),
},
]);
});
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).toBeEmptyArray();
expect(warnings).toMatchObject([
{
topic: 'Configuration Warning',
message:
'packageRules[0].extends: you should not extend "group:" presets',
},
]);
});
it('does not error on use of `global:` presets in `globalExtends`', async () => {
const config = {
globalExtends: ['global:safeEnv'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
true,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('does not error on use of `global:` presets in global `extends`', async () => {
const config = {
extends: ['global:safeEnv'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
true,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('errors on use of `global:` presets in inherit `extends`', async () => {
const config = {
extends: ['global:safeEnv'],
};
const { warnings, errors } = await configValidation.validateConfig(
'inherit',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'extends: you cannot extend from "global:" presets in a repository config\'s "extends"',
},
]);
});
it('errors on use of `global:` presets in repo `extends`', async () => {
const config = {
extends: ['global:safeEnv'],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'extends: you cannot extend from "global:" presets in a repository config\'s "extends"',
},
]);
});
// 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, errors } = 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.`,
},
]);
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
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, warnings } = 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.',
},
]);
expect(warnings).toBeEmptyArray();
});
it('reports `allowInternal` in repo config as a security error', async () => {
const config = {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toMatchObject([
{
message: `The "allowInternal" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
topic: 'Configuration Error',
},
]);
expect(errors).toMatchObject([
{
message:
"hostRules `allowInternal` is only allowed in the self-hosted administrator's own configuration.",
topic: 'Config security error',
},
]);
});
it('allows `allowInternal` in global config', async () => {
const config = {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: false,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('reports `allowInternal` in inherited config as a security error, saying how to permit it', async () => {
const config = {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'inherit',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'hostRules `allowInternal` is not allowed in inherited config, as this Renovate instance has not set `inheritConfigTrusted=true`. The administrator can either set it, or move the rule to their global config or a `repositories[]` entry.',
topic: 'Config security error',
},
]);
});
it('allows `allowInternal` in inherited config with `inheritConfigTrusted`', async () => {
GlobalConfig.set({ inheritConfigTrusted: true });
const config = {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'inherit',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('still reports `allowInternal` in repo config with `inheritConfigTrusted`', async () => {
// `inheritConfigTrusted` says nothing about a repository's own config, or the presets it extends
GlobalConfig.set({ inheritConfigTrusted: true });
const config = {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: true,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toMatchObject([
{
message: `The "allowInternal" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`,
topic: 'Configuration Error',
},
]);
expect(errors).toMatchObject([
{
message:
"hostRules `allowInternal` is only allowed in the self-hosted administrator's own configuration.",
topic: 'Config security error',
},
]);
});
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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
"hostRules header `unallowedHeader` is not allowed by this Renovate instance's `allowedHeaders`.",
topic: 'Config security error',
},
]);
});
it('errors if headers values are not string', async () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid header value type
headers: {
'X-Auth-Token': 10,
} as unknown as Record<string, string>,
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Invalid hostRules headers value configuration: header must be a string.',
topic: 'Configuration Error',
},
]);
});
it('errors if allowedHeaders is empty', async () => {
GlobalConfig.set({ allowedHeaders: [] });
const config = {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: {
'X-Auth-Token': 'token',
},
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
"hostRules header `X-Auth-Token` is not allowed by this Renovate instance's `allowedHeaders`.",
topic: 'Config security error',
},
]);
});
it('reports nested `env` with values not in `allowedEnv` as a configuration error', async () => {
// only top-level `env` is ever applied, so a nested copy must not be escalated to a security error - which callers treat as always fatal
GlobalConfig.set({ allowedEnv: [] });
const config = {
packageRules: [
{
matchManagers: ['npm'],
env: { SOME_VAR: 'some_value' },
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
"Env variable name `SOME_VAR` is not allowed by this Renovate instance's `allowedEnv`.",
},
{
topic: 'Configuration Error',
message:
'The "env" object can only be configured at the top level of a config but was found inside "packageRules[0]"',
},
]);
});
it('reports nested `hostRules[].headers` with values not in `allowedHeaders` as a configuration error', async () => {
GlobalConfig.set({ allowedHeaders: [] });
const config = {
packageRules: [
{
matchManagers: ['npm'],
hostRules: [
{
matchHost: 'https://domain.com',
headers: { 'X-Auth-Token': 'token' },
},
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
"hostRules header `X-Auth-Token` is not allowed by this Renovate instance's `allowedHeaders`.",
},
]);
});
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 Renovate instance's `allowedEnv`.",
},
{
message:
'Invalid env variable value: `env.SOME_OTHER_VAR` must be a string.',
},
]);
expect(warnings).toBeEmptyArray();
});
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).toBeEmptyArray();
});
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(warnings).toBeEmptyArray();
});
it('catches when negative number is used for integer type', async () => {
const config = {
azureWorkItemId: -2,
};
const { errors, warnings } = await configValidation.validateConfig(
'repo',
config,
);
expect(errors).toMatchObject([
{
message:
'Configuration option `azureWorkItemId` should be a positive integer. Found negative value instead.',
topic: 'Configuration Error',
},
]);
expect(warnings).toBeEmptyArray();
});
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, warnings } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(errors).toMatchObject([
{
message: 'Invalid configuration option: logFile',
topic: 'Configuration Error',
},
{
message: 'Invalid configuration option: logFileLevel',
topic: 'Configuration Error',
},
]);
expect(warnings).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toMatchObject([
{
message:
"hostRules header `X-Auth-Token` is not allowed by this Renovate instance's `allowedHeaders`.",
topic: 'Config security error',
},
]);
expect(warnings).toBeEmptyArray();
});
it('validates env', async () => {
const config = {
env: {
SOME_VAR: 'SOME_VALUE',
},
allowedEnv: ['SOME*'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('handles prefixed onboardingConfigFileName', async () => {
const config = {
platform: 'forgejo',
onboardingConfigFileName: '.forgejo/renovate.json',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: not sure why
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('allows unique onboardingConfigFileName if it is set in configFileNames', async () => {
const config = {
onboardingConfigFileName: '.forgejo/renovate.json',
configFileNames: ['.forgejo/renovate.json'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors if env object is defined but allowedEnv is empty or undefined', async () => {
const config = {
env: {
SOME_VAR: 'SOME_VALUE',
},
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toMatchObject([
{
message:
"Env variable name `SOME_VAR` is not allowed by this Renovate instance's `allowedEnv`.",
topic: 'Config security error',
},
]);
expect(warnings).toBeEmptyArray();
});
it('validates env against the allowedEnv regex', async () => {
const config = {
env: {
SOME_VAR: 'SOME_VALUE',
},
allowedEnv: ['/^SOME.*/'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
});
describe('validate globalOptions()', () => {
// TODO #40742 #40747
it('binarySource=docker is deprecated', async () => {
const config: GlobalConfig = {
binarySource: 'docker',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
topic: 'Deprecation Warning',
message:
'Usage of `binarySource=docker` is deprecated, and will be removed in the future. Please migrate to `binarySource=install`. Feedback on the usage of `binarySource=docker` is welcome at https://github.com/renovatebot/renovate/discussions/40742',
},
]);
expect(errors).toBeEmptyArray();
});
it('binarySource', async () => {
const config = {
binarySource: 'invalid' as never,
};
const { warnings, errors } = 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',
},
]);
expect(errors).toBeEmptyArray();
});
describe('validates string type options', () => {
it('binarySource', async () => {
const config = {
binarySource: 'invalid' as never,
};
const { warnings, errors } = 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',
},
]);
expect(errors).toBeEmptyArray();
});
it('baseDir', async () => {
const config = {
baseDir: false as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message: 'Configuration option `baseDir` should be a string.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('requireConfig', async () => {
const config = {
requireConfig: 'invalid' as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `requireConfig`. The allowed values are required, optional, ignored.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('dryRun', async () => {
const config = {
dryRun: 'invalid' as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `dryRun`. The allowed values are extract, lookup, full.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('repositoryCache', async () => {
const config = {
repositoryCache: 'invalid' as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `repositoryCache`. The allowed values are enabled, disabled, reset.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('onboardingConfigFileName', async () => {
const config = {
onboardingConfigFileName: 'invalid' as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message: `Invalid value \`invalid\` for \`onboardingConfigFileName\`. The allowed values are ${getConfigFileNames().join(', ')}.`,
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('onboardingConfig', async () => {
const config = {
onboardingConfig: {
extends: ['config:recommended'],
binarySource: 'global', // should not allow globalOnly options inside onboardingConfig
managerFilePatterns: ['somefile'], // invalid at top level
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
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.`,
},
{
topic: 'managerFilePatterns',
message: expect.toStartWith(
`"managerFilePatterns" can't be used in ".". Allowed objects: `,
),
},
]);
expect(errors).toBeEmptyArray();
});
it('force', async () => {
const config = {
force: {
extends: ['config:recommended'],
binarySource: 'global',
managerFilePatterns: ['somefile'], // invalid at top level
constraints: {
python: '2.7',
},
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
topic: 'managerFilePatterns',
message: expect.toStartWith(
`"managerFilePatterns" can't be used in ".". Allowed objects: `,
),
},
]);
expect(errors).toBeEmptyArray();
});
it('gitUrl', async () => {
const config = {
gitUrl: 'invalid' as never,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'Invalid value `invalid` for `gitUrl`. The allowed values are default, ssh, endpoint.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
});
it('validates boolean type options', async () => {
const config = {
unicodeEmoji: false,
detectGlobalManagerConfig: 'invalid-type',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toMatchObject([
{
message: `Configuration option \`detectGlobalManagerConfig\` should be a boolean. Found: ${JSON.stringify(
'invalid-type',
)} (string).`,
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('validates integer type options', async () => {
const config = {
prCommitsPerRunLimit: 2,
gitTimeout: 'invalid-type',
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toMatchObject([
{
message: `Configuration option \`gitTimeout\` should be an integer. Found: ${JSON.stringify(
'invalid-type',
)} (string).`,
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('validates array type options', async () => {
const config = {
allowedCommands: ['cmd'],
checkedBranches: 'invalid-type',
gitNoVerify: ['invalid'],
mergeConfidenceDatasources: [1],
};
const { warnings, errors } = 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',
},
]);
expect(errors).toBeEmptyArray();
});
it('validates repositories[]', async () => {
const config = {
repositories: [
'owner/repo1',
{
repository: 'owner/repo2',
extends: ['config:recommended'],
enabled: false, // options with parents=['.'] are allowed
binarySource: 'global' as const, // globalOnly options are allowed
npm: { enabled: false }, // manager configs are allowed
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors on repositories[] with invalid entries', async () => {
const config = {
repositories: [
{
repository: 'owner/repo1',
binarySource: 'invalid' as never,
foo: 'bar',
},
{
extends: ['config:recommended'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(errors).toEqual([
{
topic: 'Configuration Error',
message: 'Invalid configuration option: repositories[0].foo',
},
{
topic: 'Configuration Error',
message:
'repositories[1]: each repository object entry must have a `repository` string property',
},
]);
expect(warnings).toEqual([
{
topic: 'Configuration Error',
message:
'Invalid value `invalid` for `repositories[0].binarySource`. The allowed values are docker, global, install, hermit.',
},
]);
});
it('allows repositories[] as string[]', async () => {
const config = {
repositories: ['owner/repo1', 'owner/repo2'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('errors on repositories[] with empty string values', async () => {
const config: AllConfig = {
repositories: ['', ' '],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toEqual([
{
message:
'repositories[0]: each repository string entry entry must be a non-empty string',
topic: 'Configuration Error',
},
{
message:
'repositories[1]: each repository string entry entry must be a non-empty string',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('errors on repositories[] with non-string or non-object values', async () => {
const config = {
repositories: [
[
// this is invalid
'invalid',
],
null,
123,
456,
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: contains invalid values
config,
);
expect(warnings).toEqual([
{
message:
'repositories[0]: invalid type, should be either a string or an object',
topic: 'Configuration Error',
},
{
message:
'repositories[1]: invalid type, should be either a string or an object',
topic: 'Configuration Error',
},
{
message:
'repositories[2]: invalid type, should be either a string or an object',
topic: 'Configuration Error',
},
{
message:
'repositories[3]: invalid type, should be either a string or an object',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('allows `env` on repositories[] entries within the global `allowedEnv`', async () => {
const config: AllConfig = {
allowedEnv: ['PATH'],
repositories: [
{
repository: 'owner/repo1',
env: { PATH: '/home/ubuntu/bin' },
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('errors on `env` on repositories[] entries outside the global `allowedEnv`', async () => {
const config: AllConfig = {
allowedEnv: ['PATH'],
repositories: [
{
repository: 'owner/repo1',
env: { NOT_ALLOWED: 'value' },
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
},
]);
});
it('lets a repositories[] entry set its own `allowedEnv`', async () => {
const config: AllConfig = {
allowedEnv: ['NOPE'],
repositories: [
{
repository: 'owner/repo1',
allowedEnv: ['PATH'],
env: { PATH: '/home/ubuntu/bin' },
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('allows `env` within `force` inside the global `allowedEnv`', async () => {
const config: AllConfig = {
allowedEnv: ['PATH'],
force: { env: { PATH: '/home/ubuntu/bin' } },
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('reports `env` within `force` outside the global `allowedEnv`', async () => {
const config: AllConfig = {
allowedEnv: ['PATH'],
force: { env: { NOT_ALLOWED: 'value' } },
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
topic: 'Config security error',
message:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
},
]);
});
it('allows hostRules `headers` within `force` inside the global `allowedHeaders`', async () => {
const config: AllConfig = {
allowedHeaders: ['X-Custom-*'],
force: {
hostRules: [
{
matchHost: 'https://domain.com/all-versions',
headers: { 'X-Custom-Token': 'token' },
},
],
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('allows hostRules `headers` on repositories[] entries within the global `allowedHeaders`', async () => {
const config: AllConfig = {
allowedHeaders: ['X-Custom-*'],
repositories: [
{
repository: 'owner/repo1',
hostRules: [
{
matchHost: 'github.com',
headers: { 'X-Custom-Token': 'value' },
},
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toBeEmptyArray();
});
it('errors on hostRules `headers` on repositories[] entries outside the global `allowedHeaders`', async () => {
const config: AllConfig = {
allowedHeaders: ['X-Custom-*'],
repositories: [
{
repository: 'owner/repo1',
hostRules: [
{
matchHost: 'github.com',
headers: { Authorization: 'Bearer token' },
},
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message:
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
},
]);
});
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, errors } = 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',
},
]);
expect(errors).toMatchObject([
{
message: 'cacheTtlOverride: namespace `someField` does not exist',
},
]);
});
it('warns if negative number is used for integer type', async () => {
const config = {
prCommitsPerRunLimit: -2,
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toMatchObject([
{
message:
'Configuration option `prCommitsPerRunLimit` should be a positive integer. Found negative value instead.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
it('warns on invalid customEnvVariables objects', async () => {
const config = {
customEnvVariables: {
example1: 'abc',
example2: 123,
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error -- testing
config,
);
expect(warnings).toMatchObject([
{
message:
'Invalid `customEnvVariables.example2` configuration: value must be a string.',
topic: 'Configuration Error',
},
]);
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: "reportType 's3' requires a configured reportPath",
},
]);
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
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).toBeEmptyArray();
expect(errors).toMatchObject([
{
message: "reportType 'file' requires a configured reportPath",
},
]);
});
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).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('warns when registryUrls is set at the top level of global config', async () => {
const config = {
registryUrls: ['https://registry.npmjs.org'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
message: expect.stringContaining(
'Setting `registryUrls` at the top level of your config will apply it to all managers',
),
},
]);
});
it('warns when defaultRegistryUrls is set at the top level of global config', async () => {
const config = {
defaultRegistryUrls: ['https://registry.npmjs.org'],
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(errors).toBeEmptyArray();
expect(warnings).toMatchObject([
{
message: expect.stringContaining(
'Setting `defaultRegistryUrls` at the top level of your config will apply it to all managers',
),
},
]);
});
it('validates postUpgradeTasks.installTools tool names', async () => {
const config = {
postUpgradeTasks: {
executionMode: 'update' as const,
installTools: {
npm: {},
node: {},
},
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('rejects invalid postUpgradeTasks.installTools tool names', async () => {
const config = {
postUpgradeTasks: {
installTools: {
npm: {},
unknownTool: {},
},
},
};
const { warnings, errors } = await configValidation.validateConfig(
'global',
// @ts-expect-error: installTools.unknownTool is not a valid tool
config,
);
expect(warnings).toMatchObject([
{
topic: 'Configuration Error',
message:
'Invalid `postUpgradeTasks.installTools.unknownTool` configuration: not a valid tool name.',
},
]);
expect(errors).toBeEmptyArray();
});
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',
},
]);
});
describe('cacheTtlOverride', () => {
it('errors when using an invalid cache namespace', async () => {
const config: AllConfig = {
cacheTtlOverride: {
// removed in 40.9.0
'datasource-maven:metadata-xml': 123,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'cacheTtlOverride: namespace `datasource-maven:metadata-xml` does not exist',
topic: 'Configuration Error',
},
]);
});
it('allows a valid cache namespace', async () => {
const config: AllConfig = {
cacheTtlOverride: {
'datasource-docker-hub-tags': 90,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
it('allows wildcards', async () => {
const config: AllConfig = {
cacheTtlOverride: {
'datasource-rubygems': 120,
'datasource-*': 60,
'datasource-{crate,go}': 90,
'/^changelog-/': 45,
'*': 30,
},
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toBeEmptyArray();
});
});
describe('repositories', () => {
it('is validated', async () => {
const config: AllConfig = {
repositories: [
{
repository: 'valid/name',
// @ts-expect-error -- invalid config
dependencyDashboardHeader: true,
},
],
};
const { errors, warnings } = await configValidation.validateConfig(
'global',
config,
);
expect(warnings).toBeEmptyArray();
expect(errors).toMatchObject([
{
message:
'Configuration option `repositories[0].dependencyDashboardHeader` should be a string',
},
]);
});
});
});
});