test(09-01): add failing audience crossover and authorization-order tests

- Same-secret backend token is still accepted by the frontend guard
- Permission denial must not invoke the schema or database callback
- Admin error bodies must not echo secrets or raw tokens
This commit is contained in:
Jakub Zych
2026-09-24 17:19:57 +02:00
parent dfa00f7e3a
commit 8c1de83f01
2 changed files with 147 additions and 0 deletions

42
bouncer/audience_test.go Normal file
View File

@@ -0,0 +1,42 @@
package bouncer
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAudienceCrossover(t *testing.T) {
const secret = "same-secret-for-both-guards"
users := memUsers{byID: map[uint]*Principal{1: {ID: 1}}}
userTok, _, err := MintAudience(secret, "1", "https://app.test/login", time.Hour, AudienceUser)
if err != nil {
t.Fatal(err)
}
backendTok, _, err := MintAudience(secret, "1", "https://app.test/_admin/api/v1/auth/login", time.Hour, AudienceBackend)
if err != nil {
t.Fatal(err)
}
frontend := NewJWTGuard(secret, users, nil)
backend := NewBackendJWTGuard(secret, users, nil, nil)
if principal, err := backend.Authenticate(withBearer(backendTok)); err != nil || principal == nil || principal.ID != 1 {
t.Fatalf("backend guard rejected its own audience: %v", err)
}
if principal, err := frontend.Authenticate(withBearer(userTok)); err != nil || principal == nil || principal.ID != 1 {
t.Fatalf("frontend guard rejected its own audience: %v", err)
}
if principal, err := backend.Authenticate(withBearer(userTok)); err == nil || principal != nil {
t.Fatal("backend guard accepted a frontend-audience token signed with the same secret")
}
if principal, err := frontend.Authenticate(withBearer(backendTok)); err == nil || principal != nil {
t.Fatal("frontend guard accepted a backend-audience token signed with the same secret")
}
}
func withBearer(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
return req
}

105
cabana/security_test.go Normal file
View File

@@ -0,0 +1,105 @@
package cabana
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
)
type orderController struct {
perms []string
}
func (orderController) ID() string { return "acme.demo.widgets" }
func (orderController) ModelName() string { return "Widget" }
func (orderController) ConfigDir() string { return "controllers/widgets" }
func (c orderController) RequiredPermissions() []string {
return c.perms
}
func TestAuthorizationOrder(t *testing.T) {
svc := &service{reg: &Registry{byID: map[string]*CompiledController{
"acme.demo.widgets": {
Controller: orderController{perms: []string{"acme.demo.access"}},
List: &ListSchema{RecordsPerPage: 20, Columns: []ListColumn{}},
},
}}}
t.Run("permission before schema", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 4})
svc.protect(rec, req, func(*CompiledController) { called++ })
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if called != 0 {
t.Fatal("schema or database callback ran before permission denial")
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
})
t.Run("superuser reaches handler", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true})
svc.protect(rec, req, func(*CompiledController) { called++ })
if called != 1 {
t.Fatalf("superuser callback count=%d, want 1", called)
}
})
t.Run("unknown controller is not queried", func(t *testing.T) {
called := 0
rec := httptest.NewRecorder()
req := controllerRequest(&bouncer.Principal{ID: 1, IsSuperuser: true})
req.SetPathValue("controller", "missing")
svc.protect(rec, req, func(*CompiledController) { called++ })
if rec.Code != http.StatusNotFound || called != 0 {
t.Fatalf("status=%d called=%d", rec.Code, called)
}
})
}
func TestSecretRedaction(t *testing.T) {
const secret = "summercms-test-only-admin-hs256-secret"
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
rec := httptest.NewRecorder()
writeUnauthenticated(rec, errors.New(secret+" "+token))
body := rec.Body.String()
if strings.Contains(body, secret) || strings.Contains(body, token) || strings.Contains(body, "eyJ") {
t.Fatalf("unauthorized body leaked credential material: %s", body)
}
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
}
func controllerRequest(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets")
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
func assertErrorCode(t *testing.T, raw []byte, code string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != code {
t.Fatalf("error code=%q, want %s; body %s", body.Error.Code, code, raw)
}
}
var _ pact.AdminPermissioned = orderController{}