feat(registries): add exact-ID tag browser with non-401 failures (#1613)

* feat(resources): show multi-stack usedByStacks on images

Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change.

* feat(registries): add exact-ID tag browser with non-401 failures

Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet.

* fix(registries): distinguish unreachable hosts from auth failures

Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage.

* fix(registries): drop unused RegistryTagsPanel __test export

The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
This commit is contained in:
Anso
2026-07-11 13:21:38 -04:00
committed by GitHub
parent 362a18e91a
commit ce699864c1
12 changed files with 977 additions and 76 deletions
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
vi.mock('../services/registry-api', async () => {
const actual = await vi.importActual<typeof import('../services/registry-api')>('../services/registry-api');
return {
...actual,
listRegistryTagsResult: vi.fn(),
};
});
import { listRegistryTagsResult } from '../services/registry-api';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let RegistryService: typeof import('../services/RegistryService').RegistryService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ RegistryService } = await import('../services/RegistryService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(listRegistryTagsResult).mockReset();
});
describe('GET /api/registries/:id/tags', () => {
it('returns tags for an exact registry id', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({ ok: true, tags: ['latest', '1.0'] });
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.tags).toEqual(['latest', '1.0']);
expect(res.body.registryId).toBe(id);
expect(listRegistryTagsResult).toHaveBeenCalled();
});
it('maps upstream unauthorized to 424 with REGISTRY_UNAUTHORIZED (never 401)', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub2',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'bad',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UNAUTHORIZED',
message: 'Registry rejected credentials',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(424);
expect(res.body.code).toBe('REGISTRY_UNAUTHORIZED');
expect(res.status).not.toBe(401);
});
it('rejects host-looking repository values', async () => {
const id = RegistryService.getInstance().create({
name: 'GHCR',
url: 'ghcr.io',
type: 'ghcr',
username: 'user',
secret: 'token',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'ghcr.io/org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(listRegistryTagsResult).not.toHaveBeenCalled();
});
it('maps REGISTRY_UPSTREAM from the client to HTTP 502', async () => {
const id = RegistryService.getInstance().create({
name: 'Down',
url: 'https://registry.example.invalid/',
type: 'custom',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UPSTREAM',
message: 'Registry unreachable',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(502);
expect(res.body.code).toBe('REGISTRY_UPSTREAM');
});
it('returns 404 for unknown registry id', async () => {
const res = await request(app)
.get('/api/registries/999999/tags')
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
+83 -1
View File
@@ -39,7 +39,7 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE
vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, parseImageRef } from '../services/registry-api';
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api';
const TOKEN_BODY = JSON.stringify({ token: 'test-token' });
const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000';
@@ -234,4 +234,86 @@ describe('getRemoteDigestResult failure reasons', () => {
route = () => { throw new Error('ENOTFOUND'); };
expect(await get()).toEqual({ ok: false, reason: `Registry unreachable for ${REF} (ENOTFOUND)` });
});
it('falls back to anonymous manifest lookup when auth transport fails', async () => {
route = (url, method): FakeResp => {
if (url.includes('auth.docker.io/token')) {
throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
}
if (method === 'HEAD') return { statusCode: 200, headers: { 'docker-content-digest': REMOTE } };
return { statusCode: 500, headers: {} };
};
expect(await get()).toEqual({ ok: true, digest: REMOTE });
});
});
describe('listRegistryTagsResult', () => {
const creds = { username: 'u', password: 'p' };
const GHCR_CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"';
beforeEach(() => {
calls.length = 0;
});
function authThenTags(tagResp: FakeResp): (url: string, method: string) => FakeResp {
return (url) => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 200, headers: {}, body: TOKEN_BODY };
if (url.includes('/tags/list')) return tagResp;
return { statusCode: 500, headers: {} };
};
}
async function expectFailure(code: string, message?: string): Promise<void> {
const result = await listRegistryTagsResult('ghcr.io', 'acme/app', creds);
expect(result).toMatchObject(message ? { ok: false, code, message } : { ok: false, code });
}
it('returns tags on a successful list', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['latest', '1.0'] }) });
await expect(listRegistryTagsResult('ghcr.io', 'acme/app', creds)).resolves.toEqual({
ok: true,
tags: ['latest', '1.0'],
});
});
it('maps transport failure during auth ping to REGISTRY_UPSTREAM (not UNAUTHORIZED)', async () => {
route = () => { throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); };
await expectFailure('REGISTRY_UPSTREAM', 'Registry unreachable');
});
it('maps a rejected token to REGISTRY_UNAUTHORIZED', async () => {
route = (url): FakeResp => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} };
return { statusCode: 200, headers: {}, body: '{}' };
};
await expectFailure('REGISTRY_UNAUTHORIZED', 'Registry rejected credentials');
});
it('maps 403 on tags/list to REGISTRY_FORBIDDEN', async () => {
route = authThenTags({ statusCode: 403, headers: {} });
await expectFailure('REGISTRY_FORBIDDEN');
});
it('maps 429 on tags/list to REGISTRY_RATE_LIMITED', async () => {
route = authThenTags({ statusCode: 429, headers: {} });
await expectFailure('REGISTRY_RATE_LIMITED');
});
it('maps invalid JSON to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: 'not-json' });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry returned invalid JSON');
});
it('maps a non-array tags field to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: 'latest' }) });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list was malformed');
});
it('maps an oversized body to REGISTRY_INVALID_RESPONSE', async () => {
const huge = '{"tags":["' + 'x'.repeat(2 * 1024 * 1024) + '"]}';
route = authThenTags({ statusCode: 200, headers: {}, body: huge });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large');
});
});