Files
mcr/internal/oci/handler_test.go
Kyle Isom c01e7ffa30 Phase 7: OCI delete path for manifests and blobs
Manifest delete (DELETE /v2/<name>/manifests/<digest>): rejects tag
references with 405 UNSUPPORTED per OCI spec, cascades to tags and
manifest_blobs via ON DELETE CASCADE, returns 202 Accepted.

Blob delete (DELETE /v2/<name>/blobs/<digest>): removes manifest_blobs
associations only — blob row and file are preserved for GC to handle,
since other repos may reference the same content-addressed blob.

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

350 lines
8.4 KiB
Go

package oci
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"sort"
"sync"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/policy"
"git.wntrmute.dev/kyle/mcr/internal/storage"
)
// manifestKey uniquely identifies a manifest for test lookup.
type manifestKey struct {
repoID int64
reference string // tag or digest
}
// fakeDB implements DBQuerier for tests.
type fakeDB struct {
mu sync.Mutex
repos map[string]int64 // name -> id
manifests map[manifestKey]*db.ManifestRow // (repoID, ref) -> manifest
blobs map[int64]map[string]bool // repoID -> set of digests
allBlobs map[string]bool // global blob digests
tags map[int64][]string // repoID -> sorted tag names
repoNames []string // sorted repo names
uploads map[string]*db.UploadRow // uuid -> upload
nextID int64 // auto-increment counter
pushed []db.PushManifestParams // record of pushed manifests
}
func newFakeDB() *fakeDB {
return &fakeDB{
repos: make(map[string]int64),
manifests: make(map[manifestKey]*db.ManifestRow),
blobs: make(map[int64]map[string]bool),
allBlobs: make(map[string]bool),
tags: make(map[int64][]string),
uploads: make(map[string]*db.UploadRow),
nextID: 1,
}
}
func (f *fakeDB) GetRepositoryByName(name string) (int64, error) {
id, ok := f.repos[name]
if !ok {
return 0, db.ErrRepoNotFound
}
return id, nil
}
func (f *fakeDB) GetManifestByTag(repoID int64, tag string) (*db.ManifestRow, error) {
m, ok := f.manifests[manifestKey{repoID, tag}]
if !ok {
return nil, db.ErrManifestNotFound
}
return m, nil
}
func (f *fakeDB) GetManifestByDigest(repoID int64, digest string) (*db.ManifestRow, error) {
m, ok := f.manifests[manifestKey{repoID, digest}]
if !ok {
return nil, db.ErrManifestNotFound
}
return m, nil
}
func (f *fakeDB) BlobExistsInRepo(repoID int64, digest string) (bool, error) {
digests, ok := f.blobs[repoID]
if !ok {
return false, nil
}
return digests[digest], nil
}
func (f *fakeDB) ListTags(repoID int64, after string, limit int) ([]string, error) {
allTags := f.tags[repoID]
var result []string
for _, t := range allTags {
if after != "" && t <= after {
continue
}
result = append(result, t)
if len(result) >= limit {
break
}
}
return result, nil
}
func (f *fakeDB) ListRepositoryNames(after string, limit int) ([]string, error) {
var result []string
for _, n := range f.repoNames {
if after != "" && n <= after {
continue
}
result = append(result, n)
if len(result) >= limit {
break
}
}
return result, nil
}
func (f *fakeDB) GetOrCreateRepository(name string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.repos[name]
if ok {
return id, nil
}
id = f.nextID
f.nextID++
f.repos[name] = id
f.repoNames = append(f.repoNames, name)
sort.Strings(f.repoNames)
return id, nil
}
func (f *fakeDB) BlobExists(digest string) (bool, error) {
return f.allBlobs[digest], nil
}
func (f *fakeDB) InsertBlob(digest string, size int64) error {
f.allBlobs[digest] = true
_ = size
return nil
}
func (f *fakeDB) PushManifest(p db.PushManifestParams) error {
f.mu.Lock()
defer f.mu.Unlock()
f.pushed = append(f.pushed, p)
// Simulate creating the manifest in our fake data.
repoID, ok := f.repos[p.RepoName]
if !ok {
repoID = f.nextID
f.nextID++
f.repos[p.RepoName] = repoID
f.repoNames = append(f.repoNames, p.RepoName)
sort.Strings(f.repoNames)
}
m := &db.ManifestRow{
ID: f.nextID,
RepositoryID: repoID,
Digest: p.Digest,
MediaType: p.MediaType,
Content: p.Content,
Size: p.Size,
}
f.nextID++
f.manifests[manifestKey{repoID, p.Digest}] = m
if p.Tag != "" {
f.manifests[manifestKey{repoID, p.Tag}] = m
}
return nil
}
func (f *fakeDB) CreateUpload(uuid string, repoID int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.uploads[uuid] = &db.UploadRow{
ID: f.nextID,
UUID: uuid,
RepositoryID: repoID,
ByteOffset: 0,
}
f.nextID++
return nil
}
func (f *fakeDB) GetUpload(uuid string) (*db.UploadRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.uploads[uuid]
if !ok {
return nil, db.ErrUploadNotFound
}
return u, nil
}
func (f *fakeDB) UpdateUploadOffset(uuid string, offset int64) error {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.uploads[uuid]
if !ok {
return db.ErrUploadNotFound
}
u.ByteOffset = offset
return nil
}
func (f *fakeDB) DeleteUpload(uuid string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.uploads[uuid]; !ok {
return db.ErrUploadNotFound
}
delete(f.uploads, uuid)
return nil
}
func (f *fakeDB) DeleteManifest(repoID int64, digest string) error {
f.mu.Lock()
defer f.mu.Unlock()
key := manifestKey{repoID, digest}
if _, ok := f.manifests[key]; !ok {
return db.ErrManifestNotFound
}
delete(f.manifests, key)
// Also remove any tag entries pointing to this digest.
for k, m := range f.manifests {
if k.repoID == repoID && m.Digest == digest {
delete(f.manifests, k)
}
}
return nil
}
func (f *fakeDB) DeleteBlobFromRepo(repoID int64, digest string) error {
f.mu.Lock()
defer f.mu.Unlock()
digests, ok := f.blobs[repoID]
if !ok || !digests[digest] {
return db.ErrBlobNotFound
}
delete(digests, digest)
return nil
}
// addRepo adds a repo to the fakeDB and returns its ID.
func (f *fakeDB) addRepo(name string, id int64) {
f.repos[name] = id
f.repoNames = append(f.repoNames, name)
sort.Strings(f.repoNames)
if id >= f.nextID {
f.nextID = id + 1
}
}
// addManifest adds a manifest accessible by both tag and digest.
func (f *fakeDB) addManifest(repoID int64, tag, digest, mediaType string, content []byte) {
m := &db.ManifestRow{
ID: f.nextID,
RepositoryID: repoID,
Digest: digest,
MediaType: mediaType,
Content: content,
Size: int64(len(content)),
}
f.nextID++
if tag != "" {
f.manifests[manifestKey{repoID, tag}] = m
}
f.manifests[manifestKey{repoID, digest}] = m
}
// addBlob registers a blob digest in a repository.
func (f *fakeDB) addBlob(repoID int64, digest string) {
if f.blobs[repoID] == nil {
f.blobs[repoID] = make(map[string]bool)
}
f.blobs[repoID][digest] = true
}
// addGlobalBlob registers a blob in the global blob table.
func (f *fakeDB) addGlobalBlob(digest string) {
f.allBlobs[digest] = true
}
// addTag adds a tag to a repository's tag list.
func (f *fakeDB) addTag(repoID int64, tag string) {
f.tags[repoID] = append(f.tags[repoID], tag)
sort.Strings(f.tags[repoID])
}
// fakeBlobs implements BlobStore for tests.
type fakeBlobs struct {
data map[string][]byte // digest -> content
uploads map[string]*bytes.Buffer
}
func newFakeBlobs() *fakeBlobs {
return &fakeBlobs{
data: make(map[string][]byte),
uploads: make(map[string]*bytes.Buffer),
}
}
func (f *fakeBlobs) Open(digest string) (io.ReadCloser, error) {
data, ok := f.data[digest]
if !ok {
return nil, io.ErrUnexpectedEOF
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (f *fakeBlobs) Stat(digest string) (int64, error) {
data, ok := f.data[digest]
if !ok {
return 0, io.ErrUnexpectedEOF
}
return int64(len(data)), nil
}
func (f *fakeBlobs) StartUpload(uuid string) (*storage.BlobWriter, error) {
// For tests that need real storage, use a real Store in t.TempDir().
// This fake panics to catch unintended usage.
panic("fakeBlobs.StartUpload should not be called; use a real storage.Store for upload tests")
}
// fakePolicy implements PolicyEval, always returning Allow.
type fakePolicy struct {
effect policy.Effect
}
func (f *fakePolicy) Evaluate(_ policy.PolicyInput) (policy.Effect, *policy.Rule) {
return f.effect, nil
}
// allowAll returns a fakePolicy that allows all requests.
func allowAll() *fakePolicy {
return &fakePolicy{effect: policy.Allow}
}
// testRouter creates a chi.Mux with the OCI handler mounted at /v2.
func testRouter(h *Handler) *chi.Mux {
parent := chi.NewRouter()
parent.Mount("/v2", h.Router())
return parent
}
// authedRequest creates an HTTP request with authenticated claims in the context.
func authedRequest(method, path string, body io.Reader) *http.Request {
req := httptest.NewRequest(method, path, body)
claims := &auth.Claims{
Subject: "test-user",
AccountType: "human",
Roles: []string{"user"},
}
return req.WithContext(auth.ContextWithClaims(req.Context(), claims))
}