- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
287 lines
9.7 KiB
Go
287 lines
9.7 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"
|
|
)
|
|
|
|
// phase09Routes is the admin surface mounted by service.mount. API keys are
|
|
// 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. A handler added outside this set, or a protected
|
|
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
|
|
var phase09Routes = []struct {
|
|
key string
|
|
public bool
|
|
spa bool
|
|
}{
|
|
{"POST /auth/login", true, false},
|
|
{"POST /auth/refresh", true, false},
|
|
{"POST /auth/logout", false, false},
|
|
{"GET /auth/me", false, false},
|
|
{"GET /navigation", false, false},
|
|
{"GET /settings", false, false},
|
|
{"GET /settings/{code}/schema", false, false},
|
|
{"GET /settings/{code}", false, false},
|
|
{"PUT /settings/{code}", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}", false, false},
|
|
{"POST /{vendor}/{plugin}/{controller}", false, false},
|
|
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
|
|
{"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
|
|
{"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
|
|
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
|
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
|
|
{"GET ", true, true},
|
|
{"GET /{path...}", true, true},
|
|
}
|
|
|
|
// mountedKey is the full mounted route key for an inventory entry.
|
|
func mountedKey(key string, spa bool) string {
|
|
method, rel, _ := strings.Cut(key, " ")
|
|
if 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]
|
|
}
|
|
if len(got) != len(phase09Routes) {
|
|
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
|
|
}
|
|
for _, route := range phase09Routes {
|
|
key := mountedKey(route.key, route.spa)
|
|
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},
|
|
{"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("code", "demo")
|
|
if principal != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
}
|
|
return req
|
|
}
|