From 0f35c9a0ca05699a5a99467d3af9314498888215 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 13 Oct 2026 16:58:03 +0200 Subject: [PATCH] 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 --- admin/tests/app/money.test.ts | 101 +++++ admin/tests/form/MoneyField.test.ts | 32 ++ modules/cabana/README.md | 2 + modules/cabana/export_test.go | 18 + modules/cabana/money_field_postgres_test.go | 109 +++++ modules/cabana/money_field_test.go | 375 ++++++++++++++++++ modules/cabana/openapi_conformance_test.go | 63 +++ modules/cabana/payments_smoke_test.go | 107 ----- modules/cabana/record_action_danger_test.go | 75 ++++ .../cabana/settings_secret_postgres_test.go | 133 +++++++ modules/cabana/settings_secret_test.go | 191 +++++++++ modules/cabana/settings_tabs_test.go | 151 +++++++ scripts/check-phase18.sh | 55 ++- 13 files changed, 1302 insertions(+), 110 deletions(-) create mode 100644 admin/tests/app/money.test.ts create mode 100644 modules/cabana/money_field_postgres_test.go create mode 100644 modules/cabana/money_field_test.go create mode 100644 modules/cabana/record_action_danger_test.go create mode 100644 modules/cabana/settings_secret_postgres_test.go create mode 100644 modules/cabana/settings_tabs_test.go diff --git a/admin/tests/app/money.test.ts b/admin/tests/app/money.test.ts new file mode 100644 index 0000000..fae3942 --- /dev/null +++ b/admin/tests/app/money.test.ts @@ -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') + }) +}) diff --git a/admin/tests/form/MoneyField.test.ts b/admin/tests/form/MoneyField.test.ts index c6f254a..2057fc3 100644 --- a/admin/tests/form/MoneyField.test.ts +++ b/admin/tests/form/MoneyField.test.ts @@ -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') diff --git a/modules/cabana/README.md b/modules/cabana/README.md index d8f4428..7f79141 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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. diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index de47d34..1a62348 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -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) +} diff --git a/modules/cabana/money_field_postgres_test.go b/modules/cabana/money_field_postgres_test.go new file mode 100644 index 0000000..84955d6 --- /dev/null +++ b/modules/cabana/money_field_postgres_test.go @@ -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) + } +} diff --git a/modules/cabana/money_field_test.go b/modules/cabana/money_field_test.go new file mode 100644 index 0000000..8cffc7b --- /dev/null +++ b/modules/cabana/money_field_test.go @@ -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) + } +} diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index dc1bce7..5ec61cb 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -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": "

n

"}, 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 diff --git a/modules/cabana/payments_smoke_test.go b/modules/cabana/payments_smoke_test.go index dafdbc3..56b5236 100644 --- a/modules/cabana/payments_smoke_test.go +++ b/modules/cabana/payments_smoke_test.go @@ -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"` diff --git a/modules/cabana/record_action_danger_test.go b/modules/cabana/record_action_danger_test.go new file mode 100644 index 0000000..dc11cb7 --- /dev/null +++ b/modules/cabana/record_action_danger_test.go @@ -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") + } +} diff --git a/modules/cabana/settings_secret_postgres_test.go b/modules/cabana/settings_secret_postgres_test.go new file mode 100644 index 0000000..199686a --- /dev/null +++ b/modules/cabana/settings_secret_postgres_test.go @@ -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": "

n

"}, 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()) + } + } +} diff --git a/modules/cabana/settings_secret_test.go b/modules/cabana/settings_secret_test.go index a0faa4b..5b6a4c6 100644 --- a/modules/cabana/settings_secret_test.go +++ b/modules/cabana/settings_secret_test.go @@ -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: "

n

"} + 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"] != "

n

" { + 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) + } + }) +} diff --git a/modules/cabana/settings_tabs_test.go b/modules/cabana/settings_tabs_test.go new file mode 100644 index 0000000..14b110f --- /dev/null +++ b/modules/cabana/settings_tabs_test.go @@ -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 := `

Bank

` + projected := projectSettingBody(setting, map[string]any{"notes": dirty, "token": "t"}) + notices := sanitizeSettingRichEditors(setting, projected) + if projected["notes"] != "

Bank

" { + 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 := "

IBAN PL00 & \"swift\"

\n" + 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, "", "

x

"} { + if err := checkSettingRichEditors(setting, map[string]any{"notes": ok}); err != nil { + t.Fatalf("%#v refused: %v", ok, err) + } + } + for _, bad := range []any{[]any{"

x

"}, 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) + } + } +} diff --git a/scripts/check-phase18.sh b/scripts/check-phase18.sh index 4766b51..eaa99ab 100755 --- a/scripts/check-phase18.sh +++ b/scripts/check-phase18.sh @@ -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