package cabana import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "github.com/golang-jwt/jwt/v5" "gorm.io/gorm" ) // refreshSubjects is a map-backed bouncer.UserProvider for refresh tests // that run without a database; err, when set, is returned for every lookup. type refreshSubjects struct { byID map[uint]*bouncer.Principal err error } func (p refreshSubjects) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) { if p.err != nil { return nil, p.err } return p.byID[id], nil } // 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}", "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "POST /auth/login", "POST /auth/logout", "POST /auth/refresh", "POST /{vendor}/{plugin}/{controller}", "POST /{vendor}/{plugin}/{controller}/bulk-delete", "POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 16 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(), users: refreshSubjects{byID: map[uint]*bouncer.Principal{5: {ID: 5, Backend: true}}}, } 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()) } }) t.Run("refresh enforces the guard's subject checks", func(t *testing.T) { const secret = "phase10-subject-secret" now := time.Now() cutoff := now.Add(-10 * time.Minute) subjects := refreshSubjects{byID: map[uint]*bouncer.Principal{ 5: {ID: 5, Backend: true, TokensValidAfter: cutoff}, }} newService := func(users bouncer.UserProvider) *service { return &service{ secret: secret, ttl: 15 * time.Minute, refreshTTL: 2 * time.Hour, issuer: "https://app.test" + DefaultAdminPrefix, bl: bouncer.NewMemoryBlacklist(), users: users, } } sign := func(sub string, iat time.Time, jti string) string { t.Helper() tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": sub, "aud": bouncer.AudienceBackend, "jti": jti, "iat": iat.Unix(), "nbf": iat.Unix(), "exp": iat.Add(15 * time.Minute).Unix(), }).SignedString([]byte(secret)) if err != nil { t.Fatal(err) } return tok } call := func(svc *service, 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 } errorCode := func(rec *httptest.ResponseRecorder) string { t.Helper() var body struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("error json: %v body=%s", err, rec.Body.String()) } return body.Error.Code } assertExpired := func(name string, rec *httptest.ResponseRecorder) { t.Helper() if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" { t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String()) } var expired *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == AdminCookieName { expired = c } } if expired == nil || expired.Value != "" || expired.MaxAge >= 0 || expired.Path != DefaultAdminPrefix { t.Fatalf("%s: cookie = %+v, want an expiring %s with Path %s", name, expired, AdminCookieName, DefaultAdminPrefix) } } assertNoCookies := func(name string, rec *httptest.ResponseRecorder) { t.Helper() if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" { t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String()) } if got := rec.Header().Values("Set-Cookie"); len(got) != 0 { t.Fatalf("%s: set cookies %q", name, got) } } svc := newService(subjects) assertExpired("pre-cutoff cookie", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-cookie"), true)) assertExpired("unknown subject cookie", call(svc, sign("6", now.Add(-time.Minute), "unknown-cookie"), true)) assertNoCookies("pre-cutoff bearer", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-bearer"), false)) failing := newService(refreshSubjects{err: errors.New("lookup failed")}) assertNoCookies("provider error cookie", call(failing, sign("5", now.Add(-time.Minute), "provider-error"), true)) ok := call(svc, sign("5", cutoff.Add(time.Minute), "post-cutoff-cookie"), true) if ok.Code != http.StatusOK { t.Fatalf("post-cutoff cookie refresh status=%d body=%s", ok.Code, ok.Body.String()) } var rotated *http.Cookie for _, c := range ok.Result().Cookies() { if c.Name == AdminCookieName { rotated = c } } if rotated == nil || rotated.Value == "" || rotated.MaxAge <= 0 || rotated.Path != DefaultAdminPrefix { t.Fatalf("post-cutoff rotated cookie = %+v", rotated) } }) }