package cabana_test import ( "encoding/json" "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // Phase 17 plan 06 tests of the companion form features through the admin // routes on Postgres (the acme.companions fixture of companions_smoke_test.go) // and their boot refusals through cabana.Activate (Docker-free). // companionsOverlay is the acme.companions fixture tree with files replaced; // an empty value removes the file. func companionsOverlay(t *testing.T, files map[string]string) fstest.MapFS { t.Helper() out := fstest.MapFS{} err := fs.WalkDir(os.DirFS(companionsDir), ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } raw, err := os.ReadFile(filepath.Join(companionsDir, path)) if err != nil { return err } out[path] = &fstest.MapFile{Data: raw} return nil }) if err != nil { t.Fatal(err) } for path, data := range files { if data == "" { delete(out, path) continue } out[path] = &fstest.MapFile{Data: []byte(data)} } return out } // companionsFile is one file of the fixture tree. func companionsFile(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(filepath.Join(companionsDir, path)) if err != nil { t.Fatal(err) } return string(raw) } // companionsFSPlugin is the acme.companions fixture served from another // tree, optionally with other controllers or settings. type companionsFSPlugin struct { companionsPlugin fsys fs.FS controllers []pact.AdminController settings []pact.SettingsItem } func (p companionsFSPlugin) AdminFS() fs.FS { return p.fsys } func (p companionsFSPlugin) AdminControllers() []pact.AdminController { if p.controllers != nil { return p.controllers } return p.companionsPlugin.AdminControllers() } func (p companionsFSPlugin) Settings() []pact.SettingsItem { if p.settings != nil { return p.settings } return p.companionsPlugin.Settings() } // activateCompanions boots the admin of p without a database. func activateCompanions(t *testing.T, p party.Plugin) error { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions-boot\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) if err != nil { t.Fatal(err) } _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p}) return err } // companionsLinkRows reads every link row by id. func companionsLinkRows(t *testing.T, e *conformEnv) map[uint]companionsLink { t.Helper() var rows []companionsLink if err := e.db.Order("id").Find(&rows).Error; err != nil { t.Fatal(err) } out := make(map[uint]companionsLink, len(rows)) for _, row := range rows { out[row.ID] = row } return out } // sameLinkRows fails unless the link table is exactly before. func sameLinkRows(t *testing.T, e *conformEnv, before map[uint]companionsLink, what string) { t.Helper() a, err := json.Marshal(companionsLinkRows(t, e)) if err != nil { t.Fatal(err) } b, err := json.Marshal(before) if err != nil { t.Fatal(err) } if string(a) != string(b) { t.Fatalf("%s wrote rows:\nbefore %s\nafter %s", what, b, a) } } // wantStatus fails unless rec answered status, and returns the body. func wantStatus(t *testing.T, what string, rec interface { Result() *http.Response }, status int, body string) { t.Helper() if code := rec.Result().StatusCode; code != status { t.Fatalf("%s: status=%d want %d body=%s", what, code, status, body) } } // companionsError decodes the code and details of an error envelope. func companionsError(t *testing.T, raw []byte) (string, map[string][]string) { t.Helper() var body struct { Error struct { Code string `json:"code"` Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("error body %s: %v", raw, err) } return body.Error.Code, body.Error.Details } // TestRepeaterSave pins the D-07 save through the links routes: rows keep // only their nested keys with scalars coerced, required applies per row // with row-path errors, maxItems is enforced, nested richeditors are // sanitized with row-path notices, nested dependsOn is rechecked, a refused // save writes nothing, and the record response and show hydrate the rows. func TestRepeaterSave(t *testing.T) { e := newCompanionsEnv(t) rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "rows": []any{ map[string]any{"title": 5, "body": "

clean

", "type": "acme-page", "reference": 2, "extra": "dropped"}, map[string]any{"title": true, "body": `

dirty

`, "type": "acme-list", "reference": "1"}, map[string]any{"title": "third"}, }}, true) wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String()) created := decodeCompanionsRecord(t, rec.Body.Bytes()) id := dataID(t, rec.Body.Bytes()) var stored companionsLink if err := e.db.First(&stored, id).Error; err != nil { t.Fatal(err) } rows := stored.Rows.Get() want := []map[string]any{ {"title": "5", "body": "

clean

", "type": "acme-page", "reference": "2"}, {"title": "true", "body": "

dirty

", "type": "acme-list", "reference": nil}, {"title": "third", "body": nil, "type": nil, "reference": nil}, } if fmt.Sprint(rows) != fmt.Sprint(want) { t.Fatalf("stored rows = %v\nwant %v", rows, want) } var noticed []string for _, n := range created.Meta.Notices { noticed = append(noticed, n.Field+": "+n.Message) } if len(noticed) != 2 || !strings.HasPrefix(noticed[0], "rows.1.body: Usunięto 1 atrybut: onclick") || !strings.HasPrefix(noticed[1], "rows.1.body: Usunięto 1 komentarz HTML") { t.Fatalf("notices = %v", noticed) } if list, ok := created.Data["rows"].([]any); !ok || len(list) != 3 { t.Fatalf("response rows = %#v", created.Data["rows"]) } path := fmt.Sprintf("/acme/companions/links/%d", id) before := companionsLinkRows(t, e) for _, tc := range []struct { name string rows any keys []string }{ {"per-row required", []any{map[string]any{"title": "ok"}, map[string]any{"body": "

x

"}, map[string]any{"title": " "}}, []string{"rows.1.title", "rows.2.title"}}, {"nested dependent value", []any{map[string]any{"title": "a", "type": "acme-page", "reference": "99"}}, []string{"rows.0.reference"}}, {"coercion", []any{map[string]any{"title": map[string]any{"x": 1}}}, []string{"rows.0.title"}}, {"not a list", "rows", []string{"rows"}}, {"a row that is not an object", []any{"x"}, []string{"rows"}}, {"above maxItems", []any{map[string]any{"title": "1"}, map[string]any{"title": "2"}, map[string]any{"title": "3"}, map[string]any{"title": "4"}, map[string]any{"title": "5"}, map[string]any{"title": "6"}}, []string{"rows"}}, } { t.Run(tc.name, func(t *testing.T) { rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Rows", "rows": tc.rows}, true) wantStatus(t, tc.name, rec, http.StatusUnprocessableEntity, rec.Body.String()) _, details := companionsError(t, rec.Body.Bytes()) for _, key := range tc.keys { if len(details[key]) != 1 { t.Fatalf("details = %v, want %s", details, key) } } if len(details) != len(tc.keys) { t.Fatalf("details = %v, want exactly %v", details, tc.keys) } if tc.name == "above maxItems" && details["rows"][0] != "Usuń pozycje, aby zostało ich najwyżej 5." { t.Fatalf("max message = %q", details["rows"][0]) } if tc.name == "nested dependent value" && details["rows.0.reference"][0] != "Wybierz jedną z dostępnych opcji." { t.Fatalf("dependent message = %q", details["rows.0.reference"][0]) } sameLinkRows(t, e, before, tc.name) }) } t.Run("an update without rows keeps them, null stores none", func(t *testing.T) { rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true) wantStatus(t, "update", rec, http.StatusOK, rec.Body.String()) var again companionsLink if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 3 || again.Title != "Renamed" { t.Fatalf("rows after an update without rows = %+v (%v)", again, err) } rec = e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed", "rows": nil}, true) wantStatus(t, "null rows", rec, http.StatusOK, rec.Body.String()) if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 0 { t.Fatalf("rows after null = %+v (%v)", again.Rows, err) } shown := e.send(t, http.MethodGet, path, nil, true) if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[]`) { t.Fatalf("show = %d %s", shown.Code, shown.Body.String()) } }) } // TestDependentRecheck pins D-08 through the routes: the options route per // sibling value (tree depths, Applies false, unknown and empty values), its // 404s and 401, and the save recheck at the top level, with the stored // sibling or value used when only the other is sent; a refused save writes // nothing. func TestDependentRecheck(t *testing.T) { e := newCompanionsEnv(t) options := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult { t.Helper() rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true) wantStatus(t, field+" "+typ, rec, http.StatusOK, rec.Body.String()) var body cabana.Envelope[cabana.AdminDependentOptionsResult] if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } return body.Data } for _, field := range []string{"reference", "rows.reference"} { page := options(t, field, "acme-page") if !page.Applies || fmt.Sprint(page.Options) != "[{1 Home 0} {2 About 1}]" { t.Fatalf("%s acme-page = %+v", field, page) } for _, typ := range []string{"acme-list", "acme-unknown", "url", ""} { if none := options(t, field, typ); none.Applies || none.Options == nil || len(none.Options) != 0 { t.Fatalf("%s %q = %+v", field, typ, none) } } } for _, path := range []string{dependentOptionsPath("title", "acme-page"), dependentOptionsPath("type", "acme-page"), dependentOptionsPath("rows.title", "acme-page"), dependentOptionsPath("missing", "acme-page"), dependentOptionsPath("rows.missing", "acme-page")} { if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusNotFound { t.Fatalf("%s status=%d", path, rec.Code) } } if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized { t.Fatalf("anonymous status=%d", rec.Code) } rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, true) wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String()) id := dataID(t, rec.Body.Bytes()) path := fmt.Sprintf("/acme/companions/links/%d", id) load := func(t *testing.T) companionsLink { t.Helper() var link companionsLink if err := e.db.First(&link, id).Error; err != nil { t.Fatal(err) } return link } str := func(p *string) string { if p == nil { return "" } return *p } if link := load(t); str(link.Type) != "acme-page" || str(link.Reference) != "2" { t.Fatalf("created = %+v", link) } refused := func(t *testing.T, body map[string]any) { t.Helper() before := companionsLinkRows(t, e) rec := e.send(t, http.MethodPut, path, body, true) wantStatus(t, "refused", rec, http.StatusUnprocessableEntity, rec.Body.String()) code, details := companionsError(t, rec.Body.Bytes()) if code != "validation_failed" || len(details) != 1 || len(details["reference"]) != 1 || details["reference"][0] != "Wybierz jedną z dostępnych opcji." { t.Fatalf("details = %s %v", code, details) } sameLinkRows(t, e, before, "a refused dependent save") } refused(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "99"}) refused(t, map[string]any{"title": "Dep", "reference": "99"}) // stored type acme-page decides refused(t, map[string]any{"title": "Dep", "reference": 7}) accepted := func(t *testing.T, body map[string]any, wantType, wantRef string) { t.Helper() rec := e.send(t, http.MethodPut, path, body, true) wantStatus(t, "accepted", rec, http.StatusOK, rec.Body.String()) if link := load(t); str(link.Type) != wantType || str(link.Reference) != wantRef { t.Fatalf("after %v: type=%s reference=%s, want %s/%s", body, str(link.Type), str(link.Reference), wantType, wantRef) } } accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1") accepted(t, map[string]any{"title": "Dep", "reference": 2}, "acme-page", "2") accepted(t, map[string]any{"title": "Dep", "reference": nil}, "acme-page", "") accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1") // Only the sibling: the stored value is checked against the new type, // and a type without references clears it. accepted(t, map[string]any{"title": "Dep", "type": "acme-list"}, "acme-list", "") accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "") accepted(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, "acme-page", "2") accepted(t, map[string]any{"title": "Dep", "type": "url", "reference": "2"}, "url", "") } // TestRichEditor pins D-10 through the routes: a save stores the sanitized // value with notices in the request locale, clean HTML byte for byte with // none, null stays null, a value that is not a string is a 422 that writes // nothing; relation forms refuse the type at boot and settings forms accept // it (Phase 18 F3). func TestRichEditor(t *testing.T) { t.Run("boot refusals", func(t *testing.T) { for name, field := range map[string]string{ "richeditor": " body:\n type: richeditor\n", "repeater": " body:\n type: repeater\n form:\n fields:\n a:\n type: text\n", } { err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + field})}, nil) if err == nil || !strings.Contains(err.Error(), "field body: "+name+" is supported in controller forms only") { t.Fatalf("relation form with %s: err = %v", name, err) } fsys := companionsOverlay(t, map[string]string{"models/settings/fields.yaml": "fields:\n title:\n type: text\n body:\n" + strings.TrimPrefix(field, " body:\n")}) err = activateCompanions(t, companionsFSPlugin{fsys: fsys, settings: []pact.SettingsItem{{ Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile", NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"}, }}}) // Phase 18 F3: a settings form lets a richeditor through its // gate (the fixture then fails on the missing column, not on // the type); a repeater is still refused. refused := err != nil && strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") if name == "repeater" && !refused { t.Fatalf("settings form with %s: err = %v", name, err) } if name == "richeditor" && (refused || err == nil || !strings.Contains(err.Error(), "field body is not a model column")) { t.Fatalf("settings form with richeditor should pass the gate: err = %v", err) } } }) e := newCompanionsEnv(t) type noticeView struct{ field, message string } save := func(t *testing.T, method, path string, body map[string]any, status int) (uint, []noticeView) { t.Helper() rec := e.send(t, method, path, body, true) wantStatus(t, method+" "+path, rec, status, rec.Body.String()) record := decodeCompanionsRecord(t, rec.Body.Bytes()) var out []noticeView for _, n := range record.Meta.Notices { out = append(out, noticeView{n.Field, n.Message}) } return dataID(t, rec.Body.Bytes()), out } stored := func(t *testing.T, id uint) *string { t.Helper() var link companionsLink if err := e.db.First(&link, id).Error; err != nil { t.Fatal(err) } return link.Description } id, notices := save(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rich", "description": `

