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 jsonata expressions', async () => { const config = { packageRules: [ { matchJsonata: ['packageName = "foo"', '{{{something wrong}'], enabled: true, }, ], }; const { errors } = await configValidation.validateConfig('repo', config); expect(errors).toHaveLength(1); expect(errors[0].message).toContain('Invalid JSONata expression'); }); 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: [{}], }, bar: { description: 'foo', defaultRegistryUrlTemplate: 'bar', transformTemplates: ['foo = "bar"', 'bar[0'], }, }, } 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: ['^(?foo)(?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: ['^(?foo)(?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: ['^(?foo)(?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: ['^(?foo)(?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 (?.*?)\\s'], depNameTemplate: 'foo', datasourceTemplate: 'bar', registryUrlTemplate: 'foobar', extractVersionTemplate: '^(?v\\d+\\.\\d+)', depTypeTemplate: 'apple', }, { customType: 'regex', fileMatch: ['Dockerfile'], matchStrings: ['ENV (?.*?)\\s'], packageNameTemplate: 'foo', datasourceTemplate: 'bar', registryUrlTemplate: 'foobar', extractVersionTemplate: '^(?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 (?.*?)\\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: ['^(?foo)(?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, }, ], }; 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); }); }); });