- 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
707 lines
28 KiB
Go
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> & \"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())
|
|
}
|
|
}
|