test(18-07): framework unit coverage of money, settings secrets, tabs, richeditor and danger actions (task 2)

- money_field_test.go: compile gates (moved from the smoke file and
  extended), model column gate, save-time value check, list projection
- settings secret projection and put without a database; tabs, settings
  richeditor sanitize and the danger flag as internal unit tests
- Postgres: TestMoneyFieldSave, TestSettingsSecretPostgres and the Phase 18
  payload cases of TestPhase10OpenAPIConformance
- vitest: money.ts helpers spec, MoneyField blur-only and server-error cases
- gate arrays and the cabana README Testing section name every test
This commit is contained in:
2026-10-13 16:58:03 +02:00
parent eb60c329d8
commit 0f35c9a0ca
13 changed files with 1302 additions and 110 deletions

View File

@@ -370,3 +370,5 @@ go test ./modules/cabana/...
```
The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`.
The Phase 18 pieces (money field and column, write-only settings secrets, richeditor and tabs on settings forms, danger record actions) are covered by Docker-free unit tests in `money_field_test.go` (TestMoneyFieldCompile, TestMoneyFieldColumnGate, TestMoneyFieldValidate, TestMoneyListColumn), `settings_secret_test.go` (TestSettingsSecretCompile, TestSettingsSecretProjection, TestSettingsSecretPut), `settings_tabs_test.go` (TestSettingsTabs, TestSettingsRichEditor) and `record_action_danger_test.go` (TestRecordActionDanger), and by Postgres tests in `money_field_postgres_test.go` (TestMoneyFieldSave), `settings_secret_postgres_test.go` (TestSettingsSecretPostgres), the smoke tests in `payments_smoke_test.go` and the Phase 18 payload cases of TestPhase10OpenAPIConformance. `scripts/check-phase18.sh` runs them by name.

View File

@@ -3,6 +3,7 @@ package cabana
import (
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -25,3 +26,20 @@ func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *ti
plain, _, err = insertAPIToken(db, ownerID, "test", scopes, expiresAt)
return plain, err
}
// ValidateMoneyValue exposes the money field's save check (phrase key and
// placeholders of the first failure, "" when accepted) to the external
// money field tests.
func ValidateMoneyValue(value any, scale int, max string) (string, map[string]string) {
return validateMoneyValue(value, scale, max)
}
// CheckMoneyType exposes the money field's model column gate to the
// external money field tests.
func CheckMoneyType(model any, field FormField) error { return checkMoneyType(model, field) }
// ProjectRow exposes one list row's projection to the external money
// column tests.
func ProjectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
return projectRow(row, controller, cols)
}

View File

@@ -0,0 +1,109 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestMoneyFieldSave extends the plan 01 money smoke on Postgres (Phase 18
// D-07, D-10): an untouched value sent back with another change, or left
// out of the body, is re-saved byte-identical; a negative amount and the
// scale-2 field round-trip exactly; null clears; a 422 on update writes
// nothing; and the list payload serves the stored strings with each row's
// own currency.
func TestMoneyFieldSave(t *testing.T) {
e := newPaymentsEnv(t)
load := func(t *testing.T, id uint) paymentsInvoice {
t.Helper()
var row paymentsInvoice
if err := e.db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-1", "currency": "PLN", "total": "85.4193", "fee": "12.5"}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
id := dataID(t, rec.Body.Bytes())
if row := load(t, id); row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("stored = %+v", row)
}
// Another field changes, the untouched amount comes back as loaded.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1b", "currency": "EUR", "total": "85.4193", "fee": "12.5000"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Number != "S-1b" || row.Currency != "EUR" || row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("after update = %+v", row)
}
// Left out of the body, the amount is not touched.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("partial update status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("after partial update = %+v", row)
}
// Negative amount and the scale-2 field, exactly.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c", "total": "-0.5000", "fee": "0.01"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("negative status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Total.text != "-0.5000" || row.Fee.text != "0.0100" {
t.Fatalf("after negative = %+v", row)
}
// A 422 on update writes nothing.
for _, body := range []map[string]any{
{"number": "S-X", "total": "1.00001"},
{"number": "S-X", "fee": "0.001"},
{"number": "S-X", "total": "1e2"},
{"number": "S-X", "total": "1000000.0000"},
} {
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), body, true)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%v status=%d body=%s", body, rec.Code, rec.Body.String())
}
if row := load(t, id); row.Number != "S-1c" || row.Total.text != "-0.5000" || row.Fee.text != "0.0100" {
t.Fatalf("a refused update wrote: %+v", row)
}
}
// null clears.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c", "fee": nil}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clear status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Fee.valid || row.Total.text != "-0.5000" {
t.Fatalf("after clear = %+v", row)
}
// The list payload: stored strings and each row's own currency.
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-2", "currency": "PLN", "total": "99999999.9999"}, true); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("above the column max status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-2", "currency": "PLN", "total": "999999.9999", "fee": "1"}, true); rec.Code != http.StatusCreated {
t.Fatalf("create S-2 status=%d body=%s", rec.Code, rec.Body.String())
}
rec = e.send(t, http.MethodGet, "/acme/payments/invoices?sort=number&dir=asc", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
}
var list cabana.ListEnvelope[[]cabana.AdminRecord]
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if len(list.Data) != 2 {
t.Fatalf("rows = %+v", list.Data)
}
if row := list.Data[0]; row["number"] != "S-1c" || row["total"] != "-0.5000" || row["currency"] != "EUR" || row["fee"] != nil {
t.Fatalf("row 1 = %+v", row)
}
if row := list.Data[1]; row["number"] != "S-2" || row["total"] != "999999.9999" || row["currency"] != "PLN" || row["fee"] != "1.0000" {
t.Fatalf("row 2 = %+v", row)
}
}

