package oci import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" ) func makeManifest(configDigest string, layerDigests []string) []byte { layers := "" for i, d := range layerDigests { if i > 0 { layers += "," } layers += fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1000}`, d) } return []byte(fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":100},"layers":[%s]}`, configDigest, layers)) } func manifestDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) } func TestManifestPushByTag(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) fdb.addGlobalBlob("sha256:config1") fdb.addGlobalBlob("sha256:layer1") h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) content := makeManifest("sha256:config1", []string{"sha256:layer1"}) digest := manifestDigest(content) req := authedRequest("PUT", "/v2/myrepo/manifests/latest", nil) req.Body = http.NoBody // Re-create with proper body. req = authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("status: got %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String()) } dcd := rr.Header().Get("Docker-Content-Digest") if dcd != digest { t.Fatalf("Docker-Content-Digest: got %q, want %q", dcd, digest) } loc := rr.Header().Get("Location") if loc == "" { t.Fatal("Location header missing") } ct := rr.Header().Get("Content-Type") if ct != "application/vnd.oci.image.manifest.v1+json" { t.Fatalf("Content-Type: got %q", ct) } // Verify manifest was stored. if len(fdb.pushed) != 1 { t.Fatalf("pushed count: got %d, want 1", len(fdb.pushed)) } if fdb.pushed[0].Tag != "latest" { t.Fatalf("pushed tag: got %q, want %q", fdb.pushed[0].Tag, "latest") } } func TestManifestPushByDigest(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) fdb.addGlobalBlob("sha256:config1") h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) content := makeManifest("sha256:config1", nil) digest := manifestDigest(content) req := authedPushRequest("PUT", "/v2/myrepo/manifests/"+digest, content) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("status: got %d, body: %s", rr.Code, rr.Body.String()) } // Verify no tag was set. if len(fdb.pushed) != 1 { t.Fatalf("pushed count: got %d", len(fdb.pushed)) } if fdb.pushed[0].Tag != "" { t.Fatalf("pushed tag: got %q, want empty", fdb.pushed[0].Tag) } } func TestManifestPushDigestMismatch(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) content := []byte(`{"schemaVersion":2}`) wrongDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000" req := authedPushRequest("PUT", "/v2/myrepo/manifests/"+wrongDigest, content) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest) } var body ociErrorResponse if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if len(body.Errors) != 1 || body.Errors[0].Code != "DIGEST_INVALID" { t.Fatalf("error code: got %+v, want DIGEST_INVALID", body.Errors) } } func TestManifestPushMissingBlob(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) // Config blob exists but layer blob does not. fdb.addGlobalBlob("sha256:config1") h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) content := makeManifest("sha256:config1", []string{"sha256:missing_layer"}) req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d, body: %s", rr.Code, http.StatusBadRequest, rr.Body.String()) } var body ociErrorResponse if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_BLOB_UNKNOWN" { t.Fatalf("error code: got %+v, want MANIFEST_BLOB_UNKNOWN", body.Errors) } } func TestManifestPushMalformed(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", []byte("not valid json")) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest) } var body ociErrorResponse if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_INVALID" { t.Fatalf("error code: got %+v, want MANIFEST_INVALID", body.Errors) } } func TestManifestPushEmpty(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", nil) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest) } } func TestManifestPushUpdatesTag(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) fdb.addGlobalBlob("sha256:config1") h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) // First push with tag "latest". content1 := makeManifest("sha256:config1", nil) req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content1) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("first push status: got %d", rr.Code) } firstDigest := rr.Header().Get("Docker-Content-Digest") // Second push with same tag — different content. fdb.addGlobalBlob("sha256:config2") content2 := makeManifest("sha256:config2", nil) req = authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content2) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr = httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("second push status: got %d", rr.Code) } secondDigest := rr.Header().Get("Docker-Content-Digest") if firstDigest == secondDigest { t.Fatal("two pushes should produce different digests") } // Verify that the tag was atomically moved. if len(fdb.pushed) != 2 { t.Fatalf("pushed count: got %d, want 2", len(fdb.pushed)) } } func TestManifestPushRepushIdempotent(t *testing.T) { fdb := newFakeDB() fdb.addRepo("myrepo", 1) fdb.addGlobalBlob("sha256:config1") h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil) router := testRouter(h) content := makeManifest("sha256:config1", nil) // Push the same manifest twice. for i := range 2 { req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content) req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("push %d status: got %d", i+1, rr.Code) } } }