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>
This commit is contained in:
321
internal/oci/handler_test.go
Normal file
321
internal/oci/handler_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user