View File

@@ -0,0 +1,375 @@
package cabana_test
import (
"database/sql"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// Unit tests of the cabana money field and column (Phase 18 D-07 with the
// amended D-09 and D-10): the compile-only boot gates, the model column
// gate, the save-time value check and the list row projection. The
// Postgres save round trip is TestMoneyFieldSave and the smoke tests of
// plan 01 in payments_smoke_test.go; the fixtures (acme.payments) live
// there. No application names appear in framework tests.
// TestMoneyFieldCompile checks the money field's boot gates without a
// database: exactly one currency source, a scale of 2 to 4 (default 4), an
// ISO code, a max within the scale, and the keys refused elsewhere.
func TestMoneyFieldCompile(t *testing.T) {
schema, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(`fields:
currency:
type: text
total:
type: money
currencyFrom: currency
max: "999999.9999"
fee:
type: money
currency: EUR
scale: 2
`))
if err != nil {
t.Fatal(err)
}
byName := map[string]cabana.FormField{}
for _, field := range schema.Fields {
byName[field.Name] = field
}
if total := byName["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Scale != 4 || total.Max != "999999.9999" {
t.Fatalf("total = %+v", total)
}
if fee := byName["fee"]; fee.Currency != "EUR" || fee.CurrencyFrom != "" || fee.Scale != 2 || fee.Max != "" {
t.Fatalf("fee = %+v", fee)
}
raw, err := json.Marshal(byName["total"])
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"currencyFrom":"currency"`, `"scale":4`, `"max":"999999.9999"`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("schema JSON lacks %s: %s", want, raw)
}
}
// The smallest valid field takes the defaults: scale 4 and no max.
minimal, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS("fields:\n total:\n type: money\n currency: PLN\n"))
if err != nil {
t.Fatal(err)
}
if f := minimal.Fields[0]; f.Scale != 4 || f.Max != "" || f.Currency != "PLN" {
t.Fatalf("minimal = %+v", f)
}
refused := map[string]string{
"both sources": `fields:
currency:
type: text
total:
type: money
currency: PLN
currencyFrom: currency
`,
"no source": `fields:
total:
type: money
`,
"scale 5": `fields:
total:
type: money
currency: PLN
scale: 5
`,
"scale 1": `fields:
total:
type: money
currency: PLN
scale: 1
`,
"lower-case code": `fields:
total:
type: money
currency: pln
`,
"max beyond scale": `fields:
total:
type: money
currency: PLN
scale: 2
max: "10.001"
`,
"max not a decimal": `fields:
total:
type: money
currency: PLN
max: "1,5"
`,
"options on money": `fields:
total:
type: money
currency: PLN
options: {a: A}
`,
"currency on text": `fields:
total:
type: text
currency: PLN
`,
"currencyFrom on text": `fields:
total:
type: text
currencyFrom: currency
`,
"currencyFrom not a column name": `fields:
total:
type: money
currencyFrom: "currency code"
`,
"negative max": `fields:
total:
type: money
currency: PLN
max: "-1.00"
`,
"exponent max": `fields:
total:
type: money
currency: PLN
max: "1e6"
`,
"scale not a number": `fields:
total:
type: money
currency: PLN
scale: four
`,
"preset on money": `fields:
total:
type: money
currency: PLN
preset: number
`,
}
for name, fields := range refused {
t.Run(name, func(t *testing.T) {
_, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(fields))
if err == nil {
t.Fatalf("compiled")
}
if !strings.Contains(err.Error(), "field total") {
t.Fatalf("error does not name the field: %v", err)
}
})
}
}
// moneyShapes is a model with every column shape the money gate decides on.
type moneyShapes struct {
ID uint `gorm:"column:id;primaryKey"`
Currency string `gorm:"column:currency"`
Code *string `gorm:"column:code"`
Number int `gorm:"column:number"`
Text string `gorm:"column:text"`
TextPtr *string `gorm:"column:text_ptr"`
Raw rawAmount `gorm:"column:raw"`
RawPtr *rawAmount `gorm:"column:raw_ptr"`
Nullable sql.NullString `gorm:"column:nullable"`
Plain struct{ V int } `gorm:"column:plain"`
Float float64 `gorm:"column:float"`
Whole int64 `gorm:"column:whole"`
}
// TestMoneyFieldColumnGate covers the model column gate the boot runs on
// every money field: a string, *string or sql.Scanner plus driver.Valuer
// struct column is accepted; an integer, a float or a plain struct is
// refused naming the field; currencyFrom must name a string column of the
// model.
func TestMoneyFieldColumnGate(t *testing.T) {
accepted := []string{"text", "text_ptr", "raw", "raw_ptr", "nullable"}
for _, column := range accepted {
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: column, Type: "money", Currency: "PLN"}); err != nil {
t.Fatalf("%s refused: %v", column, err)
}
}
for _, column := range []string{"number", "plain", "float", "whole"} {
err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: column, Type: "money", Currency: "PLN"})
if err == nil || !strings.Contains(err.Error(), "field "+column+": money needs a string column") {
t.Fatalf("%s: %v", column, err)
}
}
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "missing", Type: "money", Currency: "PLN"}); err == nil || !strings.Contains(err.Error(), "field missing is not a model column") {
t.Fatalf("missing column: %v", err)
}
for _, sibling := range []string{"currency", "code"} {
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "raw", Type: "money", CurrencyFrom: sibling}); err != nil {
t.Fatalf("currencyFrom %s refused: %v", sibling, err)
}
}
for _, sibling := range []string{"number", "id", "absent"} {
err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "raw", Type: "money", CurrencyFrom: sibling})
if err == nil || !strings.Contains(err.Error(), "field raw: currencyFrom "+sibling+" is not a string column") {
t.Fatalf("currencyFrom %s: %v", sibling, err)
}
}
}
// TestMoneyFieldValidate checks the save-time value check: a decimal string
// with a dot and at most scale fraction digits is accepted exactly as sent
// (the check never rewrites it), null is accepted, and every other input
// fails with the money.* key and its placeholders, comparing magnitudes as
// digit strings rather than floats.
func TestMoneyFieldValidate(t *testing.T) {
const (
invalid = "backend::lang.money.invalid"
tooPrecise = "backend::lang.money.too_precise"
tooLarge = "backend::lang.money.too_large"
)
for _, tc := range []struct {
value any
scale int
max string
key string
params map[string]string
}{
// Accepted.
{nil, 4, "999999.9999", "", nil},
{"85.4193", 4, "", "", nil},
{"-0.5000", 4, "", "", nil},
{"0", 4, "", "", nil},
{"12", 2, "", "", nil},
{"12.5", 4, "", "", nil},
{"999999.9999", 4, "999999.9999", "", nil},
{"0999999.99990", 4, "999999.9999", "", nil},
{"12345678901234567890.1234", 4, "", "", nil},
// Trailing zeros beyond the scale carry no precision: a scale-2 field
// over a four-decimal column re-saves its loaded value.
{"12.5000", 2, "", "", nil},
{"0.10000", 4, "", "", nil},
{"999999.990000", 2, "999999.99", "", nil},
// money.invalid: not the decimal grammar, or not a string.
{"1,5", 4, "", invalid, nil},
{"1.2.3", 4, "", invalid, nil},
{"1e3", 4, "", invalid, nil},
{" 12.50", 4, "", invalid, nil},
{"12.50 ", 4, "", invalid, nil},
{"abc", 4, "", invalid, nil},
{"", 4, "", invalid, nil},
{".5", 4, "", invalid, nil},
{"5.", 4, "", invalid, nil},
{"+5", 4, "", invalid, nil},
{"--5", 4, "", invalid, nil},
{"1 000", 4, "", invalid, nil},
{85.4193, 4, "", invalid, nil},
{int64(85), 4, "", invalid, nil},
{true, 4, "", invalid, nil},
{json.Number("85.4193"), 4, "", invalid, nil},
// money.too_precise: more fraction digits than the scale, never rounded.
{"12.34567", 4, "", tooPrecise, map[string]string{"scale": "4"}},
{"1.234", 2, "", tooPrecise, map[string]string{"scale": "2"}},
{"0.10001", 4, "", tooPrecise, map[string]string{"scale": "4"}},
{"1.2301", 2, "", tooPrecise, map[string]string{"scale": "2"}},
// money.too_large: above max in absolute magnitude.
{"1000000.0000", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"1000000", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"-1000000.00", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"10.01", 2, "10.00", tooLarge, map[string]string{"max": "10.00"}},
} {
key, params := cabana.ValidateMoneyValue(tc.value, tc.scale, tc.max)
if key != tc.key {
t.Errorf("%#v (scale %d, max %q): key %q, want %q", tc.value, tc.scale, tc.max, key, tc.key)
continue
}
if len(params) != len(tc.params) {
t.Errorf("%#v: params %v, want %v", tc.value, params, tc.params)
continue
}
for k, v := range tc.params {
if params[k] != v {
t.Errorf("%#v: params %v, want %v", tc.value, params, tc.params)
}
}
}
}
// moneyRow is a list row of the invoices fixture with the currency column
// that the money columns follow.
type moneyRow struct {
ID uint `gorm:"column:id;primaryKey"`
Number string `gorm:"column:number"`
Currency string `gorm:"column:currency"`
Total rawAmount `gorm:"column:total"`
Fee rawAmount `gorm:"column:fee"`
}
// TestMoneyListColumn checks the money list column without a database: a
// currencyFrom column compiles not searchable and not sortable by default,
// and every projected row carries the sibling currency value under its own
// key although currency is not a declared column, so each row's amount is
// formatted with that row's code; a literal currency column adds no key.
func TestMoneyListColumn(t *testing.T) {
schema, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(`columns:
number:
label: Number
total:
label: Total
type: money
currencyFrom: currency
`))
if err != nil {
t.Fatal(err)
}
var total cabana.ListColumn
for _, col := range schema.Columns {
if col.Key == "total" {
total = col
}
if col.Key == "currency" {
t.Fatalf("currency became a declared column: %+v", schema.Columns)
}
}
if total.Type != "money" || total.CurrencyFrom != "currency" || total.Searchable || total.Sortable {
t.Fatalf("total = %+v", total)
}
rows := []moneyRow{
{ID: 1, Number: "A", Currency: "PLN", Total: rawAmount{text: "85.4193", valid: true}},
{ID: 2, Number: "B", Currency: "EUR", Total: rawAmount{text: "5.0000", valid: true}},
{ID: 3, Number: "C", Currency: "", Total: rawAmount{}},
}
want := []map[string]any{
{"total": "85.4193", "currency": "PLN"},
{"total": "5.0000", "currency": "EUR"},
{"total": nil, "currency": ""},
}
for i, row := range rows {
out := cabana.ProjectRow(&row, paymentsInvoices{}, schema.Columns)
raw, err := json.Marshal(out)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
for key, value := range want[i] {
if got[key] != value {
t.Fatalf("row %d %s = %#v, want %#v (row %s)", i+1, key, got[key], value, raw)
}
}
if got["number"] != row.Number {
t.Fatalf("row %d number = %#v", i+1, got["number"])
}
}
literal, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS("columns:\n fee:\n type: money\n currency: EUR\n"))
if err != nil {
t.Fatal(err)
}
out := cabana.ProjectRow(&rows[0], paymentsInvoices{}, literal.Columns)
if _, has := out["currency"]; has {
t.Fatalf("a literal-currency column added the currency key: %#v", out)
}
}

View File

@@ -229,6 +229,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": env.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("re-login status=%d body=%s", rec.Code, rec.Body.String())
}
// Phase 18 payloads on the acme.payments fixture.
conformPhase18(t, spec)
for _, tc := range companionsErrorCases() {
t.Run(fmt.Sprintf("%s %d %s", tc.key, tc.status, tc.name), func(t *testing.T) {
rec := tc.call(t, env)
@@ -252,6 +255,66 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
}
}
// conformPhase18 checks the Phase 18 payload shapes against admin.json on
// the acme.payments fixture (D-07, D-13): the settings GET and PUT with
// meta.secrets, and a form schema with a money field, each decoded with
// unknown fields disallowed into the type admin.json documents.
func conformPhase18(t *testing.T, spec conformSpecDoc) {
t.Helper()
e := newPaymentsEnv(t)
strict := func(t *testing.T, key string, rec *httptest.ResponseRecorder, out any, ref string) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", key, rec.Code, rec.Body.String())
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
t.Fatalf("%s body does not match its documented type: %v\n%s", key, err, rec.Body.String())
}
method, path, _ := strings.Cut(key, " ")
if got := spec.ref(path, strings.ToLower(method), "200"); got != ref {
t.Fatalf("admin.json documents %q for %s 200, the handler writes %s", got, key, ref)
}
}
t.Run("PUT /settings/{code} meta.secrets", func(t *testing.T) {
var body cabana.Envelope[cabana.SettingsResult]
strict(t, "PUT /settings/{code}", e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": "sk_conform", "notes": "<p onclick=\"x()\">n</p>"}, true), &body, "cabana.Envelope-cabana_SettingsResult")
if body.Data.Meta == nil || body.Data.Meta.Secrets["api_key"] != cabana.SecretStored || len(body.Data.Meta.Notices) != 1 {
t.Fatalf("meta = %+v", body.Data.Meta)
}
})
t.Run("GET /settings/{code} meta.secrets", func(t *testing.T) {
var body cabana.Envelope[cabana.SettingsResult]
strict(t, "GET /settings/{code}", e.send(t, http.MethodGet, "/settings/vault", nil, true), &body, "cabana.Envelope-cabana_SettingsResult")
if _, leaked := body.Data.Data["api_key"]; leaked || body.Data.Meta == nil || body.Data.Meta.Secrets["api_key"] != cabana.SecretStored || body.Data.Meta.Secrets["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("settings = %+v", body.Data)
}
})
t.Run("GET /{vendor}/{plugin}/{controller}/schema/form money", func(t *testing.T) {
var body cabana.Envelope[cabana.FormView]
strict(t, "GET /{vendor}/{plugin}/{controller}/schema/form", e.send(t, http.MethodGet, "/acme/payments/invoices/schema/form", nil, true), &body, "cabana.Envelope-cabana_FormView")
found := 0
for _, f := range body.Data.Fields {
switch f.Name {
case "total":
if f.Type != "money" || f.CurrencyFrom != "currency" || f.Scale != 4 || f.Max != "999999.9999" {
t.Fatalf("total = %+v", f)
}
found++
case "fee":
if f.Type != "money" || f.Currency != "EUR" || f.Scale != 2 {
t.Fatalf("fee = %+v", f)
}
found++
}
}
if found != 2 {
t.Fatalf("money fields = %+v", body.Data.Fields)
}
})
}
// companionsErrorCase is one documented error answer of a Phase 17 route.
type companionsErrorCase struct {
key string

View File

@@ -235,113 +235,6 @@ func moneyFormFS(fields string) fstest.MapFS {
}
}
// TestMoneyFieldCompile checks the money field's boot gates without a
// database: exactly one currency source, a scale of 2 to 4 (default 4), an
// ISO code, a max within the scale, and the keys refused elsewhere.
func TestMoneyFieldCompile(t *testing.T) {
schema, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(`fields:
currency:
type: text
total:
type: money
currencyFrom: currency
max: "999999.9999"
fee:
type: money
currency: EUR
scale: 2
`))
if err != nil {
t.Fatal(err)
}
byName := map[string]cabana.FormField{}
for _, field := range schema.Fields {
byName[field.Name] = field
}
if total := byName["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Scale != 4 || total.Max != "999999.9999" {
t.Fatalf("total = %+v", total)
}
if fee := byName["fee"]; fee.Currency != "EUR" || fee.CurrencyFrom != "" || fee.Scale != 2 || fee.Max != "" {
t.Fatalf("fee = %+v", fee)
}
raw, err := json.Marshal(byName["total"])
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"currencyFrom":"currency"`, `"scale":4`, `"max":"999999.9999"`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("schema JSON lacks %s: %s", want, raw)
}
}
refused := map[string]string{
"both sources": `fields:
currency:
type: text
total:
type: money
currency: PLN
currencyFrom: currency
`,
"no source": `fields:
total:
type: money
`,
"scale 5": `fields:
total:
type: money
currency: PLN
scale: 5
`,
"scale 1": `fields:
total:
type: money
currency: PLN
scale: 1
`,
"lower-case code": `fields:
total:
type: money
currency: pln
`,
"max beyond scale": `fields:
total:
type: money
currency: PLN
scale: 2
max: "10.001"
`,
"max not a decimal": `fields:
total:
type: money
currency: PLN
max: "1,5"
`,
"options on money": `fields:
total:
type: money
currency: PLN
options: {a: A}
`,
"currency on text": `fields:
total:
type: text
currency: PLN
`,
}
for name, fields := range refused {
t.Run(name, func(t *testing.T) {
_, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(fields))
if err == nil {
t.Fatalf("compiled")
}
if !strings.Contains(err.Error(), "field total") {
t.Fatalf("error does not name the field: %v", err)
}
})
}
}
// invoiceRecord is a create or update answer of the invoices controller.
type invoiceRecord struct {
Data map[string]any `json:"data"`

View File

@@ -0,0 +1,75 @@
package cabana
import (
"context"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestRecordActionDanger checks UI-SPEC F5 without a database: the record
// actions offered on a record carry pact.AdminRecordAction.Danger through
// to cabana's RecordAction, in the form's declared order; an ordinary
// action defaults to false and omits the key on the wire, so existing
// payloads stay byte-identical; an action the principal may not run or
// that does not apply is not offered at all.
func TestRecordActionDanger(t *testing.T) {
run := func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return pact.AdminRecordActionResult{}, nil
}
cc := &CompiledController{
Form: &FormSchema{recordActions: []string{"resend", "void", "archive", "locked"}},
RecordActions: map[string]pact.AdminRecordAction{
"void": {Name: "void", Label: "Void invoice", Confirm: "Void this invoice?", Danger: true, Run: run},
"resend": {Name: "resend", Label: "Send again", Run: run},
"archive": {Name: "archive", Label: "Archive", Danger: true, Run: run, Applies: func(context.Context, any) (bool, error) { return false, nil }},
"locked": {Name: "locked", Label: "Locked", Permissions: []string{"acme.payments.void"}, Run: run},
},
}
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.payments.access": true}}
actions, err := (CRUDService{}).offeredRecordActions(bouncer.WithUser(context.Background(), editor), cc, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(actions) != 2 {
t.Fatalf("actions = %+v, want resend and void (archive does not apply, locked needs a permission)", actions)
}
if actions[0].Name != "resend" || actions[0].Danger || actions[1].Name != "void" || !actions[1].Danger {
t.Fatalf("actions = %+v", actions)
}
if actions[1].Label != "Void invoice" || actions[1].Confirm != "Void this invoice?" {
t.Fatalf("void = %+v", actions[1])
}
resend, err := json.Marshal(actions[0])
if err != nil {
t.Fatal(err)
}
void, err := json.Marshal(actions[1])
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(resend), "danger") {
t.Fatalf("an ordinary action carries the danger key: %s", resend)
}
if !strings.Contains(string(void), `"danger":true`) {
t.Fatalf("the danger action lacks danger:true: %s", void)
}
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
actions, err = (CRUDService{}).offeredRecordActions(bouncer.WithUser(context.Background(), super), cc, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(actions) != 3 || actions[2].Name != "locked" || actions[2].Danger {
t.Fatalf("superuser actions = %+v", actions)
}
// The zero value of the pact field is false: actions written before
// Phase 18 keep the default confirm variant.
if (pact.AdminRecordAction{Name: "legacy"}).Danger {
t.Fatal("Danger defaults to true")
}
}

View File

@@ -0,0 +1,133 @@
package cabana_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestSettingsSecretPostgres checks the D-13 contract against the real
// settings table: the ciphertext column never holds the plaintext and
// decrypts back to it through lagoon.Encrypted; each secret is kept,
// replaced or cleared on its own; a save of other fields keeps both
// secrets (a row save re-encrypts them with a fresh nonce, so the kept
// plaintext is compared, not the ciphertext bytes); a refused save writes
// nothing; the env state comes from the fallback key; and no response body
// ever carries a secret.
func TestSettingsSecretPostgres(t *testing.T) {
e := newPaymentsEnv(t)
const live, test, replaced = "sk_live_pg_1", "sk_test_pg_1", "sk_live_pg_2"
secrets := []string{live, test, replaced, "sk_env_pg"}
put := func(t *testing.T, env *conformEnv, body map[string]any, want int) *httptest.ResponseRecorder {
t.Helper()
rec := env.send(t, http.MethodPut, "/settings/vault", body, true)
if rec.Code != want {
t.Fatalf("put %v status=%d want %d body=%s", body, rec.Code, want, rec.Body.String())
}
for _, s := range secrets {
if strings.Contains(rec.Body.String(), s) {
t.Fatalf("body leaks %s: %s", s, rec.Body.String())
}
}
return rec
}
columns := func(t *testing.T) (string, string) {
t.Helper()
var row struct{ APIKey, TestAPIKey *string }
if err := e.db.Raw("SELECT api_key, test_api_key FROM cabana_payments_vault WHERE id = 1").Scan(&row).Error; err != nil {
t.Fatal(err)
}
deref := func(p *string) string {
if p == nil {
return ""
}
return *p
}
return deref(row.APIKey), deref(row.TestAPIKey)
}
revealed := func(t *testing.T) (string, string) {
t.Helper()
var vault paymentsVault
if err := e.db.First(&vault, 1).Error; err != nil {
t.Fatal(err)
}
return vault.APIKey.Reveal(), vault.TestAPIKey.Reveal()
}
rec := put(t, e, map[string]any{"api_key": live, "test_api_key": test}, http.StatusOK)
if got := decodeVault(t, rec.Body.Bytes()).Data.Meta.Secrets; got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretStored {
t.Fatalf("states = %v", got)
}
liveCipher, testCipher := columns(t)
for _, c := range []string{liveCipher, testCipher} {
if c == "" || strings.Contains(c, "sk_") {
t.Fatalf("column is not ciphertext: %q", c)
}
}
if liveCipher == testCipher {
t.Fatal("two secrets share a ciphertext")
}
if l, tt := revealed(t); l != live || tt != test {
t.Fatalf("round trip = %q %q", l, tt)
}
// Other fields only: both secrets kept, still ciphertext.
put(t, e, map[string]any{"notes": "<p>n</p>"}, http.StatusOK)
if l, tt := revealed(t); l != live || tt != test {
t.Fatalf("a save without secrets changed them: %q %q", l, tt)
}
liveCipher, testCipher = columns(t)
if strings.Contains(liveCipher, "sk_") || strings.Contains(testCipher, "sk_") {
t.Fatal("a row save stored a plaintext")
}
// A refused save writes nothing: the ciphertexts are byte for byte.
put(t, e, map[string]any{"api_key": replaced, "test_api_key": 7}, http.StatusUnprocessableEntity)
if l, tt := columns(t); l != liveCipher || tt != testCipher {
t.Fatal("a refused save wrote a secret")
}
// Replace one, keep the other with an empty string.
put(t, e, map[string]any{"api_key": replaced, "test_api_key": ""}, http.StatusOK)
if l, tt := revealed(t); l != replaced || tt != test {
t.Fatalf("after replace = %q %q", l, tt)
}
// Clear one with null: the column is NULL or empty, the other stays.
rec = put(t, e, map[string]any{"test_api_key": nil}, http.StatusOK)
if got := decodeVault(t, rec.Body.Bytes()).Data.Meta.Secrets; got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states after clear = %v", got)
}
if _, tt := columns(t); tt != "" {
t.Fatalf("null left %q", tt)
}
if l, _ := revealed(t); l != replaced {
t.Fatalf("clearing one secret touched the other: %q", l)
}
// With the fallback set: api_key stays stored (the column wins), and
// after clearing it the state is env.
h, err := paymentsAssemble(t, e.db, []party.Plugin{paymentsPlugin{}}, "SUMMER_ACME__VAULT__API_KEY=sk_env_pg")
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: e.db, login: e.login}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
get := env.send(t, http.MethodGet, "/settings/vault", nil, true)
if got := decodeVault(t, get.Body.Bytes()).Data.Meta.Secrets; got["api_key"] != cabana.SecretStored {
t.Fatalf("stored with a fallback = %v", got)
}
rec = put(t, env, map[string]any{"api_key": nil}, http.StatusOK)
if got := decodeVault(t, rec.Body.Bytes()).Data.Meta.Secrets; got["api_key"] != cabana.SecretEnv || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states with the fallback = %v", got)
}
get = env.send(t, http.MethodGet, "/settings/vault", nil, true)
for _, s := range secrets {
if strings.Contains(get.Body.String(), s) {
t.Fatalf("GET leaks %s: %s", s, get.Body.String())
}
}
}

