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

@@ -0,0 +1,101 @@
// The exact-decimal money helpers of the money field and list column
// (Phase 18 D-07 with the amended D-09 and D-10): text in, text out, never a
// JS number. No application names appear in framework tests.
import { describe, expect, it } from 'vitest'
import { decimalSeparator, formatMoney, parseMoneyInput, trimMoneyDisplay } from '../../src/app/money'
const spaces = (s: string) => s.replace(/\s/g, ' ')
describe('parseMoneyInput', () => {
it('accepts a comma or a dot as the separator and strips spaces and no-break spaces', () => {
expect(parseMoneyInput('1234,56', 4)).toEqual({ value: '1234.56', error: null })
expect(parseMoneyInput('1234.56', 4)).toEqual({ value: '1234.56', error: null })
expect(parseMoneyInput(' 1 234,5 ', 4)).toEqual({ value: '1234.5', error: null })
expect(parseMoneyInput('1 234,56', 4)).toEqual({ value: '1234.56', error: null })
expect(parseMoneyInput('7', 2)).toEqual({ value: '7', error: null })
})
it('keeps a leading minus and refuses a minus anywhere else', () => {
expect(parseMoneyInput('-0,5', 4)).toEqual({ value: '-0.5', error: null })
expect(parseMoneyInput('5-', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('--5', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('+5', 4)).toEqual({ value: null, error: 'invalid' })
})
it('is null for empty or blank text', () => {
expect(parseMoneyInput('', 2)).toEqual({ value: null, error: null })
expect(parseMoneyInput('   ', 2)).toEqual({ value: null, error: null })
})
it('refuses letters, exponents, two separators and lone separators as invalid', () => {
for (const text of ['abc', '1e3', '1E3', '1.2.3', '1,234.56', '1.234,56', '.5', '5.', ',', '0x10', 'NaN', 'Infinity', '12 a']) {
expect(parseMoneyInput(text, 4), text).toEqual({ value: null, error: 'invalid' })
}
})
it('refuses more significant fraction digits than the scale, never rounding or truncating', () => {
expect(parseMoneyInput('12.34567', 4)).toEqual({ value: null, error: 'too_precise' })
expect(parseMoneyInput('1,234', 2)).toEqual({ value: null, error: 'too_precise' })
expect(parseMoneyInput('1.234', 3)).toEqual({ value: '1.234', error: null })
expect(parseMoneyInput('0.0001', 4)).toEqual({ value: '0.0001', error: null })
})
it('accepts and keeps trailing zeros beyond the scale, as the server does', () => {
expect(parseMoneyInput('12.5000', 2)).toEqual({ value: '12.5000', error: null })
expect(parseMoneyInput('39,840000', 4)).toEqual({ value: '39.840000', error: null })
expect(parseMoneyInput('12.5001', 2)).toEqual({ value: null, error: 'too_precise' })
})
it('keeps amounts beyond the float range digit for digit', () => {
expect(parseMoneyInput('99999999999999999999.9999', 4)).toEqual({ value: '99999999999999999999.9999', error: null })
expect(parseMoneyInput('0.1', 4).value).toBe('0.1')
})
})
describe('trimMoneyDisplay', () => {
it('trims trailing fraction zeros down to two decimals and keeps the rest', () => {
expect(trimMoneyDisplay('39.8400')).toBe('39.84')
expect(trimMoneyDisplay('85.4193')).toBe('85.4193')
expect(trimMoneyDisplay('85.4190')).toBe('85.419')
expect(trimMoneyDisplay('5.0000')).toBe('5.00')
expect(trimMoneyDisplay('-0.5000')).toBe('-0.50')
expect(trimMoneyDisplay('7')).toBe('7')
expect(trimMoneyDisplay('7.5')).toBe('7.5')
})
})
describe('decimalSeparator', () => {
it('is a dot in en and a comma in pl', () => {
expect(decimalSeparator('en')).toBe('.')
expect(decimalSeparator('pl')).toBe(',')
})
})
describe('formatMoney', () => {
it('formats en and pl with the ISO code and at least two decimals', () => {
expect(spaces(formatMoney('39.8400', 'PLN', 'en'))).toBe('PLN 39.84')
expect(spaces(formatMoney('39.8400', 'PLN', 'pl'))).toBe('39,84 PLN')
expect(spaces(formatMoney('7', 'EUR', 'en'))).toBe('EUR 7.00')
expect(spaces(formatMoney('1234567.5', 'EUR', 'pl'))).toBe('1 234 567,50 EUR')
})
it('keeps up to scale decimals and never rounds a four-decimal amount', () => {
expect(spaces(formatMoney('85.4193', 'PLN', 'en', 4))).toBe('PLN 85.4193')
expect(spaces(formatMoney('99999999.9999', 'PLN', 'en', 4))).toBe('PLN 99,999,999.9999')
expect(spaces(formatMoney('99999999.9999', 'PLN', 'pl', 4))).toBe('99 999 999,9999 PLN')
expect(spaces(formatMoney('12.5', 'EUR', 'en', 2))).toBe('EUR 12.50')
})
it('formats a negative amount', () => {
expect(spaces(formatMoney('-0.5000', 'PLN', 'en'))).toBe('-PLN 0.50')
})
it('formats the amount alone without a currency', () => {
expect(spaces(formatMoney('12.5', '', 'en'))).toBe('12.50')
expect(spaces(formatMoney('1234.5678', '', 'pl'))).toBe('1234,5678')
})
it('appends an unknown code to the plain amount instead of throwing', () => {
expect(spaces(formatMoney('12.5', 'ZZZZ', 'en'))).toBe('12.50 ZZZZ')
})
})

View File

@@ -186,6 +186,38 @@ describe('money field component (UI-SPEC F1)', () => {
expect(empty.find('[data-money-readonly] [data-empty]').text()).toBe('—')
})
it('validates on blur only: a malformed draft shows nothing while typing until the first blur', async () => {
const { wrapper } = mountField({ modelValue: null })
await type(wrapper, 'abc')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(input(wrapper).attributes('aria-invalid')).toBeUndefined()
// The raw text is emitted so the server's 422 stays the gate.
expect(emitted(wrapper)).toEqual(['abc'])
await input(wrapper).trigger('blur')
expect(wrapper.find('[data-money-error]').text()).toBe('This is not an amount.')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
// Once shown, errors follow the typing.
await type(wrapper, '12,5')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(input(wrapper).attributes('aria-invalid')).toBeUndefined()
expect(emitted(wrapper)).toEqual(['abc', '12.5'])
})
it('marks the input invalid when the form reports a server error for the field', () => {
const { wrapper } = mountField({ modelValue: '1.00', invalid: true })
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
expect(input(wrapper).classes()).toContain('border-danger')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
})
it('accepts a loaded four-decimal value on a scale-2 field without an error on blur', async () => {
const { wrapper } = mountField({ modelValue: '12.5000', field: { scale: 2 } })
expect(input(wrapper).element.value).toBe('12.50')
await input(wrapper).trigger('blur')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(emitted(wrapper)).toEqual([])
})
it('replaces the draft when the value is set from outside', async () => {
const { wrapper } = mountField({ modelValue: '1.00' })
await type(wrapper, '2')

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)
}
}
}

