mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user