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>
267 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|