images_test.go
⎇
Raw
1package e2e
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11)
12
13func (e *env) blobFile(digest string) string {
14 return filepath.Join(e.DataDir, "registry", "blobs", strings.Replace(digest, ":", "/", 1))
15}
16
17func TestImagesTab(t *testing.T) {
18 e := newEnv(t, "REGISTRY_PULL", "users")
19 admin := e.admin()
20 alice := e.register("alice", "password123")
21 e.createRepo(admin, "img-repo")
22 e.createRepo(admin, "other-repo")
23 cfgA, layA, _ := pushImage(t, e, "img-repo", "v1", "a")
24 _, _, manB := pushImage(t, e, "img-repo", "v2", "b")
25 pushImage(t, e, "img-repo/web", "latest", "c")
26 // Shared layer: other-repo references the same bytes as img-repo v1.
27 sharedCfg, sharedLay, _ := pushImage(t, e, "other-repo", "v1", "a")
28 if sharedCfg != cfgA || sharedLay != layA {
29 t.Fatal("expected identical digests for identical content")
30 }
31
32 t.Run("tab visible and lists images with tags", func(t *testing.T) {
33 r := admin.get("/img-repo/images").mustStatus(200)
34 if !contains(admin.get("/img-repo").Texts(".repo-tab"), "Images") {
35 t.Error("Images tab missing")
36 }
37 titles := r.Texts(".release-item-title")
38 if len(titles) != 2 || !contains(titles, "img-repo") || !contains(titles, "img-repo/web") {
39 t.Errorf("images = %v", titles)
40 }
41 if tags := r.Texts(".image-tag .badge"); len(tags) != 3 {
42 t.Errorf("tags = %v", tags)
43 }
44 })
45
46 t.Run("tags are listed newest first", func(t *testing.T) {
47 for tag, at := range map[string]string{"v1": "2026-01-01T00:00:00Z", "v2": "2026-02-01T00:00:00Z"} {
48 if _, err := e.DB.Exec(`UPDATE registry_tags SET updated_at = ? WHERE tag = ? AND image = ''`, at, tag); err != nil {
49 t.Fatal(err)
50 }
51 }
52 tags := admin.get("/img-repo/images").Texts(".image-tag .badge")
53 if want := []string{"v2", "v1", "latest"}; !eqStrings(tags, want) {
54 t.Errorf("tags = %v, want %v", tags, want)
55 }
56 })
57
58 t.Run("access follows REGISTRY_PULL", func(t *testing.T) {
59 alice.get("/img-repo/images").mustStatus(200)
60 if e.anon().get("/img-repo/images").Code != 403 {
61 t.Error("anonymous could open the Images tab with REGISTRY_PULL=users")
62 }
63 if contains(e.anon().get("/img-repo").Texts(".repo-tab"), "Images") {
64 t.Error("Images tab shown to anonymous")
65 }
66 if alice.get("/img-repo/images").Has(`form[action="/img-repo/images/delete"]`) {
67 t.Error("delete form shown to non-admin")
68 }
69 alice.post("/img-repo/images/delete-all", nil).mustStatus(403)
70 })
71
72 t.Run("delete tag removes an untagged manifest", func(t *testing.T) {
73 // v2 is unique to this image, so its manifest file must go.
74 admin.post("/img-repo/images/delete", url.Values{"image": {""}, "tag": {"v2"}}).mustRedirect("/img-repo/images")
75 if got := regTags(t, e, "img-repo", ""); len(got) != 1 || got[0] != "v1" {
76 t.Errorf("tags = %v", got)
77 }
78 regAdmin(t, e, http.MethodGet, "/v2/img-repo/manifests/"+manB, nil).mustStatus(404)
79 if _, err := os.Stat(e.blobFile(manB)); !os.IsNotExist(err) {
80 t.Error("manifest file still on disk")
81 }
82 // The v1 layer stays: other-repo links the same bytes.
83 if _, err := os.Stat(e.blobFile(layA)); err != nil {
84 t.Error("shared layer file removed")
85 }
86 })
87
88 t.Run("delete image removes only its unshared files", func(t *testing.T) {
89 admin.post("/img-repo/images/delete", url.Values{"image": {""}}).mustRedirect("/img-repo/images")
90 regAdmin(t, e, http.MethodGet, "/v2/img-repo/tags/list", nil).mustStatus(200)
91 if got := regTags(t, e, "img-repo", ""); len(got) != 0 {
92 t.Errorf("tags = %v", got)
93 }
94 if _, err := os.Stat(e.blobFile(layA)); err != nil {
95 t.Error("layer shared with other-repo was removed")
96 }
97 if titles := admin.get("/img-repo/images").Texts(".release-item-title"); len(titles) != 1 {
98 t.Errorf("images after delete = %v", titles)
99 }
100 })
101
102 t.Run("delete all empties the tab", func(t *testing.T) {
103 admin.post("/img-repo/images/delete-all", nil).mustRedirect("/img-repo/images")
104 r := admin.get("/img-repo/images")
105 if r.Count(".release-item-title") != 0 || !r.Contains("No images yet") {
106 t.Error("images remain after delete all")
107 }
108 })
109
110 t.Run("deleting a repo removes its unshared image files", func(t *testing.T) {
111 _, layD, manD := pushImage(t, e, "other-repo", "v2", "d")
112 admin.post("/other-repo/settings/delete", nil).mustRedirect("/")
113 for _, d := range []string{layD, manD} {
114 if _, err := os.Stat(e.blobFile(d)); !os.IsNotExist(err) {
115 t.Errorf("file %s survived repo delete", d)
116 }
117 }
118 })
119}
120
121func TestImagesTabPublicAndPrivate(t *testing.T) {
122 e := newEnv(t, "REGISTRY_PULL", "public")
123 admin := e.admin()
124 e.createRepo(admin, "pub-img")
125 e.createRepo(admin, "priv-img", "is_private", "1")
126 pushImage(t, e, "pub-img", "v1", "p")
127 pushImage(t, e, "priv-img", "v1", "q")
128
129 if !contains(e.anon().get("/pub-img").Texts(".repo-tab"), "Images") {
130 t.Error("Images tab hidden from anonymous with REGISTRY_PULL=public")
131 }
132 e.anon().get("/pub-img/images").mustStatus(200)
133 if e.anon().get("/priv-img/images").Code == 200 {
134 t.Error("private repo images visible to anonymous")
135 }
136 admin.get("/priv-img/images").mustStatus(200)
137}
138
139func TestImageLayerCleanup(t *testing.T) {
140 e := newEnv(t)
141 admin := e.admin()
142 e.createRepo(admin, "gc-repo")
143 cfgData := []byte(`{"cfg":"shared"}`)
144 cfg := regUpload(t, e, "gc-repo", cfgData)
145
146 // push stores a manifest that shares cfg and has its own layer. An
147 // empty tag pushes it by digest.
148 push := func(seed, tag string) (layer, manifest string) {
149 t.Helper()
150 lay := []byte("layer-" + seed)
151 layer = regUpload(t, e, "gc-repo", lay)
152 body := ociManifest(cfg, len(cfgData), layer, len(lay))
153 manifest = regDigest(body)
154 if tag == "" {
155 tag = manifest
156 }
157 regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body,
158 "Content-Type", ociManifestType).mustStatus(201)
159 return layer, manifest
160 }
161 pushIndex := func(tag string, children ...string) string {
162 t.Helper()
163 var list []string
164 for _, c := range children {
165 list = append(list, fmt.Sprintf(`{"mediaType":%q,"digest":%q,"size":1}`, ociManifestType, c))
166 }
167 body := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
168 strings.Join(list, ",") + `]}`)
169 regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body,
170 "Content-Type", "application/vnd.oci.image.index.v1+json").mustStatus(201)
171 return regDigest(body)
172 }
173 exists := func(d string) bool {
174 _, err := os.Stat(e.blobFile(d))
175 return err == nil
176 }
177
178 t.Run("deleting a tag removes the layers only it used", func(t *testing.T) {
179 lay1, man1 := push("1", "v1")
180 lay2, _ := push("2", "v2")
181 admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"v1"}}).mustRedirect("/gc-repo/images")
182 if exists(lay1) || exists(man1) {
183 t.Error("files of the deleted tag survived")
184 }
185 regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay1, nil).mustStatus(404)
186 if !exists(cfg) || !exists(lay2) {
187 t.Error("files still used by v2 were removed")
188 }
189 regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+cfg, nil).mustStatus(200)
190 })
191
192 t.Run("moving a tag removes the old manifest", func(t *testing.T) {
193 lay3, man3 := push("3", "moving")
194 lay4, _ := push("4", "moving")
195 if exists(lay3) || exists(man3) {
196 t.Error("files of the replaced manifest survived")
197 }
198 if !exists(lay4) {
199 t.Error("new layer missing")
200 }
201 })
202
203 t.Run("moving an index tag keeps children the new index uses", func(t *testing.T) {
204 lay5, man5 := push("5", "")
205 lay6, man6 := push("6", "")
206 pushIndex("multi", man5, man6)
207 pushIndex("multi", man5)
208 if exists(lay6) || exists(man6) {
209 t.Error("child dropped from the index survived")
210 }
211 if !exists(lay5) || !exists(man5) {
212 t.Error("child still in the index was removed")
213 }
214 })
215
216 t.Run("a layer shared with another repo stays", func(t *testing.T) {
217 e.createRepo(admin, "gc-other")
218 _, shared, _ := pushImage(t, e, "gc-other", "v1", "shared")
219 lay, _ := push("shared", "s1")
220 if lay != shared {
221 t.Fatal("expected identical layer digests")
222 }
223 admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"s1"}}).mustRedirect("/gc-repo/images")
224 regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay, nil).mustStatus(404)
225 regAdmin(t, e, http.MethodHead, "/v2/gc-other/blobs/"+lay, nil).mustStatus(200)
226 })
227
228 t.Run("a child used by another index stays", func(t *testing.T) {
229 lay8, man8 := push("8", "")
230 pushIndex("a", man8)
231 pushIndex("b", man8)
232 r := regAdmin(t, e, http.MethodDelete, "/v2/gc-repo/manifests/"+man8, nil).mustStatus(409)
233 if code := regErrCode(r); code != "DENIED" {
234 t.Errorf("error code = %q", code)
235 }
236 admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"a"}}).mustRedirect("/gc-repo/images")
237 if !exists(lay8) || !exists(man8) {
238 t.Error("child still used by index b was removed")
239 }
240 })
241
242 t.Run("the startup sweep removes leftovers", func(t *testing.T) {
243 stray := regUpload(t, e, "gc-repo", []byte("never used by a manifest"))
244 lay7, man7 := push("7", "")
245 junk := filepath.Join(filepath.Dir(e.blobFile(cfg)), ".manifest-123")
246 if err := os.WriteFile(junk, []byte("x"), 0o644); err != nil {
247 t.Fatal(err)
248 }
249 if err := e.Srv.SweepRegistry(t.Context()); err != nil {
250 t.Fatal(err)
251 }
252 for _, d := range []string{stray, lay7, man7} {
253 if exists(d) {
254 t.Errorf("%s survived the sweep", d)
255 }
256 }
257 if _, err := os.Stat(junk); !os.IsNotExist(err) {
258 t.Error("stray file survived the sweep")
259 }
260 regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/multi", nil).mustStatus(200)
261 regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/v2", nil).mustStatus(200)
262 if !exists(cfg) {
263 t.Error("shared config removed")
264 }
265 })
266
267 t.Run("an unreadable manifest stops the cleanup", func(t *testing.T) {
268 const img = "gc-repo/broken"
269 layer := regUpload(t, e, img, []byte("shared-layer"))
270 var mans []string
271 for _, tag := range []string{"keep", "drop"} {
272 c := []byte(`{"cfg":"` + tag + `"}`)
273 body := ociManifest(regUpload(t, e, img, c), len(c), layer, len("shared-layer"))
274 regAdmin(t, e, http.MethodPut, "/v2/"+img+"/manifests/"+tag, body,
275 "Content-Type", ociManifestType).mustStatus(201)
276 mans = append(mans, regDigest(body))
277 }
278 if err := os.WriteFile(e.blobFile(mans[0]), []byte("not json"), 0o644); err != nil {
279 t.Fatal(err)
280 }
281 admin.post("/gc-repo/images/delete", url.Values{"image": {"broken"}, "tag": {"drop"}}).mustRedirect("/gc-repo/images")
282 regAdmin(t, e, http.MethodHead, "/v2/"+img+"/blobs/"+layer, nil).mustStatus(200)
283 if !exists(layer) {
284 t.Error("layer of the unreadable manifest was removed")
285 }
286 })
287}
288