package e2e import ( "fmt" "net/http" "net/url" "os" "path/filepath" "strings" "testing" ) func (e *env) blobFile(digest string) string { return filepath.Join(e.DataDir, "registry", "blobs", strings.Replace(digest, ":", "/", 1)) } func TestImagesTab(t *testing.T) { e := newEnv(t, "REGISTRY_PULL", "users") admin := e.admin() alice := e.register("alice", "password123") e.createRepo(admin, "img-repo") e.createRepo(admin, "other-repo") cfgA, layA, _ := pushImage(t, e, "img-repo", "v1", "a") _, _, manB := pushImage(t, e, "img-repo", "v2", "b") pushImage(t, e, "img-repo/web", "latest", "c") // Shared layer: other-repo references the same bytes as img-repo v1. sharedCfg, sharedLay, _ := pushImage(t, e, "other-repo", "v1", "a") if sharedCfg != cfgA || sharedLay != layA { t.Fatal("expected identical digests for identical content") } t.Run("tab visible and lists images with tags", func(t *testing.T) { r := admin.get("/img-repo/images").mustStatus(200) if !contains(admin.get("/img-repo").Texts(".repo-tab"), "Images") { t.Error("Images tab missing") } titles := r.Texts(".release-item-title") if len(titles) != 2 || !contains(titles, "img-repo") || !contains(titles, "img-repo/web") { t.Errorf("images = %v", titles) } if tags := r.Texts(".image-tag .badge"); len(tags) != 3 { t.Errorf("tags = %v", tags) } }) t.Run("tags are listed newest first", func(t *testing.T) { for tag, at := range map[string]string{"v1": "2026-01-01T00:00:00Z", "v2": "2026-02-01T00:00:00Z"} { if _, err := e.DB.Exec(`UPDATE registry_tags SET updated_at = ? WHERE tag = ? AND image = ''`, at, tag); err != nil { t.Fatal(err) } } tags := admin.get("/img-repo/images").Texts(".image-tag .badge") if want := []string{"v2", "v1", "latest"}; !eqStrings(tags, want) { t.Errorf("tags = %v, want %v", tags, want) } }) t.Run("access follows REGISTRY_PULL", func(t *testing.T) { alice.get("/img-repo/images").mustStatus(200) if e.anon().get("/img-repo/images").Code != 403 { t.Error("anonymous could open the Images tab with REGISTRY_PULL=users") } if contains(e.anon().get("/img-repo").Texts(".repo-tab"), "Images") { t.Error("Images tab shown to anonymous") } if alice.get("/img-repo/images").Has(`form[action="/img-repo/images/delete"]`) { t.Error("delete form shown to non-admin") } alice.post("/img-repo/images/delete-all", nil).mustStatus(403) }) t.Run("delete tag removes an untagged manifest", func(t *testing.T) { // v2 is unique to this image, so its manifest file must go. admin.post("/img-repo/images/delete", url.Values{"image": {""}, "tag": {"v2"}}).mustRedirect("/img-repo/images") if got := regTags(t, e, "img-repo", ""); len(got) != 1 || got[0] != "v1" { t.Errorf("tags = %v", got) } regAdmin(t, e, http.MethodGet, "/v2/img-repo/manifests/"+manB, nil).mustStatus(404) if _, err := os.Stat(e.blobFile(manB)); !os.IsNotExist(err) { t.Error("manifest file still on disk") } // The v1 layer stays: other-repo links the same bytes. if _, err := os.Stat(e.blobFile(layA)); err != nil { t.Error("shared layer file removed") } }) t.Run("delete image removes only its unshared files", func(t *testing.T) { admin.post("/img-repo/images/delete", url.Values{"image": {""}}).mustRedirect("/img-repo/images") regAdmin(t, e, http.MethodGet, "/v2/img-repo/tags/list", nil).mustStatus(200) if got := regTags(t, e, "img-repo", ""); len(got) != 0 { t.Errorf("tags = %v", got) } if _, err := os.Stat(e.blobFile(layA)); err != nil { t.Error("layer shared with other-repo was removed") } if titles := admin.get("/img-repo/images").Texts(".release-item-title"); len(titles) != 1 { t.Errorf("images after delete = %v", titles) } }) t.Run("delete all empties the tab", func(t *testing.T) { admin.post("/img-repo/images/delete-all", nil).mustRedirect("/img-repo/images") r := admin.get("/img-repo/images") if r.Count(".release-item-title") != 0 || !r.Contains("No images yet") { t.Error("images remain after delete all") } }) t.Run("deleting a repo removes its unshared image files", func(t *testing.T) { _, layD, manD := pushImage(t, e, "other-repo", "v2", "d") admin.post("/other-repo/settings/delete", nil).mustRedirect("/") for _, d := range []string{layD, manD} { if _, err := os.Stat(e.blobFile(d)); !os.IsNotExist(err) { t.Errorf("file %s survived repo delete", d) } } }) } func TestImagesTabPublicAndPrivate(t *testing.T) { e := newEnv(t, "REGISTRY_PULL", "public") admin := e.admin() e.createRepo(admin, "pub-img") e.createRepo(admin, "priv-img", "is_private", "1") pushImage(t, e, "pub-img", "v1", "p") pushImage(t, e, "priv-img", "v1", "q") if !contains(e.anon().get("/pub-img").Texts(".repo-tab"), "Images") { t.Error("Images tab hidden from anonymous with REGISTRY_PULL=public") } e.anon().get("/pub-img/images").mustStatus(200) if e.anon().get("/priv-img/images").Code == 200 { t.Error("private repo images visible to anonymous") } admin.get("/priv-img/images").mustStatus(200) } func TestImageLayerCleanup(t *testing.T) { e := newEnv(t) admin := e.admin() e.createRepo(admin, "gc-repo") cfgData := []byte(`{"cfg":"shared"}`) cfg := regUpload(t, e, "gc-repo", cfgData) // push stores a manifest that shares cfg and has its own layer. An // empty tag pushes it by digest. push := func(seed, tag string) (layer, manifest string) { t.Helper() lay := []byte("layer-" + seed) layer = regUpload(t, e, "gc-repo", lay) body := ociManifest(cfg, len(cfgData), layer, len(lay)) manifest = regDigest(body) if tag == "" { tag = manifest } regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body, "Content-Type", ociManifestType).mustStatus(201) return layer, manifest } pushIndex := func(tag string, children ...string) string { t.Helper() var list []string for _, c := range children { list = append(list, fmt.Sprintf(`{"mediaType":%q,"digest":%q,"size":1}`, ociManifestType, c)) } body := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` + strings.Join(list, ",") + `]}`) regAdmin(t, e, http.MethodPut, "/v2/gc-repo/manifests/"+tag, body, "Content-Type", "application/vnd.oci.image.index.v1+json").mustStatus(201) return regDigest(body) } exists := func(d string) bool { _, err := os.Stat(e.blobFile(d)) return err == nil } t.Run("deleting a tag removes the layers only it used", func(t *testing.T) { lay1, man1 := push("1", "v1") lay2, _ := push("2", "v2") admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"v1"}}).mustRedirect("/gc-repo/images") if exists(lay1) || exists(man1) { t.Error("files of the deleted tag survived") } regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay1, nil).mustStatus(404) if !exists(cfg) || !exists(lay2) { t.Error("files still used by v2 were removed") } regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+cfg, nil).mustStatus(200) }) t.Run("moving a tag removes the old manifest", func(t *testing.T) { lay3, man3 := push("3", "moving") lay4, _ := push("4", "moving") if exists(lay3) || exists(man3) { t.Error("files of the replaced manifest survived") } if !exists(lay4) { t.Error("new layer missing") } }) t.Run("moving an index tag keeps children the new index uses", func(t *testing.T) { lay5, man5 := push("5", "") lay6, man6 := push("6", "") pushIndex("multi", man5, man6) pushIndex("multi", man5) if exists(lay6) || exists(man6) { t.Error("child dropped from the index survived") } if !exists(lay5) || !exists(man5) { t.Error("child still in the index was removed") } }) t.Run("a layer shared with another repo stays", func(t *testing.T) { e.createRepo(admin, "gc-other") _, shared, _ := pushImage(t, e, "gc-other", "v1", "shared") lay, _ := push("shared", "s1") if lay != shared { t.Fatal("expected identical layer digests") } admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"s1"}}).mustRedirect("/gc-repo/images") regAdmin(t, e, http.MethodHead, "/v2/gc-repo/blobs/"+lay, nil).mustStatus(404) regAdmin(t, e, http.MethodHead, "/v2/gc-other/blobs/"+lay, nil).mustStatus(200) }) t.Run("a child used by another index stays", func(t *testing.T) { lay8, man8 := push("8", "") pushIndex("a", man8) pushIndex("b", man8) r := regAdmin(t, e, http.MethodDelete, "/v2/gc-repo/manifests/"+man8, nil).mustStatus(409) if code := regErrCode(r); code != "DENIED" { t.Errorf("error code = %q", code) } admin.post("/gc-repo/images/delete", url.Values{"image": {""}, "tag": {"a"}}).mustRedirect("/gc-repo/images") if !exists(lay8) || !exists(man8) { t.Error("child still used by index b was removed") } }) t.Run("the startup sweep removes leftovers", func(t *testing.T) { stray := regUpload(t, e, "gc-repo", []byte("never used by a manifest")) lay7, man7 := push("7", "") junk := filepath.Join(filepath.Dir(e.blobFile(cfg)), ".manifest-123") if err := os.WriteFile(junk, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := e.Srv.SweepRegistry(t.Context()); err != nil { t.Fatal(err) } for _, d := range []string{stray, lay7, man7} { if exists(d) { t.Errorf("%s survived the sweep", d) } } if _, err := os.Stat(junk); !os.IsNotExist(err) { t.Error("stray file survived the sweep") } regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/multi", nil).mustStatus(200) regAdmin(t, e, http.MethodGet, "/v2/gc-repo/manifests/v2", nil).mustStatus(200) if !exists(cfg) { t.Error("shared config removed") } }) t.Run("an unreadable manifest stops the cleanup", func(t *testing.T) { const img = "gc-repo/broken" layer := regUpload(t, e, img, []byte("shared-layer")) var mans []string for _, tag := range []string{"keep", "drop"} { c := []byte(`{"cfg":"` + tag + `"}`) body := ociManifest(regUpload(t, e, img, c), len(c), layer, len("shared-layer")) regAdmin(t, e, http.MethodPut, "/v2/"+img+"/manifests/"+tag, body, "Content-Type", ociManifestType).mustStatus(201) mans = append(mans, regDigest(body)) } if err := os.WriteFile(e.blobFile(mans[0]), []byte("not json"), 0o644); err != nil { t.Fatal(err) } admin.post("/gc-repo/images/delete", url.Values{"image": {"broken"}, "tag": {"drop"}}).mustRedirect("/gc-repo/images") regAdmin(t, e, http.MethodHead, "/v2/"+img+"/blobs/"+layer, nil).mustStatus(200) if !exists(layer) { t.Error("layer of the unreadable manifest was removed") } }) }