package e2e import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "testing" "hearthforge/internal/db" ) // The registry suite drives the OCI Distribution endpoints under /v2/. // Registry clients use HTTP Basic auth, not the session cookie, so these // tests build their own requests instead of using a session. const ( ociManifestType = "application/vnd.oci.image.manifest.v1+json" registryRealm = `Basic realm="Hearthforge registry"` ) // regReq sends one registry request and reads the whole response. An empty // user sends no Authorization header. Redirects are not followed. func regReq(t *testing.T, e *env, method, path string, body []byte, user, pass string, headers ...string) *response { t.Helper() var rd io.Reader if body != nil { rd = bytes.NewReader(body) } req, err := http.NewRequest(method, e.Base+path, rd) if err != nil { t.Fatal(err) } if user != "" { req.SetBasicAuth(user, pass) } for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } res, err := e.anon().client.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } defer res.Body.Close() data, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return &response{t: t, Code: res.StatusCode, Header: res.Header, Body: data} } // regAdmin sends a request signed in as the admin. func regAdmin(t *testing.T, e *env, method, path string, body []byte, headers ...string) *response { t.Helper() return regReq(t, e, method, path, body, db.AdminUsername, adminPass, headers...) } // regDigest is the content digest the registry expects. func regDigest(b []byte) string { sum := sha256.Sum256(b) return "sha256:" + hex.EncodeToString(sum[:]) } // regErrCode returns the first error code of a registry error envelope. func regErrCode(r *response) string { var body struct { Errors []struct{ Code string } `json:"errors"` } if err := json.Unmarshal(r.Body, &body); err != nil || len(body.Errors) == 0 { return "" } return body.Errors[0].Code } // regUpload pushes one blob to an image in a single request. func regUpload(t *testing.T, e *env, image string, data []byte) string { t.Helper() d := regDigest(data) regAdmin(t, e, http.MethodPost, "/v2/"+image+"/blobs/uploads/?digest="+d, data).mustStatus(201) return d } // pushImage uploads two blobs and a manifest under image:tag. It returns the // digests of the config blob, layer blob and manifest. func pushImage(t *testing.T, e *env, image, tag, seed string) (config, layer, manifest string) { t.Helper() cfg := []byte(`{"cfg":"` + seed + `"}`) lay := []byte("layer-" + seed) config = regUpload(t, e, image, cfg) layer = regUpload(t, e, image, lay) body := ociManifest(config, len(cfg), layer, len(lay)) regAdmin(t, e, http.MethodPut, "/v2/"+image+"/manifests/"+tag, body, "Content-Type", ociManifestType).mustStatus(201) return config, layer, regDigest(body) } // regTags reads the tag list. query is appended to the URL, e.g. "?n=1". func regTags(t *testing.T, e *env, image, query string) []string { t.Helper() r := regAdmin(t, e, http.MethodGet, "/v2/"+image+"/tags/list"+query, nil).mustStatus(200) var body struct { Name string `json:"name"` Tags []string `json:"tags"` } if err := json.Unmarshal(r.Body, &body); err != nil { t.Fatalf("tags list %q: %v (%s)", image, err, r.BodyString()) } if body.Name != image { t.Errorf("tags list name = %q, want %q", body.Name, image) } return body.Tags } // ociManifest builds a minimal image manifest pointing at two blobs. func ociManifest(config string, configSize int, layer string, layerSize int) []byte { return fmt.Appendf(nil, `{"schemaVersion":2,"mediaType":%q,`+ `"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":%d},`+ `"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":%q,"size":%d}]}`, ociManifestType, config, configSize, layer, layerSize) } func TestRegistry(t *testing.T) { e := newEnv(t) admin := e.admin() e.createRepo(admin, "reg-repo") e.register("alice", "password123") configBlob := []byte(`{"architecture":"amd64","os":"linux"}`) layerBlob := []byte("layer-bytes-0123456789") configDigest := regDigest(configBlob) layerDigest := regDigest(layerBlob) manifest := ociManifest(configDigest, len(configBlob), layerDigest, len(layerBlob)) manifestDigest := regDigest(manifest) t.Run("version check challenges an anonymous client", func(t *testing.T) { r := regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(401) if got := r.Header.Get("WWW-Authenticate"); got != registryRealm { t.Errorf("WWW-Authenticate = %q, want %q", got, registryRealm) } if got := r.Header.Get("Docker-Distribution-API-Version"); got != "registry/2.0" { t.Errorf("API version header = %q", got) } if code := regErrCode(r); code != "UNAUTHORIZED" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } }) t.Run("version check succeeds for the admin", func(t *testing.T) { regAdmin(t, e, http.MethodGet, "/v2/", nil).mustStatus(200) }) t.Run("a chunked upload stores a blob", func(t *testing.T) { start := regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil).mustStatus(202) loc := start.Header.Get("Location") if loc == "" || start.Header.Get("Docker-Upload-UUID") == "" { t.Fatalf("Location = %q, UUID = %q", loc, start.Header.Get("Docker-Upload-UUID")) } patch := regAdmin(t, e, http.MethodPatch, loc, configBlob).mustStatus(202) wantRange := "0-" + strconv.Itoa(len(configBlob)-1) if got := patch.Header.Get("Range"); got != wantRange { t.Errorf("Range = %q, want %q", got, wantRange) } done := regAdmin(t, e, http.MethodPut, loc+"?digest="+configDigest, nil).mustStatus(201) if got := done.Header.Get("Docker-Content-Digest"); got != configDigest { t.Errorf("Docker-Content-Digest = %q, want %q", got, configDigest) } head := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+configDigest, nil).mustStatus(200) if got := head.Header.Get("Content-Length"); got != strconv.Itoa(len(configBlob)) { t.Errorf("Content-Length = %q, want %d", got, len(configBlob)) } if got := head.Header.Get("Docker-Content-Digest"); got != configDigest { t.Errorf("Docker-Content-Digest = %q", got) } }) t.Run("a monolithic upload stores a blob", func(t *testing.T) { if got := regUpload(t, e, "reg-repo", layerBlob); got != layerDigest { t.Fatalf("digest = %q", got) } r := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(200) if !bytes.Equal(r.Body, layerBlob) { t.Errorf("blob body = %q", r.BodyString()) } }) t.Run("a manifest is readable by tag and by digest", func(t *testing.T) { put := regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/v1", manifest, "Content-Type", ociManifestType).mustStatus(201) if got := put.Header.Get("Docker-Content-Digest"); got != manifestDigest { t.Errorf("Docker-Content-Digest = %q, want %q", got, manifestDigest) } byTag := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil).mustStatus(200) if !bytes.Equal(byTag.Body, manifest) { t.Errorf("manifest body = %q", byTag.BodyString()) } if got := byTag.Header.Get("Content-Type"); got != ociManifestType { t.Errorf("Content-Type = %q, want %q", got, ociManifestType) } if got := byTag.Header.Get("Docker-Content-Digest"); got != manifestDigest { t.Errorf("Docker-Content-Digest = %q, want %q", got, manifestDigest) } byDigest := regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/"+manifestDigest, nil).mustStatus(200) if !bytes.Equal(byDigest.Body, manifest) { t.Errorf("manifest by digest = %q", byDigest.BodyString()) } regAdmin(t, e, http.MethodHead, "/v2/reg-repo/manifests/v1", nil).mustStatus(200) regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/nosuchtag", nil).mustStatus(404) }) t.Run("the tag list is sorted and paginated", func(t *testing.T) { if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"v1"}) { t.Fatalf("tags = %v", got) } regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/latest", manifest, "Content-Type", ociManifestType).mustStatus(201) if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"latest", "v1"}) { t.Errorf("tags = %v", got) } if got := regTags(t, e, "reg-repo", "?n=1"); !eqStrings(got, []string{"latest"}) { t.Errorf("tags?n=1 = %v", got) } if got := regTags(t, e, "reg-repo", "?last=latest"); !eqStrings(got, []string{"v1"}) { t.Errorf("tags?last=latest = %v", got) } }) t.Run("a manifest referencing an unknown blob is rejected", func(t *testing.T) { missing := regDigest([]byte("never uploaded")) bad := ociManifest(configDigest, len(configBlob), missing, 14) r := regAdmin(t, e, http.MethodPut, "/v2/reg-repo/manifests/broken", bad, "Content-Type", ociManifestType).mustStatus(400) if code := regErrCode(r); code != "MANIFEST_BLOB_UNKNOWN" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/broken", nil).mustStatus(404) }) t.Run("a wrong digest discards the upload", func(t *testing.T) { data := []byte("content that does not match") claimed := regDigest([]byte("something else")) loc := regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil). mustStatus(202).Header.Get("Location") regAdmin(t, e, http.MethodPatch, loc, data).mustStatus(202) r := regAdmin(t, e, http.MethodPut, loc+"?digest="+claimed, nil).mustStatus(400) if code := regErrCode(r); code != "DIGEST_INVALID" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+claimed, nil).mustStatus(404) regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+regDigest(data), nil).mustStatus(404) }) t.Run("a sub-path image keeps its own blobs", func(t *testing.T) { r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/frontend/blobs/"+configDigest, nil).mustStatus(404) if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" { t.Errorf("error code = %q", code) } regUpload(t, e, "reg-repo/frontend", configBlob) regAdmin(t, e, http.MethodHead, "/v2/reg-repo/frontend/blobs/"+configDigest, nil).mustStatus(200) if got := regTags(t, e, "reg-repo/frontend", ""); len(got) != 0 { t.Errorf("sub-image tags = %v", got) } }) t.Run("an unknown repository is not found", func(t *testing.T) { r := regAdmin(t, e, http.MethodGet, "/v2/nope/tags/list", nil).mustStatus(404) if code := regErrCode(r); code != "NAME_UNKNOWN" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } }) t.Run("only the admin may push", func(t *testing.T) { r := regReq(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil, "alice", "password123"). mustStatus(403) if code := regErrCode(r); code != "DENIED" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } regReq(t, e, http.MethodPost, "/v2/reg-repo/blobs/uploads/", nil, "", "").mustStatus(401) }) t.Run("pull is admin only by default", func(t *testing.T) { r := regReq(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil, "alice", "password123"). mustStatus(403) if code := regErrCode(r); code != "DENIED" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } anon := regReq(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil, "", "").mustStatus(401) if got := anon.Header.Get("WWW-Authenticate"); got != registryRealm { t.Errorf("WWW-Authenticate = %q", got) } }) t.Run("deleting a tag keeps the other tags", func(t *testing.T) { regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/latest", nil).mustStatus(202) if got := regTags(t, e, "reg-repo", ""); !eqStrings(got, []string{"v1"}) { t.Errorf("tags = %v", got) } regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/latest", nil).mustStatus(404) }) t.Run("deleting a manifest by digest drops its tags and layers", func(t *testing.T) { regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/manifests/"+manifestDigest, nil).mustStatus(202) regAdmin(t, e, http.MethodGet, "/v2/reg-repo/manifests/v1", nil).mustStatus(404) if got := regTags(t, e, "reg-repo", ""); len(got) != 0 { t.Errorf("tags = %v", got) } regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+layerDigest, nil).mustStatus(404) }) t.Run("deleting a blob unlinks it from the image", func(t *testing.T) { d := regUpload(t, e, "reg-repo", layerBlob) regAdmin(t, e, http.MethodDelete, "/v2/reg-repo/blobs/"+d, nil).mustStatus(202) r := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+d, nil).mustStatus(404) if code := regErrCode(r); code != "" && code != "BLOB_UNKNOWN" { t.Errorf("error code = %q", code) } }) t.Run("a repository cannot be named v2", func(t *testing.T) { r := admin.post("/new", url.Values{"name": {"v2"}, "default_branch": {"main"}}).mustStatus(200) if !r.Contains("Invalid repository name") { t.Error("error message missing") } if r.Location() != "" { t.Errorf("redirected to %q", r.Location()) } }) t.Run("blob uploads ignore the request body limit", func(t *testing.T) { // MAX_UPLOAD_BYTES defaults to 10 MiB. A layer is routinely larger. big := bytes.Repeat([]byte("0123456789abcdef"), 11<<20/16) digest := regUpload(t, e, "reg-repo", big) head := regAdmin(t, e, http.MethodHead, "/v2/reg-repo/blobs/"+digest, nil).mustStatus(200) if got := head.Header.Get("Content-Length"); got != strconv.Itoa(len(big)) { t.Errorf("Content-Length = %q, want %d", got, len(big)) } }) t.Run("image names are matched case-insensitively", func(t *testing.T) { e.createRepo(admin, "MixedCase") d := regUpload(t, e, "mixedcase", []byte("mixed")) regAdmin(t, e, http.MethodHead, "/v2/mixedcase/blobs/"+d, nil).mustStatus(200) if got := regTags(t, e, "mixedcase", ""); len(got) != 0 { t.Errorf("tags = %v", got) } }) t.Run("segments that clash with the path keywords are rejected", func(t *testing.T) { regAdmin(t, e, http.MethodPost, "/v2/reg-repo/blobs/blobs/uploads/", nil).mustStatus(404) }) } // TestRegistryPullUsers checks REGISTRY_PULL=users: any signed-in user may // pull a public repo's images, anonymous clients may not. func TestRegistryPullUsers(t *testing.T) { e := newEnv(t, "REGISTRY_PULL", "users") admin := e.admin() e.createRepo(admin, "pub-repo") e.createRepo(admin, "priv-repo", "is_private", "1") e.register("alice", "password123") _, _, manifest := pushImage(t, e, "pub-repo", "v1", "shared") pushImage(t, e, "priv-repo", "v1", "shared") t.Run("a signed-in user may pull a public image", func(t *testing.T) { r := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "alice", "password123"). mustStatus(200) if regDigest(r.Body) != manifest { t.Errorf("manifest = %q", r.BodyString()) } regReq(t, e, http.MethodGet, "/v2/", nil, "alice", "password123").mustStatus(200) }) t.Run("an anonymous client is challenged", func(t *testing.T) { regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(401) regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(401) }) t.Run("a private repository looks unknown to other users", func(t *testing.T) { // Same answer as for a repo that does not exist, so the name cannot // be probed through the status code. r := regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "alice", "password123"). mustStatus(404) if code := regErrCode(r); code != "NAME_UNKNOWN" { t.Errorf("error code = %q, body %s", code, r.BodyString()) } regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "alice", "password123").mustStatus(404) regAdmin(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil).mustStatus(200) }) } // TestRegistryPullPublic checks REGISTRY_PULL=public: anyone may pull a // public repo's images without credentials. func TestRegistryPullPublic(t *testing.T) { e := newEnv(t, "REGISTRY_PULL", "public") admin := e.admin() e.createRepo(admin, "pub-repo") e.createRepo(admin, "priv-repo", "is_private", "1") _, layerDigest, manifest := pushImage(t, e, "pub-repo", "v1", "shared") pushImage(t, e, "priv-repo", "v1", "shared") t.Run("the version check needs no credentials", func(t *testing.T) { regReq(t, e, http.MethodGet, "/v2/", nil, "", "").mustStatus(200) }) t.Run("anyone may pull a public image", func(t *testing.T) { m := regReq(t, e, http.MethodGet, "/v2/pub-repo/manifests/v1", nil, "", "").mustStatus(200) if regDigest(m.Body) != manifest { t.Errorf("manifest = %q", m.BodyString()) } b := regReq(t, e, http.MethodGet, "/v2/pub-repo/blobs/"+layerDigest, nil, "", "").mustStatus(200) if regDigest(b.Body) != layerDigest { t.Errorf("blob = %q", b.BodyString()) } }) t.Run("a private repository looks unknown to anonymous", func(t *testing.T) { regReq(t, e, http.MethodGet, "/v2/priv-repo/manifests/v1", nil, "", "").mustStatus(404) regReq(t, e, http.MethodGet, "/v2/no-such-repo/manifests/v1", nil, "", "").mustStatus(404) }) t.Run("pushing still needs the admin", func(t *testing.T) { regReq(t, e, http.MethodPost, "/v2/pub-repo/blobs/uploads/", nil, "", "").mustStatus(401) }) }