View File

@@ -55,7 +55,7 @@ PG_LAYER="${PG_LAYER:-$ROOT/../vue-paymentgateway-layer}"
# must pass; a name that skips or does not run fails the stage.
# go stage: Docker-free framework suites (-short).
CABANA_SHORT_TESTS=(TestMoneyFieldCompile TestMoneyListCompile TestSettingsSecretCompile TestSettingsDropdownMethod)
CABANA_SHORT_TESTS=(TestMoneyFieldCompile TestMoneyFieldColumnGate TestMoneyFieldValidate TestMoneyListCompile TestMoneyListColumn TestSettingsSecretCompile TestSettingsSecretProjection TestSettingsSecretPut TestSettingsTabs TestSettingsRichEditor TestSettingsDropdownMethod TestRecordActionDanger TestPhase10ConformanceInventory)
# plugins stage: Docker-free suites (-short), per repository and package.
PG_MONEY_TESTS=(TestMoneyVectorsSmoke TestAmount4ScanValue)
@@ -63,19 +63,68 @@ PG_ROOT_TESTS=(TestNotificationRoute)
PGSTRIPE_ROOT_TESTS=(TestStripeWebhook TestFixtureAbsentFromProduction)
# postgres stage: testcontainers suites.
PG_CABANA_TESTS=(TestMoneyFieldSmoke)
PG_CABANA_TESTS=(TestMoneyFieldSmoke TestMoneyListSmoke TestRecordActionDangerSmoke TestSettingsSecretSmoke TestSettingsRichEditorSmoke TestMoneyFieldSave TestSettingsSecretPostgres TestRichEditor TestPhase10OpenAPIConformance)
PG_PG_TESTS=(TestPaymentGatewayTracer)
PG_PGSTRIPE_TESTS=(TestHostStripeFlow)
# admin stage: vitest spec files and full test names (describe + it).
ADMIN_FILES=(
tests/form/MoneyField.test.ts
tests/app/money.test.ts
tests/form/PasswordField.test.ts
tests/views/SettingsFormView.test.ts
tests/form/RecordActions.test.ts
tests/form/PreviewView.test.ts
tests/list/CellValue.test.ts
)
ADMIN_REQUIRE=(
"parseMoneyInput accepts a comma or a dot as the separator and strips spaces and no-break spaces"
"parseMoneyInput keeps a leading minus and refuses a minus anywhere else"
"parseMoneyInput is null for empty or blank text"
"parseMoneyInput refuses letters, exponents, two separators and lone separators as invalid"
"parseMoneyInput refuses more significant fraction digits than the scale, never rounding or truncating"
"parseMoneyInput accepts and keeps trailing zeros beyond the scale, as the server does"
"parseMoneyInput keeps amounts beyond the float range digit for digit"
"trimMoneyDisplay trims trailing fraction zeros down to two decimals and keeps the rest"
"decimalSeparator is a dot in en and a comma in pl"
"formatMoney formats en and pl with the ISO code and at least two decimals"
"formatMoney keeps up to scale decimals and never rounds a four-decimal amount"
"formatMoney formats a negative amount"
"formatMoney formats the amount alone without a currency"
"formatMoney appends an unknown code to the plain amount instead of throwing"
"money helpers parses typed text into the canonical dot string without a number"
"money helpers trims trailing zeros down to two decimals for display"
"money helpers formats through Intl with the string itself, keeping four decimals"
"money field component (UI-SPEC F1) is a text input in decimal mode with the currency suffix"
"money field component (UI-SPEC F1) shows a loaded value trimmed, with the locale separator, and does not emit"
"money field component (UI-SPEC F1) submits an untouched 85.4193 byte-identical: no emit on load or blur"
"money field component (UI-SPEC F1) emits the canonical dot string for \"1 234,56\" and null for empty"
"money field component (UI-SPEC F1) refuses 12.34567 at scale 4 on blur, neither rounded nor truncated"
"money helpers formats through Intl with the string itself, keeping four decimals"
"money field component (UI-SPEC F1) shows money.invalid for a second separator"
"money field component (UI-SPEC F1) follows the currencyFrom sibling and omits the suffix without a code"
"money field component (UI-SPEC F1) renders a read-only box with the formatted amount or an em dash"
"money field component (UI-SPEC F1) validates on blur only: a malformed draft shows nothing while typing until the first blur"
"money field component (UI-SPEC F1) marks the input invalid when the form reports a server error for the field"
"money field component (UI-SPEC F1) accepts a loaded four-decimal value on a scale-2 field without an error on blur"
"money field component (UI-SPEC F1) replaces the draft when the value is set from outside"
"settings secret states (UI-SPEC F2, D-13) renders no status line on a record form (no state provided)"
"settings secret states (UI-SPEC F2, D-13) renders the stored, env and empty lines with the input always empty"
"settings secret states (UI-SPEC F2, D-13) does not emit for an untouched field, emits null on Remove stored value and \"\" on Undo"
"settings secret states (UI-SPEC F2, D-13) never shows the typed value after the save response replaces it"
"preview edit rule (UI-SPEC F6) shows no Edit button and no primary fill when the form holds only partial fields"
"preview edit rule (UI-SPEC F6) keeps the Edit button when the form has a text field the update context allows"
"preview edit rule (UI-SPEC F6) hides the Edit button when every value field is preview-only"
"danger record actions (UI-SPEC F5) gives the dialog confirm button the danger variant for a danger action, the default otherwise"
"money column (UI-SPEC F1, D-07) formats the decimal string with the row's currencyFrom code in en and pl"
"money column (UI-SPEC F1, D-07) uses the literal currency of the column"
"money column (UI-SPEC F1, D-07) shows the amount alone when the row has no currency, and the dash when empty"
"money column (UI-SPEC F1, D-07) aligns the header and the cells of a money column to the right"
"settings tabs (UI-SPEC F4) renders no tab strip for an untabbed schema"
"settings tabs (UI-SPEC F4) renders the tabs with the default tab first and the card inside the panel"
"settings tabs (UI-SPEC F4) activates the first tab with an error after a 422 and shows its error chip"
"settings secrets (UI-SPEC F2, D-13) shows each secret state and sends neither an untouched secret nor its empty input"
"settings secrets (UI-SPEC F2, D-13) sends a typed secret once, then clears the input and shows the stored line from the response"
"settings secrets (UI-SPEC F2, D-13) sends null for a removed stored value and restores the line on Undo"
)
# layers stage: test files (must exist and match the test script's glob) and