mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
e3944295f2
* feat: link container images to registry and source metadata Turn image references on the stack surfaces into actionable links so an operator can review what an image ships before approving an update. A new pure helper maps a reference to its registry page (Docker Hub for official and namespace images, the owner profile for GitHub Container Registry) and never guesses a link for unknown or private registries. A shared dropdown adds copy-image-reference plus source, homepage, documentation, and revision links read from the image OCI labels via a local inspect, so they need no internet access and only appear when the image ships them. Version and non-commit revisions render as plain text. The menu is wired into the stack header image row, each per-container health card, and the update-readiness cards. * fix: invalidate image-source inspect token synchronously on image change The request token that drops a stale image inspect response was bumped only in a passive effect cleanup, which runs after paint. A response for a superseded image could resolve in the gap after the image-id change committed and still pass the token check, writing stale source labels into the new menu. Move the invalidation to a layout effect so the token is bumped during commit, before any network response can interleave. Add a regression test that holds the first inspect open, changes the image id, then resolves the stale response and asserts it is discarded.
185 lines
7.4 KiB
TypeScript
185 lines
7.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parseImageRef, buildImageLinks, extractImageSourceMeta } from './imageLinks';
|
|
|
|
describe('parseImageRef', () => {
|
|
it('parses an official Docker Hub image with a tag', () => {
|
|
expect(parseImageRef('nginx:latest')).toEqual({
|
|
registry: 'docker.io', namespace: null, repo: 'nginx', tag: 'latest', digest: null,
|
|
});
|
|
});
|
|
|
|
it('parses a bare image name with no tag', () => {
|
|
expect(parseImageRef('nginx')).toEqual({
|
|
registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest: null,
|
|
});
|
|
});
|
|
|
|
it('parses a Docker Hub namespace image', () => {
|
|
expect(parseImageRef('linuxserver/sonarr:latest')).toEqual({
|
|
registry: 'docker.io', namespace: 'linuxserver', repo: 'sonarr', tag: 'latest', digest: null,
|
|
});
|
|
});
|
|
|
|
it('parses a GHCR image', () => {
|
|
expect(parseImageRef('ghcr.io/owner/image:tag')).toEqual({
|
|
registry: 'ghcr.io', namespace: 'owner', repo: 'image', tag: 'tag', digest: null,
|
|
});
|
|
});
|
|
|
|
it('parses a private registry with a port', () => {
|
|
expect(parseImageRef('registry.example.com:5000/team/app:1.2')).toEqual({
|
|
registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: '1.2', digest: null,
|
|
});
|
|
});
|
|
|
|
it('captures the digest of a digest-pinned namespace image', () => {
|
|
const digest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
|
|
expect(parseImageRef(`myrepo/app@${digest}`)).toEqual({
|
|
registry: 'docker.io', namespace: 'myrepo', repo: 'app', tag: null, digest,
|
|
});
|
|
});
|
|
|
|
it('captures the digest of a digest-pinned official image', () => {
|
|
const digest = 'sha256:1111111111111111111111111111111111111111111111111111111111111111';
|
|
expect(parseImageRef(`nginx@${digest}`)).toEqual({
|
|
registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest,
|
|
});
|
|
});
|
|
|
|
it('keeps both the tag and the digest of a tag-and-digest pinned ref', () => {
|
|
const digest = 'sha256:3333333333333333333333333333333333333333333333333333333333333333';
|
|
expect(parseImageRef(`nginx:1.25@${digest}`)).toEqual({
|
|
registry: 'docker.io', namespace: null, repo: 'nginx', tag: '1.25', digest,
|
|
});
|
|
});
|
|
|
|
it('does not mistake a registry port for a tag when no tag is present', () => {
|
|
expect(parseImageRef('registry.example.com:5000/team/app')).toEqual({
|
|
registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: null, digest: null,
|
|
});
|
|
});
|
|
|
|
it('returns null for a bare digest and for empty input', () => {
|
|
expect(parseImageRef('sha256:abc')).toBeNull();
|
|
expect(parseImageRef(' ')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildImageLinks', () => {
|
|
it('links official Docker Hub images to the _/ page', () => {
|
|
const links = buildImageLinks('nginx:latest');
|
|
expect(links?.kind).toBe('dockerhub-official');
|
|
expect(links?.registryUrl).toBe('https://hub.docker.com/_/nginx');
|
|
});
|
|
|
|
it('treats library/ as an official image', () => {
|
|
expect(buildImageLinks('library/nginx')?.registryUrl).toBe('https://hub.docker.com/_/nginx');
|
|
});
|
|
|
|
it('treats docker.io / registry-1 / index aliases as Docker Hub', () => {
|
|
for (const ref of [
|
|
'docker.io/library/nginx',
|
|
'registry-1.docker.io/library/nginx:latest',
|
|
'index.docker.io/library/nginx',
|
|
]) {
|
|
const links = buildImageLinks(ref);
|
|
expect(links?.kind, ref).toBe('dockerhub-official');
|
|
expect(links?.registryUrl, ref).toBe('https://hub.docker.com/_/nginx');
|
|
}
|
|
});
|
|
|
|
it('links Docker Hub namespace images to the r/ page', () => {
|
|
const links = buildImageLinks('linuxserver/sonarr:latest');
|
|
expect(links?.kind).toBe('dockerhub-namespace');
|
|
expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
|
|
});
|
|
|
|
it('links GHCR images to the owner profile', () => {
|
|
const links = buildImageLinks('ghcr.io/owner/image:tag');
|
|
expect(links?.kind).toBe('ghcr');
|
|
expect(links?.registryHost).toBe('ghcr.io');
|
|
expect(links?.registryUrl).toBe('https://github.com/owner');
|
|
});
|
|
|
|
it('never guesses a link for an unknown/private registry', () => {
|
|
const links = buildImageLinks('registry.example.com:5000/team/app:1.2');
|
|
expect(links?.kind).toBe('other');
|
|
expect(links?.registryHost).toBe('registry.example.com:5000');
|
|
expect(links?.registryUrl).toBeNull();
|
|
});
|
|
|
|
it('still resolves the registry page for a digest-pinned ref', () => {
|
|
const links = buildImageLinks('linuxserver/sonarr@sha256:2222222222222222222222222222222222222222222222222222222222222222');
|
|
expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
|
|
});
|
|
|
|
it('returns null for an unparseable ref', () => {
|
|
expect(buildImageLinks('sha256:abc')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractImageSourceMeta', () => {
|
|
it('returns nothing for empty or missing labels', () => {
|
|
expect(extractImageSourceMeta(null)).toEqual({ links: [], version: null, revision: null });
|
|
expect(extractImageSourceMeta({})).toEqual({ links: [], version: null, revision: null });
|
|
});
|
|
|
|
it('emits links only for valid absolute http(s) label values', () => {
|
|
const meta = extractImageSourceMeta({
|
|
'org.opencontainers.image.source': 'https://github.com/owner/repo',
|
|
'org.opencontainers.image.url': 'https://example.com',
|
|
'org.opencontainers.image.documentation': 'http://docs.example.com',
|
|
});
|
|
expect(meta.links.map(l => l.id)).toEqual(['source', 'url', 'documentation']);
|
|
});
|
|
|
|
it('rejects relative, javascript:, and non-URL label values', () => {
|
|
const meta = extractImageSourceMeta({
|
|
'org.opencontainers.image.source': '/owner/repo',
|
|
'org.opencontainers.image.url': 'javascript:alert(1)',
|
|
'org.opencontainers.image.documentation': 'not a url',
|
|
});
|
|
expect(meta.links).toEqual([]);
|
|
});
|
|
|
|
it('renders version as text only, never a derived release link', () => {
|
|
const meta = extractImageSourceMeta({ 'org.opencontainers.image.version': '1.2.3' });
|
|
expect(meta.version).toBe('1.2.3');
|
|
expect(meta.links).toEqual([]);
|
|
});
|
|
|
|
it('links a SHA-like revision to a GitHub commit when source is a github repo', () => {
|
|
const meta = extractImageSourceMeta({
|
|
'org.opencontainers.image.source': 'https://github.com/owner/repo.git',
|
|
'org.opencontainers.image.revision': 'abcdef1234567890',
|
|
});
|
|
const rev = meta.links.find(l => l.id === 'revision');
|
|
expect(rev?.url).toBe('https://github.com/owner/repo/commit/abcdef1234567890');
|
|
expect(meta.revision).toBeNull();
|
|
});
|
|
|
|
it('keeps a SHA-like revision as text when there is no source label to anchor it', () => {
|
|
const meta = extractImageSourceMeta({
|
|
'org.opencontainers.image.revision': 'abcdef1234567890',
|
|
});
|
|
expect(meta.links).toEqual([]);
|
|
expect(meta.revision).toBe('abcdef1234567890');
|
|
});
|
|
|
|
it('keeps revision as text when it is not SHA-like or source is not github', () => {
|
|
const notSha = extractImageSourceMeta({
|
|
'org.opencontainers.image.source': 'https://github.com/owner/repo',
|
|
'org.opencontainers.image.revision': 'v1.2.3',
|
|
});
|
|
expect(notSha.links.find(l => l.id === 'revision')).toBeUndefined();
|
|
expect(notSha.revision).toBe('v1.2.3');
|
|
|
|
const notGithub = extractImageSourceMeta({
|
|
'org.opencontainers.image.source': 'https://gitlab.com/owner/repo',
|
|
'org.opencontainers.image.revision': 'abcdef1234567890',
|
|
});
|
|
expect(notGithub.links.find(l => l.id === 'revision')).toBeUndefined();
|
|
expect(notGithub.revision).toBe('abcdef1234567890');
|
|
});
|
|
});
|