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

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

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

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

2125 lines
74 KiB
TypeScript

import { isNullOrUndefined } from '@sindresorhus/is';
import { codeBlock } from 'common-tags';
import type { MockInstance } from 'vitest';
import * as httpMock from '~test/http-mock.ts';
import type { RenovateConfig } from '~test/util.ts';
import { fs, logger, partial, platform, scm } from '~test/util.ts';
import * as decrypt from '../../../config/decrypt.ts';
import { getConfig } from '../../../config/defaults.ts';
import { GlobalConfig } from '../../../config/global.ts';
import * as _migrateAndValidate from '../../../config/migrate-validate.ts';
import * as _migrate from '../../../config/migration.ts';
import type { AllConfig } from '../../../config/types.ts';
import * as configValidation from '../../../config/validation.ts';
import { CONFIG_VALIDATION } from '../../../constants/error-messages.ts';
import * as npmApi from '../../../modules/datasource/npm/index.ts';
import type { HostRule } from '../../../types/index.ts';
import * as memCache from '../../../util/cache/memory/index.ts';
import * as repoCache from '../../../util/cache/repository/index.ts';
import { initRepoCache } from '../../../util/cache/repository/init.ts';
import type { RepoCacheData } from '../../../util/cache/repository/types.ts';
import { getUserEnv } from '../../../util/env.ts';
import * as hostRules from '../../../util/host-rules.ts';
import * as queue from '../../../util/http/queue.ts';
import * as throttle from '../../../util/http/throttle.ts';
import * as _onboardingCache from '../onboarding/branch/onboarding-branch-cache.ts';
import { OnboardingState } from '../onboarding/common.ts';
import {
applyHostRules,
applyNpmrc,
checkForRepoConfigError,
detectRepoFileConfig,
mergeRenovateConfig,
resolveStaticRepoConfig,
setNpmTokenInNpmrc,
} from './merge.ts';
import type { RepositoryWorkerConfig } from './types.ts';
vi.mock('../../../util/fs/index.ts');
vi.mock('../onboarding/branch/config.ts');
vi.mock('../onboarding/branch/onboarding-branch-cache.ts');
const migrate = vi.mocked(_migrate);
const migrateAndValidate = vi.mocked(_migrateAndValidate);
const onboardingCache = vi.mocked(_onboardingCache);
let config: RenovateConfig;
function mockProcessExitOnce(): [MockInstance<NodeJS.Process['exit']>, Error] {
const mockedError = new Error('mocked exit called');
return [
vi.spyOn(process, 'exit').mockImplementationOnce(() => {
throw mockedError;
}),
mockedError,
];
}
beforeEach(() => {
memCache.init();
config = getConfig();
config.errors = [];
config.warnings = [];
});
// only `migrateConfig` needs mocking
vi.mock('../../../config/migration.ts', async (importOriginal) => ({
...(await importOriginal<typeof _migrate>()),
migrateConfig: vi.fn(),
}));
vi.mock('../../../config/migrate-validate.ts');
describe('workers/repository/init/merge', () => {
afterEach(() => {
vi.restoreAllMocks();
hostRules.clear();
GlobalConfig.reset();
});
describe('detectRepoFileConfig()', () => {
beforeEach(async () => {
await initRepoCache({ repoFingerprint: '0123456789abcdef' });
});
it('returns config if not found', async () => {
scm.getFileList.mockResolvedValue(['package.json']);
fs.readLocalFile.mockResolvedValue('{}');
await expect(detectRepoFileConfig()).resolves.toEqual({});
});
it('returns config if not found - uses cache', async () => {
vi.spyOn(repoCache, 'getCache').mockReturnValueOnce(
partial<RepoCacheData>({ configFileName: 'renovate.json' }),
);
platform.getRawFile.mockRejectedValueOnce(new Error());
scm.getFileList.mockResolvedValue(['package.json']);
fs.readLocalFile.mockResolvedValue('{}');
await expect(detectRepoFileConfig()).resolves.toEqual({});
expect(logger.logger.debug).toHaveBeenCalledWith(
'Existing config file no longer exists',
);
});
it('returns cache config from onboarding cache - package.json', async () => {
const pJson = JSON.stringify({
schema: 'https://docs.renovate.com',
});
OnboardingState.onboardingCacheValid = true;
onboardingCache.getOnboardingFileNameFromCache.mockReturnValueOnce(
'package.json',
);
onboardingCache.getOnboardingConfigFromCache.mockReturnValueOnce(pJson);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'package.json',
configFileParsed: { schema: 'https://docs.renovate.com' },
});
});
it('clones, if onboarding cache is valid but parsed config is undefined', async () => {
OnboardingState.onboardingCacheValid = true;
onboardingCache.getOnboardingFileNameFromCache.mockReturnValueOnce(
'package.json',
);
onboardingCache.getOnboardingConfigFromCache.mockReturnValueOnce(
undefined,
);
scm.getFileList.mockResolvedValueOnce(['package.json']);
const pJson = JSON.stringify({
name: 'something',
renovate: {
prHourlyLimit: 10,
},
});
fs.readLocalFile.mockResolvedValueOnce(pJson);
platform.getRawFile.mockResolvedValueOnce(pJson);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'package.json',
configFileParsed: { prHourlyLimit: 10 },
});
});
it('returns cache config from onboarding cache - renovate.json', async () => {
const configParsed = JSON.stringify({
schema: 'https://docs.renovate.com',
});
OnboardingState.onboardingCacheValid = true;
onboardingCache.getOnboardingFileNameFromCache.mockReturnValueOnce(
'renovate.json',
);
onboardingCache.getOnboardingConfigFromCache.mockReturnValueOnce(
configParsed,
);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'renovate.json',
configFileParsed: {
schema: 'https://docs.renovate.com',
},
});
});
it('uses package.json config if found', async () => {
scm.getFileList.mockResolvedValue(['package.json']);
const pJson = JSON.stringify({
name: 'something',
renovate: {
prHourlyLimit: 10,
},
});
fs.readLocalFile.mockResolvedValue(pJson);
platform.getRawFile.mockResolvedValueOnce(pJson);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'package.json',
configFileParsed: { prHourlyLimit: 10 },
});
// get from repoCache
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'package.json',
configFileParsed: { prHourlyLimit: 10 },
});
});
it('massages package.json renovate string', async () => {
scm.getFileList.mockResolvedValue(['package.json']);
const pJson = JSON.stringify({
name: 'something',
renovate: 'github>renovatebot/renovate',
});
fs.readLocalFile.mockResolvedValue(pJson);
platform.getRawFile.mockResolvedValueOnce(pJson);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'package.json',
configFileParsed: { extends: ['github>renovatebot/renovate'] },
});
});
it('returns error if cannot parse', async () => {
scm.getFileList.mockResolvedValue(['package.json', 'renovate.json']);
fs.readLocalFile.mockResolvedValue('cannot parse');
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'renovate.json',
configFileParseError: {
validationError: 'Invalid JSON (parsing failed)',
validationMessage: 'Syntax error near cannot par',
},
});
});
it('throws error if duplicate keys', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc']);
fs.readLocalFile.mockResolvedValue(
'{ "enabled": true, "enabled": false }',
);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.renovaterc',
configFileParseError: {
validationError: 'Duplicate keys in JSON',
validationMessage:
'"Syntax error: duplicated keys \\"enabled\\" near \\": false }"',
},
});
});
it('finds and parse renovate.json5', async () => {
const configFileRaw = codeBlock`
{
// this is json5 format
}
`;
scm.getFileList.mockResolvedValue(['package.json', 'renovate.json5']);
fs.readLocalFile.mockResolvedValue(configFileRaw);
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: 'renovate.json5',
configFileParsed: {},
});
});
it('finds .github/renovate.json', async () => {
scm.getFileList.mockResolvedValue([
'package.json',
'.github/renovate.json',
]);
fs.readLocalFile.mockResolvedValue('{}');
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.github/renovate.json',
configFileParsed: {},
});
});
it('finds .gitlab/renovate.json', async () => {
scm.getFileList.mockResolvedValue([
'package.json',
'.gitlab/renovate.json',
]);
fs.readLocalFile.mockResolvedValue('{}');
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.gitlab/renovate.json',
configFileParsed: {},
});
});
it('finds .renovaterc.json', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue('{}');
platform.getRawFile.mockResolvedValueOnce('{"something":"new"}');
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.renovaterc.json',
configFileParsed: {},
});
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.renovaterc.json',
configFileParsed: {
something: 'new',
},
});
});
it('finds .renovaterc.json5', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json5']);
fs.readLocalFile.mockResolvedValue('{}');
platform.getRawFile.mockResolvedValueOnce('{"something":"new"}');
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.renovaterc.json5',
configFileParsed: {},
});
await expect(detectRepoFileConfig()).resolves.toEqual({
configFileName: '.renovaterc.json5',
configFileParsed: {
something: 'new',
},
});
});
});
describe('checkForRepoConfigError', () => {
it('returns if no error', () => {
expect(checkForRepoConfigError({})).toBeUndefined();
});
it('throws on error', () => {
expect(() =>
checkForRepoConfigError({
configFileParseError: { validationError: '', validationMessage: '' },
}),
).toThrow('config-validation');
});
});
describe('mergeRenovateConfig()', () => {
beforeEach(() => {
migrate.migrateConfig.mockReturnValue({
isMigrated: false,
migratedConfig: {},
});
});
it('uses onboarding config if silent', async () => {
scm.getFileList.mockResolvedValue([]);
migrateAndValidate.migrateAndValidate.mockResolvedValue({
warnings: [],
errors: [],
});
config.mode = 'silent';
config.repository = 'some-org/some-repo';
const res = await mergeRenovateConfig(config);
expect(res).toBeDefined();
});
it('throws error if misconfigured', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue('{}');
migrateAndValidate.migrateAndValidate.mockResolvedValueOnce({
errors: [{ topic: 'dep', message: 'test error' }],
});
let e: Error | undefined;
try {
await mergeRenovateConfig(config);
} catch (err) {
e = err;
}
expect(e).toBeDefined();
expect(e?.toString()).toBe('Error: config-validation');
});
it('migrates nested config', async () => {
scm.getFileList.mockResolvedValue(['renovate.json']);
fs.readLocalFile.mockResolvedValue('{}');
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) => {
// We shouldn't see packageRules here (avoids #14827).
// (someday the validation should probably be reworked to know about `sourceUrl` from the repo config, but that day isn't today)
expect(c).not.toHaveProperty('packageRules');
return Promise.resolve({
...c,
warnings: [],
errors: [],
});
});
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
config.extends = [':automergeAll'];
config.packageRules = [{ extends: ['monorepo:react'] }];
const ret = await mergeRenovateConfig(config);
expect(ret).toMatchObject({
automerge: true,
packageRules: [
{
matchSourceUrls: [
'https://github.com/facebook/react',
'https://github.com/react/react',
],
},
],
});
});
it('ignores presets', async () => {
scm.getFileList.mockResolvedValue(['renovate.json']);
fs.readLocalFile.mockResolvedValue('{}');
migrateAndValidate.migrateAndValidate.mockResolvedValue({
extends: ['config:recommended'],
warnings: [],
errors: [],
});
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
config.extends = ['config:recommended'];
config.ignorePresets = [':ignoreModulesAndTests'];
config.ignorePaths = ['**/examples/**'];
const res = await mergeRenovateConfig(config);
expect(res.ignorePaths).toEqual(config.ignorePaths);
});
it('continues if no errors', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue('{}');
migrateAndValidate.migrateAndValidate.mockResolvedValue({
warnings: [],
errors: [],
});
config.extends = [':automergeDisabled'];
await expect(mergeRenovateConfig(config)).resolves.toBeDefined();
});
it('continues if no errors-2', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue('{}');
migrateAndValidate.migrateAndValidate.mockResolvedValue({
warnings: [],
errors: [],
});
GlobalConfig.set({ requireConfig: 'ignored' });
await expect(
mergeRenovateConfig({
...config,
requireConfig: 'ignored',
// @ts-expect-error -- TODO: do we still need this?
configFileParsed: undefined,
warnings: undefined,
secrets: undefined,
}),
).resolves.toBeDefined();
});
it('sets npmToken to npmrc when it is not inside encrypted', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue(
'{"npmToken": "{{ secrets.NPM_TOKEN }}", "npmrc": "something_authToken=${NPM_TOKEN}"}',
);
migrateAndValidate.migrateAndValidate.mockResolvedValue({
...config,
npmToken: '{{ secrets.NPM_TOKEN }}',
npmrc: 'something_authToken=${NPM_TOKEN}',
warnings: [],
errors: [],
});
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
config.secrets = {
NPM_TOKEN: 'confidential',
};
const res = await mergeRenovateConfig(config);
expect(res.npmrc).toBe('something_authToken=confidential');
});
it('sets npmToken to npmrc when it is inside encrypted', async () => {
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue(
'{"encrypted": { "npmToken": "encrypted-token" }, "npmrc": "something_authToken=${NPM_TOKEN}"}',
);
migrateAndValidate.migrateAndValidate.mockResolvedValue({
...config,
npmrc: 'something_authToken=${NPM_TOKEN}',
encrypted: {
npmToken: 'encrypted-token',
},
warnings: [],
errors: [],
});
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
vi.spyOn(decrypt, 'decryptConfig').mockResolvedValueOnce({
...config,
npmrc: 'something_authToken=${NPM_TOKEN}',
npmToken: 'token',
});
const res = await mergeRenovateConfig(config);
expect(res.npmrc).toBe('something_authToken=token');
});
it('deletes user conifgured env after setting in mem cache', async () => {
GlobalConfig.set({ allowedEnv: ['var'] });
scm.getFileList.mockResolvedValue(['package.json', '.renovaterc.json']);
fs.readLocalFile.mockResolvedValue('{"env": { "var": "value" }}');
migrateAndValidate.migrateAndValidate.mockResolvedValue({
...config,
env: {
var: 'value',
},
warnings: [],
errors: [],
});
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
const res = await mergeRenovateConfig(config);
expect(res.env).toBeUndefined();
expect(getUserEnv()).toEqual({
var: 'value',
});
});
it('applies repositoryEntryConfig between global and repo file config', async () => {
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) =>
Promise.resolve({ ...c, warnings: [], errors: [] }),
);
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
const setNpmrcSpy = vi.spyOn(npmApi, 'setNpmrc');
const npmrcValue = '//registry.npmjs.org/:_authToken=preset-token\n';
const globalPresetRule = {
matchPackageNames: ['globalPresetDep'],
enabled: false,
};
const ignoredByGlobalRule = {
matchPackageNames: ['ignoredByGlobalDep'],
enabled: false,
};
const ignoredByEntryRule = {
matchPackageNames: ['ignoredByEntryDep'],
enabled: false,
};
const ignoredByRepoRule = {
matchPackageNames: ['ignoredByRepoDep'],
enabled: false,
};
const repoEntryPresetRule = {
matchPackageNames: ['repoEntryPresetDep'],
enabled: false,
};
const repoFilePresetRule = {
matchPackageNames: ['repoFilePresetDep'],
enabled: false,
};
const globalRule = {
matchPackageNames: ['globalDep'],
enabled: false,
};
const repoEntryRule = {
matchPackageNames: ['repoEntryDep'],
enabled: false,
};
const repoFileRule = {
matchPackageNames: ['repoFileDep'],
enabled: false,
};
memCache.set('preset:local>globalPreset', {
packageRules: [globalPresetRule],
hostRules: [
{
matchHost: 'https://npm.example.com',
token: '{{ secrets.HOST_TOKEN }}',
},
],
npmrc: npmrcValue,
});
memCache.set('preset:local>ignoredByGlobal', {
packageRules: [ignoredByGlobalRule],
});
memCache.set('preset:local>ignoredByEntry', {
packageRules: [ignoredByEntryRule],
});
memCache.set('preset:local>ignoredByRepo', {
packageRules: [ignoredByRepoRule],
});
memCache.set('preset:local>repoEntryPreset', {
packageRules: [repoEntryPresetRule],
});
memCache.set('preset:local>repoFilePreset', {
packageRules: [repoFilePresetRule],
});
scm.getFileList.mockResolvedValue(['renovate.json']);
fs.readLocalFile.mockResolvedValue(
JSON.stringify({
extends: ['local>repoFilePreset'],
ignorePresets: ['local>ignoredByRepo'],
packageRules: [repoFileRule],
}),
);
const inputConfig: RepositoryWorkerConfig = {
...config,
extends: [
'local>globalPreset',
'local>ignoredByGlobal',
'local>ignoredByEntry',
'local>ignoredByRepo',
],
ignorePresets: ['local>ignoredByGlobal'],
packageRules: [globalRule],
secrets: { HOST_TOKEN: 'resolved-secret-token' },
repositoryEntryConfig: {
extends: ['local>repoEntryPreset'],
ignorePresets: ['local>ignoredByEntry'],
packageRules: [repoEntryRule],
},
};
const res = await mergeRenovateConfig(inputConfig);
expect(res.packageRules).toMatchObject([
globalRule,
globalPresetRule,
// ignoredByGlobalRule should not be here
// ignoredByEntryRule should not be here
// ignoredByRepoRule should not be here
repoEntryPresetRule,
repoEntryRule,
repoFilePresetRule,
repoFileRule,
]);
expect(hostRules.find({ url: 'https://npm.example.com' })).toMatchObject({
token: 'resolved-secret-token',
});
expect(setNpmrcSpy).toHaveBeenCalledWith(npmrcValue);
});
it('supports repositoryEntryConfig without extends or ignorePresets', async () => {
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) =>
Promise.resolve({ ...c, warnings: [], errors: [] }),
);
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
const repoEntryRule = {
matchPackageNames: ['repoEntryDep'],
enabled: false,
};
scm.getFileList.mockResolvedValue([]);
fs.readLocalFile.mockResolvedValue(null);
const inputConfig: RepositoryWorkerConfig = {
...config,
extends: undefined,
ignorePresets: undefined,
repositoryEntryConfig: {
packageRules: [repoEntryRule],
},
};
const res = await mergeRenovateConfig(inputConfig);
expect(res.packageRules).toMatchObject([repoEntryRule]);
});
describe('allowedEnv', () => {
beforeEach(() => {
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) =>
Promise.resolve({ ...c, warnings: [], errors: [] }),
);
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: false,
migratedConfig: c,
}));
scm.getFileList.mockResolvedValue(['renovate.json']);
});
it('applies `env` from the repository config, if in `allowedEnv`', async () => {
GlobalConfig.set({ allowedEnv: ['SOME_*'] });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ env: { SOME_VAR: 'from-repo' } }),
);
await mergeRenovateConfig(config);
expect(getUserEnv()).toEqual({ SOME_VAR: 'from-repo' });
});
it('applies `env` from a `repositories[]` entry, if it is NOT in `allowedEnv`', async () => {
// self-hosted administrators' `repositories[].env` are not restricted by `allowedEnv`, and are intentionally treated as "safe"
GlobalConfig.set({ allowedEnv: ['SOME_*'] });
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await mergeRenovateConfig({
...config,
repositoryEntryConfig: { env: { ADMIN_VAR: 'from-admin' } },
});
expect(getUserEnv()).toEqual({ ADMIN_VAR: 'from-admin' });
});
it('rejects `env` injected by a repository, if it is not in `allowedEnv`', async () => {
// previously this would be filtered (with a WARN log), but now leads to a config validation error
// the repo file's own violation is caught by the first validation pass (`migrateAndValidate`) - the resolved-config pass only re-runs when preset resolution or decryption changed something - so that pass must be real here
const { migrateAndValidate: actualMigrateAndValidate } =
await vi.importActual<typeof _migrateAndValidate>(
'../../../config/migrate-validate.ts',
);
migrateAndValidate.migrateAndValidate.mockImplementation(
actualMigrateAndValidate,
);
GlobalConfig.set({ allowedEnv: ['SOME_*'] });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ env: { NOT_ALLOWED: 'from-repo' } }),
);
await expect(mergeRenovateConfig(config)).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
});
expect(getUserEnv()).toEqual({});
});
it('rejects `env` injected by a preset, if it is not in `allowedEnv`', async () => {
// previously this would be filtered (with a WARN log), but now leads to a config validation error
GlobalConfig.set({ allowedEnv: ['SOME_*'] });
memCache.set('preset:local>envPreset', {
env: { NOT_ALLOWED: 'from-preset' },
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>envPreset'] }),
);
await expect(mergeRenovateConfig(config)).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
});
expect(getUserEnv()).toEqual({});
});
it('applies `env` from `GlobalConfig`, even if it is NOT in `allowedEnv`', async () => {
GlobalConfig.set({
allowedEnv: ['SOME_*'],
});
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await mergeRenovateConfig({
...config,
env: { ADMIN_VAR: 'from-config.js' },
});
expect(getUserEnv()).toEqual({ ADMIN_VAR: 'from-config.js' });
});
});
describe('allowedHeaders', () => {
beforeEach(() => {
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) =>
Promise.resolve({ ...c, warnings: [], errors: [] }),
);
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: false,
migratedConfig: c,
}));
scm.getFileList.mockResolvedValue(['renovate.json']);
});
it('applies `hostRules` headers from the repository config within `allowedHeaders`', async () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-Allowed': 'from-repo' },
},
],
}),
);
await mergeRenovateConfig(config);
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual(
{ headers: { 'X-Allowed': 'from-repo' } },
);
});
it("applies a `repositories[]` entry's `hostRules` in full", async () => {
GlobalConfig.set({ allowedHeaders: ['X-*', 'custom-header'] });
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await mergeRenovateConfig({
...config,
secrets: { TOKEN: 'admin-secret' },
repositoryEntryConfig: {
hostRules: [
{
matchHost: 'registry.example.com',
token: '{{ secrets.TOKEN }}',
headers: {
'custom-header': 'Bearer {{ secrets.TOKEN }}',
'X-Allowed': 'yes',
},
},
],
},
});
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual(
{
token: 'admin-secret',
headers: {
'custom-header': 'Bearer admin-secret',
'X-Allowed': 'yes',
},
internalHostGrant: { implicit: true },
},
);
});
it("applies a `repositories[]` entry's `allowInternal` without a validation error", async () => {
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await expect(
mergeRenovateConfig({
...config,
repositoryEntryConfig: {
hostRules: [
{
matchHost: 'http://10.1.2.3',
allowInternal: true,
},
],
},
}),
).toResolve();
expect(
hostRules.find({ url: 'http://10.1.2.3' }).internalHostGrant,
).toEqual({ explicit: true, scoped: true, implicit: true });
});
it('permits an internal HTTP preset granted by the repositories[] entry', async () => {
// proves the entry's trusted rules are registered before the repository config's presets resolve
httpMock
.scope('http://10.1.2.3')
.get('/granted-preset.json')
.reply(200, {});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['http://10.1.2.3/granted-preset.json'] }),
);
await expect(
mergeRenovateConfig({
...config,
repositoryEntryConfig: {
hostRules: [
{
hostType: 'preset',
matchHost: 'http://10.1.2.3/',
allowInternal: true,
},
],
},
}),
).toResolve();
});
it('rejects an internal HTTP preset without a deliberate grant', async () => {
GlobalConfig.set({ internalHostAccess: 'block' });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({
extends: ['http://10.1.2.3/ungranted-preset.json'],
}),
);
await expect(mergeRenovateConfig(config)).rejects.toMatchObject({
message: CONFIG_VALIDATION,
validationError: expect.stringContaining(
'Preset host is blocked by this Renovate instance',
),
});
});
it('rejects `allowInternal` injected by a preset as a security error', async () => {
memCache.set('preset:local>internalPreset', {
hostRules: [{ matchHost: 'http://10.1.2.3', allowInternal: true }],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>internalPreset'] }),
);
await expect(mergeRenovateConfig(config)).rejects.toMatchObject({
message: CONFIG_VALIDATION,
validationMessage:
"hostRules `allowInternal` is only allowed in the self-hosted administrator's own configuration.",
});
expect(
hostRules.find({ url: 'http://10.1.2.3' }).internalHostGrant,
).toBeUndefined();
});
it('drops `repositories[]` entry headers, if it is not in `allowedHeaders`', async () => {
// previously this would apply due to a gap in re-validating `allowedHeaders` against the resolved config.
// `applyHostRules` filters by header name at request time, so this does not reach the final HTTP call, but we should make sure this also doesn't break
GlobalConfig.set({ allowedHeaders: ['X-*'] });
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'from-admin' },
},
],
},
});
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual(
{ internalHostGrant: { implicit: true } },
);
expect(logger.logger.warn).toHaveBeenCalledWith(
{ denied: ['Authorization'] },
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
);
});
it('rejects `hostRules` headers injected by a preset, if it is not in `allowedHeaders`', async () => {
// previously this would be filtered (with a WARN log), but now leads to a config validation error
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>headerPreset', {
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'from-preset' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>headerPreset'] }),
);
await expect(mergeRenovateConfig(config)).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationError:
'The Renovate configuration file contains some invalid settings',
validationMessage:
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
});
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual(
{},
);
});
});
});
describe('setNpmTokenInNpmrc', () => {
it('skips in no npmToken found', () => {
const config = {};
setNpmTokenInNpmrc(config);
expect(config).toMatchObject({});
});
it('adds default npmrc registry if it does not exist', () => {
const config = { npmToken: 'token' };
setNpmTokenInNpmrc(config);
expect(config).toMatchObject({
npmrc: '//registry.npmjs.org/:_authToken=token\n',
});
});
it('adds npmToken at end of npmrc string if ${NPM_TOKEN} string not found', () => {
const config = { npmToken: 'token', npmrc: 'something\n' };
setNpmTokenInNpmrc(config);
expect(config).toMatchObject({ npmrc: 'something\n_authToken=token\n' });
});
it('replaces ${NPM_TOKEN} with npmToken value', () => {
const config = {
npmToken: 'token',
npmrc: 'something_auth=${NPM_TOKEN}\n',
};
setNpmTokenInNpmrc(config);
expect(config).toMatchObject({ npmrc: 'something_auth=token\n' });
});
});
describe('applyNpmrc', () => {
it('does nothing if npmrc is missing after token migration', () => {
const setNpmrcSpy = vi.spyOn(npmApi, 'setNpmrc');
applyNpmrc({});
expect(setNpmrcSpy).not.toHaveBeenCalled();
});
it('migrates npmToken and sets npmrc', () => {
const setNpmrcSpy = vi.spyOn(npmApi, 'setNpmrc');
const config = {
npmToken: 'token',
npmrc: 'something_authToken=${NPM_TOKEN}',
};
applyNpmrc(config);
expect(config.npmToken).toBeUndefined();
expect(config.npmrc).toBe('something_authToken=token');
expect(setNpmrcSpy).toHaveBeenCalledExactlyOnceWith(
'something_authToken=token',
);
});
});
describe('resolved config validation', () => {
beforeEach(() => {
migrateAndValidate.migrateAndValidate.mockImplementation((_, c) =>
Promise.resolve({ ...c, warnings: [], errors: [] }),
);
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: false,
migratedConfig: c,
}));
scm.getFileList.mockResolvedValue(['renovate.json']);
});
it('applies env a repositories[] entry preset contributes', async () => {
// the entry, and everything the presets it extends contribute, is the self-hosted admin's own config, so `allowedEnv` does not constrain it
memCache.set('preset:local>entryInjectsEnv', {
env: { NODE_OPTIONS: '--require /tmp/from-admin.js' },
});
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: { extends: ['local>entryInjectsEnv'] },
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({
NODE_OPTIONS: '--require /tmp/from-admin.js',
});
expect(logger.logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
"Ignoring env variables not permitted by this Renovate instance's `allowedEnv`",
);
});
it('exempts `allowInternal` a repositories[] entry preset contributes', async () => {
// the entry, and everything the presets it extends contribute, is the self-hosted admin's own config, so its `allowInternal` is not a violation
memCache.set('preset:local>entryGrantsInternal', {
hostRules: [
{ matchHost: 'http://10.1.2.3', allowInternal: true },
// host-less rules are the admin's own too
{ allowInternal: false },
],
});
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: { extends: ['local>entryGrantsInternal'] },
});
expect(res).toBeDefined();
expect(
hostRules.find({ url: 'http://10.1.2.3' }).internalHostGrant,
).toEqual({ explicit: true, scoped: true, implicit: true });
});
it("reports a repository preset replaying the admin's header to a host of its own choosing", async () => {
// the `matchHost` is part of a header's identity, so the same name and value against a different host is the preset's own header, not the self-hosted admin's
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>replaysHeader', {
hostRules: [
{
matchHost: 'evil.example.com',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>replaysHeader'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
{
matchHost: 'admin.example.com',
headers: { Authorization: 'admin-secret' },
},
],
}),
).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
});
});
it("exempts a repository preset narrowing the admin's host-less header to one host", async () => {
// the self-hosted admin's own catch-all header already reaches that host, so scoping it narrower is not the preset introducing one of its own
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>narrowsHeader', {
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>narrowsHeader'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [{ headers: { Authorization: 'admin-secret' } }],
}),
).toResolve();
});
it('migrates a legacy option name in a repositories[] entry before validating it', async () => {
// unmigrated, `renovateFork` is not a known option, so `validateConfig` reports it as invalid - and `configValidationError` turns that into a failed repository
const { migrateConfig } = await vi.importActual<typeof _migrate>(
'../../../config/migration.ts',
);
migrate.migrateConfig.mockImplementation(migrateConfig);
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
configValidationError: true,
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- a legacy option name, so deliberately absent from `RenovateConfig`
repositoryEntryConfig: { renovateFork: true } as RenovateConfig,
});
expect(res.forkProcessing).toBe('enabled');
});
it('validates a resolved config whose options migration left as strings', async () => {
// `validateConfig` assumes `allowString` options are already arrays, which only `massageConfig` guarantees
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ schedule: 'before 5am' }),
);
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {},
});
expect(res.schedule).toBe('before 5am');
});
it('applies only top-level env, and does not fail for a nested one', async () => {
// guards the claim the topic choice rests on: only the top-level value of a requiresCheckAtTrustBoundary option is ever applied, so a nested one is inert and must not abort the repository
GlobalConfig.set({ allowedEnv: ['ALLOWED_*'] });
memCache.set('preset:local>nestsEnv', {
env: { ALLOWED_VAR: 'top-level' },
packageRules: [
{
matchManagers: ['npm'],
env: { GIT_SSH_COMMAND: 'sh -c "malicious"' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>nestsEnv'] }),
);
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ ALLOWED_VAR: 'top-level' });
});
it('applies only top-level hostRules, and does not fail for a nested one', async () => {
GlobalConfig.set({ allowedHeaders: ['custom-*'] });
memCache.set('preset:local>nestsHostRules', {
hostRules: [
{
matchHost: 'top-level.example.com',
headers: { 'custom-token': 'token' },
},
],
packageRules: [
{
matchManagers: ['npm'],
hostRules: [
{
matchHost: 'nested.example.com',
headers: { 'custom-token': 'token' },
},
],
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>nestsHostRules'] }),
);
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {},
});
expect(res).toBeDefined();
expect(
hostRules.find({ url: 'https://top-level.example.com' }),
).toMatchObject({ headers: { 'custom-token': 'token' } });
expect(
hostRules.find({ url: 'https://nested.example.com' }).headers,
).toBeUndefined();
});
it('exempts env the self-hosted admin supplied from allowedEnv', async () => {
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
env: { ADMIN_VAR: 'set-by-admin' },
repositoryEntryConfig: {},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ ADMIN_VAR: 'set-by-admin' });
expect(logger.logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
"Ignoring env variables not permitted by this Renovate instance's `allowedEnv`",
);
});
it('exempts env and headers supplied by the repositories[] entry itself', async () => {
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
env: { ENTRY_VAR: 'set-by-admin' },
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'custom-token': 'token' },
},
],
},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ ENTRY_VAR: 'set-by-admin' });
});
it("applies a repositories[] entry's header over one the admin set globally", async () => {
// the entry is the admin's own config, so its rules are registered `trusted` too - otherwise an admin could not override, for a single repository, a header they set for every one of them
GlobalConfig.set({ allowedHeaders: ['X-*'] });
hostRules.add(
{
matchHost: 'registry.example.com',
headers: { 'X-Token': 'for-every-repo' },
},
{ trusted: true },
);
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-Token': 'for-this-repo' },
},
],
},
});
expect(res).toBeDefined();
expect(
hostRules.find({ url: 'https://registry.example.com' }),
).toMatchObject({ headers: { 'X-Token': 'for-this-repo' } });
});
it('keeps going when a repositories[] entry carries a hostRule migration cannot migrate', async () => {
// `hostRules` migration throws for a rule with more than one host-matching field: `applyHostRules` drops just that rule and carries on, so migrating the entry as a whole must not abort the repository instead
const { migrateConfig } = await vi.importActual<typeof _migrate>(
'../../../config/migration.ts',
);
migrate.migrateConfig.mockImplementation(migrateConfig);
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
hostRules: [
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- a legacy spelling, so deliberately absent from `HostRule`
{
matchHost: 'registry.example.com',
hostName: 'other.example.com',
} as HostRule,
],
},
});
expect(res).toBeDefined();
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Error migrating config',
);
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Error setting hostRule from config',
);
});
it('drops, rather than rejects, a header a repositories[] entry preset contributes outside `allowedHeaders`', async () => {
// the entry's presets are the admin's own config, so this is not a violation to abort the repository over - but `allowedHeaders` binds the admin too, and `applyHostRule` would drop the header at request time regardless, so it is dropped at registration with a WARN
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>entryInjectsHeader', {
hostRules: [
{ matchHost: 'github.com', headers: { Authorization: 'Bearer x' } },
],
});
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
extends: ['local>entryInjectsHeader'],
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-Token': 'token' },
},
],
},
});
expect(res).toBeDefined();
expect(
hostRules.find({ url: 'https://github.com' }).headers,
).toBeUndefined();
expect(logger.logger.warn).toHaveBeenCalledWith(
{ denied: ['Authorization'] },
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
);
});
it('treats non-security resolved-preset issues as advisory by default', async () => {
memCache.set('preset:local>injectsInvalid', { notARealOption: true });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>injectsInvalid'] }),
);
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {},
});
expect(res).toBeDefined();
});
it('escalates non-security resolved-preset issues to fatal under configValidationError', async () => {
memCache.set('preset:local>injectsInvalid', { notARealOption: true });
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>injectsInvalid'] }),
);
await expect(
mergeRenovateConfig({
...config,
configValidationError: true,
repositoryEntryConfig: {},
}),
).rejects.toMatchObject({
message: 'config-validation',
validationMessage: expect.stringContaining(
'Invalid configuration option: notARealOption',
),
});
});
it("exempts the admin's own header when migration rewrites their matchHost", async () => {
// `adminSuppliedValues` must compare migrated spellings: the resolved config only ever contains the migrated `matchHost` (massaged to a full URL), so building the exemption keys from the admin's unmigrated config would report their own header as a violation
const { migrateConfig } = await vi.importActual<typeof _migrate>(
'../../../config/migration.ts',
);
migrate.migrateConfig.mockImplementation(migrateConfig);
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>replaysAdminHost', {
hostRules: [
{
matchHost: 'nexus.example.com:8443',
headers: { Authorization: 'from-admin' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>replaysAdminHost'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
{
matchHost: 'nexus.example.com:8443',
headers: { Authorization: 'from-admin' },
},
],
}),
).toResolve();
});
it("reports a preset replaying the admin's legacy `hostName` header against a host of its own choosing", async () => {
// a legacy `hostName` migrates to a `matchHost`, so the exemption key must be host-scoped: indexing the admin's unmigrated config would record the header host-less, wrongly exempting a preset replaying it against another host
const { migrateConfig } = await vi.importActual<typeof _migrate>(
'../../../config/migration.ts',
);
migrate.migrateConfig.mockImplementation(migrateConfig);
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>replaysLegacyHeader', {
hostRules: [
{
matchHost: 'evil.example.com',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>replaysLegacyHeader'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
// oxlint-disable-next-line renovate/prefer-partial-in-specs -- a legacy spelling, so deliberately absent from `HostRule`
{
hostName: 'admin.example.com',
headers: { Authorization: 'admin-secret' },
} as HostRule,
],
}),
).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
});
});
it("applies env the admin's global `extends` contributes", async () => {
// presets in the global config's `extends` resolve on the repository path, mixed in with the repository's own, so they are resolved separately to tell them apart: what the admin chose to extend is their config, and a repository which merely inherits it must neither be blamed for it nor have it filtered out
memCache.set('preset:local>adminExtends', {
env: { CORP_VAR: 'from-admin-preset' },
});
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
extends: ['local>adminExtends'],
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ CORP_VAR: 'from-admin-preset' });
expect(logger.logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
"Ignoring env variables not permitted by this Renovate instance's `allowedEnv`",
);
});
it("still reports env a repository's own preset injects alongside the admin's global `extends`", async () => {
// the admin's exemption covers only what their own presets contributed
memCache.set('preset:local>adminExtends', {
env: { CORP_VAR: 'from-admin-preset' },
});
memCache.set('preset:local>repoExtends', {
env: { NOT_ALLOWED: 'from-repo-preset' },
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>repoExtends'] }),
);
await expect(
mergeRenovateConfig({ ...config, extends: ['local>adminExtends'] }),
).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
});
});
it("continues without the exemption when the admin's global `extends` cannot be resolved", async () => {
// resolving them here only builds the exemption; the resolution whose result is actually used reports the failure, so it must not be reported twice - nor first
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
await expect(
mergeRenovateConfig({ ...config, extends: ['default:doesNotExist'] }),
).rejects.toMatchObject({ message: 'config-validation' });
expect(logger.logger.debug).toHaveBeenCalledWith(
expect.anything(),
'Error resolving the self-hosted config presets - continuing without their exemption',
);
});
it("applies a repositories[] entry's env that uses entry-level secrets", async () => {
// the entry's secrets only interpolate as the entry is applied (there is no earlier global pass when the secrets are not defined globally), so the admin-supplied env snapshot must carry the interpolated value
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
env: { NPM_TOKEN: '{{ secrets.TOKEN }}' },
secrets: { TOKEN: 'real-value' },
},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ NPM_TOKEN: 'real-value' });
expect(logger.logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
"Ignoring env variables not permitted by this Renovate instance's `allowedEnv`",
);
});
it("exempts a preset re-spelling the admin's matchHost with a scheme", async () => {
// 'registry.example.com' and 'https://registry.example.com' match the same requests, so the same header against either spelling is still the admin's own
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>respellsHost', {
hostRules: [
{
matchHost: 'https://registry.example.com',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>respellsHost'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'admin-secret' },
},
],
}),
).toResolve();
});
it("exempts a preset narrowing the admin's matchHost to a subpath", async () => {
// the admin's own header already reaches every request under the wider host, so a narrower scope introduces nothing new
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>narrowsToPath', {
hostRules: [
{
matchHost: 'https://registry.example.com/npm/',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>narrowsToPath'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
{
matchHost: 'https://registry.example.com',
headers: { Authorization: 'admin-secret' },
},
],
}),
).toResolve();
});
it("reports a preset broadening the admin's header to a wider host", async () => {
// the admin scoped the header to a subpath; replaying it at the origin reaches requests the admin's rule does not
GlobalConfig.set({ allowedHeaders: ['X-*'] });
memCache.set('preset:local>broadensHost', {
hostRules: [
{
matchHost: 'https://registry.example.com',
headers: { Authorization: 'admin-secret' },
},
],
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>broadensHost'] }),
);
await expect(
mergeRenovateConfig({
...config,
hostRules: [
{
matchHost: 'https://registry.example.com/npm/',
headers: { Authorization: 'admin-secret' },
},
],
}),
).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"hostRules header `Authorization` is not allowed by this Renovate instance's `allowedHeaders`.",
});
});
it('attributes a resolved-config violation to `config` when the repository has no config file', async () => {
// the static repository config applies as repository config, but is not one of the repository's own files, so there is nothing to blame the failure on
memCache.set('preset:local>injectsEnvNoFile', {
env: { NOT_ALLOWED: 'from-preset' },
});
scm.getFileList.mockResolvedValue([]);
fs.readSystemFile.mockResolvedValue(
JSON.stringify({ extends: ['local>injectsEnvNoFile'] }),
);
vi.stubEnv('RENOVATE_X_STATIC_REPO_CONFIG_FILE', 'static-config.json');
await expect(mergeRenovateConfig({ ...config })).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'config',
validationMessage:
"Env variable name `NOT_ALLOWED` is not allowed by this Renovate instance's `allowedEnv`.",
});
});
it('rejects env a preset nests under `force`', async () => {
// `validateConfig` demotes `force` sub-validation findings to warnings, but `mergeChildConfig` promotes `force` values into the applied config, so Security findings there must stay fatal
memCache.set('preset:local>forcesEnv', {
force: { env: { GIT_SSH_COMMAND: 'sh -c "malicious"' } },
});
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ extends: ['local>forcesEnv'] }),
);
await expect(
mergeRenovateConfig({ ...config, repositoryEntryConfig: {} }),
).rejects.toMatchObject({
message: 'config-validation',
validationSource: 'renovate.json',
validationMessage:
"Env variable name `GIT_SSH_COMMAND` is not allowed by this Renovate instance's `allowedEnv`.",
});
});
it('exempts `force` env supplied by the repositories[] entry itself', async () => {
// the harmless preset makes the resolved entry differ from the entry, so validation is not skipped as a no-op
memCache.set('preset:local>harmlessLabels', { labels: ['from-preset'] });
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
extends: ['local>harmlessLabels'],
force: { env: { ENTRY_FORCED: 'yes' } },
},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ ENTRY_FORCED: 'yes' });
});
it("applies a repositories[] entry's `force.env` that uses entry-level secrets", async () => {
// as with the entry's top-level `env`, the entry's own secrets only interpolate as the entry is applied, so the admin-supplied env snapshot must carry the interpolated value rather than the literal template
fs.readLocalFile.mockResolvedValue(JSON.stringify({}));
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: {
force: { env: { NPM_TOKEN: '{{ secrets.TOKEN }}' } },
secrets: { TOKEN: 'real-value' },
},
});
expect(res).toBeDefined();
expect(getUserEnv()).toEqual({ NPM_TOKEN: 'real-value' });
expect(logger.logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
"Ignoring env variables not permitted by this Renovate instance's `allowedEnv`",
);
});
it('skips re-validating a resolved config that resolution left unchanged', async () => {
// with no presets to resolve and nothing to decrypt, both the `repositories[]` entry and the repo file were already validated (at startup and by `migrateAndValidate` respectively), so no resolved-config validation walk should run
const validateConfigSpy = vi.spyOn(configValidation, 'validateConfig');
fs.readLocalFile.mockResolvedValue(
JSON.stringify({ labels: ['from-repo'] }),
);
const res = await mergeRenovateConfig({
...config,
repositoryEntryConfig: { labels: ['from-entry'] },
});
expect(res).toBeDefined();
expect(validateConfigSpy).not.toHaveBeenCalled();
});
});
describe('applyHostRules', () => {
it('does nothing when hostRules is not configured', () => {
const addSpy = vi.spyOn(hostRules, 'add');
const clearQueueSpy = vi.spyOn(queue, 'clear');
const clearThrottleSpy = vi.spyOn(throttle, 'clear');
applyHostRules({});
expect(addSpy).not.toHaveBeenCalled();
expect(clearQueueSpy).not.toHaveBeenCalled();
expect(clearThrottleSpy).not.toHaveBeenCalled();
});
it('adds hostRules and clears queue and throttle', () => {
const addSpy = vi
.spyOn(hostRules, 'add')
.mockImplementation(() => undefined);
const clearQueueSpy = vi.spyOn(queue, 'clear');
const clearThrottleSpy = vi.spyOn(throttle, 'clear');
const config = {
hostRules: [{ matchHost: 'registry.npmjs.org' }],
};
applyHostRules(config);
expect(addSpy).toHaveBeenCalledExactlyOnceWith(
{ matchHost: 'registry.npmjs.org' },
undefined,
);
expect(clearQueueSpy).toHaveBeenCalledOnce();
expect(clearThrottleSpy).toHaveBeenCalledOnce();
expect(config.hostRules).toBeUndefined();
});
it('filters headers against allowedHeaders when adding', () => {
// the filtering lives within `hostRules.add` itself, so no registration path can bypass it
GlobalConfig.set({ allowedHeaders: ['X-*'] });
const config = {
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-Allowed': 'yes', Authorization: 'Bearer secret' },
},
],
};
applyHostRules(config);
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual({
headers: { 'X-Allowed': 'yes' },
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{ denied: ['Authorization'] },
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
);
});
it('merges with an already-registered admin hostRule instead of replacing its headers', () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
// simulates the self-hosted admin's own `hostRules`, registered earlier via `globalInitialize` - `hostRules.add` filters them against `allowedHeaders` itself, and registers them as `trusted`
hostRules.add(
{
matchHost: 'registry.example.com',
headers: { 'X-From-Admin': 'yes', Authorization: 'from-admin' },
},
{ trusted: true },
);
applyHostRules({
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-From-Repo': 'yes' },
},
],
});
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual({
headers: { 'X-From-Admin': 'yes', 'X-From-Repo': 'yes' },
internalHostGrant: { implicit: true },
});
});
it('does not let a repo hostRule mask an admin hostRule with a narrower match', () => {
GlobalConfig.set({ allowedHeaders: ['X-*'] });
hostRules.add(
{
matchHost: 'example.com',
headers: { 'X-Api-Key': 'from-admin' },
},
{ trusted: true },
);
applyHostRules({
hostRules: [
{
matchHost: 'https://registry.example.com/some/path',
headers: { 'X-Api-Key': 'from-repo' },
},
],
});
expect(
hostRules.find({
url: 'https://registry.example.com/some/path/resource',
}),
).toEqual({
headers: { 'X-Api-Key': 'from-admin' },
internalHostGrant: { implicit: true },
});
});
it('warns on invalid hostRule and continues applying others', () => {
const addSpy = vi
.spyOn(hostRules, 'add')
.mockImplementationOnce(() => {
throw new Error('invalid host rule');
})
.mockImplementation(() => undefined);
const clearQueueSpy = vi.spyOn(queue, 'clear');
const clearThrottleSpy = vi.spyOn(throttle, 'clear');
const config = {
hostRules: [{ matchHost: 'one.example' }, { matchHost: 'two.example' }],
};
applyHostRules(config);
expect(addSpy).toHaveBeenCalledTimes(2);
expect(logger.logger.warn).toHaveBeenCalledOnce();
expect(clearQueueSpy).toHaveBeenCalledOnce();
expect(clearThrottleSpy).toHaveBeenCalledOnce();
expect(config.hostRules).toBeUndefined();
});
});
describe('static repository config', () => {
const repoStaticConfigFileKey = 'RENOVATE_X_STATIC_REPO_CONFIG_FILE';
beforeEach(() => {
migrate.migrateConfig.mockImplementation((c) => ({
isMigrated: true,
migratedConfig: c,
}));
migrateAndValidate.migrateAndValidate.mockImplementationOnce((_, c) => {
return Promise.resolve({
...c,
warnings: [],
errors: [],
});
});
});
describe('resolveStaticRepoConfig()', () => {
interface MergeRepoEnvTestCase {
name: string;
currentConfig: AllConfig;
staticConfig: AllConfig | undefined;
want: AllConfig;
}
const testCases: MergeRepoEnvTestCase[] = [
{
name: 'it does nothing',
staticConfig: undefined,
currentConfig: { repositories: ['some/repo'] },
want: { repositories: ['some/repo'] },
},
{
name: 'it merges static config with the current config',
staticConfig: { dependencyDashboard: true },
currentConfig: { repositories: ['some/repo'] },
want: {
dependencyDashboard: true,
repositories: ['some/repo'],
},
},
];
it.each(testCases)(
'$name',
async ({ currentConfig, staticConfig, want }: MergeRepoEnvTestCase) => {
const [exitMock] = mockProcessExitOnce();
let configFileName: string | undefined;
if (!isNullOrUndefined(staticConfig)) {
configFileName = 'static_config.json5';
fs.readSystemFile.mockResolvedValueOnce(
JSON.stringify(staticConfig),
);
}
const got = await resolveStaticRepoConfig(
currentConfig,
configFileName,
);
expect(got).toEqual(want);
expect(exitMock).not.toHaveBeenCalled();
},
);
describe('resolveStaticRepoConfig termination cases', () => {
it.each([
{
name: 'should terminate when static config is missing',
setup: () => fs.readSystemFile.mockRejectedValueOnce('missing'),
},
{
name: 'should terminate when static config is invalid JSON',
setup: () => fs.readSystemFile.mockResolvedValue('invalid json'),
},
])('$name', async ({ setup }) => {
const [exitMock, error] = mockProcessExitOnce();
setup();
await expect(
resolveStaticRepoConfig({}, 'static_config.json'),
).rejects.toThrow(error);
expect(exitMock).toHaveBeenCalledExactlyOnceWith(1);
});
it('should log static config validation errors and warnings', async () => {
const invalidConfig = { foo: 'bar' };
fs.readSystemFile.mockResolvedValue(JSON.stringify(invalidConfig));
const resolved = await resolveStaticRepoConfig(
{},
'static_config.json',
);
expect(resolved).toStrictEqual(invalidConfig);
expect(logger.logger.info).toHaveBeenCalledWith(
{
errors: [
{
message: 'Invalid configuration option: foo',
topic: 'Configuration Error',
},
],
warnings: [],
},
'Static repo config validation issues detected',
);
});
});
});
describe('mergeRenovateConfig() with a static repository config', () => {
beforeEach(() => {
scm.getFileList.mockResolvedValueOnce(['renovate.json']);
});
interface MergeRepoFileAndEnvConfigTestCase {
name: string;
currentConfig: AllConfig;
repoFileConfig: AllConfig;
staticConfig: AllConfig;
wantConfig: AllConfig;
}
it.each<MergeRepoFileAndEnvConfigTestCase>([
{
name: 'it does nothing',
currentConfig: {},
repoFileConfig: {},
staticConfig: {},
wantConfig: {
renovateJsonPresent: true,
warnings: [],
},
},
{
name: 'it should resolve and use the repo file config when the static config is not set',
currentConfig: {},
repoFileConfig: {
extends: ['group:socketio'],
},
staticConfig: {},
wantConfig: {
description: ['Group socket.io packages.'],
packageRules: [
{
groupName: 'socket.io packages',
matchPackageNames: ['socket.io**'],
},
],
renovateJsonPresent: true,
warnings: [],
},
},
{
name: 'it should resolve and use the static config when no repo file present',
currentConfig: {},
repoFileConfig: {},
staticConfig: { extends: ['group:socketio'] },
wantConfig: {
description: ['Group socket.io packages.'],
packageRules: [
{
groupName: 'socket.io packages',
matchPackageNames: ['socket.io**'],
},
],
renovateJsonPresent: true,
warnings: [],
},
},
{
name: 'it should merge both configs and and repo config is higher priority',
currentConfig: {},
repoFileConfig: {
extends: ['group:socketio'],
packageRules: [
{
matchConfidence: ['high', 'very high'],
groupName: 'high merge confidence',
},
],
},
staticConfig: {
dependencyDashboard: true,
packageRules: [
{
groupName: 'my-custom-socketio-override',
matchPackageNames: ['socket.io**'],
},
],
},
wantConfig: {
dependencyDashboard: true,
description: ['Group socket.io packages.'],
packageRules: [
{
groupName: 'socket.io packages',
matchPackageNames: ['socket.io**'],
},
{
groupName: 'my-custom-socketio-override',
matchPackageNames: ['socket.io**'],
},
{
groupName: 'high merge confidence',
matchConfidence: ['high', 'very high'],
},
],
renovateJsonPresent: true,
warnings: [],
},
},
{
name: 'it should merge extends from both a repo config and static repo config by appending it',
currentConfig: {},
repoFileConfig: {
extends: ['group:springAndroid'],
},
staticConfig: {
dependencyDashboard: true,
extends: ['group:springAmqp'],
packageRules: [
{
groupName: 'some-package-rule',
matchPackageNames: ['anything**'],
},
],
},
wantConfig: {
dependencyDashboard: true,
description: [
'Group Java Spring AMQP packages.',
'Group Java Spring Android packages.',
],
packageRules: [
{
groupName: 'spring amqp',
matchPackageNames: ['org.springframework.amqp:**'],
},
{
groupName: 'spring android',
matchPackageNames: ['org.springframework.android:**'],
},
{
groupName: 'some-package-rule',
matchPackageNames: ['anything**'],
},
],
renovateJsonPresent: true,
warnings: [],
},
},
])(
'$name',
async ({
staticConfig,
repoFileConfig,
currentConfig,
wantConfig,
}: MergeRepoFileAndEnvConfigTestCase) => {
fs.readLocalFile.mockResolvedValueOnce(
JSON.stringify(repoFileConfig),
);
vi.stubEnv(repoStaticConfigFileKey, 'static_config.json');
fs.readSystemFile.mockResolvedValueOnce(JSON.stringify(staticConfig));
const got = await mergeRenovateConfig(currentConfig);
expect(got).toStrictEqual(wantConfig);
},
);
});
});
});