Files
summercms/modules/cabana/payments_smoke_test.go
Jakub Zych 0f35c9a0ca 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
2026-10-13 16:58:03 +02:00

707 lines
28 KiB
Go

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 := `<p onclick="steal()">Bank</p><script>alert(1)</script>`
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"] != "<p>Bank</p>" {
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 != "<p>Bank</p>" {
t.Fatalf("stored notes = %q", stored.Notes)
}
clean := "<p>IBAN <strong>PL00</strong> &amp; \"swift\"</p>\n<ul><li>one</li></ul>"
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{"<p>x</p>"}}, 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())
}
}