View File

@@ -1,10 +1,16 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -97,3 +103,188 @@ func TestSettingsSecretCompile(t *testing.T) {
t.Fatal("a non-string secret was accepted")
}
}
// secretTwoFields compiles the unit settings form with the secret on the
// Keys tab, an untabbed plain token text field and a richeditor.
func secretTwoFields(t *testing.T) *CompiledSetting {
t.Helper()
item, fsys := secretSettingsItem("fields:\n api_key:\n label: API key\n type: password\n tab: Keys\n token:\n type: text\n notes:\n type: richeditor\n")
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
return setting
}
// secretConfig is a compass config whose environment sets the given
// SUMMER_* variables.
func secretConfig(t *testing.T, environ ...string) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-secrets\nlocale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: environ})
if err != nil {
t.Fatal(err)
}
return cfg
}
// TestSettingsSecretProjection checks the D-13 read side without a
// database: the data projection never carries a password field (whatever
// it holds), every other column stays; the three states stored, env and
// empty come from the model and the fallback config key; the meta is
// omitted from the JSON when there is nothing to report; and no marshalled
// result contains the plaintext.
func TestSettingsSecretProjection(t *testing.T) {
setting := secretTwoFields(t)
const plaintext = "sk_live_projection"
model := &secretSettings{APIKey: lagoon.NewEncrypted(plaintext), Token: "tok", Notes: "<p>n</p>"}
data := withoutSecrets(setting, projectSetting(setting, model))
if _, leaked := data["api_key"]; leaked {
t.Fatalf("api_key in data: %#v", data)
}
if data["token"] != "tok" || data["notes"] != "<p>n</p>" {
t.Fatalf("other columns dropped: %#v", data)
}
envCfg := secretConfig(t, "SUMMER_ACME__SECRET__API_KEY=sk_env_value")
emptyCfg := secretConfig(t)
for _, tc := range []struct {
name string
model *secretSettings
cfg *compass.Config
want string
}{
{"stored without config", &secretSettings{APIKey: lagoon.NewEncrypted(plaintext)}, nil, SecretStored},
{"stored wins over env", &secretSettings{APIKey: lagoon.NewEncrypted(plaintext)}, envCfg, SecretStored},
{"env when the column is empty", &secretSettings{}, envCfg, SecretEnv},
{"empty when the fallback is unset", &secretSettings{}, emptyCfg, SecretEmpty},
{"empty without config", &secretSettings{}, nil, SecretEmpty},
} {
t.Run(tc.name, func(t *testing.T) {
states := secretStates(setting, tc.model, tc.cfg)
if len(states) != 1 || states["api_key"] != tc.want {
t.Fatalf("states = %v, want api_key %s", states, tc.want)
}
result := SettingsResult{Exists: true, Data: withoutSecrets(setting, projectSetting(setting, tc.model)), Meta: settingsMeta(context.Background(), nil, states, nil)}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), plaintext) || strings.Contains(string(raw), "sk_env_value") {
t.Fatalf("result leaks a secret: %s", raw)
}
if !strings.Contains(string(raw), `"meta":{"secrets":{"api_key":"`+tc.want+`"}}`) {
t.Fatalf("meta = %s", raw)
}
})
}
// A form without a password field: no states, no meta key at all.
item, fsys := secretSettingsItem("fields:\n token:\n type: text\n")
plain, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
if states := secretStates(plain, &secretSettings{}, envCfg); states != nil {
t.Fatalf("states without a password field = %v", states)
}
raw, err := json.Marshal(SettingsResult{Data: withoutSecrets(plain, map[string]any{"token": "t"}), Meta: settingsMeta(context.Background(), nil, nil, nil)})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "meta") {
t.Fatalf("meta not omitted: %s", raw)
}
}
// TestSettingsSecretPut checks the D-13 write side without a database: an
// absent key and an empty string keep the stored value (the key never
// reaches Fill), null clears it, a new string replaces it, anything else
// is a 422 on the field before anything is written; a required password
// field is not required on a save; and the states after the fill are what
// the response meta reports.
func TestSettingsSecretPut(t *testing.T) {
setting := secretTwoFields(t)
const stored, replaced = "sk_old", "sk_new"
fill := func(t *testing.T, model *secretSettings, body map[string]any) {
t.Helper()
if err := liftSecretValues(setting, body); err != nil {
t.Fatal(err)
}
if err := lagoon.Fill(model, settingWritable(setting), projectSettingBody(setting, body), false); err != nil {
t.Fatal(err)
}
}
t.Run("absent keeps", func(t *testing.T) {
model := &secretSettings{APIKey: lagoon.NewEncrypted(stored)}
fill(t, model, map[string]any{"token": "t"})
if model.APIKey.Reveal() != stored || model.Token != "t" {
t.Fatalf("model = %+v", model)
}
})
t.Run("empty string keeps", func(t *testing.T) {
model := &secretSettings{APIKey: lagoon.NewEncrypted(stored)}
body := map[string]any{"api_key": "", "token": "t"}
fill(t, model, body)
if _, present := body["api_key"]; present {
t.Fatalf("empty string reached the projection: %#v", body)
}
if model.APIKey.Reveal() != stored {
t.Fatalf("api_key = %q", model.APIKey.Reveal())
}
})
t.Run("null clears", func(t *testing.T) {
model := &secretSettings{APIKey: lagoon.NewEncrypted(stored)}
fill(t, model, map[string]any{"api_key": nil})
if model.APIKey.Reveal() != "" {
t.Fatalf("api_key = %q, want cleared", model.APIKey.Reveal())
}
if got := secretStates(setting, model, nil)["api_key"]; got != SecretEmpty {
t.Fatalf("state after clear = %s", got)
}
})
t.Run("string replaces", func(t *testing.T) {
model := &secretSettings{APIKey: lagoon.NewEncrypted(stored)}
fill(t, model, map[string]any{"api_key": replaced})
if model.APIKey.Reveal() != replaced {
t.Fatalf("api_key = %q", model.APIKey.Reveal())
}
if got := secretStates(setting, model, nil)["api_key"]; got != SecretStored {
t.Fatalf("state after replace = %s", got)
}
})
for _, bad := range []any{5, true, []any{"x"}, map[string]any{"v": "x"}} {
t.Run("non-string is 422", func(t *testing.T) {
err := liftSecretValues(setting, map[string]any{"api_key": bad})
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("%#v: err = %v", bad, err)
}
if _, ok := verr.Details["api_key"]; !ok || len(verr.Details) != 1 {
t.Fatalf("details = %#v", verr.Details)
}
})
}
t.Run("required password is not required on save", func(t *testing.T) {
item, fsys := secretSettingsItem("fields:\n api_key:\n type: password\n required: true\n token:\n type: text\n required: true\n")
req, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
err = requireSettingInputs(req, map[string]any{})
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err = %v", err)
}
if _, ok := verr.Details["api_key"]; ok {
t.Fatalf("absent password reported required: %#v", verr.Details)
}
if _, ok := verr.Details["token"]; !ok {
t.Fatalf("required text not reported: %#v", verr.Details)
}
})
}

