package cabana import ( "strings" "testing" "testing/fstest" "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") } }