- 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
134 lines
5.2 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|