- 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
291 lines
11 KiB
Go
291 lines
11 KiB
Go
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"
|
|
)
|
|
|
|
// secretSettings is a settings model with one lagoon.Encrypted column and
|
|
// one plain string column, for the write-only password compile gate.
|
|
type secretSettings struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
|
|
Token string `gorm:"column:token"`
|
|
Notes string `gorm:"column:notes"`
|
|
}
|
|
|
|
func (secretSettings) TableName() string { return "cabana_secret_settings" }
|
|
func (secretSettings) Fillable() []string { return []string{"api_key", "token", "notes"} }
|
|
func (secretSettings) Rules() map[string]string { return map[string]string{} }
|
|
|
|
// SecretFallbacks maps each secret field to the config key the application
|
|
// reads when the column is empty; SUMMER_* variables map onto those keys.
|
|
func (secretSettings) SecretFallbacks() map[string]string {
|
|
return map[string]string{"api_key": "acme.secret.api_key"}
|
|
}
|
|
|
|
func secretSettingsItem(fields string) (pact.SettingsItem, fstest.MapFS) {
|
|
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte(fields)}}
|
|
item := pact.SettingsItem{Code: "secret", Label: "Secret", Form: "models/settings/fields.yaml", Model: "SecretSettings", NewModel: func() any { return &secretSettings{} }}
|
|
return item, fsys
|
|
}
|
|
|
|
// TestSettingsSecretCompile checks the D-13 boot gate without a database: a
|
|
// password field on a settings form binds a lagoon.Encrypted column as a
|
|
// write-only secret, keeps its tab, and is refused on any other column type
|
|
// naming the field.
|
|
func TestSettingsSecretCompile(t *testing.T) {
|
|
item, fsys := secretSettingsItem("fields:\n api_key:\n label: API key\n type: password\n tab: Keys\n notes:\n type: richeditor\n")
|
|
setting, err := compileSetting("acme.unit", item, fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(setting.Secrets) != 1 || setting.Secrets[0] != "api_key" {
|
|
t.Fatalf("secrets = %v", setting.Secrets)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, w := range setting.Writable {
|
|
names[w.Name] = true
|
|
}
|
|
if !names["api_key"] || !names["notes"] {
|
|
t.Fatalf("writable = %+v", setting.Writable)
|
|
}
|
|
if setting.Form.Fields[0].Tab != "Keys" {
|
|
t.Fatalf("tab dropped: %+v", setting.Form.Fields[0])
|
|
}
|
|
data := withoutSecrets(setting, map[string]any{"api_key": lagoon.NewEncrypted("x"), "notes": "n"})
|
|
if _, leaked := data["api_key"]; leaked || data["notes"] != "n" {
|
|
t.Fatalf("projection = %#v", data)
|
|
}
|
|
if (secretSettings{}).SecretFallbacks()["api_key"] != "acme.secret.api_key" {
|
|
t.Fatal("fallback key")
|
|
}
|
|
states := secretStates(setting, &secretSettings{}, nil)
|
|
if states["api_key"] != SecretEmpty {
|
|
t.Fatalf("states = %v", states)
|
|
}
|
|
states = secretStates(setting, &secretSettings{APIKey: lagoon.NewEncrypted("sk")}, nil)
|
|
if states["api_key"] != SecretStored {
|
|
t.Fatalf("states = %v", states)
|
|
}
|
|
|
|
item, fsys = secretSettingsItem("fields:\n token:\n type: password\n")
|
|
_, err = compileSetting("acme.unit", item, fsys)
|
|
if err == nil || !strings.Contains(err.Error(), "field token: password on a settings form needs a lagoon.Encrypted column") {
|
|
t.Fatalf("password on a string column: %v", err)
|
|
}
|
|
|
|
body := map[string]any{"api_key": "", "notes": "n"}
|
|
if err := liftSecretValues(setting, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, kept := body["api_key"]; kept {
|
|
t.Fatalf("empty string did not keep the stored value: %#v", body)
|
|
}
|
|
body = map[string]any{"api_key": nil}
|
|
if err := liftSecretValues(setting, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value, present := body["api_key"]; !present || value != nil {
|
|
t.Fatalf("null did not stay as the clear: %#v", body)
|
|
}
|
|
if err := liftSecretValues(setting, map[string]any{"api_key": 5}); err == nil {
|
|
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)
|
|
}
|
|
})
|
|
}
|