Files
renovate/lib/workers/global/index.spec.ts
Jamie Tanna 09d2da2f1b feat(host-rules): add allowInternal
As part of follow-up changes, we're looking to tighten the way that
Renovate handles outgoing HTTP traffic.

Before we do this, we can introduce a new config option,
`hostRules[].allowInternal`, that allows a self-hosted administrator to
mark an internal host as "trusted" for access.

This wires in the new global-only config option, `allowInternal`, which
implies a new internal field `internalHostGrant`.

We'll add full documentation in a future commit, when the full
enforcement is in place, as there are some wider changes to enforce it
fully.

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

422 lines
15 KiB
TypeScript

import { ERROR, WARN } from 'bunyan';
import fs from 'fs-extra';
import type { RenovateConfig } from '~test/util.ts';
import { logger, partial } from '~test/util.ts';
import { GlobalConfig } from '../../config/global.ts';
import { DockerDatasource } from '../../modules/datasource/docker/index.ts';
import * as platform from '../../modules/platform/index.ts';
import * as hostRules from '../../util/host-rules.ts';
import * as secrets from '../../util/sanitize.ts';
import * as repositoryWorker from '../repository/index.ts';
import type { ProcessResult } from '../repository/result.ts';
import * as configParser from './config/parse/index.ts';
import * as globalWorker from './index.ts';
import * as limits from './limits.ts';
vi.mock('../repository/index.ts');
vi.mock('../../util/fs/index.ts');
vi.mock('fs-extra', async () => {
const realFs = await vi.importActual<typeof fs>('fs-extra');
return {
default: {
ensureDir: vi.fn(),
remove: vi.fn(),
readFile: vi.fn((file: string, options: any) => {
if (file.endsWith('.wasm.gz')) {
return realFs.readFile(file, options);
}
return undefined;
}),
writeFile: vi.fn(),
outputFile: vi.fn(),
},
};
});
// TODO: why do we need git here?
vi.unmock('../../util/git/index.ts');
const addSecretForSanitizing = vi.spyOn(secrets, 'addSecretForSanitizing');
const parseConfigs = vi.spyOn(configParser, 'parseConfigs');
const initPlatform = vi.spyOn(platform, 'initPlatform');
describe('workers/global/index', () => {
beforeEach(() => {
logger.getProblems.mockImplementation(() => []);
logger.logLevel.mockImplementation(() => 'info');
initPlatform.mockImplementation((input) => Promise.resolve(input));
vi.stubEnv('AWS_SECRET_ACCESS_KEY', undefined);
vi.stubEnv('AWS_SESSION_TOKEN', undefined);
vi.stubEnv('COREPACK_NPM_TOKEN', undefined);
vi.stubEnv('COREPACK_NPM_PASSWORD', undefined);
});
describe('getRepositoryConfig', () => {
const globalConfig: RenovateConfig = { baseDir: '/tmp/base' };
GlobalConfig.set({ platform: 'gitlab' });
it('should generate correct topLevelOrg/parentOrg with multiple levels', async () => {
const repository = 'a/b/c/d';
const repoConfig = await globalWorker.getRepositoryConfig(
globalConfig,
repository,
);
expect(repoConfig.topLevelOrg).toBe('a');
expect(repoConfig.parentOrg).toBe('a/b/c');
expect(repoConfig.repository).toBe('a/b/c/d');
});
it('should generate correct topLevelOrg/parentOrg with two levels', async () => {
const repository = 'a/b';
const repoConfig = await globalWorker.getRepositoryConfig(
globalConfig,
repository,
);
expect(repoConfig.topLevelOrg).toBe('a');
expect(repoConfig.parentOrg).toBe('a');
expect(repoConfig.repository).toBe('a/b');
});
it('stores repositoryEntryConfig for repositories[] object entries', async () => {
const repoConfig = await globalWorker.getRepositoryConfig(globalConfig, {
repository: 'test/repo',
extends: [':automergeAll'],
packageRules: [{ matchPackageNames: ['lodash'], enabled: false }],
});
expect(repoConfig.repositoryEntryConfig).toEqual({
extends: [':automergeAll'],
packageRules: [{ matchPackageNames: ['lodash'], enabled: false }],
});
expect(repoConfig.repository).toBe('test/repo');
});
it('extracts admin-level options from object entry to top level', async () => {
const repoConfig = await globalWorker.getRepositoryConfig(
{ ...globalConfig, onboarding: true },
{
repository: 'test/repo',
onboarding: false,
requireConfig: 'optional',
allowedCommands: ['^hack/vendor$'],
dryRun: 'full',
extends: [':automergeAll'],
packageRules: [{ matchPackageNames: ['lodash'], enabled: false }],
},
);
// Admin options promoted to top level and override global config
expect(repoConfig.onboarding).toBe(false);
expect(repoConfig.requireConfig).toBe('optional');
expect(repoConfig.allowedCommands).toEqual(['^hack/vendor$']);
expect(repoConfig.dryRun).toBe('full');
// Repo-level options remain in repositoryEntryConfig
expect(repoConfig.repositoryEntryConfig).toEqual({
extends: [':automergeAll'],
packageRules: [{ matchPackageNames: ['lodash'], enabled: false }],
});
});
it('does not set repositoryEntryConfig when only admin-level options are present', async () => {
const repoConfig = await globalWorker.getRepositoryConfig(globalConfig, {
repository: 'test/repo',
onboarding: false,
});
expect(repoConfig.onboarding).toBe(false);
expect(repoConfig.repositoryEntryConfig).toBeUndefined();
});
it('GlobalConfig.set picks up per-repo onboarding override', async () => {
const repoConfig = await globalWorker.getRepositoryConfig(
{ ...globalConfig, onboarding: true },
{
repository: 'test/repo',
onboarding: false,
},
);
GlobalConfig.set(repoConfig);
expect(GlobalConfig.get('onboarding')).toBe(false);
});
it('does not store repositoryEntryConfig for repositories[] string entries', async () => {
const repoConfig = await globalWorker.getRepositoryConfig(
globalConfig,
'test/repo',
);
expect(repoConfig.repositoryEntryConfig).toBeUndefined();
expect(repoConfig.repository).toBe('test/repo');
});
});
it('handles config warnings and errors', async () => {
parseConfigs.mockResolvedValueOnce({
repositories: [],
// @ts-expect-error -- testing
maintainYarnLock: true,
foo: 1,
});
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'key');
vi.stubEnv('AWS_SESSION_TOKEN', 'token');
vi.stubEnv('COREPACK_NPM_TOKEN', 'corepack-token');
vi.stubEnv('COREPACK_NPM_PASSWORD', 'corepack-password');
await expect(globalWorker.start()).resolves.toBe(0);
expect(addSecretForSanitizing).toHaveBeenCalledTimes(4);
});
it('handles zero repos', async () => {
parseConfigs.mockResolvedValueOnce({
baseDir: '/tmp/base',
cacheDir: '/tmp/cache',
repositories: [],
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).not.toHaveBeenCalled();
});
it('handles local', async () => {
parseConfigs.mockResolvedValueOnce({
platform: 'local',
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(1);
});
it('processes repositories', async () => {
parseConfigs.mockResolvedValueOnce({
gitAuthor: 'a@b.com',
enabled: true,
repositories: ['a', 'b'],
hostRules: [
{
hostType: DockerDatasource.id,
username: 'some-user',
password: 'some-password',
},
],
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(2);
});
it('returns the exit code of the first errored repository when exitCodeForErrors is enabled', async () => {
parseConfigs.mockResolvedValueOnce({
enabled: true,
exitCodeForErrors: true,
repositories: ['a', 'b', 'c'],
});
vi.mocked(repositoryWorker.renovateRepository)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(partial<ProcessResult>({ exitCode: 5 }))
.mockResolvedValueOnce(partial<ProcessResult>({ exitCode: 6 }));
await expect(globalWorker.start()).resolves.toBe(5);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(3);
});
it('ignores repository exit codes when exitCodeForErrors is disabled', async () => {
parseConfigs.mockResolvedValueOnce({
enabled: true,
repositories: ['a'],
});
vi.mocked(repositoryWorker.renovateRepository).mockResolvedValueOnce(
partial<ProcessResult>({ exitCode: 5 }),
);
await expect(globalWorker.start()).resolves.toBe(0);
});
it("filters the self-hosted admin's own hostRules headers against allowedHeaders", async () => {
// `allowedHeaders` has never exempted the self-hosted admin - `applyHostRule` filters by header name whoever set it - so we drop them here, with a WARN, rather than leave them to be discarded at request time
parseConfigs.mockResolvedValueOnce({
enabled: true,
repositories: ['a'],
allowedHeaders: ['X-*'],
hostRules: [
{
matchHost: 'registry.example.com',
headers: { 'X-Allowed': 'yes', Authorization: 'from-admin' },
},
],
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(hostRules.find({ url: 'https://registry.example.com' })).toEqual({
headers: { 'X-Allowed': 'yes' },
internalHostGrant: { implicit: true },
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{ denied: ['Authorization'] },
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
);
});
it("honors a repositories[] entry's allowedHeaders override when filtering the admin's own hostRules", async () => {
// `GlobalConfig` still reflects the previous repository (or the global config) when the admin's hostRules are re-registered per repo, so the filter must use this repository's own `allowedHeaders` - including any `repositories[]` entry override - and must not carry an override over to the next repository
const headersSeenPerRepo: (string | undefined)[] = [];
vi.mocked(repositoryWorker.renovateRepository).mockImplementation(() => {
headersSeenPerRepo.push(
hostRules.find({ url: 'https://registry.example.com' }).headers
?.Authorization,
);
return Promise.resolve(undefined);
});
parseConfigs.mockResolvedValueOnce({
enabled: true,
allowedHeaders: ['X-*'],
repositories: [
{ repository: 'a', allowedHeaders: ['Authorization'] },
'b',
],
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'from-admin' },
},
],
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(headersSeenPerRepo).toEqual(['from-admin', undefined]);
});
it('warns about disallowed admin hostRules headers once, not once per repository', async () => {
parseConfigs.mockResolvedValueOnce({
enabled: true,
allowedHeaders: ['X-*'],
repositories: ['a', 'b', 'c'],
hostRules: [
{
matchHost: 'registry.example.com',
headers: { Authorization: 'from-admin' },
},
],
});
await expect(globalWorker.start()).resolves.toBe(0);
const denialWarnings = logger.logger.warn.mock.calls.filter(
([, message]) =>
message ===
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
);
// once from `setGlobalHostRules` during initialization, and not again for its second registration nor for any repository in the loop
expect(denialWarnings).toHaveLength(1);
});
it('processes repositories break', async () => {
const isLimitReached = vi.spyOn(limits, 'isLimitReached');
isLimitReached.mockReturnValue(true);
parseConfigs.mockResolvedValueOnce({
gitAuthor: 'a@b.com',
enabled: true,
repositories: ['a', 'b'],
hostRules: [
{
hostType: DockerDatasource.id,
username: 'some-user',
password: 'some-password',
},
],
});
await globalWorker.start();
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(0);
isLimitReached.mockReset();
});
it('exits with non-zero when errors are logged', async () => {
parseConfigs.mockResolvedValueOnce({
baseDir: '/tmp/base',
cacheDir: '/tmp/cache',
repositories: [],
});
logger.getProblems.mockReset();
logger.getProblems.mockImplementation(() => [
{
level: ERROR,
msg: 'meh',
},
]);
await expect(globalWorker.start()).resolves.not.toBe(0);
});
it('exits with zero when warnings are logged', async () => {
vi.stubEnv('LOG_LEVEL', undefined);
parseConfigs.mockResolvedValueOnce({
baseDir: '/tmp/base',
cacheDir: '/tmp/cache',
repositories: [],
});
logger.getProblems.mockReset();
logger.getProblems.mockImplementation(() => [
{
level: WARN,
msg: 'meh',
},
]);
await expect(globalWorker.start()).resolves.toBe(0);
});
it('does not log info message when log level is not info', async () => {
logger.logLevel.mockImplementation(() => 'debug');
parseConfigs.mockResolvedValueOnce({
baseDir: '/tmp/base',
cacheDir: '/tmp/cache',
repositories: [],
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(logger.logger.info).not.toHaveBeenCalledExactlyOnceWith(
expect.stringContaining('Set LOG_LEVEL=debug'),
);
});
describe('processes platforms', () => {
it('github', async () => {
parseConfigs.mockResolvedValueOnce({
repositories: ['a'],
platform: 'github',
endpoint: 'https://github.com/',
});
await globalWorker.start();
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(1);
});
it('gitlab', async () => {
parseConfigs.mockResolvedValueOnce({
repositories: [{ repository: 'a' }],
platform: 'gitlab',
endpoint: 'https://my.gitlab.com/',
});
await globalWorker.start();
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(1);
});
});
describe('write repositories to file', () => {
it('successfully write file', async () => {
parseConfigs.mockResolvedValueOnce({
repositories: ['myOrg/myRepo'],
platform: 'github',
endpoint: 'https://github.com/',
writeDiscoveredRepos: '/tmp/renovate-output.json',
});
await expect(globalWorker.start()).resolves.toBe(0);
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(fs.writeFile).toHaveBeenCalledExactlyOnceWith(
'/tmp/renovate-output.json',
'["myOrg/myRepo"]',
);
expect(parseConfigs).toHaveBeenCalledTimes(1);
expect(repositoryWorker.renovateRepository).toHaveBeenCalledTimes(0);
});
});
});