package cabana_test import ( "bytes" "context" "database/sql/driver" "encoding/base64" "encoding/json" "fmt" "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" "time" "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/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" "gorm.io/gorm" ) // paymentsDir is the neutral acme.payments fixture plugin tree of the Phase // 18 cabana features: the exact-decimal money field and column. No // application names appear in framework tests. const paymentsDir = "testdata/payments" // rawAmount is the fixture's amount column type: a sql.Scanner and // driver.Valuer that keeps the decimal string exactly as the database or the // save handed it, so a test can assert that cabana never rewrote it. type rawAmount struct { text string valid bool } // Scan keeps the text of a numeric column; a float would mean the driver // rounded the value, so it is refused. func (a *rawAmount) Scan(src any) error { switch v := src.(type) { case nil: *a = rawAmount{} case string: *a = rawAmount{text: v, valid: true} case []byte: *a = rawAmount{text: string(v), valid: true} default: return fmt.Errorf("rawAmount: cannot scan %T", src) } return nil } // Value writes the text back unchanged, or NULL. func (a rawAmount) Value() (driver.Value, error) { if !a.valid { return nil, nil } return a.text, nil } // MarshalJSON serves the stored text as a JSON string, or null. func (a rawAmount) MarshalJSON() ([]byte, error) { if !a.valid { return []byte("null"), nil } return json.Marshal(a.text) } // paymentsInvoice is the fixture model of the invoices controller: a // currency code column and two money columns, one following the currency // column and one with a fixed currency. type paymentsInvoice struct { ID uint `gorm:"column:id;primaryKey"` Number string `gorm:"column:number"` Currency string `gorm:"column:currency"` Total rawAmount `gorm:"column:total;type:numeric(12,4)"` Fee rawAmount `gorm:"column:fee;type:numeric(10,4)"` } func (paymentsInvoice) TableName() string { return "cabana_payments_invoices" } func (paymentsInvoice) Fillable() []string { return []string{"number", "currency", "total", "fee"} } func (paymentsInvoice) Rules() map[string]string { return map[string]string{"number": "required"} } // paymentsModels are the fixture tables, dropped and recreated per env. func paymentsModels() []any { return []any{&paymentsInvoice{}, &paymentsVault{}} } // paymentsPlugin registers the invoices controller. type paymentsPlugin struct{} func (paymentsPlugin) ID() string { return "acme.payments" } func (paymentsPlugin) Requires() []string { return nil } func (paymentsPlugin) Register(*backpack.App) error { return nil } func (paymentsPlugin) Boot(*backpack.App) error { return nil } func (paymentsPlugin) AdminFS() fs.FS { return os.DirFS(paymentsDir) } func (paymentsPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{paymentsInvoices{}} } func (paymentsPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.payments.access", Roles: []string{"developer"}}} } // Settings registers the vault page: two write-only secrets on two tabs // and a richeditor on the default tab (D-13, F2 to F4). func (paymentsPlugin) Settings() []pact.SettingsItem { return []pact.SettingsItem{{Code: "vault", Label: "Vault", Category: "Acme", Icon: "settings", Model: "Vault", Permissions: []string{"acme.payments.access"}, Form: "models/vault/fields.yaml", NewModel: func() any { return &paymentsVault{} }}} } // paymentsVault is the settings model: two lagoon.Encrypted secrets, the // first with a host-config fallback, and a richeditor notes column. type paymentsVault struct { ID uint `gorm:"column:id;primaryKey"` APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"` TestAPIKey lagoon.Encrypted `gorm:"column:test_api_key;type:text"` Notes string `gorm:"column:notes"` } func (paymentsVault) TableName() string { return "cabana_payments_vault" } func (paymentsVault) Fillable() []string { return []string{"api_key", "test_api_key", "notes"} } func (paymentsVault) Rules() map[string]string { return map[string]string{} } // SecretFallbacks maps api_key to the acme.vault.api_key config key // (SUMMER_ACME__VAULT__API_KEY). func (paymentsVault) SecretFallbacks() map[string]string { return map[string]string{"api_key": "acme.vault.api_key"} } var _ cabana.SecretFallbacks = paymentsVault{} // paymentsAppKey is the SUMMER_APP__KEY of the fixture: 32 bytes, base64. var paymentsAppKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("P"), 32)) // paymentsInvoices is the invoices controller. type paymentsInvoices struct{} func (paymentsInvoices) ID() string { return "acme.payments.invoices" } func (paymentsInvoices) ModelName() string { return "Invoice" } func (paymentsInvoices) ConfigDir() string { return "controllers/invoices" } func (paymentsInvoices) RequiredPermissions() []string { return []string{"acme.payments.access"} } func (paymentsInvoices) NewRecord() any { return &paymentsInvoice{} } // AdminRecordActions registers a danger action (void) and an ordinary one // (resend) for the F5 flag. func (paymentsInvoices) AdminRecordActions() []pact.AdminRecordAction { run := func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { return pact.AdminRecordActionResult{}, nil } return []pact.AdminRecordAction{ {Name: "void", Label: "Void invoice", Confirm: "Void this invoice? This cannot be undone.", Danger: true, Run: run}, {Name: "resend", Label: "Send again", Run: run}, } } var _ pact.HasAdminRecordActions = paymentsInvoices{} // paymentsAssemble boots plugins on the test database with the fixture's // config: the assembled admin handler, or the boot error. func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin, environ ...string) (http.Handler, error) { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-payments\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { t.Fatal(err) } environ = append([]string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret, "SUMMER_APP__KEY=" + paymentsAppKey}, environ...) cfg, err := compass.Open(compass.Options{Dir: dir, Environ: environ}) if err != nil { t.Fatal(err) } for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} { if err := cfg.Set(key, value); err != nil { t.Fatal(err) } } app := backpack.New(cfg) if err := lagoon.Publish(app, adminSQL, gdb); err != nil { t.Fatal(err) } // The column-encryption keys of the vault secrets (OpenFromApp does this // at boot; the test publishes the shared test database by hand). primary, previous, err := lagoon.LoadAppKey(app.Config) if err != nil { t.Fatal(err) } if err := lagoon.PublishEncryptionKeys(app, primary, previous); err != nil { t.Fatal(err) } if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } return surf.Assemble(app, plugins) } // newPaymentsEnv boots the acme.payments fixture alone on Postgres and signs // an administrator in. func newPaymentsEnv(t *testing.T) *conformEnv { t.Helper() gdb := adminGorm(t) if err := gdb.Migrator().DropTable(paymentsModels()...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(paymentsModels()...); err != nil { t.Fatal(err) } login := fmt.Sprintf("payments-%d", time.Now().UnixNano()) insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) h, err := paymentsAssemble(t, gdb, []party.Plugin{paymentsPlugin{}}) if err != nil { t.Fatal(err) } env := &conformEnv{h: h, db: gdb, login: login} if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) } return env } // moneyFormFS is a one-controller plugin tree whose invoice fields.yaml is // the given text, for compile-only checks of the money keys. func moneyFormFS(fields string) fstest.MapFS { return fstest.MapFS{ "controllers/invoices/config_form.yaml": {Data: []byte("name: invoices\nform: ~/plugins/acme/payments/models/invoice/fields.yaml\nmodelClass: Invoice\n")}, "models/invoice/fields.yaml": {Data: []byte(fields)}, } } // invoiceRecord is a create or update answer of the invoices controller. type invoiceRecord struct { Data map[string]any `json:"data"` } // TestMoneyFieldSmoke checks the money save path end to end on Postgres: an // accepted decimal is stored byte for byte and served back unchanged, and a // too-precise, malformed or too-large value is a 422 on the field with the // money.* message (Polish, the request locale of the env). func TestMoneyFieldSmoke(t *testing.T) { e := newPaymentsEnv(t) rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"}, true) if rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } id := dataID(t, rec.Body.Bytes()) var stored paymentsInvoice if err := e.db.First(&stored, id).Error; err != nil { t.Fatal(err) } if !stored.Total.valid || stored.Total.text != "85.4193" { t.Fatalf("stored total = %+v, want the exact string 85.4193", stored.Total) } if !stored.Fee.valid || stored.Fee.text != "1.5000" { t.Fatalf("stored fee = %+v, want 1.5000 (numeric(10,4) pads the text; the input was not changed by cabana)", stored.Fee) } show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true) if show.Code != http.StatusOK { t.Fatalf("show status=%d body=%s", show.Code, show.Body.String()) } var record invoiceRecord if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil { t.Fatal(err) } if record.Data["total"] != "85.4193" { t.Fatalf("served total = %#v, want the string 85.4193", record.Data["total"]) } // An untouched value sent back is re-saved byte-identical. rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193"}, true) if rec.Code != http.StatusOK { t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) } if err := e.db.First(&stored, id).Error; err != nil { t.Fatal(err) } if stored.Total.text != "85.4193" { t.Fatalf("re-saved total = %+v", stored.Total) } expect422 := func(t *testing.T, body map[string]any, field, message string) { t.Helper() rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String()) } var out struct { Error struct { Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("422 body: %v\n%s", err, rec.Body.String()) } msgs := out.Error.Details[field] if len(msgs) != 1 || !strings.Contains(msgs[0], message) { t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String()) } } expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "12.34567"}, "total", "najwyżej 4 miejsc") expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1,5"}, "total", "To nie jest kwota") expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1000000.0000"}, "total", "najwyżej 999999.9999") expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": 85.4193}, "total", "To nie jest kwota") expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "fee": "1.234"}, "fee", "najwyżej 2 miejsc") // Exactly max is accepted; null clears. rec = e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-3", "currency": "PLN", "total": "999999.9999", "fee": nil}, true) if rec.Code != http.StatusCreated { t.Fatalf("max status=%d body=%s", rec.Code, rec.Body.String()) } var capped paymentsInvoice if err := e.db.First(&capped, dataID(t, rec.Body.Bytes())).Error; err != nil { t.Fatal(err) } if capped.Total.text != "999999.9999" || capped.Fee.valid { t.Fatalf("stored = %+v", capped) } // A boot whose money column cannot hold the string fails naming the field. t.Run("column type", func(t *testing.T) { // The list compiles before the form, so the columns.yaml gate names // the column; both gates carry the same message naming total. _, err := paymentsAssemble(t, e.db, []party.Plugin{moneyBadColumnPlugin{}}) if err == nil || !strings.Contains(err.Error(), "total: money needs a string column") { t.Fatalf("boot with an int money column: %v", err) } }) } // moneyBadInvoice has an integer total: not a money column. type moneyBadInvoice struct { ID uint `gorm:"column:id;primaryKey"` Number string `gorm:"column:number"` Currency string `gorm:"column:currency"` Total int64 `gorm:"column:total"` Fee string `gorm:"column:fee"` } func (moneyBadInvoice) TableName() string { return "cabana_payments_bad" } func (moneyBadInvoice) Fillable() []string { return []string{"number", "currency", "total", "fee"} } func (moneyBadInvoice) Rules() map[string]string { return map[string]string{} } type moneyBadColumnPlugin struct{ paymentsPlugin } func (moneyBadColumnPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{moneyBadInvoices{}} } type moneyBadInvoices struct{ paymentsInvoices } func (moneyBadInvoices) NewRecord() any { return &moneyBadInvoice{} } // moneyListFS is a one-controller plugin tree whose invoice columns.yaml is // the given text, for compile-only checks of the money column keys. func moneyListFS(columns string) fstest.MapFS { return fstest.MapFS{ "controllers/invoices/config_list.yaml": {Data: []byte("list: ~/plugins/acme/payments/models/invoice/columns.yaml\nmodelClass: Invoice\ntitle: Invoices\nrecordsPerPage: 20\n")}, "models/invoice/columns.yaml": {Data: []byte(columns)}, } } // TestMoneyListCompile checks the money column's boot gates without a // database: type money with exactly one currency source, never searchable, // not sortable by default, the keys refused on other types, and the model // column checks. func TestMoneyListCompile(t *testing.T) { schema, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(`columns: number: label: Number total: label: Total type: money currencyFrom: currency fee: label: Fee type: money currency: EUR sortable: true `)) if err != nil { t.Fatal(err) } byKey := map[string]cabana.ListColumn{} for _, col := range schema.Columns { byKey[col.Key] = col } if total := byKey["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Sortable || total.Searchable { t.Fatalf("total = %+v", total) } if fee := byKey["fee"]; fee.Type != "money" || fee.Currency != "EUR" || !fee.Sortable { t.Fatalf("fee = %+v", fee) } raw, err := json.Marshal(byKey["total"]) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"currencyFrom":"currency"`) || strings.Contains(string(raw), `"currency":`) { t.Fatalf("column JSON = %s", raw) } refused := map[string]string{ "searchable": "columns:\n total:\n type: money\n currency: PLN\n searchable: true\n", "both": "columns:\n total:\n type: money\n currency: PLN\n currencyFrom: currency\n", "none": "columns:\n total:\n type: money\n", "code": "columns:\n total:\n type: money\n currency: zl\n", "sibling": "columns:\n total:\n type: money\n currencyFrom: id\n", "on text": "columns:\n number:\n currency: PLN\n", "int column": "columns:\n id:\n type: money\n currency: PLN\n", } for name, columns := range refused { t.Run(name, func(t *testing.T) { if _, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(columns)); err == nil { t.Fatal("compiled") } }) } } // TestMoneyListSmoke checks the list projection on Postgres: a money column // is served with its keys, each row carries the currencyFrom column's value // although it is not a declared column, and the column is neither // searchable nor sortable by default. func TestMoneyListSmoke(t *testing.T) { e := newPaymentsEnv(t) for _, body := range []map[string]any{ {"number": "L-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"}, {"number": "L-2", "currency": "EUR", "total": "5.0000"}, } { if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true); rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } } rec := e.send(t, http.MethodGet, "/acme/payments/invoices/schema/list", nil, true) if rec.Code != http.StatusOK { t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String()) } var schema cabana.Envelope[cabana.ListSchema] if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil { t.Fatal(err) } keys := map[string]cabana.ListColumn{} for _, col := range schema.Data.Columns { keys[col.Key] = col } if _, declared := keys["currency"]; declared { t.Fatalf("currency is not a declared column: %+v", schema.Data.Columns) } if total := keys["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Sortable || total.Searchable { t.Fatalf("total column = %+v", total) } if fee := keys["fee"]; fee.Type != "money" || fee.Currency != "EUR" { t.Fatalf("fee column = %+v", fee) } 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["total"] != "85.4193" || row["currency"] != "PLN" || row["fee"] != "1.5000" { t.Fatalf("row 1 = %+v (want the amount strings and the currencyFrom value)", row) } if row := list.Data[1]; row["total"] != "5.0000" || row["currency"] != "EUR" || row["fee"] != nil { t.Fatalf("row 2 = %+v", row) } if rec := e.send(t, http.MethodGet, "/acme/payments/invoices?sort=total&dir=asc", nil, true); rec.Code == http.StatusOK { t.Fatalf("sort by a money column was accepted: %s", rec.Body.String()) } } // TestRecordActionDangerSmoke checks that the record response carries the // F5 danger flag for a danger action and omits it for an ordinary one. func TestRecordActionDangerSmoke(t *testing.T) { e := newPaymentsEnv(t) rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "D-1", "currency": "PLN", "total": "1.00"}, true) if rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } id := dataID(t, rec.Body.Bytes()) show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true) if show.Code != http.StatusOK { t.Fatalf("show status=%d body=%s", show.Code, show.Body.String()) } var out struct { Meta struct { Actions []json.RawMessage `json:"actions"` } `json:"meta"` } if err := json.Unmarshal(show.Body.Bytes(), &out); err != nil { t.Fatal(err) } if len(out.Meta.Actions) != 2 { t.Fatalf("actions = %s", show.Body.String()) } var void, resend cabana.RecordAction if err := json.Unmarshal(out.Meta.Actions[0], &void); err != nil { t.Fatal(err) } if err := json.Unmarshal(out.Meta.Actions[1], &resend); err != nil { t.Fatal(err) } if void.Name != "void" || !void.Danger || resend.Name != "resend" || resend.Danger { t.Fatalf("actions = %+v %+v", void, resend) } if !strings.Contains(string(out.Meta.Actions[0]), `"danger":true`) || strings.Contains(string(out.Meta.Actions[1]), "danger") { t.Fatalf("raw actions = %s %s (danger true on void, the key absent on resend)", out.Meta.Actions[0], out.Meta.Actions[1]) } } // vaultResult is a settings answer of the vault page. type vaultResult struct { Data struct { Exists bool `json:"exists"` Data map[string]any `json:"data"` Meta *struct { Secrets map[string]string `json:"secrets"` Notices []cabana.FieldNotice `json:"notices"` } `json:"meta"` } `json:"data"` } func decodeVault(t *testing.T, raw []byte) vaultResult { t.Helper() var out vaultResult if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("vault: %v\n%s", err, raw) } return out } // TestSettingsSecretSmoke checks the D-13 write-only contract on Postgres: // the GET never carries a secret and reports its state; a PUT with a value // stores ciphertext; an empty string keeps the stored value; null clears it; // a non-string is a 422; an empty column with the fallback key set in the // environment reports env; and no body ever contains the plaintext. func TestSettingsSecretSmoke(t *testing.T) { e := newPaymentsEnv(t) const secret = "sk_1" noLeak := func(t *testing.T, rec *httptest.ResponseRecorder) { t.Helper() if strings.Contains(rec.Body.String(), secret) || strings.Contains(rec.Body.String(), "sk_env") { t.Fatalf("body leaks the secret: %s", rec.Body.String()) } } states := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { t.Helper() out := decodeVault(t, rec.Body.Bytes()) for _, name := range []string{"api_key", "test_api_key"} { if _, leaked := out.Data.Data[name]; leaked { t.Fatalf("%s is in data: %s", name, rec.Body.String()) } } if out.Data.Meta == nil { t.Fatalf("no meta: %s", rec.Body.String()) } return out.Data.Meta.Secrets } rec := e.send(t, http.MethodGet, "/settings/vault", nil, true) if rec.Code != http.StatusOK { t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String()) } if got := states(t, rec); got["api_key"] != cabana.SecretEmpty || got["test_api_key"] != cabana.SecretEmpty { t.Fatalf("initial states = %v", got) } if _, has := decodeVault(t, rec.Body.Bytes()).Data.Data["notes"]; !has { t.Fatalf("notes missing from data: %s", rec.Body.String()) } rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": secret}, true) if rec.Code != http.StatusOK { t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String()) } noLeak(t, rec) if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty { t.Fatalf("states after put = %v", got) } var column struct{ APIKey *string } if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil { t.Fatal(err) } if column.APIKey == nil || *column.APIKey == "" || *column.APIKey == secret { t.Fatalf("stored api_key = %v, want non-empty ciphertext", column.APIKey) } ciphertext := *column.APIKey rec = e.send(t, http.MethodGet, "/settings/vault", nil, true) noLeak(t, rec) if got := states(t, rec); got["api_key"] != cabana.SecretStored { t.Fatalf("states after get = %v", got) } // An empty string keeps the stored value, byte for byte. rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": "", "test_api_key": ""}, true) if rec.Code != http.StatusOK { t.Fatalf("keep status=%d body=%s", rec.Code, rec.Body.String()) } noLeak(t, rec) if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty { t.Fatalf("states after keep = %v", got) } if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil { t.Fatal(err) } if column.APIKey == nil || *column.APIKey != ciphertext { t.Fatalf("an empty string rewrote the stored secret") } // A non-string is a 422 on the field, and nothing is written. rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": 5}, true) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"api_key"`) { t.Fatalf("non-string status=%d body=%s", rec.Code, rec.Body.String()) } noLeak(t, rec) // null clears. rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": nil}, true) if rec.Code != http.StatusOK { t.Fatalf("clear status=%d body=%s", rec.Code, rec.Body.String()) } if got := states(t, rec); got["api_key"] != cabana.SecretEmpty { t.Fatalf("states after clear = %v", got) } if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil { t.Fatal(err) } if column.APIKey != nil && *column.APIKey != "" { t.Fatalf("null did not clear the column: %q", *column.APIKey) } // With the fallback key set and the column empty, the state is env. h, err := paymentsAssemble(t, e.db, []party.Plugin{paymentsPlugin{}}, "SUMMER_ACME__VAULT__API_KEY=sk_env") 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()) } rec = env.send(t, http.MethodGet, "/settings/vault", nil, true) if rec.Code != http.StatusOK { t.Fatalf("env get status=%d body=%s", rec.Code, rec.Body.String()) } noLeak(t, rec) if got := states(t, rec); got["api_key"] != cabana.SecretEnv || got["test_api_key"] != cabana.SecretEmpty { t.Fatalf("env states = %v", got) } } // TestSettingsRichEditorSmoke checks F3 on Postgres: a settings richeditor // value is stored sanitized with notices, clean HTML byte for byte with no // notice, and a value that is not a string is a 422 on the field. func TestSettingsRichEditorSmoke(t *testing.T) { e := newPaymentsEnv(t) dirty := `

