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:
101
admin/tests/app/money.test.ts
Normal file
101
admin/tests/app/money.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
109
modules/cabana/money_field_postgres_test.go
Normal file
109
modules/cabana/money_field_postgres_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
375
modules/cabana/money_field_test.go
Normal file
375
modules/cabana/money_field_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
75
modules/cabana/record_action_danger_test.go
Normal file
75
modules/cabana/record_action_danger_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
133
modules/cabana/settings_secret_postgres_test.go
Normal file
133
modules/cabana/settings_secret_postgres_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
151
modules/cabana/settings_tabs_test.go
Normal file
151
modules/cabana/settings_tabs_test.go
Normal 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> & \"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user