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)