Files
mcr/internal/oci/push_test.go
Kyle Isom dddc66f31b Phases 5, 6, 8: OCI pull/push paths and admin REST API
Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by
tag/digest, blob GET/HEAD with repo membership check, tag listing with
OCI pagination, catalog listing. Multi-segment repo names via
parseOCIPath() right-split routing. DB query layer in
internal/db/repository.go.

Phase 6 (OCI push): blob uploads (monolithic and chunked) with
uploadManager tracking in-progress BlobWriters, manifest push
implementing full ARCHITECTURE.md §5 flow in a single SQLite
transaction (create repo, upsert manifest, populate manifest_blobs,
atomic tag move). Digest verification on both blob commit and manifest
push-by-digest.

Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health),
repository management (list/detail/delete), policy CRUD with engine
reload, audit log listing with filters, GC trigger/status stubs.
RequireAdmin middleware, platform-standard error format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:25:18 -07:00

267 lines
8.0 KiB
Go

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)
}
}
}