main bac21c6

service_test.go RawHistoryFolder

1package images
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"testing"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/gordon/internal/boundaries/out"
15	"github.com/bnema/gordon/internal/domain"
16	pkgruntime "github.com/bnema/gordon/pkg/runtime"
17)
18
19func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20	manifestStorage := newFakeManifestStorage()
21	blobStorage := noopBlobStorage{}
22
23	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
24	rt := &fakeRuntime{
25		listDetails: []pkgruntime.ImageDetail{
26			{
27				ID:       "sha256:111",
28				RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"},
29				Size:     1234,
30				Created:  createdAt,
31			},
32			{
33				ID:       "sha256:222",
34				RepoTags: []string{"<none>:<none>"},
35				Size:     4321,
36				Created:  createdAt.Add(-time.Hour),
37			},
38		},
39	}
40
41	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
42
43	images, err := svc.ListImages(context.Background())
44
45	require.NoError(t, err)
46	require.Len(t, images, 3)
47
48	assert.Equal(t, domain.ImageInfo{
49		Repository: "gordon/api",
50		Tag:        "latest",
51		Size:       1234,
52		Created:    createdAt,
53		ID:         "sha256:111",
54		Dangling:   false,
55	}, images[0])
56
57	assert.Equal(t, domain.ImageInfo{
58		Repository: "gordon/api",
59		Tag:        "v1.2.3",
60		Size:       1234,
61		Created:    createdAt,
62		ID:         "sha256:111",
63		Dangling:   false,
64	}, images[1])
65
66	assert.Equal(t, domain.ImageInfo{
67		Repository: "",
68		Tag:        "",
69		Size:       4321,
70		Created:    createdAt.Add(-time.Hour),
71		ID:         "sha256:222",
72		Dangling:   true,
73	}, images[2])
74}
75
76func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77	manifestStorage := newFakeManifestStorage()
78	blobStorage := noopBlobStorage{}
79	rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
80
81	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
82
83	images, err := svc.ListImages(context.Background())
84
85	require.NoError(t, err)
86	assert.Empty(t, images)
87}
88
89func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90	manifestStorage := newFakeManifestStorage()
91	blobStorage := noopBlobStorage{}
92	createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
93
94	rt := &fakeRuntime{
95		listDetails: []pkgruntime.ImageDetail{{
96			ID:       "sha256:mixed",
97			RepoTags: []string{"<none>:<none>", "gordon/web:latest"},
98			Size:     100,
99			Created:  createdAt,
100		}},
101	}
102
103	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
104
105	images, err := svc.ListImages(context.Background())
106
107	require.NoError(t, err)
108	require.Len(t, images, 1)
109	assert.Equal(t, "gordon/web", images[0].Repository)
110	assert.Equal(t, "latest", images[0].Tag)
111	assert.False(t, images[0].Dangling)
112}
113
114func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115	manifestStorage := newFakeManifestStorage()
116	blobStorage := noopBlobStorage{}
117	rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118
119	svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
120
121	_, err := svc.ListImages(context.Background())
122
123	require.Error(t, err)
124	assert.Contains(t, err.Error(), "failed to list images")
125}
126
127func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) {
128	manifestStorage := newFakeManifestStorage()
129	manifestStorage.repositories = []string{"gordon/api"}
130	manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"}
131	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
132	manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
133
134	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
135	rt := &fakeRuntime{
136		listDetails: []pkgruntime.ImageDetail{
137			{
138				ID:       "sha256:runtime",
139				RepoTags: []string{"reg.bnema.dev/gordon/api:latest"},
140				Size:     4096,
141				Created:  createdAt,
142			},
143		},
144	}
145
146	svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default())
147
148	images, err := svc.ListImages(context.Background())
149
150	require.NoError(t, err)
151	require.Len(t, images, 3)
152
153	assert.Equal(t, domain.ImageInfo{
154		Repository: "reg.bnema.dev/gordon/api",
155		Tag:        "latest",
156		Size:       4096,
157		Created:    createdAt,
158		ID:         "sha256:runtime",
159		Dangling:   false,
160	}, images[0])
161
162	assert.Equal(t, domain.ImageInfo{
163		Repository: "reg.bnema.dev/gordon/api",
164		Tag:        "v1.2.3",
165		Size:       0,
166		Created:    time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC),
167		ID:         "",
168		Dangling:   false,
169	}, images[1])
170
171	assert.Equal(t, domain.ImageInfo{
172		Repository: "reg.bnema.dev/gordon/api",
173		Tag:        "v1.2.2",
174		Size:       0,
175		Created:    time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC),
176		ID:         "",
177		Dangling:   false,
178	}, images[2])
179}
180
181type noopBlobStorage struct {
182	out.BlobStorage
183}
184
185type fakeRuntime struct {
186	pkgruntime.Runtime
187
188	listDetails []pkgruntime.ImageDetail
189	listErr     error
190}
191
192type manifestRef struct {
193	name      string
194	reference string
195}
196
197type fakeManifestStorage struct {
198	out.ManifestStorage
199
200	repositories []string
201	tagsByRepo   map[string][]string
202	modTimes     map[string]time.Time
203	manifests    map[string][]byte
204
205	listRepositoriesCalls int
206	deletedManifests      []manifestRef
207	deleteErr             error
208}
209
210type fakeBlobStorage struct {
211	out.BlobStorage
212
213	blobs          []string
214	blobSizes      map[string]int64
215	blobModTimes   map[string]time.Time
216	blobModTimeErr error
217	deletedBlobs   []string
218}
219
220func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
221	return f.listDetails, f.listErr
222}
223
224func newFakeManifestStorage() *fakeManifestStorage {
225	return &fakeManifestStorage{
226		tagsByRepo: make(map[string][]string),
227		modTimes:   make(map[string]time.Time),
228		manifests:  make(map[string][]byte),
229	}
230}
231
232func manifestRefKey(name, reference string) string {
233	return name + "@" + reference
234}
235
236func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
237	t.Helper()
238
239	type descriptor struct {
240		Digest string `json:"digest"`
241	}
242
243	payload := struct {
244		Config descriptor   `json:"config"`
245		Layers []descriptor `json:"layers"`
246	}{
247		Config: descriptor{Digest: configDigest},
248		Layers: make([]descriptor, 0, len(layerDigests)),
249	}
250
251	for _, digest := range layerDigests {
252		payload.Layers = append(payload.Layers, descriptor{Digest: digest})
253	}
254
255	data, err := json.Marshal(payload)
256	require.NoError(t, err)
257	return data
258}
259
260func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
261	t.Helper()
262
263	type descriptor struct {
264		Digest string `json:"digest"`
265	}
266
267	payload := struct {
268		Manifests []descriptor `json:"manifests"`
269	}{
270		Manifests: make([]descriptor, 0, len(digests)),
271	}
272
273	for _, digest := range digests {
274		payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
275	}
276
277	data, err := json.Marshal(payload)
278	require.NoError(t, err)
279	return data
280}
281
282func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
283	f.listRepositoriesCalls++
284	return append([]string(nil), f.repositories...), nil
285}
286
287func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
288	return append([]string(nil), f.tagsByRepo[name]...), nil
289}
290
291func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
292	modTime, ok := f.modTimes[manifestRefKey(name, reference)]
293	if !ok {
294		return time.Time{}, errors.New("manifest modtime not found")
295	}
296	return modTime, nil
297}
298
299func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
300	if f.deleteErr != nil {
301		return f.deleteErr
302	}
303	f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
304	tags := f.tagsByRepo[name]
305	filtered := tags[:0]
306	for _, tag := range tags {
307		if tag != reference {
308			filtered = append(filtered, tag)
309		}
310	}
311	f.tagsByRepo[name] = filtered
312	delete(f.manifests, manifestRefKey(name, reference))
313	return nil
314}
315
316func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
317	manifest, ok := f.manifests[manifestRefKey(name, reference)]
318	if !ok {
319		return nil, "", errors.New("manifest not found")
320	}
321	return manifest, "application/vnd.oci.image.manifest.v1+json", nil
322}
323
324func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
325	return append([]string(nil), f.blobs...), nil
326}
327
328func (f *fakeBlobStorage) GetBlobModTime(digest string) (time.Time, error) {
329	if f.blobModTimeErr != nil {
330		return time.Time{}, f.blobModTimeErr
331	}
332	return f.blobModTimes[digest], nil
333}
334
335func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
336	f.deletedBlobs = append(f.deletedBlobs, digest)
337	var size int64
338	if f.blobSizes != nil {
339		size = f.blobSizes[digest]
340	}
341	for i, existing := range f.blobs {
342		if existing != digest {
343			continue
344		}
345		f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
346		break
347	}
348	return size, nil
349}
350
351func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
352	return 0, 0, nil
353}
354
355func TestSplitRepoTag(t *testing.T) {
356	tests := []struct {
357		name     string
358		input    string
359		wantRepo string
360		wantTag  string
361	}{
362		{name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
363		{name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
364		{name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
365		{name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
366		{name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
367		{name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
368		{name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
369	}
370
371	for _, tt := range tests {
372		t.Run(tt.name, func(t *testing.T) {
373			repo, tag := splitRepoTag(tt.input)
374			assert.Equal(t, tt.wantRepo, repo)
375			assert.Equal(t, tt.wantTag, tag)
376		})
377	}
378}
379
380var _ pkgruntime.Runtime = (*fakeRuntime)(nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close