- type: password on a settings form binds a lagoon.Encrypted column as a write-only secret (any other column fails boot naming the field): the GET omits it from data and reports stored, env or empty per field in meta.secrets; on PUT an absent key or "" keeps the value, null clears it, a non-string is a 422; the answer never carries a value - cabana.SecretFallbacks (field to config key) gives the env state; cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported - settings forms accept richeditor: the Put sanitizes through seaglass like a controller save and returns meta.notices; repeater and dependsOn stay refused (settingsFormOnly next to controllerFormOnly) - backend::lang secret.* phrases in en and pl - admin SPA: PasswordField secret status line (stored with Remove stored value, env, empty, pending clear with Undo), SettingsFormView tabs with error-tab activation and the secrets/notices meta, settings payload that never sends an untouched secret; regenerated OpenAPI and TS types; rebuilt dist - vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke and TestSettingsRichEditorSmoke, conform settings fixture with a password field; settings.md sections, forms.md notes, cabana README
100 lines
3.9 KiB
Go
100 lines
3.9 KiB
Go
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")
|
|
}
|
|
}
|