x

`}, http.StatusCreated) if got := stored(t, id); got == nil || *got != "

x

" { t.Fatalf("stored = %v", got) } want := []noticeView{ {"description", "Usunięto 2 elementy: script, iframe"}, {"description", "Usunięto 2 atrybuty: onclick, onmouseover"}, {"description", "Usunięto 1 komentarz HTML"}, } if fmt.Sprint(notices) != fmt.Sprint(want) { t.Fatalf("notices = %v\nwant %v", notices, want) } path := fmt.Sprintf("/acme/companions/links/%d", id) for _, clean := range []string{ "

It's fine & \"quoted\"  —

\n
    \n
  • one
  • \n
", `

link

`, "", } { if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 { t.Fatalf("clean %q reported %v", clean, notices) } if got := stored(t, id); got == nil || *got != clean { t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean) } } if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil { t.Fatalf("null description: %v %v", notices, stored(t, id)) } keep := "

kept

" save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK) save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK) if got := stored(t, id); got == nil || *got != keep { t.Fatalf("an update without the field changed it: %v", got) } before := companionsLinkRows(t, e) for _, bad := range []any{12, true, []any{"

x

"}, map[string]any{"pl": "

x

"}} { rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true) wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String()) if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 { t.Fatalf("details = %v", details) } sameLinkRows(t, e, before, "a non-string description") } }