View File

@@ -0,0 +1,151 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
)
// TestSettingsTabs checks UI-SPEC F4 without a database: a settings form
// accepts `tab`, the compiled and localized schema carries each field's tab
// (untabbed fields form the default tab and omit the key), and an untabbed
// form's schema carries no tab key at all, byte for byte as before the
// feature. A repeater and a dependsOn dropdown stay refused on settings.
func TestSettingsTabs(t *testing.T) {
item, fsys := secretSettingsItem(`fields:
token:
label: Token
type: text
api_key:
label: API key
type: password
tab: Keys
notes:
label: Notes
type: richeditor
tab: Texts
`)
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
view, err := setting.Form.Localize(context.Background(), nil, nil)
if err != nil {
t.Fatal(err)
}
tabs := map[string]string{}
var order []string
for _, field := range view.Fields {
tabs[field.Name] = field.Tab
order = append(order, field.Name)
}
if strings.Join(order, ",") != "token,api_key,notes" {
t.Fatalf("field order = %v", order)
}
if tabs["token"] != "" || tabs["api_key"] != "Keys" || tabs["notes"] != "Texts" {
t.Fatalf("tabs = %v", tabs)
}
raw, err := json.Marshal(view)
if err != nil {
t.Fatal(err)
}
if strings.Count(string(raw), `"tab":`) != 2 {
t.Fatalf("tabbed schema JSON = %s", raw)
}
item, fsys = secretSettingsItem("fields:\n token:\n type: text\n notes:\n type: richeditor\n")
plain, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
plainView, err := plain.Form.Localize(context.Background(), nil, nil)
if err != nil {
t.Fatal(err)
}
raw, err = json.Marshal(plainView)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"tab"`) {
t.Fatalf("untabbed schema carries a tab key: %s", raw)
}
for name, fields := range map[string]string{
"repeater": "fields:\n notes:\n type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": "fields:\n token:\n type: dropdown\n dependsOn: notes\n options: {a: A}\n notes:\n type: text\n",
} {
t.Run(name+" refused", func(t *testing.T) {
item, fsys := secretSettingsItem(fields)
if _, err := compileSetting("acme.unit", item, fsys); err == nil {
t.Fatalf("%s compiled on a settings form", name)
}
})
}
}
// TestSettingsRichEditor checks UI-SPEC F3 without a database: a settings
// richeditor value is sanitized through seaglass before Fill sees it and
// what was removed comes back as notices on the field (one per kind); clean
// HTML passes byte for byte with no notice; null is accepted and a value
// that is neither a string nor null is a 422 on the field before anything
// is written.
func TestSettingsRichEditor(t *testing.T) {
setting := secretTwoFields(t)
dirty := `<p onclick="steal()">Bank</p><script>alert(1)</script>`
projected := projectSettingBody(setting, map[string]any{"notes": dirty, "token": "t"})
notices := sanitizeSettingRichEditors(setting, projected)
if projected["notes"] != "<p>Bank</p>" {
t.Fatalf("sanitized notes = %#v", projected["notes"])
}
if projected["token"] != "t" {
t.Fatalf("a text field was touched: %#v", projected["token"])
}
if len(notices) != 2 {
t.Fatalf("notices = %+v, want one for the element and one for the attribute", notices)
}
messages := map[string]bool{}
for _, n := range notices {
if n.field != "notes" {
t.Fatalf("notice on %q", n.field)
}
messages[n.notice.Message] = true
}
if !messages[msgRichRemovedElements] || !messages[msgRichRemovedAttributes] {
t.Fatalf("notice keys = %v", messages)
}
meta := settingsMeta(context.Background(), nil, nil, notices)
if meta == nil || len(meta.Notices) != 2 || meta.Notices[0].Field != "notes" || meta.Secrets != nil {
t.Fatalf("meta = %+v", meta)
}
clean := "<p>IBAN <strong>PL00</strong> &amp; \"swift\"</p>\n<ul><li>one</li></ul>"
projected = projectSettingBody(setting, map[string]any{"notes": clean})
if notices := sanitizeSettingRichEditors(setting, projected); len(notices) != 0 {
t.Fatalf("clean HTML reported notices: %+v", notices)
}
if projected["notes"] != clean {
t.Fatalf("clean HTML rewritten: %q", projected["notes"])
}
if meta := settingsMeta(context.Background(), nil, nil, nil); meta != nil {
t.Fatalf("meta without secrets or notices = %+v", meta)
}
for _, ok := range []any{nil, "", "<p>x</p>"} {
if err := checkSettingRichEditors(setting, map[string]any{"notes": ok}); err != nil {
t.Fatalf("%#v refused: %v", ok, err)
}
}
for _, bad := range []any{[]any{"<p>x</p>"}, 5, true, map[string]any{"html": "x"}} {
err := checkSettingRichEditors(setting, map[string]any{"notes": bad})
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("%#v: err = %v", bad, err)
}
if _, ok := verr.Details["notes"]; !ok {
t.Fatalf("details = %#v", verr.Details)
}
}
}