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>
338 lines
9.1 KiB
Go
338 lines
9.1 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"git.wntrmute.dev/kyle/mcr/internal/db"
|
|
)
|
|
|
|
func TestAdminPolicyCRUDCycle(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, reloader := buildAdminRouter(t, database)
|
|
|
|
// Create a rule.
|
|
createBody := `{
|
|
"priority": 50,
|
|
"description": "allow CI push",
|
|
"effect": "allow",
|
|
"roles": ["ci"],
|
|
"actions": ["registry:push", "registry:pull"],
|
|
"repositories": ["production/*"],
|
|
"enabled": true
|
|
}`
|
|
rr := adminReq(t, router, "POST", "/v1/policy/rules/", createBody)
|
|
if rr.Code != 201 {
|
|
t.Fatalf("create status: got %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if reloader.reloadCount != 1 {
|
|
t.Fatalf("reload count after create: got %d, want 1", reloader.reloadCount)
|
|
}
|
|
|
|
var created db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created: %v", err)
|
|
}
|
|
if created.ID == 0 {
|
|
t.Fatal("expected non-zero ID")
|
|
}
|
|
if created.Effect != "allow" {
|
|
t.Fatalf("effect: got %q, want %q", created.Effect, "allow")
|
|
}
|
|
if len(created.Roles) != 1 || created.Roles[0] != "ci" {
|
|
t.Fatalf("roles: got %v, want [ci]", created.Roles)
|
|
}
|
|
if created.CreatedBy != "admin-uuid" {
|
|
t.Fatalf("created_by: got %q, want %q", created.CreatedBy, "admin-uuid")
|
|
}
|
|
|
|
// Get the rule.
|
|
rr = adminReq(t, router, "GET", fmt.Sprintf("/v1/policy/rules/%d", created.ID), "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("get status: got %d, want 200", rr.Code)
|
|
}
|
|
|
|
var got db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode got: %v", err)
|
|
}
|
|
if got.ID != created.ID {
|
|
t.Fatalf("id: got %d, want %d", got.ID, created.ID)
|
|
}
|
|
|
|
// List rules.
|
|
rr = adminReq(t, router, "GET", "/v1/policy/rules/", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("list status: got %d, want 200", rr.Code)
|
|
}
|
|
|
|
var rules []db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&rules); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(rules) != 1 {
|
|
t.Fatalf("rule count: got %d, want 1", len(rules))
|
|
}
|
|
|
|
// Update the rule.
|
|
updateBody := `{"priority": 25, "description": "updated CI push"}`
|
|
rr = adminReq(t, router, "PATCH", fmt.Sprintf("/v1/policy/rules/%d", created.ID), updateBody)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("update status: got %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
if reloader.reloadCount != 2 {
|
|
t.Fatalf("reload count after update: got %d, want 2", reloader.reloadCount)
|
|
}
|
|
|
|
var updated db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode updated: %v", err)
|
|
}
|
|
if updated.Priority != 25 {
|
|
t.Fatalf("updated priority: got %d, want 25", updated.Priority)
|
|
}
|
|
if updated.Description != "updated CI push" {
|
|
t.Fatalf("updated description: got %q, want %q", updated.Description, "updated CI push")
|
|
}
|
|
// Effect should be unchanged.
|
|
if updated.Effect != "allow" {
|
|
t.Fatalf("updated effect: got %q, want %q (unchanged)", updated.Effect, "allow")
|
|
}
|
|
|
|
// Delete the rule.
|
|
rr = adminReq(t, router, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), "")
|
|
if rr.Code != 204 {
|
|
t.Fatalf("delete status: got %d, want 204", rr.Code)
|
|
}
|
|
if reloader.reloadCount != 3 {
|
|
t.Fatalf("reload count after delete: got %d, want 3", reloader.reloadCount)
|
|
}
|
|
|
|
// Verify it's gone.
|
|
rr = adminReq(t, router, "GET", fmt.Sprintf("/v1/policy/rules/%d", created.ID), "")
|
|
if rr.Code != 404 {
|
|
t.Fatalf("after delete status: got %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminCreatePolicyRuleValidation(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want int
|
|
}{
|
|
{
|
|
name: "priority too low",
|
|
body: `{"priority":0,"description":"test","effect":"allow","actions":["registry:pull"]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "missing description",
|
|
body: `{"priority":1,"effect":"allow","actions":["registry:pull"]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "invalid effect",
|
|
body: `{"priority":1,"description":"test","effect":"maybe","actions":["registry:pull"]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "no actions",
|
|
body: `{"priority":1,"description":"test","effect":"allow","actions":[]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "invalid action",
|
|
body: `{"priority":1,"description":"test","effect":"allow","actions":["bogus:action"]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "bad JSON",
|
|
body: `not json`,
|
|
want: 400,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rr := adminReq(t, router, "POST", "/v1/policy/rules/", tc.body)
|
|
if rr.Code != tc.want {
|
|
t.Fatalf("status: got %d, want %d; body: %s", rr.Code, tc.want, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminUpdatePolicyRuleValidation(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
// Create a rule to update.
|
|
createBody := `{"priority":50,"description":"test","effect":"allow","actions":["registry:pull"]}`
|
|
rr := adminReq(t, router, "POST", "/v1/policy/rules/", createBody)
|
|
if rr.Code != 201 {
|
|
t.Fatalf("create status: got %d, want 201", rr.Code)
|
|
}
|
|
var created db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want int
|
|
}{
|
|
{
|
|
name: "priority too low",
|
|
body: `{"priority":0}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "invalid effect",
|
|
body: `{"effect":"maybe"}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "empty actions",
|
|
body: `{"actions":[]}`,
|
|
want: 400,
|
|
},
|
|
{
|
|
name: "invalid action",
|
|
body: `{"actions":["bogus:action"]}`,
|
|
want: 400,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rr := adminReq(t, router, "PATCH", fmt.Sprintf("/v1/policy/rules/%d", created.ID), tc.body)
|
|
if rr.Code != tc.want {
|
|
t.Fatalf("status: got %d, want %d; body: %s", rr.Code, tc.want, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminUpdatePolicyRuleNotFound(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
rr := adminReq(t, router, "PATCH", "/v1/policy/rules/9999", `{"description":"nope"}`)
|
|
if rr.Code != 404 {
|
|
t.Fatalf("status: got %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminDeletePolicyRuleNotFound(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
rr := adminReq(t, router, "DELETE", "/v1/policy/rules/9999", "")
|
|
if rr.Code != 404 {
|
|
t.Fatalf("status: got %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminGetPolicyRuleNotFound(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
rr := adminReq(t, router, "GET", "/v1/policy/rules/9999", "")
|
|
if rr.Code != 404 {
|
|
t.Fatalf("status: got %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminGetPolicyRuleInvalidID(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
rr := adminReq(t, router, "GET", "/v1/policy/rules/not-a-number", "")
|
|
if rr.Code != 400 {
|
|
t.Fatalf("status: got %d, want 400", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminPolicyRulesNonAdmin(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router := buildNonAdminRouter(t, database)
|
|
|
|
// All policy rule endpoints require admin.
|
|
endpoints := []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"GET", "/v1/policy/rules/", ""},
|
|
{"POST", "/v1/policy/rules/", `{"priority":1,"description":"test","effect":"allow","actions":["registry:pull"]}`},
|
|
{"GET", "/v1/policy/rules/1", ""},
|
|
{"PATCH", "/v1/policy/rules/1", `{"description":"updated"}`},
|
|
{"DELETE", "/v1/policy/rules/1", ""},
|
|
}
|
|
|
|
for _, ep := range endpoints {
|
|
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
|
rr := adminReq(t, router, ep.method, ep.path, ep.body)
|
|
if rr.Code != 403 {
|
|
t.Fatalf("status: got %d, want 403; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminUpdatePolicyRuleEnabled(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
// Create an enabled rule.
|
|
createBody := `{"priority":50,"description":"test","effect":"allow","actions":["registry:pull"],"enabled":true}`
|
|
rr := adminReq(t, router, "POST", "/v1/policy/rules/", createBody)
|
|
if rr.Code != 201 {
|
|
t.Fatalf("create status: got %d, want 201", rr.Code)
|
|
}
|
|
var created db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !created.Enabled {
|
|
t.Fatal("expected created rule to be enabled")
|
|
}
|
|
|
|
// Disable it.
|
|
rr = adminReq(t, router, "PATCH", fmt.Sprintf("/v1/policy/rules/%d", created.ID), `{"enabled":false}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("update status: got %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var updated db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if updated.Enabled {
|
|
t.Fatal("expected updated rule to be disabled")
|
|
}
|
|
}
|
|
|
|
func TestAdminListPolicyRulesEmpty(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
router, _ := buildAdminRouter(t, database)
|
|
|
|
rr := adminReq(t, router, "GET", "/v1/policy/rules/", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status: got %d, want 200", rr.Code)
|
|
}
|
|
|
|
var rules []db.PolicyRuleRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&rules); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(rules) != 0 {
|
|
t.Fatalf("rule count: got %d, want 0", len(rules))
|
|
}
|
|
}
|