Bank

` rec := e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": dirty}, true) if rec.Code != http.StatusOK { t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String()) } out := decodeVault(t, rec.Body.Bytes()) if out.Data.Data["notes"] != "

Bank

" { t.Fatalf("notes = %#v", out.Data.Data["notes"]) } if out.Data.Meta == nil || len(out.Data.Meta.Notices) != 2 || out.Data.Meta.Notices[0].Field != "notes" { t.Fatalf("notices = %s", rec.Body.String()) } var stored paymentsVault if err := e.db.First(&stored, 1).Error; err != nil { t.Fatal(err) } if stored.Notes != "

Bank

" { t.Fatalf("stored notes = %q", stored.Notes) } clean := "

IBAN PL00 & \"swift\"

\n" rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": clean}, true) if rec.Code != http.StatusOK { t.Fatalf("clean status=%d body=%s", rec.Code, rec.Body.String()) } if out := decodeVault(t, rec.Body.Bytes()); out.Data.Meta != nil && len(out.Data.Meta.Notices) != 0 { t.Fatalf("clean HTML reported notices: %s", rec.Body.String()) } var again paymentsVault if err := e.db.First(&again, 1).Error; err != nil { t.Fatal(err) } if again.Notes != clean { t.Fatalf("clean notes not stored byte for byte: %q", again.Notes) } rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": []any{"

x

"}}, true) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes"`) { t.Fatalf("non-string notes status=%d body=%s", rec.Code, rec.Body.String()) } }