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