Files
renovate/lib/modules/datasource/docker/common.spec.ts
T
Sebastian Poxhofer 5ad36a2387 test(linters): add linting rule to enforce usage of host rules test util (#44692)
* ci(linter): add renovate/no-host-rules-mock rule

Flags vi.mock()/vi.doMock() calls whose module specifier contains
"host-rules" in spec files. Tests should configure real rules via
hostRules.add() instead of mocking the host-rules module.

Enabled at "warn" severity instead of "error": the repo currently has
35 pre-existing violations across 34 spec files, which exceeds the
agreed fix threshold of 12 files. No violation fixes are included;
migrating each spec to real hostRules.add()/clear() setup is a
semantic test rewrite that should be done incrementally, after which
the rule can be promoted to "error".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(linter): enforce renovate/no-host-rules-mock at error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(datasource): use real host-rules instead of mocking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(manager): use real host-rules instead of mocking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(platform): use real host-rules instead of mocking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(util,workers): use real host-rules instead of mocking

Also narrow renovate/no-host-rules-mock to the host-rules module itself so that mocking e.g. host-rules-from-env is not flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(linter): mention '~test/host-rules.ts' util in no-host-rules-mock message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:36:12 +00:00

373 lines
11 KiB
TypeScript

import { hostRules } from '~test/host-rules.ts';
import * as httpMock from '~test/http-mock.ts';
import { partial } from '~test/util.ts';
import { PAGE_NOT_FOUND_ERROR } from '../../../constants/error-messages.ts';
import { Http } from '../../../util/http/index.ts';
import {
dockerDatasourceId,
findHelmSourceUrl,
findLatestStable,
getAuthHeaders,
getRegistryRepository,
} from './common.ts';
import type { OciHelmConfig } from './schema.ts';
const http = new Http(dockerDatasourceId);
describe('modules/datasource/docker/common', () => {
describe('getRegistryRepository', () => {
it('handles local registries', () => {
const res = getRegistryRepository(
'registry:5000/org/package',
'https://index.docker.io',
);
expect(res).toStrictEqual({
dockerRepository: 'org/package',
registryHost: 'https://registry:5000',
});
});
it('supports registryUrls', () => {
const res = getRegistryRepository(
'my.local.registry/prefix/image',
'https://my.local.registry/prefix',
);
expect(res).toStrictEqual({
dockerRepository: 'prefix/image',
registryHost: 'https://my.local.registry',
});
});
it('supports http registryUrls', () => {
const res = getRegistryRepository(
'my.local.registry/prefix/image',
'http://my.local.registry/prefix',
);
expect(res).toStrictEqual({
dockerRepository: 'prefix/image',
registryHost: 'http://my.local.registry',
});
});
it('supports schemeless registryUrls', () => {
const res = getRegistryRepository(
'my.local.registry/prefix/image',
'my.local.registry/prefix',
);
expect(res).toStrictEqual({
dockerRepository: 'prefix/image',
registryHost: 'https://my.local.registry',
});
});
it('supports insecure registryUrls', () => {
hostRules.add({ insecureRegistry: true });
const res = getRegistryRepository(
'prefix/image',
'my.local.registry/prefix',
);
expect(res).toStrictEqual({
dockerRepository: 'prefix/prefix/image',
registryHost: 'http://my.local.registry',
});
});
it.each([
{
name: 'strimzi-kafka-operator',
url: 'https://quay.io/strimzi-helm/',
res: {
dockerRepository: 'strimzi-helm/strimzi-kafka-operator',
registryHost: 'https://quay.io',
},
},
{
name: 'strimzi-kafka-operator',
url: 'https://docker.io/strimzi-helm/',
res: {
dockerRepository: 'strimzi-helm/strimzi-kafka-operator',
registryHost: 'https://index.docker.io',
},
},
{
name: 'nginx',
url: 'https://docker.io',
res: {
dockerRepository: 'library/nginx',
registryHost: 'https://index.docker.io',
},
},
{
name: 'registry-1.docker.io/bitnamicharts/cert-manager',
url: 'https://index.docker.io',
res: {
dockerRepository: 'bitnamicharts/cert-manager',
registryHost: 'https://index.docker.io',
},
},
])('($name, $url)', ({ name, url, res }) => {
expect(getRegistryRepository(name, url)).toStrictEqual(res);
});
it('returns raw registryHost and dockerRepository when fullUrl is invalid', () => {
// 'https://[/prefix' is a syntactically invalid URL (unclosed bracket)
const res = getRegistryRepository('[/prefix/image', 'https://[/prefix');
expect(res).toStrictEqual({
registryHost: 'https://[/prefix',
dockerRepository: 'image',
});
});
});
describe('getAuthHeaders', () => {
beforeEach(() => {
hostRules.add({
username: 'some-username',
password: 'some-password',
});
});
it('throw page not found exception', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/repo/tags/list?n=1000')
.reply(404, {});
await expect(
getAuthHeaders(
http,
'https://my.local.registry',
'repo',
'https://my.local.registry/v2/repo/tags/list?n=1000',
),
).rejects.toThrow(PAGE_NOT_FOUND_ERROR);
});
it('falls back to base /v2/ auth probe when tags URL returns 405 ECR maxResults error', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/repo/tags/list?n=10000')
.reply(
405,
{
errors: [
{
code: 'UNSUPPORTED',
message:
"Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'",
},
],
},
{ 'docker-distribution-api-version': 'registry/2.0' },
)
.get('/v2/')
.reply(200, '');
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'repo',
'https://my.local.registry/v2/repo/tags/list?n=10000',
);
expect(headers).toEqual({});
});
it('falls back to base /v2/ auth probe and negotiates token when 405 ECR maxResults error is received', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/repo/tags/list?n=10000')
.reply(
405,
{
errors: [
{
code: 'UNSUPPORTED',
message:
"Invalid parameter at 'maxResults' failed to satisfy constraint: 'Member must have value less than or equal to 1000'",
},
],
},
{ 'docker-distribution-api-version': 'registry/2.0' },
)
.get('/v2/', undefined, { badheaders: ['authorization'] })
.reply(401, '', {
'www-authenticate':
'Bearer realm="https://my.local.registry/oauth2/token",service="my.local.registry"',
})
.get(
'/oauth2/token?service=my.local.registry&scope=repository:repo:pull',
)
.reply(200, { token: 'abc' });
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'repo',
'https://my.local.registry/v2/repo/tags/list?n=10000',
);
expect(headers).toEqual({ authorization: 'Bearer abc' });
});
it('returns "authType token" if both provided', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/', undefined, { badheaders: ['authorization'] })
.reply(401, '', { 'www-authenticate': 'Authenticate you must' });
hostRules.clear();
hostRules.add({
authType: 'some-authType',
token: 'some-token',
});
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'https://my.local.registry/prefix',
);
// do not inline, otherwise we get false positive from codeql
expect(headers).toMatchInlineSnapshot(`
{
"authorization": "some-authType some-token",
}
`);
});
it('returns "Bearer token" if only token provided', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/', undefined, { badheaders: ['authorization'] })
.reply(401, '', { 'www-authenticate': 'Authenticate you must' });
hostRules.clear();
hostRules.add({
token: 'some-token',
});
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'https://my.local.registry/prefix',
);
// do not inline, otherwise we get false positive from codeql
expect(headers).toMatchInlineSnapshot(`
{
"authorization": "Bearer some-token",
}
`);
});
it('fails', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/', undefined, { badheaders: ['authorization'] })
.reply(401, '', { 'www-authenticate': 'Authenticate you must' });
httpMock.clear(false);
httpMock
.scope('https://my.local.registry')
.get('/v2/', undefined, { badheaders: ['authorization'] })
.reply(401, '', {});
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'https://my.local.registry/prefix',
);
expect(headers).toBeNull();
});
it('use resources URL and resolve scope in www-authenticate header', async () => {
httpMock
.scope('https://my.local.registry')
.get('/v2/my/node/resource')
.reply(401, '', {
'www-authenticate':
'Bearer realm="https://my.local.registry/oauth2/token",service="my.local.registry",scope="repository:my/node:whatever"',
})
.get(
'/oauth2/token?service=my.local.registry&scope=repository:my/node:whatever',
)
.reply(200, { token: 'some-token' });
const headers = await getAuthHeaders(
http,
'https://my.local.registry',
'my/node/prefix',
'https://my.local.registry/v2/my/node/resource',
);
// do not inline, otherwise we get false positive from codeql
expect(headers).toMatchInlineSnapshot(`
{
"authorization": "Bearer some-token",
}
`);
});
it('supports multiple challenges in www-authenticate header', async () => {
httpMock
.scope('https://codeberg.org')
.get('/v2/')
.reply(401, '', {
'www-authenticate':
'Bearer realm="https://codeberg.org/v2/token",service="container_registry",scope="*",Basic realm="https://codeberg.org/v2",service="container_registry",scope="*"',
})
.get(
'/v2/token?service=container_registry&scope=repository:my/node/prefix:pull',
)
.reply(200, { token: 'abc' });
const headers = await getAuthHeaders(
http,
'https://codeberg.org',
'my/node/prefix',
);
// do not inline, otherwise we get false positive from codeql
expect(headers).toMatchInlineSnapshot(`
{
"authorization": "Bearer abc",
}
`);
});
});
it('findLatestStable works', () => {
expect(findLatestStable([])).toBeNull();
});
it('findHelmSourceUrl works', () => {
expect(
findHelmSourceUrl(
partial<OciHelmConfig>({
home: 'https://github.com/bitnami/charts/tree/main/bitnami/harbor',
}),
),
).toBe('https://github.com/bitnami/charts/tree/main/bitnami/harbor');
expect(findHelmSourceUrl(partial<OciHelmConfig>({}))).toBeNull();
expect(
findHelmSourceUrl(
partial<OciHelmConfig>({
sources: [
'https://github.com/bitnami/charts/tree/main/bitnami/harbor',
],
}),
),
).toBe('https://github.com/bitnami/charts/tree/main/bitnami/harbor');
expect(
findHelmSourceUrl(
partial<OciHelmConfig>({
sources: ['https://some.test'],
}),
),
).toBe('https://some.test');
});
});