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>
149 lines
4.0 KiB
Go
149 lines
4.0 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
func openAdminTestDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
d, err := db.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
if err := d.Migrate(); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
type fakePolicyReloader struct {
|
|
reloadCount int
|
|
}
|
|
|
|
func (f *fakePolicyReloader) Reload(_ policy.RuleStore) error {
|
|
f.reloadCount++
|
|
return nil
|
|
}
|
|
|
|
// buildAdminRouter creates a chi router with admin routes wired up,
|
|
// using a fakeValidator that returns admin claims for any bearer token.
|
|
func buildAdminRouter(t *testing.T, database *db.DB) (chi.Router, *fakePolicyReloader) {
|
|
t.Helper()
|
|
|
|
validator := &fakeValidator{
|
|
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
|
|
}
|
|
login := &fakeLoginClient{token: "test-token", expiresIn: 3600}
|
|
reloader := &fakePolicyReloader{}
|
|
gcState := &GCState{}
|
|
|
|
r := chi.NewRouter()
|
|
MountAdminRoutes(r, validator, "mcr-test", AdminDeps{
|
|
DB: database,
|
|
Login: login,
|
|
Engine: reloader,
|
|
AuditFn: nil,
|
|
GCState: gcState,
|
|
})
|
|
return r, reloader
|
|
}
|
|
|
|
// buildNonAdminRouter creates a chi router that returns non-admin claims.
|
|
func buildNonAdminRouter(t *testing.T, database *db.DB) chi.Router {
|
|
t.Helper()
|
|
|
|
validator := &fakeValidator{
|
|
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
|
|
}
|
|
login := &fakeLoginClient{token: "test-token", expiresIn: 3600}
|
|
reloader := &fakePolicyReloader{}
|
|
gcState := &GCState{}
|
|
|
|
r := chi.NewRouter()
|
|
MountAdminRoutes(r, validator, "mcr-test", AdminDeps{
|
|
DB: database,
|
|
Login: login,
|
|
Engine: reloader,
|
|
AuditFn: nil,
|
|
GCState: gcState,
|
|
})
|
|
return r
|
|
}
|
|
|
|
// adminReq is a convenience helper for making HTTP requests against the admin
|
|
// router, automatically including the Authorization header.
|
|
func adminReq(t *testing.T, router http.Handler, method, path string, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var bodyReader io.Reader
|
|
if body != "" {
|
|
bodyReader = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, bodyReader)
|
|
req.Header.Set("Authorization", "Bearer valid-token")
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestRequireAdminAllowed(t *testing.T) {
|
|
claims := &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}}
|
|
handler := RequireAdmin()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
req = req.WithContext(auth.ContextWithClaims(req.Context(), claims))
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("admin allowed: got %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAdminDenied(t *testing.T) {
|
|
claims := &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}}
|
|
handler := RequireAdmin()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
t.Fatal("inner handler should not be called")
|
|
}))
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
req = req.WithContext(auth.ContextWithClaims(req.Context(), claims))
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("non-admin denied: got %d, want 403", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireAdminNoClaims(t *testing.T) {
|
|
handler := RequireAdmin()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
t.Fatal("inner handler should not be called")
|
|
}))
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("no claims: got %d, want 401", rr.Code)
|
|
}
|
|
}
|