- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
309 lines
10 KiB
Go
309 lines
10 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/bouncer"
|
|
"git.golem15.com/golem15/summercms/pact"
|
|
)
|
|
|
|
// adminRoute is one logical admin route. key is method plus the path relative
|
|
// to {backend.uri}/api/v1 (D-03); spa entries are the public SPA shell routes
|
|
// relative to {backend.uri} and are not part of the OpenAPI inventory.
|
|
// mounted, when set, is the relative ServeMux key that serves the route:
|
|
// logical routes ServeMux cannot hold side by side share one dispatching
|
|
// pattern (service.nestedGet).
|
|
type adminRoute struct {
|
|
key string
|
|
public bool
|
|
spa bool
|
|
mounted string
|
|
}
|
|
|
|
// nestedGetRoute is the shared six-segment GET pattern.
|
|
const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"
|
|
|
|
// phase09Routes is the admin surface mounted by service.mount. A handler added
|
|
// outside this set, or a protected handler missing the backend guard, fails
|
|
// TestPhase09PermissionMatrix.
|
|
var phase09Routes = []adminRoute{
|
|
{key: "POST /auth/login", public: true},
|
|
{key: "POST /auth/refresh", public: true},
|
|
{key: "POST /auth/logout"},
|
|
{key: "GET /auth/me"},
|
|
{key: "GET /navigation"},
|
|
{key: "GET /settings"},
|
|
{key: "GET /settings/{code}/schema"},
|
|
{key: "GET /settings/{code}"},
|
|
{key: "PUT /settings/{code}"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
|
|
{key: "GET /{vendor}/{plugin}/{controller}"},
|
|
{key: "POST /{vendor}/{plugin}/{controller}"},
|
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
|
{key: "PUT /{vendor}/{plugin}/{controller}/{id}"},
|
|
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
|
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
|
{key: "GET ", public: true, spa: true},
|
|
{key: "GET /{path...}", public: true, spa: true},
|
|
}
|
|
|
|
// mountedKey is the full mounted route key for an inventory entry.
|
|
func mountedKey(route adminRoute) string {
|
|
key := route.key
|
|
if route.mounted != "" {
|
|
key = route.mounted
|
|
}
|
|
method, rel, _ := strings.Cut(key, " ")
|
|
if route.spa {
|
|
return method + " " + DefaultAdminPrefix + rel
|
|
}
|
|
return method + " " + adminAPI(rel)
|
|
}
|
|
|
|
func TestPhase09PermissionMatrix(t *testing.T) {
|
|
router := &captureRouter{}
|
|
(&service{}).mount(router)
|
|
got := map[string][]string{}
|
|
for _, key := range router.routes {
|
|
if _, exists := got[key]; exists {
|
|
t.Fatalf("route %s registered more than once", key)
|
|
}
|
|
got[key] = router.middleware[key]
|
|
}
|
|
want := map[string]bool{}
|
|
for _, route := range phase09Routes {
|
|
want[mountedKey(route)] = true
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(want), router.routes)
|
|
}
|
|
for _, route := range phase09Routes {
|
|
key := mountedKey(route)
|
|
mw, ok := got[key]
|
|
if !ok {
|
|
t.Fatalf("missing mounted route %s in %v", key, router.routes)
|
|
}
|
|
hasBackend := false
|
|
for _, name := range mw {
|
|
if name == "backend" {
|
|
hasBackend = true
|
|
}
|
|
}
|
|
if route.public && hasBackend {
|
|
t.Fatalf("%s is a public auth route but carries the backend guard", route.key)
|
|
}
|
|
if !route.public && !hasBackend {
|
|
t.Fatalf("%s is missing the backend guard: %v", route.key, mw)
|
|
}
|
|
}
|
|
|
|
svc := phase09DeniedService()
|
|
denied := &bouncer.Principal{ID: 4, Backend: true}
|
|
frontend := &bouncer.Principal{ID: 4, Backend: false, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
|
for _, call := range phase09ProtectedCalls() {
|
|
t.Run("denied "+call.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
call.fn(svc, rec, phase09Request(denied))
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
|
|
})
|
|
t.Run("frontend "+call.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
call.fn(svc, rec, phase09Request(frontend))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
|
|
})
|
|
}
|
|
|
|
for _, call := range []phase09Call{
|
|
{"navigation", (*service).navigation},
|
|
{"settings-list", (*service).settingsList},
|
|
} {
|
|
t.Run("filtered "+call.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
call.fn(svc, rec, phase09Request(denied))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"data":[]`) {
|
|
t.Fatalf("permissionless metadata was not an empty list: %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPhase09SecurityCoverage(t *testing.T) {
|
|
t.Run("mass assignment", func(t *testing.T) {
|
|
cc := &CompiledController{Writable: []WritableField{
|
|
{Name: "name", FillKey: "name"},
|
|
{Name: "password", FillKey: "password"},
|
|
{Name: "role_id", FillKey: "role_id"},
|
|
}}
|
|
got := ProjectWritableFields(cc, map[string]any{
|
|
"name": "Ada",
|
|
"Name": "Case",
|
|
"password": "hunter2",
|
|
"role_id": 1,
|
|
"extra": "nope",
|
|
"nested": map[string]any{"id": 1},
|
|
})
|
|
if len(got) != 1 || got["name"] != "Ada" {
|
|
t.Fatalf("projected = %#v, want only name", got)
|
|
}
|
|
})
|
|
|
|
t.Run("identifier injection", func(t *testing.T) {
|
|
for _, raw := range []string{"", "1;drop", "1 OR 1", "../1", "-1", "1.5", "0x10"} {
|
|
req := phase09Request(nil)
|
|
req.SetPathValue("id", raw)
|
|
if _, err := pathID(req); err == nil {
|
|
t.Fatalf("path id %q was accepted", raw)
|
|
}
|
|
}
|
|
req := phase09Request(nil)
|
|
req.SetPathValue("id", "15")
|
|
id, err := pathID(req)
|
|
if err != nil || id != 15 {
|
|
t.Fatalf("id=%d err=%v", id, err)
|
|
}
|
|
})
|
|
|
|
t.Run("auth log redaction", func(t *testing.T) {
|
|
const secret = "summercms-test-only-admin-hs256-secret"
|
|
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
|
|
var buf bytes.Buffer
|
|
previous := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(previous) })
|
|
req := phase09Request(nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.URL.RawQuery = "password=" + secret
|
|
(&service{}).logAuth(req, "denied", 7)
|
|
logged := buf.String()
|
|
for _, leak := range []string{secret, token, "eyJ", "hunter2", "password="} {
|
|
if strings.Contains(logged, leak) {
|
|
t.Fatalf("auth log contains %q: %s", leak, logged)
|
|
}
|
|
}
|
|
if !strings.Contains(logged, `"outcome":"denied"`) || !strings.Contains(logged, `"admin_id":7`) {
|
|
t.Fatalf("auth log dropped the outcome: %s", logged)
|
|
}
|
|
})
|
|
|
|
t.Run("pivot body", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/relations/editors/link", strings.NewReader(`{"ids":[1],"role":"developer"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if _, err := decodeRelationMutation(req); err == nil {
|
|
t.Fatal("forged pivot field was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("hook failure rolls back", func(t *testing.T) {
|
|
svc, _, cc, db, hooks := hookFixture(t)
|
|
hooks.fail = "form_before_create"
|
|
ctx := principalCtx(hooks, superUser())
|
|
if _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "password": "hunter2"}}); err == nil {
|
|
t.Fatal("failing before-create hook was ignored")
|
|
}
|
|
if n := countCrud(t, db); n != 0 {
|
|
t.Fatalf("rows=%d after rejected create", n)
|
|
}
|
|
})
|
|
|
|
t.Run("permission before list query", func(t *testing.T) {
|
|
listSvc, _ := newListService(t)
|
|
locked := *listSvc
|
|
current := locked.reg.byID["acme.demo.widgets"]
|
|
locked.reg = &Registry{byID: map[string]*CompiledController{
|
|
"acme.demo.widgets": {
|
|
Controller: queryController{perms: []string{"acme.demo.access"}},
|
|
List: current.List,
|
|
},
|
|
}}
|
|
rec := httptest.NewRecorder()
|
|
req := phase09Request(&bouncer.Principal{ID: 4, Backend: true})
|
|
req.SetPathValue("vendor", "acme")
|
|
req.SetPathValue("plugin", "demo")
|
|
req.SetPathValue("controller", "widgets")
|
|
req.URL.RawQuery = "sort=name%3Bdrop"
|
|
locked.list(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), "drop") {
|
|
t.Fatalf("denial body echoed the identifier: %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
type phase09Call struct {
|
|
name string
|
|
fn func(*service, http.ResponseWriter, *http.Request)
|
|
}
|
|
|
|
func phase09ProtectedCalls() []phase09Call {
|
|
return []phase09Call{
|
|
{"list", (*service).list},
|
|
{"create", (*service).create},
|
|
{"bulk-delete", (*service).bulkDelete},
|
|
{"show", (*service).show},
|
|
{"update", (*service).update},
|
|
{"delete", (*service).deleteRecord},
|
|
{"list-schema", (*service).listSchema},
|
|
{"form-schema", (*service).formSchema},
|
|
{"relation-schema", (*service).relationSchema},
|
|
{"relation-linked", (*service).relationLinked},
|
|
{"field-options", (*service).fieldOptions},
|
|
{"nested-get", (*service).nestedGet},
|
|
{"relation-candidates", (*service).relationCandidates},
|
|
{"relation-link", (*service).relationLink},
|
|
{"relation-unlink", (*service).relationUnlink},
|
|
{"settings-schema", (*service).settingsSchema},
|
|
{"settings-get", (*service).settingsGet},
|
|
{"settings-put", (*service).settingsPut},
|
|
}
|
|
}
|
|
|
|
func phase09DeniedService() *service {
|
|
controller := orderController{perms: []string{"acme.demo.access"}}
|
|
return &service{reg: &Registry{
|
|
byID: map[string]*CompiledController{
|
|
"acme.demo.widgets": {Controller: controller, Relations: map[string]*CompiledRelation{}},
|
|
},
|
|
settings: map[string]*CompiledSetting{
|
|
"demo": {Item: pact.SettingsItem{Code: "demo", Permissions: []string{"acme.demo.manage_settings"}}},
|
|
},
|
|
}}
|
|
}
|
|
|
|
func phase09Request(principal *bouncer.Principal) *http.Request {
|
|
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/widgets/1/relations/editors/link"), strings.NewReader(`{}`))
|
|
req.SetPathValue("vendor", "acme")
|
|
req.SetPathValue("plugin", "demo")
|
|
req.SetPathValue("controller", "widgets")
|
|
req.SetPathValue("id", "1")
|
|
req.SetPathValue("name", "editors")
|
|
req.SetPathValue("segment", "relations")
|
|
req.SetPathValue("code", "demo")
|
|
if principal != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
}
|
|
return req
|
|
}
|