Files
summercms/modules/cabana/settings_secret_postgres_test.go
Jakub Zych 0f35c9a0ca 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
2026-10-13 16:58:03 +02:00

134 lines
5.2 KiB
Go

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())
}
}
}