mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-24 22:48:06 +00:00
af055eba22
In a similar way to how `allowedEnv` works, we should allow a self-hosted admin to specify whichever headers they wish in their `hostRules` - whether in their global config, a `repositories[]` entry, or a preset a `repositories[]` entry extends. Previously, this would require a self-hosted admin to add any header they wished to set to `allowedHeaders`, which would also allow any of their users' repositories to set that same header. We can align this behaviour with `allowedEnv`, which makes sure that the admin's own `headers` are always applied regardless of `allowedHeaders`, while a repository's own `hostRules` - and those of any preset it extends - remain constrained by it as before. We make sure to use an explicit `[]` to make it clear when there are no headers to allow, opposed to using something less clear like `undefined`. We do not currently allow an `extends` (without a `repositories[]` entry) to bypass, and they'll be followed-up with in #45670. Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { codeBlock } from 'common-tags';
|
|
import { execa } from 'execa';
|
|
import { dir } from 'tmp-promise';
|
|
import { getConfigFileNames } from './config/app-strings.ts';
|
|
import type { AllConfig, RenovateConfig } from './config/types.ts';
|
|
|
|
const CLI = path.resolve('lib/config-validator.ts');
|
|
|
|
async function runValidator(
|
|
args: string[],
|
|
opts: { cwd?: string; env?: Record<string, string> } = {},
|
|
) {
|
|
return execa('node', [CLI, ...args], {
|
|
cwd: opts.cwd,
|
|
reject: false,
|
|
all: true,
|
|
env: {
|
|
...process.env,
|
|
LOG_LEVEL: 'info',
|
|
LOG_FORMAT: 'json',
|
|
...opts.env,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function withTmpDir<T>(fn: (dirPath: string) => Promise<T>): Promise<T> {
|
|
const tmpDir = await dir({ unsafeCleanup: true });
|
|
try {
|
|
return await fn(tmpDir.path);
|
|
} finally {
|
|
await tmpDir.cleanup();
|
|
}
|
|
}
|
|
|
|
async function writeRepoConfig(
|
|
dirPath: string,
|
|
name: string,
|
|
content: RenovateConfig,
|
|
): Promise<string> {
|
|
const filePath = path.join(dirPath, name);
|
|
await fs.writeFile(filePath, JSON.stringify(content, null, 2));
|
|
return filePath;
|
|
}
|
|
|
|
async function writeGlobalConfig(
|
|
dirPath: string,
|
|
name: string,
|
|
content: AllConfig,
|
|
): Promise<string> {
|
|
const filePath = path.join(dirPath, name);
|
|
await fs.writeFile(filePath, JSON.stringify(content, null, 2));
|
|
return filePath;
|
|
}
|
|
|
|
describe.concurrent('config-validator', () => {
|
|
describe('--version', () => {
|
|
it('exits 0 and prints version', async () => {
|
|
const { exitCode, stdout } = await runValidator(['-v']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toMatch(/\d+\.\d+/);
|
|
});
|
|
});
|
|
|
|
describe('explicit file arguments', () => {
|
|
it('exits 0 for a valid repo config', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
extends: ['config:recommended'],
|
|
});
|
|
|
|
const { exitCode, all } = await runValidator(['--no-global', file]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain(
|
|
'Config validated successfully against 1 file(s)',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('exits 1 for a config with an unknown option', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- intentionally invalid unknown option to test validator error handling
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
notARealOption: true,
|
|
} as RenovateConfig);
|
|
|
|
const { exitCode, all } = await runValidator(['--no-global', file]);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(all).toContain('Found errors in configuration');
|
|
});
|
|
});
|
|
|
|
it('exits 1 when the file does not exist', async () => {
|
|
const { exitCode, all } = await runValidator([
|
|
'--no-global',
|
|
'/tmp/does-not-exist-renovate.json',
|
|
]);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(all).toContain('File does not exist');
|
|
});
|
|
|
|
it('exits 0 without --strict when migration is needed', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
packageRules: [
|
|
{
|
|
// @ts-expect-error -- intentionally using an unmigrated config fields
|
|
packagePatterns: ['foo'],
|
|
updateTypes: ['major'],
|
|
automerge: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const { exitCode, all } = await runValidator(['--no-global', file]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain('Config migration necessary');
|
|
});
|
|
});
|
|
|
|
it('exits 1 with --strict when migration is needed', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
packageRules: [
|
|
{
|
|
// @ts-expect-error -- intentionally using unmigrated config fields
|
|
packagePatterns: ['foo'],
|
|
updateTypes: ['major'],
|
|
automerge: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const { exitCode, all } = await runValidator([
|
|
'--no-global',
|
|
'--strict',
|
|
file,
|
|
]);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(all).toContain('Config migration necessary');
|
|
});
|
|
});
|
|
|
|
it('treats file as global config by default', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeGlobalConfig(dirPath, 'config.json', {
|
|
platform: 'github',
|
|
repositories: ['foo/bar'],
|
|
});
|
|
|
|
const { exitCode } = await runValidator([file]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('treats file as repo config with --no-global', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
extends: ['config:recommended'],
|
|
});
|
|
|
|
const { exitCode, all } = await runValidator(['--no-global', file]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain('Validating');
|
|
expect(all).toContain('as repo config');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('regex engine', () => {
|
|
it('logs that validation may be inaccurate when RE2 is not used', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const { all } = await runValidator([], {
|
|
cwd: dirPath,
|
|
env: { LOG_LEVEL: 'debug', RENOVATE_X_IGNORE_RE2: 'true' },
|
|
});
|
|
|
|
expect(all).toContain('regex validation may be inaccurate');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('auto-discovery (no file arguments)', () => {
|
|
it('exits 0 with no warning when no config files are present', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain(
|
|
'No files to perform configuration validation against',
|
|
);
|
|
});
|
|
});
|
|
|
|
const autoDiscoveredFiles = getConfigFileNames().filter(
|
|
(name) => name !== 'package.json',
|
|
);
|
|
|
|
it.each(autoDiscoveredFiles)(
|
|
'exits 0 for a valid config in %s',
|
|
async (configFile) => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const fullPath = path.join(dirPath, configFile);
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(
|
|
fullPath,
|
|
JSON.stringify({ extends: ['config:recommended'] }),
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain(
|
|
'Config validated successfully against 1 file(s)',
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(autoDiscoveredFiles)(
|
|
'exits 1 for an invalid config in %s',
|
|
async (configFile) => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const fullPath = path.join(dirPath, configFile);
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(
|
|
fullPath,
|
|
JSON.stringify({ notARealOption: true }),
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(all).toContain('Found errors in configuration');
|
|
});
|
|
},
|
|
);
|
|
|
|
it('validates JSONC comment syntax in .jsonc files', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const fullPath = path.join(dirPath, 'renovate.jsonc');
|
|
await fs.writeFile(
|
|
fullPath,
|
|
codeBlock`
|
|
{
|
|
// Use the recommended preset
|
|
"extends": ["config:recommended"]
|
|
}
|
|
`,
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain(
|
|
'Config validated successfully against 1 file(s)',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('validates JSON5 syntax in .json5 files', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const fullPath = path.join(dirPath, 'renovate.json5');
|
|
await fs.writeFile(
|
|
fullPath,
|
|
codeBlock`
|
|
{
|
|
// trailing commas and unquoted keys are valid JSON5
|
|
extends: ["config:recommended"],
|
|
}
|
|
`,
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain(
|
|
'Config validated successfully against 1 file(s)',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('validates package.json renovate-config presets', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
await fs.writeFile(
|
|
path.join(dirPath, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'test',
|
|
'renovate-config': {
|
|
default: { extends: ['config:recommended'] },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain('Validating package.json > renovate-config');
|
|
});
|
|
});
|
|
|
|
it('validates package.json renovate field', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
await fs.writeFile(
|
|
path.join(dirPath, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'test',
|
|
renovate: { extends: ['config:recommended'] },
|
|
}),
|
|
);
|
|
|
|
const { exitCode, all } = await runValidator([], { cwd: dirPath });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(all).toContain('Validating package.json > renovate');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('hostRules', () => {
|
|
it("does not drop the self-hosted admin's own hostRules headers against allowedHeaders when registering them", async () => {
|
|
// `allowedHeaders` constrains what a repository or preset may set, not the self-hosted administrator - `hostRules.add()` no longer filters (and warns about) the admin's own headers
|
|
//
|
|
// this does not (yet) extend to the separate, pre-existing top-level config security validation in `lib/config/validation.ts`, which still reports a global config's own `hostRules[].headers` outside its own `allowedHeaders` as a `Config security error` - the same limitation `allowedEnv`/`env` already have there, so `exitCode` is still 1 here
|
|
await withTmpDir(async (dirPath) => {
|
|
const configFile = await writeGlobalConfig(dirPath, 'config.json', {
|
|
allowedHeaders: ['X-*'],
|
|
hostRules: [
|
|
{
|
|
matchHost: 'registry.example.com',
|
|
headers: { 'X-Allowed': 'yes', Authorization: 'from-admin' },
|
|
},
|
|
],
|
|
});
|
|
|
|
const { exitCode, all } = await runValidator([], {
|
|
cwd: dirPath,
|
|
env: { RENOVATE_CONFIG_FILE: configFile },
|
|
});
|
|
|
|
expect(all).not.toContain(
|
|
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(all).toContain(
|
|
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
|
|
);
|
|
});
|
|
});
|
|
|
|
it('filters hostRules headers of a validated config file against allowedHeaders', async () => {
|
|
await withTmpDir(async (dirPath) => {
|
|
const file = await writeRepoConfig(dirPath, 'renovate.json', {
|
|
hostRules: [
|
|
{
|
|
matchHost: 'registry.example.com',
|
|
headers: { 'X-Allowed': 'yes', Authorization: 'denied' },
|
|
},
|
|
],
|
|
});
|
|
|
|
const { all } = await runValidator(['--no-global', file], {
|
|
env: { RENOVATE_ALLOWED_HEADERS: '["X-*"]' },
|
|
});
|
|
|
|
expect(all).toContain(
|
|
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
|
|
);
|
|
expect(all).toContain('Authorization');
|
|
});
|
|
});
|
|
});
|
|
});
|