mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
8d08b51db0
* refactor(onboarding): return repository result from ensureOnboarding Rename ensureOnboardingPr to ensureOnboarding and have it return a new 'onboarding' RepositoryResult. renovateRepository now branches on repoIsOnboarded, so exactly one of ensureOnboarding/updateRepo produces the repository result and the non-null assertion on the update result (TODO #22198) is no longer needed. updateRepo/update no longer return undefined for non-onboarded repositories. * refactor(onboarding): keep ensureOnboardingPr name
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
import { fakeSha, logger, scm } from '~test/util.ts';
|
|
import type { PackageFile } from '../../../modules/manager/types.ts';
|
|
import * as _repositoryCache from '../../../util/cache/repository/index.ts';
|
|
import type { BaseBranchCache } from '../../../util/cache/repository/types.ts';
|
|
import { fingerprint } from '../../../util/fingerprint.ts';
|
|
import { generateFingerprintConfig } from '../extract/extract-fingerprint-config.ts';
|
|
import * as _branchify from '../updates/branchify.ts';
|
|
import {
|
|
EXTRACT_CACHE_REVISION,
|
|
extract,
|
|
isCacheExtractValid,
|
|
lookup,
|
|
update,
|
|
} from './extract-update.ts';
|
|
import * as _fetch from './fetch.ts';
|
|
import * as _write from './write.ts';
|
|
|
|
const createVulnerabilitiesMock = vi.fn();
|
|
|
|
vi.mock('./write.ts');
|
|
vi.mock('./sort.ts');
|
|
vi.mock('./fetch.ts');
|
|
vi.mock('./vulnerabilities.ts', () => {
|
|
return {
|
|
__esModule: true,
|
|
Vulnerabilities: class {
|
|
static create() {
|
|
return createVulnerabilitiesMock();
|
|
}
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../updates/branchify.ts');
|
|
vi.mock('../extract/index.ts');
|
|
vi.mock('../../../util/cache/repository/index.ts');
|
|
|
|
const branchify = vi.mocked(_branchify);
|
|
const repositoryCache = vi.mocked(_repositoryCache);
|
|
const fetch = vi.mocked(_fetch);
|
|
const write = vi.mocked(_write);
|
|
|
|
describe('workers/repository/process/extract-update', () => {
|
|
const branchSha = fakeSha('123test');
|
|
|
|
beforeEach(() => {
|
|
branchify.branchifyUpgrades.mockResolvedValue({
|
|
branches: [
|
|
{
|
|
manager: 'some-manager',
|
|
branchName: 'some-branch',
|
|
baseBranch: 'base',
|
|
upgrades: [],
|
|
},
|
|
],
|
|
branchList: ['branchName'],
|
|
});
|
|
});
|
|
|
|
describe('extract()', () => {
|
|
it('runs with no baseBranchPatterns', async () => {
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
};
|
|
repositoryCache.getCache.mockReturnValueOnce({ scan: {} });
|
|
scm.checkoutBranch.mockResolvedValueOnce(branchSha);
|
|
const packageFiles = await extract(config);
|
|
const res = await lookup(config, packageFiles);
|
|
expect(res).toEqual({
|
|
branchList: ['branchName'],
|
|
branches: [
|
|
{
|
|
branchName: 'some-branch',
|
|
manager: 'some-manager',
|
|
baseBranch: 'base',
|
|
upgrades: [],
|
|
},
|
|
],
|
|
packageFiles: undefined,
|
|
});
|
|
});
|
|
|
|
it('runs with baseBranchPatterns', async () => {
|
|
const config = {
|
|
baseBranchPatterns: ['master', 'dev'],
|
|
baseBranches: ['master', 'dev'],
|
|
repoIsOnboarded: true,
|
|
enabledManagers: ['npm'],
|
|
javascript: {
|
|
labels: ['js'],
|
|
},
|
|
npm: {
|
|
addLabels: 'npm',
|
|
},
|
|
};
|
|
scm.checkoutBranch.mockResolvedValueOnce(branchSha);
|
|
repositoryCache.getCache.mockReturnValueOnce({ scan: {} });
|
|
const packageFiles = await extract(config);
|
|
expect(packageFiles).toBeUndefined();
|
|
});
|
|
|
|
it('uses repository cache', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {};
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
baseBranch: 'master',
|
|
};
|
|
repositoryCache.getCache.mockReturnValueOnce({
|
|
scan: {
|
|
master: {
|
|
revision: EXTRACT_CACHE_REVISION,
|
|
sha: branchSha,
|
|
configHash: fingerprint(generateFingerprintConfig(config)),
|
|
extractionFingerprints: {},
|
|
packageFiles,
|
|
},
|
|
},
|
|
});
|
|
scm.getBranchCommit.mockResolvedValueOnce(branchSha);
|
|
scm.checkoutBranch.mockResolvedValueOnce(branchSha);
|
|
const res = await extract(config);
|
|
expect(res).toEqual(packageFiles);
|
|
});
|
|
|
|
it('fetches vulnerabilities', async () => {
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
osvVulnerabilityAlerts: true,
|
|
};
|
|
const appendVulnerabilityPackageRulesMock = vi.fn();
|
|
createVulnerabilitiesMock.mockResolvedValue({
|
|
appendVulnerabilityPackageRules: appendVulnerabilityPackageRulesMock,
|
|
});
|
|
repositoryCache.getCache.mockReturnValueOnce({ scan: {} });
|
|
scm.checkoutBranch.mockResolvedValueOnce(branchSha);
|
|
|
|
const packageFiles = await extract(config);
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(createVulnerabilitiesMock).toHaveBeenCalledTimes(2);
|
|
expect(appendVulnerabilityPackageRulesMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('handles exception when fetching vulnerabilities', async () => {
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
osvVulnerabilityAlerts: true,
|
|
};
|
|
createVulnerabilitiesMock.mockRejectedValueOnce(new Error());
|
|
repositoryCache.getCache.mockReturnValueOnce({ scan: {} });
|
|
scm.checkoutBranch.mockResolvedValueOnce(branchSha);
|
|
|
|
const packageFiles = await extract(config);
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(createVulnerabilitiesMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
describe('malicious package detection', () => {
|
|
// this follows how the calls should actually work, but as it's heavily mocked, this may end up changing from actual behaviour
|
|
describe('when using mocks', () => {
|
|
it('skips malicious package updates', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
deps: [
|
|
// has a malicious update
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [
|
|
// will be populated by our mock
|
|
],
|
|
packageName: 'axios',
|
|
},
|
|
// not malicious
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
},
|
|
],
|
|
packageFile: 'package.json',
|
|
},
|
|
],
|
|
};
|
|
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
baseBranch: 'main',
|
|
osvVulnerabilityAlerts: true,
|
|
};
|
|
const appendVulnerabilityPackageRulesMock = vi.fn();
|
|
createVulnerabilitiesMock.mockResolvedValue({
|
|
appendVulnerabilityPackageRules:
|
|
appendVulnerabilityPackageRulesMock,
|
|
});
|
|
|
|
// the first time, we're checking what updates are available, so don't modify anything
|
|
appendVulnerabilityPackageRulesMock.mockImplementationOnce(
|
|
async (
|
|
_config: any,
|
|
_packageFiles: Record<string, PackageFile[]>,
|
|
): Promise<void> => {
|
|
// no-op
|
|
},
|
|
);
|
|
|
|
fetch.fetchUpdates.mockImplementation(
|
|
(
|
|
_config: any,
|
|
packageFiles: Record<string, PackageFile[]>,
|
|
): Promise<void> => {
|
|
packageFiles.npm[0].deps[0].updates = [
|
|
// MAL-2026-2307
|
|
{ newVersion: '1.14.1' },
|
|
];
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
appendVulnerabilityPackageRulesMock.mockImplementationOnce(
|
|
(
|
|
_config: any,
|
|
packageFiles: Record<string, PackageFile[]>,
|
|
): Promise<void> => {
|
|
const updates = packageFiles?.npm[0]?.deps[0]?.updates;
|
|
|
|
if (updates && updates[0]?.newVersion === '1.14.1') {
|
|
packageFiles.npm[0].deps[0].skipReason =
|
|
'malicious-update-proposed';
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(fetch.fetchUpdates).toHaveBeenCalled();
|
|
expect(appendVulnerabilityPackageRulesMock).toHaveBeenCalledTimes(2);
|
|
|
|
expect(packageFiles.npm).toHaveLength(1);
|
|
expect(packageFiles.npm[0].deps).toHaveLength(2);
|
|
expect(packageFiles.npm[0].deps[0].skipReason).toEqual(
|
|
'malicious-update-proposed',
|
|
);
|
|
});
|
|
});
|
|
|
|
// this
|
|
describe('when manually specifying the `skipReason`s', () => {
|
|
describe('when skipReason=malicious-version-in-use', () => {
|
|
it('logs a warning', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
deps: [
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.1',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.1',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
|
|
// most importantly
|
|
skipReason: 'malicious-version-in-use',
|
|
},
|
|
// not malicious
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
},
|
|
],
|
|
packageFile: 'package.json',
|
|
},
|
|
],
|
|
};
|
|
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
baseBranch: 'main',
|
|
};
|
|
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(logger.logger.warn).toHaveBeenCalledWith(
|
|
{
|
|
packageFile: 'package.json',
|
|
depName: 'axios',
|
|
packageName: 'axios',
|
|
manager: 'npm',
|
|
datasource: 'npm',
|
|
},
|
|
'Dependency is currently using a malicious version',
|
|
);
|
|
});
|
|
|
|
it('deletes the skipReason and skipStage, to allow the update phase to continue updating', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
deps: [
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.1',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.1',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
|
|
// most importantly
|
|
skipReason: 'malicious-version-in-use',
|
|
skipStage: 'lookup',
|
|
},
|
|
// not malicious
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
},
|
|
],
|
|
packageFile: 'package.json',
|
|
},
|
|
],
|
|
};
|
|
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
baseBranch: 'main',
|
|
};
|
|
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(packageFiles.npm[0].deps[0].skipReason).toBeUndefined();
|
|
expect(packageFiles.npm[0].deps[0].skipStage).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('when skipReason=malicious-version-in-use, it logs a warning for each skipReason', async () => {
|
|
const packageFiles: Record<string, PackageFile[]> = {
|
|
npm: [
|
|
{
|
|
deps: [
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [
|
|
{
|
|
newVersion: '1.14.1',
|
|
},
|
|
{
|
|
// unrelated, using newValue
|
|
newValue: '1.14.2',
|
|
},
|
|
{
|
|
// unrelated
|
|
newVersion: '2.0.0',
|
|
},
|
|
{
|
|
// doesn't have a newVersion or newValue
|
|
updateType: 'digest',
|
|
newDigest: '1234',
|
|
},
|
|
],
|
|
packageName: 'axios',
|
|
|
|
// most importantly
|
|
skipReason: 'malicious-update-proposed',
|
|
},
|
|
// not malicious
|
|
{
|
|
depType: 'devDependencies',
|
|
depName: 'axios',
|
|
currentValue: '1.14.0',
|
|
datasource: 'npm',
|
|
prettyDepType: 'devDependency',
|
|
lockedVersion: '1.14.0',
|
|
updates: [],
|
|
packageName: 'axios',
|
|
},
|
|
],
|
|
packageFile: 'package.json',
|
|
},
|
|
],
|
|
};
|
|
|
|
const config = {
|
|
repoIsOnboarded: true,
|
|
baseBranch: 'main',
|
|
};
|
|
|
|
await lookup(config, packageFiles);
|
|
|
|
expect(logger.logger.warn).toHaveBeenCalledWith(
|
|
{
|
|
packageFile: 'package.json',
|
|
depName: 'axios',
|
|
packageName: 'axios',
|
|
manager: 'npm',
|
|
datasource: 'npm',
|
|
newVersions: ['1.14.1', '1.14.2', '2.0.0'],
|
|
},
|
|
'Dependency has update(s) proposed which would update you to a malicious version - skipping',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('update()', () => {
|
|
it('writes the updates', async () => {
|
|
const config = {};
|
|
const branches = [
|
|
{
|
|
manager: 'some-manager',
|
|
branchName: 'some-branch',
|
|
baseBranch: 'base',
|
|
upgrades: [],
|
|
},
|
|
];
|
|
write.writeUpdates.mockResolvedValueOnce('automerged');
|
|
|
|
const res = await update(config, branches);
|
|
|
|
expect(res).toBe('automerged');
|
|
expect(write.writeUpdates).toHaveBeenCalledExactlyOnceWith(
|
|
config,
|
|
branches,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isCacheExtractValid()', () => {
|
|
let cachedExtract: BaseBranchCache;
|
|
|
|
beforeEach(() => {
|
|
cachedExtract = {
|
|
revision: EXTRACT_CACHE_REVISION,
|
|
sha: 'sha',
|
|
configHash: undefined as never,
|
|
extractionFingerprints: {},
|
|
packageFiles: {},
|
|
};
|
|
});
|
|
|
|
it('undefined cache', () => {
|
|
expect(isCacheExtractValid('sha', 'hash', undefined)).toBe(false);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('returns false if no revision', () => {
|
|
delete cachedExtract.revision;
|
|
expect(isCacheExtractValid('sha', 'hash', cachedExtract)).toBe(false);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns false if revision mismatch', () => {
|
|
cachedExtract.revision = -1;
|
|
expect(isCacheExtractValid('sha', 'hash', cachedExtract)).toBe(false);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('partial cache', () => {
|
|
expect(isCacheExtractValid('sha', 'hash', cachedExtract)).toBe(false);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('sha mismatch', () => {
|
|
cachedExtract.configHash = 'hash';
|
|
expect(isCacheExtractValid('new_sha', 'hash', cachedExtract)).toBe(false);
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
`Cached extract result cannot be used due to base branch SHA change (old=sha, new=new_sha)`,
|
|
);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('config change', () => {
|
|
cachedExtract.configHash = 'hash';
|
|
expect(isCacheExtractValid('sha', 'new_hash', cachedExtract)).toBe(false);
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
'Cached extract result cannot be used due to config change',
|
|
);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('invalid if no extractionFingerprints', () => {
|
|
cachedExtract.configHash = 'hash';
|
|
const { extractionFingerprints: _, ...restOfCache } = cachedExtract;
|
|
expect(
|
|
isCacheExtractValid(
|
|
'sha',
|
|
'hash',
|
|
restOfCache as never as BaseBranchCache,
|
|
),
|
|
).toBe(false);
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
'Cached extract is missing extractionFingerprints, so cannot be used',
|
|
);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('invalid if changed fingerprints', () => {
|
|
cachedExtract.configHash = 'hash';
|
|
cachedExtract.extractionFingerprints = { npm: 'old-fingerprint' };
|
|
expect(isCacheExtractValid('sha', 'hash', cachedExtract)).toBe(false);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('valid cache and config', () => {
|
|
cachedExtract.configHash = 'hash';
|
|
expect(isCacheExtractValid('sha', 'hash', cachedExtract)).toBe(true);
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
'Cached extract for sha=sha is valid and can be used',
|
|
);
|
|
expect(logger.logger.debug).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|