Files
summercms/modules/cabana/token_routes_test.go
Jakub Zych 23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00

406 lines
17 KiB
Go

package cabana
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// tokenRouteClass is the D-05/D-06 inventory class for a phase09Routes entry.
type tokenRouteClass string
const (
classPublic tokenRouteClass = "public"
classOpenAnyToken tokenRouteClass = "open_any_token"
classInteractive tokenRouteClass = "interactive_only"
classExplicitGate tokenRouteClass = "explicit_gate"
classAllowsProtect tokenRouteClass = "allows_protect"
classAllowsNav tokenRouteClass = "allows_navigation"
classAllowsSettings tokenRouteClass = "allows_settings"
)
// tokenRouteClasses maps every phase09Routes key to its personal-token class.
// Adding a route without a class fails TestTokenRouteGates.
var tokenRouteClasses = map[string]tokenRouteClass{
"POST /auth/login": classPublic,
"POST /auth/refresh": classInteractive, // public to JWT, but interactive-only for tokens
"GET /lang": classPublic,
"POST /auth/logout": classInteractive,
"GET /auth/me": classOpenAnyToken,
"PUT /account": classInteractive,
"POST /account/avatar": classInteractive,
"DELETE /account/avatar": classInteractive,
"GET /account/tokens": classInteractive,
"POST /account/tokens": classInteractive,
"GET /account/tokens/scopes": classInteractive,
"DELETE /account/tokens/{id}": classInteractive,
"GET /navigation": classAllowsNav,
"GET /field-types": classExplicitGate,
"GET /settings": classAllowsSettings,
"GET /settings/{code}/schema": classAllowsSettings,
"GET /settings/{code}": classAllowsSettings,
"PUT /settings/{code}": classAllowsSettings,
"POST /markdown/preview": classExplicitGate,
"GET /{vendor}/{plugin}/{controller}/schema/list": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/schema/form": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/partials/{name}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/fields/{field}/options": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/bulk-delete": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/singleton": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/bulk/{action}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/widgets/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
"PUT /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
"DELETE /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect,
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect,
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder": classAllowsProtect,
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect,
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": classAllowsProtect,
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect,
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": classAllowsProtect,
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": classAllowsProtect,
"POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": classAllowsProtect,
"GET /assets/{vendor}/{plugin}/{file...}": classPublic,
"GET ": classPublic,
"GET /{path...}": classPublic,
}
func TestTokenRouteGates(t *testing.T) {
for _, route := range phase09Routes {
if _, ok := tokenRouteClasses[route.key]; !ok {
t.Errorf("phase09Routes key %q has no token class", route.key)
}
}
for key := range tokenRouteClasses {
found := false
for _, route := range phase09Routes {
if route.key == key {
found = true
break
}
}
if !found {
t.Errorf("token class map has unknown key %q not in phase09Routes", key)
}
}
if t.Failed() {
t.Fatal("token route classification incomplete")
}
cat := tracerCatalog()
reg := &Registry{tokenScopes: cat}
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
dev := &bouncer.Principal{
ID: 1,
Backend: true,
PermissionGrants: map[string]bool{
"acme.demo.access": true,
"acme.demo.reports": true,
},
}
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
guard := newCompositeGuard(&stubJWTGuard{}, store, users, reg)
mint := func(id uint, scopes []string) string {
plain, hash, hint, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
store.put(&BackendAPIToken{
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
})
return plain
}
starTok := mint(1, []string{"*"})
adminReadTok := mint(2, []string{"admin:read"})
demoTok := mint(3, []string{"demo"})
authPrincipal := func(r *http.Request, token string) (*bouncer.Principal, any, error) {
r.Header.Set("Authorization", "Bearer "+token)
return guard.AuthenticateCredential(r)
}
// open_any_token: GET /auth/me — any valid token passes the guard.
for name, tok := range map[string]string{"*": starTok, "admin:read": adminReadTok, "demo": demoTok} {
r := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
p, _, err := authPrincipal(r, tok)
if err != nil || p == nil {
t.Fatalf("open_any_token %s: auth err=%v", name, err)
}
}
// interactive_only: credential or raw g15_ Bearer → 403 interactive_only.
interactivePaths := []string{
"PUT /account", "POST /account/avatar", "DELETE /account/avatar",
"GET /account/tokens", "POST /account/tokens", "GET /account/tokens/scopes",
"DELETE /account/tokens/{id}", "POST /auth/refresh", "POST /auth/logout",
}
for _, key := range interactivePaths {
if tokenRouteClasses[key] != classInteractive {
t.Fatalf("%s class = %s, want interactive_only", key, tokenRouteClasses[key])
}
method, path, _ := strings.Cut(key, " ")
path = strings.ReplaceAll(path, "{id}", "1")
h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
tokens := map[string]string{"*": starTok, "demo": demoTok}
if method == http.MethodGet || method == http.MethodHead {
tokens["admin:read"] = adminReadTok
}
for name, tok := range tokens {
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+tok)
p, cred, err := guard.AuthenticateCredential(req)
if err != nil {
t.Fatalf("%s %s auth: %v", key, name, err)
}
ctx := bouncer.WithUser(req.Context(), p)
if cred != nil {
ctx = bouncer.WithCredential(ctx, cred)
}
h(rec, req.WithContext(ctx))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s: want 403, got %d", key, name, rec.Code)
}
assertReason(t, rec, "interactive_only")
}
// admin:read cannot authenticate on non-GET interactive routes (method filter).
if method != http.MethodGet && method != http.MethodHead {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+adminReadTok)
if _, _, err := guard.AuthenticateCredential(req); !errors.Is(err, ErrTokenScope) {
t.Fatalf("%s admin:read: want ErrTokenScope, got %v", key, err)
}
}
}
// explicit_gate: only Full scopes pass (admin:read on GET).
svc := &service{reg: reg}
runGate := func(method, path, tok string, handler http.HandlerFunc) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, nil)
r.Header.Set("Authorization", "Bearer "+tok)
p, cred, err := guard.AuthenticateCredential(r)
if err != nil || p == nil {
t.Fatalf("auth %s: %v", tok[:8], err)
}
ctx := bouncer.WithUser(r.Context(), p)
if cred != nil {
ctx = bouncer.WithCredential(ctx, cred)
}
rec := httptest.NewRecorder()
handler(rec, r.WithContext(ctx))
return rec
}
if rec := runGate(http.MethodGet, "/field-types", demoTok, svc.fieldTypesList); rec.Code != http.StatusForbidden {
t.Fatalf("demo field-types: want 403, got %d", rec.Code)
} else {
assertReason(t, rec, "token_scope")
}
if rec := runGate(http.MethodGet, "/field-types", adminReadTok, svc.fieldTypesList); rec.Code != http.StatusOK {
t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String())
}
if rec := runGate(http.MethodGet, "/field-types", starTok, svc.fieldTypesList); rec.Code != http.StatusOK {
t.Fatalf("* field-types: want 200, got %d", rec.Code)
}
if rec := runGate(http.MethodPost, "/markdown/preview", demoTok, svc.markdownPreview); rec.Code != http.StatusForbidden {
t.Fatalf("demo markdown: want 403, got %d", rec.Code)
} else {
assertReason(t, rec, "token_scope")
}
// admin:read is GET/HEAD only → POST method refusal before handler.
r := httptest.NewRequest(http.MethodPost, "/markdown/preview", nil)
r.Header.Set("Authorization", "Bearer "+adminReadTok)
if _, _, err := guard.AuthenticateCredential(r); !errors.Is(err, ErrTokenScope) {
t.Fatalf("admin:read POST markdown: want ErrTokenScope, got %v", err)
}
r = httptest.NewRequest(http.MethodPost, "/markdown/preview", strings.NewReader(`{"markdown":"hi"}`))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+starTok)
pStar, _, err := guard.AuthenticateCredential(r)
if err != nil {
t.Fatal(err)
}
recMD := httptest.NewRecorder()
svc.markdownPreview(recMD, r.WithContext(bouncer.WithUser(r.Context(), pStar)))
if recMD.Code == http.StatusForbidden {
var body struct {
Error struct {
Details map[string]any `json:"details"`
} `json:"error"`
}
_ = json.Unmarshal(recMD.Body.Bytes(), &body)
if body.Error.Details["reason"] == "token_scope" {
t.Fatalf("* markdown refused with token_scope: %s", recMD.Body.String())
}
}
// allows_protect: scoped demo token passes Allows for acme.demo.access; reports scope fails.
protectH := func(w http.ResponseWriter, r *http.Request) {
p, _ := bouncer.User(r.Context())
if !Allows(p, []string{"acme.demo.access"}) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
w.WriteHeader(http.StatusOK)
}
for name, tok := range map[string]string{"*": starTok, "demo": demoTok} {
r := httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil)
r.Header.Set("Authorization", "Bearer "+tok)
p, cred, err := guard.AuthenticateCredential(r)
if err != nil {
t.Fatalf("protect %s: %v", name, err)
}
ctx := bouncer.WithUser(r.Context(), p)
if cred != nil {
ctx = bouncer.WithCredential(ctx, cred)
}
rec := httptest.NewRecorder()
protectH(rec, r.WithContext(ctx))
if rec.Code != http.StatusOK {
t.Fatalf("protect %s: want 200, got %d", name, rec.Code)
}
}
r = httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil)
r.Header.Set("Authorization", "Bearer "+adminReadTok)
p, _, err := guard.AuthenticateCredential(r)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
protectH(rec, r.WithContext(bouncer.WithUser(r.Context(), p)))
// admin:read is Full → Allows uses owner grants → 200 for developer.
if rec.Code != http.StatusOK {
t.Fatalf("protect admin:read: want 200, got %d", rec.Code)
}
// Class expectations summary for *, admin:read (GET), demo.
type expect struct {
star, adminRead, demo string // "ok", "interactive_only", "token_scope", "public"
}
byClass := map[tokenRouteClass]expect{
classPublic: {star: "public", adminRead: "public", demo: "public"},
classOpenAnyToken: {star: "ok", adminRead: "ok", demo: "ok"},
classInteractive: {star: "interactive_only", adminRead: "interactive_only", demo: "interactive_only"},
classExplicitGate: {star: "ok", adminRead: "ok", demo: "token_scope"},
classAllowsProtect: {star: "ok", adminRead: "ok", demo: "ok"},
classAllowsNav: {star: "ok", adminRead: "ok", demo: "ok"},
classAllowsSettings: {star: "ok", adminRead: "ok", demo: "ok"},
}
for key, class := range tokenRouteClasses {
exp, ok := byClass[class]
if !ok {
t.Fatalf("no expect for class %s on %s", class, key)
}
_ = exp // exercised via class representatives above; map completeness is the gate
if class == "" {
t.Fatalf("empty class for %s", key)
}
}
}
func assertReason(t *testing.T, rec *httptest.ResponseRecorder, reason string) {
t.Helper()
var body struct {
Error struct {
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
if body.Error.Details["reason"] != reason {
t.Fatalf("reason = %v, want %s body=%s", body.Error.Details["reason"], reason, rec.Body.String())
}
}
func TestInteractiveOnly(t *testing.T) {
h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Token credential refused.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/account", nil)
req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 1}))
h(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("cred: %d", rec.Code)
}
assertReason(t, rec, "interactive_only")
// Raw g15_ Bearer refused even without credential.
plain, _, _, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
for _, path := range []struct {
method, path string
}{
{http.MethodPut, "/account"},
{http.MethodPost, "/account/avatar"},
{http.MethodDelete, "/account/avatar"},
{http.MethodGet, "/account/tokens"},
{http.MethodPost, "/account/tokens"},
{http.MethodGet, "/account/tokens/scopes"},
{http.MethodDelete, "/account/tokens/1"},
{http.MethodPost, "/auth/refresh"},
{http.MethodPost, "/auth/logout"},
} {
rec = httptest.NewRecorder()
req = httptest.NewRequest(path.method, path.path, nil)
req.Header.Set("Authorization", "Bearer "+plain)
h(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s: %d", path.method, path.path, rec.Code)
}
assertReason(t, rec, "interactive_only")
}
// JWT session (non-g15_ bearer, no token credential) passes.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/account", nil)
req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true}))
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("jwt session: %d", rec.Code)
}
}