Files
summercms/cabana/phase10_coverage_test.go
Jakub Zych ef448da1cc test(10-05): cover every Phase 10 Go change with branch-level tests
- bouncer TestPhase10CookieGuard: cookie read without Bearer, Bearer wins,
  empty cookie, frontend audience and blacklisted jti rejected
- boardwalk TestPhase10BoardwalkServing: HEAD, query strings, encoded
  traversal, index by name, nested prefix, MIME fallback, constructor errors
- cabana TestPhase10Coverage: mounted unsafe routes vs the CSRF walk, option
  and filter edges, read-only labels, relation message defaults, bundle
  fallback locale, cookie refresh of an expired token in the refresh window
- phrasebook override precedence, new locale, Bundle merge order, Forms shapes
- surf prefix collision for deeper paths and the default /backend prefix
- swagger2openapi TestUnionRewrite and converter branch tests
- framework tests no longer name the application (acme fixtures instead)
2026-09-27 18:05:28 +02:00

367 lines
15 KiB
Go

package cabana
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/phrasebook"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
// emptyOptionsRow is a filter source whose scope has no choices yet;
// literalOptionsRow's choice label is a literal rather than a phrase key.
type emptyOptionsRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (emptyOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (emptyOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
func (emptyOptionsRow) FilterOptions(string) []pact.Option { return []pact.Option{} }
type literalOptionsRow struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
}
func (literalOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
func (literalOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
func (literalOptionsRow) FilterOptions(string) []pact.Option {
return []pact.Option{{Value: "x", Label: "Literal label"}}
}
// TestPhase10Coverage fills the Phase 10 cabana branches the feature tests
// left without a named case: the mounted route table against the CSRF walk,
// relation and filter option edges, read-only relation labels, relation
// message defaults, the bundle's configured fallback and a cookie refresh of
// an expired access token inside the refresh window.
func TestPhase10Coverage(t *testing.T) {
t.Run("every unsafe mounted route is CSRF-walked", func(t *testing.T) {
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
svc := phase09DeniedService()
svc.mount(router)
api := adminAPI("")
var unsafe, safe []string
for _, key := range router.order {
method, path, _ := strings.Cut(key, " ")
rel := strings.TrimPrefix(path, api)
switch method {
case http.MethodGet:
safe = append(safe, rel)
case http.MethodPost, http.MethodPut, http.MethodDelete:
unsafe = append(unsafe, method+" "+rel)
default:
t.Fatalf("unexpected method mounted: %s", key)
}
}
sort.Strings(unsafe)
want := []string{
"DELETE /{vendor}/{plugin}/{controller}/{id}",
"POST /auth/login",
"POST /auth/logout",
"POST /auth/refresh",
"POST /{vendor}/{plugin}/{controller}",
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 9 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.
for _, need := range []string{"/lang", "/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"} {
found := false
for _, rel := range safe {
found = found || rel == need
}
if !found {
t.Fatalf("GET %s is not mounted: %v", need, safe)
}
}
// A safe read with only the cookie is never refused by the CSRF check.
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only"})
rec := httptest.NewRecorder()
router.handlers[http.MethodGet+" "+adminAPI("/lang")](rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("cookie-only GET /lang status=%d", rec.Code)
}
})
t.Run("relation option edges", func(t *testing.T) {
svc, _, _ := p10Fixture(t)
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []RelationOption `json:"data"`
Meta ListMeta `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data == nil {
t.Fatalf("data is null: %s", rec.Body.String())
}
return body.Data, body.Meta
}
all, allMeta := decode(p10Options(svc, "tags", "", p10Principal(true)))
for _, query := range []string{"search=", "search=%20%20"} {
rows, meta := decode(p10Options(svc, "tags", query, p10Principal(true)))
if len(rows) != len(all) || meta.Total != allMeta.Total {
t.Fatalf("%s is not a no-op search: %d rows, total %d", query, len(rows), meta.Total)
}
}
beyond, meta := decode(p10Options(svc, "tags", "page=9", p10Principal(true)))
if len(beyond) != 0 || meta.Page != 9 || meta.Total != allMeta.Total || meta.LastPage != allMeta.LastPage {
t.Fatalf("page beyond last: rows=%d meta=%+v", len(beyond), meta)
}
capped, cappedMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
if cappedMeta.PerPage != 100 || len(capped) != len(all) {
t.Fatalf("per_page at the cap: %+v", cappedMeta)
}
above := p10Options(svc, "tags", "per_page=1000", p10Principal(true))
if above.Code != http.StatusUnprocessableEntity || !strings.Contains(above.Body.String(), `"per_page"`) {
t.Fatalf("per_page above the cap status=%d body=%s", above.Code, above.Body.String())
}
none, noneMeta := decode(p10Options(svc, "tags", "search=no-such-tag", p10Principal(true)))
if len(none) != 0 || noneMeta.Total != 0 || noneMeta.LastPage < 1 {
t.Fatalf("empty result: %d rows meta=%+v", len(none), noneMeta)
}
})
t.Run("filter option edges", func(t *testing.T) {
granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}}
empty := filterOptionsCall(filterOptionsService(t, &emptyOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "", granted)
if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), `"data":[]`) {
t.Fatalf("no choices status=%d body=%s", empty.Code, empty.Body.String())
}
literal := filterOptionsCall(filterOptionsService(t, &literalOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "pl", granted)
if literal.Code != http.StatusOK || !strings.Contains(literal.Body.String(), `{"value":"x","label":"Literal label"}`) {
t.Fatalf("literal label status=%d body=%s", literal.Code, literal.Body.String())
}
})
t.Run("read-only relation label outside the options scope", func(t *testing.T) {
svc, db, seed := p10Fixture(t)
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "labelled"}), http.StatusCreated)
id := fmt.Sprintf("%d", p10ID(created.Data["id"]))
// The options hook hides every person (1 = 0), yet the label shows.
shown := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
if p10ID(shown.Data["person"]) != seed.person || len(shown.Meta.Labels["person"]) != 1 || shown.Meta.Labels["person"][0].Label != "admin@acme.test" {
t.Fatalf("read-only label data=%v labels=%+v", shown.Data["person"], shown.Meta.Labels["person"])
}
// A dangling foreign key keeps the value and gets no label.
if err := db.Delete(&p10Person{}, seed.person).Error; err != nil {
t.Fatal(err)
}
dangling := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
if labels, ok := dangling.Meta.Labels["person"]; !ok || len(labels) != 0 {
t.Fatalf("dangling person labels=%+v", dangling.Meta.Labels)
}
// Saving never writes the read-only key.
p10Decode(t, p10Save(svc, http.MethodPut, id, map[string]any{"name": "again", "person": 42}), http.StatusOK)
if stored := p10Stored(t, db, p10ID(created.Data["id"])); stored.UserID != seed.person {
t.Fatalf("read-only foreign key written: %+v", stored)
}
})
t.Run("relation messages default every key", func(t *testing.T) {
tr := messagesTranslator(t)
ctl := relationTestController{}
fsys := relationTestFS(validRelationYAML)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
for _, locale := range []string{"pl", "en"} {
view := relations["editors"].Schema.Localize(localeCtx(locale), tr)
raw, err := json.Marshal(view.Messages)
if err != nil {
t.Fatal(err)
}
var messages map[string]map[string]string
if err := json.Unmarshal(raw, &messages); err != nil {
t.Fatal(err)
}
for _, key := range []string{"link", "linkHint", "candidateSearch", "linked", "unlinkSelected", "unlinkConfirm", "unlinked", "empty"} {
if messages[key]["other"] == "" || strings.Contains(messages[key]["other"], "backend::") {
t.Fatalf("%s %s default = %v", locale, key, messages[key])
}
}
if locale == "pl" && len(messages["linked"]) != 4 {
t.Fatalf("pl linked is not plural: %v", messages["linked"])
}
}
if err := validateMessageKeys(&Registry{byID: map[string]*CompiledController{
"acme.demo.owners": {Controller: ctl, PluginID: "acme.demo", Relations: relations},
}}, tr); err != nil {
t.Fatalf("framework defaults failed validation: %v", err)
}
})
t.Run("form-less controller drops create from any toolbar", func(t *testing.T) {
config := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\ntoolbar:\n buttons: [create]\n"
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(config, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if len(cc.List.ToolbarButtons) != 0 || cc.List.ToolbarButtons == nil {
t.Fatalf("toolbar=%#v, want an empty list", cc.List.ToolbarButtons)
}
})
t.Run("bundle falls back to app.fallback_locale", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: coverage\nlocale: pl\nfallback_locale: pl\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := phrasebook.Activate(app, []langPlugin{}); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
for _, requested := range []string{"de", ""} {
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if requested != "" {
req = req.WithContext(localeCtx(requested))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Meta.Locale != "pl" || body.Data["backend::lang.form.save"]["other"] != "Zapisz" {
t.Fatalf("requested %q: locale=%q save=%v", requested, body.Meta.Locale, body.Data["backend::lang.form.save"])
}
}
})
t.Run("cookie refresh of an expired access token inside the refresh window", func(t *testing.T) {
const secret = "phase10-coverage-secret"
svc := &service{
secret: secret,
ttl: 15 * time.Minute,
refreshTTL: 2 * time.Hour,
issuer: "https://app.test" + DefaultAdminPrefix,
bl: bouncer.NewMemoryBlacklist(),
}
sign := func(iat, exp time.Time, jti string) string {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "5", "aud": bouncer.AudienceBackend, "iss": svc.issuer, "jti": jti,
"iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(),
}).SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
return tok
}
call := func(token string, cookie bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if cookie {
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token})
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
requireAjax(svc.refresh)(rec, req)
return rec
}
now := time.Now()
expired := sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "expired-in-window")
if _, _, _, _, err := bouncer.VerifyClaimsAudience(expired, secret, bouncer.AudienceBackend); err == nil {
t.Fatal("fixture token is not expired")
}
rec := call(expired, true)
if rec.Code != http.StatusOK {
t.Fatalf("cookie refresh in window status=%d body=%s", rec.Code, rec.Body.String())
}
var next *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == AdminCookieName {
next = c
}
}
if next == nil || next.Value == "" || next.Value == expired || !next.HttpOnly || !next.Secure || next.SameSite != http.SameSiteStrictMode ||
next.Path != DefaultAdminPrefix || next.MaxAge != int((2*time.Hour)/time.Second) {
t.Fatalf("refreshed cookie = %+v", next)
}
if strings.Contains(rec.Body.String(), "access_token") || strings.Contains(rec.Body.String(), next.Value) {
t.Fatalf("cookie refresh put the token in the body: %s", rec.Body.String())
}
if _, _, _, _, err := bouncer.VerifyClaimsAudience(next.Value, secret, bouncer.AudienceBackend); err != nil {
t.Fatalf("refreshed cookie does not verify: %v", err)
}
// The old token is blacklisted by the rotation: a second refresh fails.
if again := call(expired, true); again.Code != http.StatusUnauthorized {
t.Fatalf("replayed refresh status=%d", again.Code)
}
bearer := call(sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "bearer-in-window"), false)
if bearer.Code != http.StatusOK || !strings.Contains(bearer.Body.String(), `"access_token"`) || len(bearer.Result().Cookies()) != 0 {
t.Fatalf("bearer refresh status=%d body=%s", bearer.Code, bearer.Body.String())
}
stale := call(sign(now.Add(-3*time.Hour), now.Add(-170*time.Minute), "outside-window"), true)
if stale.Code != http.StatusUnauthorized || len(stale.Result().Cookies()) != 0 {
t.Fatalf("refresh outside the window status=%d cookies=%v", stale.Code, stale.Result().Cookies())
}
missing := call("", true)
if missing.Code != http.StatusUnauthorized {
t.Fatalf("refresh without a token status=%d", missing.Code)
}
frontend := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "5", "aud": bouncer.AudienceUser, "jti": "frontend", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(),
})
frontendTok, err := frontend.SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
if cross := call(frontendTok, true); cross.Code != http.StatusUnauthorized {
t.Fatalf("frontend token refreshed through the admin cookie: %d", cross.Code)
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"token_type"`)) {
t.Fatalf("cookie refresh body=%s", rec.Body.String())
}
})
}