| new file mode 100644 |
| index 000000000..db95ca691 |
| --- /dev/null |
| +++ b/internal/console/apiconfig_test.go |
| @@ -0,0 +1,904 @@ |
| +package console |
| + |
| +// The CLI's config API surface (apiconfig.go): `carlos env` and `carlos |
| +// secrets` reading and writing through the console instead of the bucket. |
| +// The properties that matter, in the order they'd hurt if lost: a secret's |
| +// PLAINTEXT is never anywhere near this console and its ENVELOPE never comes |
| +// back out of it, an unknown account / unknown app / someone else's account |
| +// are one byte-identical 404 on all three doors, every refusal happens before |
| +// anything is written, and the audit trail records counts rather than |
| +// content. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// putJSON is postJSON's PUT twin — the config write door is the only PUT in |
| +// this API, so it gets its own helper rather than a method parameter on the |
| +// existing ones. |
| +func putJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + return doJSON(t, req) |
| +} |
| + |
| +// putRaw is putJSON's byte-exact cousin, for the gate assertions: two |
| +// refusals have to be IDENTICAL, not merely equal key-by-key, and |
| +// http.NotFound's body is not JSON at all. |
| +func putRaw(t *testing.T, u string, body any, bearer string) (int, string) { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, string(out) |
| +} |
| + |
| +// configFixture is a console with titogo claimed by the ops account and a |
| +// member token for it — the shape every test below starts from. |
| +func configFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// configURL is the config door for one app in the fixture's account. |
| +func configURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/bab/" + app + "/config" |
| +} |
| + |
| +// storedManifest reads an app's config manifest straight out of the bucket — |
| +// the check that a secret's envelope arrived VERBATIM, and the check that a |
| +// refused write left nothing behind. found==false means no object at all. |
| +func storedManifest(t *testing.T, fx *consoleFixture, app string) (appconfig.Manifest, bool) { |
| + t.Helper() |
| + raw, _, err := fx.rel.Get(context.Background(), appconfig.Key(release.OpsAccount, app)) |
| + if err != nil { |
| + return appconfig.Manifest{}, false |
| + } |
| + var m appconfig.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatalf("stored manifest %s: %v", app, err) |
| + } |
| + return m, true |
| +} |
| + |
| +// genEtag is the app's generation-object etag, "" when the object does not |
| +// exist yet — the signal the agent's fast loop watches (issue #44). |
| +func genEtag(t *testing.T, fx *consoleFixture, app string) string { |
| + t.Helper() |
| + _, etag, err := fx.rel.Get(context.Background(), appconfig.GenKey(release.OpsAccount, app)) |
| + if err != nil { |
| + return "" |
| + } |
| + return etag |
| +} |
| + |
| +// varsByKey indexes a GET response's vars array by key name. |
| +func varsByKey(t *testing.T, out map[string]any) map[string]map[string]any { |
| + t.Helper() |
| + vars, ok := out["vars"].([]any) |
| + if !ok { |
| + t.Fatalf("response has no vars array: %v", out) |
| + } |
| + byKey := map[string]map[string]any{} |
| + for _, v := range vars { |
| + m, _ := v.(map[string]any) |
| + byKey[str(t, m, "key")] = m |
| + } |
| + return byKey |
| +} |
| + |
| +// sealedFor mints a secrets keypair and seals plaintext with it, returning |
| +// the base64 public key, the base64 private key and the envelope — a REAL |
| +// envelope from the real primitive, so CheckEnvelope's acceptance here means |
| +// what it means in production. |
| +func sealedFor(t *testing.T, plaintext string) (pub, priv, envelope string) { |
| + t.Helper() |
| + priv, pub, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pk, err := appconfig.ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + envelope, err = appconfig.Seal(pk, []byte(plaintext)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return pub, priv, envelope |
| +} |
| + |
| +// ---- the gate --------------------------------------------------------------- |
| + |
| +// TestAPIConfigGateIsByteIdentical: on ALL THREE doors, a token that belongs |
| +// to no account, an account segment that doesn't exist, and an app the |
| +// account has never claimed are one and the same 404 — byte-identical to |
| +// http.NotFound's own output, so probing this API teaches a token holder |
| +// nothing about which accounts or apps exist. |
| +func TestAPIConfigGateIsByteIdentical(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-gate") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-config-gate-eve") |
| + |
| + doors := []struct { |
| + name string |
| + call func(u, bearer string) (int, string) |
| + }{ |
| + {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }}, |
| + {"PUT", func(u, bearer string) (int, string) { |
| + return putRaw(t, u, map[string]any{"unset": []string{"X"}}, bearer) |
| + }}, |
| + {"sync", func(u, bearer string) (int, string) { |
| + return postRaw(t, u+"/sync", map[string]any{}, bearer) |
| + }}, |
| + } |
| + for _, d := range doors { |
| + t.Run(d.name, func(t *testing.T) { |
| + // (a) a member of nothing, against a real account and a real app. |
| + statusA, bodyA := d.call(configURL(fx, "titogo"), eve) |
| + // (b) a member, against an account that does not exist. |
| + statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/config", token) |
| + // (c) a member of the account, against an app it never claimed. |
| + statusC, bodyC := d.call(configURL(fx, "ghost-app"), token) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| + t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC) |
| + } |
| + if bodyA != bodyB || bodyB != bodyC { |
| + t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC) |
| + } |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAPIConfigNoToken: every door answers the same 401 cliUser always gives |
| +// — before it can matter what account or app was named. |
| +func TestAPIConfigNoToken(t *testing.T) { |
| + fx, _ := configFixture(t, "code-config-notoken") |
| + |
| + status, out := getJSON(t, configURL(fx, "titogo"), "") |
| + if status != http.StatusUnauthorized || !strings.Contains(str(t, out, "error"), "not signed in") { |
| + t.Fatalf("GET with no token = %d %v, want 401 and the cliUser sentence", status, out) |
| + } |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"unset": []string{"X"}}, "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("PUT with no token = %d %v, want 401", status, out) |
| + } |
| + status, out = postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("sync with no token = %d %v, want 401", status, out) |
| + } |
| +} |
| + |
| +// ---- GET -------------------------------------------------------------------- |
| + |
| +// TestAPIConfigGetEmpty: an app that has never been configured answers as an |
| +// empty DEFAULT bundle rather than a 404 — the same "never configured is not |
| +// an error" convention appconfig.Get itself keeps. environments is present |
| +// and empty (a client reads it without testing for absence); exec_delivery |
| +// and secrets_pubkey are absent, not empty strings. |
| +func TestAPIConfigGetEmpty(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-get-empty") |
| + |
| + status, out := getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if str(t, out, "bundle") != appconfig.DefaultBundle { |
| + t.Fatalf("bundle = %v, want %q", out["bundle"], appconfig.DefaultBundle) |
| + } |
| + envs, present := out["environments"].([]any) |
| + if !present || len(envs) != 0 { |
| + t.Fatalf("environments = %v, want a present, empty list", out["environments"]) |
| + } |
| + vars, present := out["vars"].([]any) |
| + if !present || len(vars) != 0 { |
| + t.Fatalf("vars = %v, want a present, empty list", out["vars"]) |
| + } |
| + if _, present := out["exec_delivery"]; present { |
| + t.Fatalf("exec_delivery = %v, want the field absent when unset", out["exec_delivery"]) |
| + } |
| + if _, present := out["secrets_pubkey"]; present { |
| + t.Fatalf("secrets_pubkey = %v, want it absent when the console serves none", out["secrets_pubkey"]) |
| + } |
| +} |
| + |
| +// TestAPIConfigGetSecretsPubkey: a console configured with |
| +// CARLOS_SECRETS_PUBKEY serves it — the whole point being that `carlos |
| +// secrets set` can seal with `carlos auth login` and nothing else. It is |
| +// public material: it only ever encrypts. |
| +func TestAPIConfigGetSecretsPubkey(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-pubkey") |
| + pub, _, _ := sealedFor(t, "irrelevant") |
| + fx.srv.cfg.SecretsPub = pub |
| + |
| + status, out := getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if str(t, out, "secrets_pubkey") != pub { |
| + t.Fatalf("secrets_pubkey = %v, want the console's own %q", out["secrets_pubkey"], pub) |
| + } |
| +} |
| + |
| +// ---- PUT: the round trips --------------------------------------------------- |
| + |
| +// TestAPIConfigPutRoundTrip is the property the whole feature rests on: an |
| +// env var round-trips in full, and a secret goes IN as an envelope, lands in |
| +// the bucket verbatim, opens with the private key the console has never |
| +// seen — and NEVER comes back out of the API, not as a value and not as an |
| +// envelope. The audit trail carries neither. |
| +func TestAPIConfigPutRoundTrip(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-roundtrip") |
| + const plaintext = "s3cr3t-plaintext-never-relayed" |
| + _, priv, envelope := sealedFor(t, plaintext) |
| + |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{ |
| + "PORT": map[string]any{"kind": "env", "value": "8080"}, |
| + "BANK_KEY": map[string]any{"kind": "secret", "envelope": envelope}, |
| + }, |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "ok" { |
| + t.Fatalf("PUT body = %v, want result ok", out) |
| + } |
| + if n, _ := out["removed"].(float64); n != 0 { |
| + t.Fatalf("removed = %v, want 0 (nothing was unset)", out["removed"]) |
| + } |
| + |
| + status, out = getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + byKey := varsByKey(t, out) |
| + port, ok := byKey["PORT"] |
| + if !ok { |
| + t.Fatalf("vars = %v, want PORT", out["vars"]) |
| + } |
| + if str(t, port, "kind") != "env" || str(t, port, "value") != "8080" { |
| + t.Fatalf("PORT = %v, want kind env value 8080", port) |
| + } |
| + bank, ok := byKey["BANK_KEY"] |
| + if !ok { |
| + t.Fatalf("vars = %v, want BANK_KEY", out["vars"]) |
| + } |
| + if str(t, bank, "kind") != "secret" { |
| + t.Fatalf("BANK_KEY = %v, want kind secret", bank) |
| + } |
| + // Structurally absent, not empty: there is no field here for a secret's |
| + // content to occupy in the first place. |
| + if _, present := bank["value"]; present { |
| + t.Fatalf("BANK_KEY = %v, want NO value field at all", bank) |
| + } |
| + if _, present := bank["envelope"]; present { |
| + t.Fatalf("BANK_KEY = %v, want NO envelope field at all — the console must not relay ciphertext back", bank) |
| + } |
| + // And the raw response body, in case a future shape smuggles it elsewhere. |
| + _, rawBody := getRaw(t, configURL(fx, "titogo"), token) |
| + if strings.Contains(rawBody, envelope) || strings.Contains(rawBody, plaintext) { |
| + t.Fatalf("GET body carries the secret: %q", rawBody) |
| + } |
| + |
| + // The bucket holds the envelope byte-for-byte, and it still opens. |
| + m, found := storedManifest(t, fx, "titogo") |
| + if !found { |
| + t.Fatal("no config manifest in the bucket after a successful PUT") |
| + } |
| + if m.Vars["BANK_KEY"].Envelope != envelope { |
| + t.Fatalf("stored envelope = %q, want the exact bytes sent", m.Vars["BANK_KEY"].Envelope) |
| + } |
| + if m.Vars["BANK_KEY"].Value != "" { |
| + t.Fatalf("stored secret carries a plaintext value %q, want none", m.Vars["BANK_KEY"].Value) |
| + } |
| + pk, err := appconfig.ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opened, err := appconfig.Open(pk, m.Vars["BANK_KEY"].Envelope) |
| + if err != nil { |
| + t.Fatalf("stored envelope does not open: %v", err) |
| + } |
| + if string(opened) != plaintext { |
| + t.Fatalf("opened secret = %q, want %q", opened, plaintext) |
| + } |
| + if m.Vars["PORT"].Value != "8080" { |
| + t.Fatalf("stored PORT = %+v, want value 8080", m.Vars["PORT"]) |
| + } |
| + |
| + // The generation object moved: this write is visible to the agent's fast |
| + // loop rather than waiting on the hourly sweep (issue #44). |
| + if genEtag(t, fx, "titogo") == "" { |
| + t.Fatal("no generation object after a successful config write") |
| + } |
| + |
| + // Audit: counts and the bundle, never content. |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found = false |
| + for _, e := range events { |
| + if strings.Contains(e.Detail, plaintext) || strings.Contains(e.Detail, envelope) { |
| + t.Fatalf("audit row leaks the secret: %q", e.Detail) |
| + } |
| + if e.Action == "config" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + if !strings.Contains(e.Detail, "2 set") || !strings.Contains(e.Detail, appconfig.DefaultBundle) { |
| + t.Fatalf("config audit detail = %q, want the counts and the bundle", e.Detail) |
| + } |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a config row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// TestAPIConfigPutRemovedCount: `removed` is how many keys were ACTUALLY |
| +// there, not how many the caller listed — the CLI prints it, so an |
| +// argument-count answer would tell an operator they removed something they |
| +// didn't (2026-08-02 review, MINOR 9, on the wire too). |
| +func TestAPIConfigPutRemovedCount(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-removed") |
| + |
| + if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{ |
| + "A": map[string]any{"kind": "env", "value": "1"}, |
| + "B": map[string]any{"kind": "env", "value": "2"}, |
| + }, |
| + }, token); status != http.StatusOK { |
| + t.Fatalf("seed PUT = %d %v", status, out) |
| + } |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "unset": []string{"A", "NEVER_SET"}, |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("unset PUT = %d %v", status, out) |
| + } |
| + if n, _ := out["removed"].(float64); n != 1 { |
| + t.Fatalf("removed = %v, want 1 (A was there, NEVER_SET was not)", out["removed"]) |
| + } |
| + m, _ := storedManifest(t, fx, "titogo") |
| + if _, still := m.Vars["A"]; still { |
| + t.Fatalf("A survived the unset: %v", m.Vars) |
| + } |
| + if _, gone := m.Vars["B"]; !gone { |
| + t.Fatalf("B was removed by an unset that never named it: %v", m.Vars) |
| + } |
| +} |
| + |
| +// TestAPIConfigPutSetAndUnsetTogether: one PUT carrying both halves is ONE |
| +// CAS write (appconfig.Apply), so it can never half-apply. |
| +func TestAPIConfigPutSetAndUnsetTogether(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-both") |
| + |
| + if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"OLD": map[string]any{"kind": "env", "value": "1"}}, |
| + }, token); status != http.StatusOK { |
| + t.Fatalf("seed PUT = %d %v", status, out) |
| + } |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"NEW": map[string]any{"kind": "env", "value": "2"}}, |
| + "unset": []string{"OLD"}, |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("mixed PUT = %d %v", status, out) |
| + } |
| + if n, _ := out["removed"].(float64); n != 1 { |
| + t.Fatalf("removed = %v, want 1", out["removed"]) |
| + } |
| + m, _ := storedManifest(t, fx, "titogo") |
| + if _, still := m.Vars["OLD"]; still { |
| + t.Fatalf("OLD survived: %v", m.Vars) |
| + } |
| + if m.Vars["NEW"].Value != "2" { |
| + t.Fatalf("NEW = %+v, want value 2", m.Vars["NEW"]) |
| + } |
| +} |
| + |
| +// TestAPIConfigNamedEnvironment: a named bundle round-trips at its own |
| +// sibling key, does not bleed into the default bundle, and shows up in |
| +// `environments` — which is what powers `carlos env environments` without a |
| +// fourth door. An environment that has never been written answers as an |
| +// empty bundle, matching appconfig's own convention for an absent config. |
| +func TestAPIConfigNamedEnvironment(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-env") |
| + |
| + // Absent bundle: an empty answer, not a 404 and not an error. |
| + status, out := getJSON(t, configURL(fx, "titogo")+"?environment=staging", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET absent environment = %d %v, want 200", status, out) |
| + } |
| + if str(t, out, "bundle") != "staging" { |
| + t.Fatalf("bundle = %v, want staging", out["bundle"]) |
| + } |
| + if vars, _ := out["vars"].([]any); len(vars) != 0 { |
| + t.Fatalf("vars = %v, want empty for a bundle that doesn't exist yet", out["vars"]) |
| + } |
| + |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "environment": "staging", |
| + "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}}, |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT staging = %d %v", status, out) |
| + } |
| + |
| + status, out = getJSON(t, configURL(fx, "titogo")+"?environment=staging", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET staging = %d %v", status, out) |
| + } |
| + if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "9090" { |
| + t.Fatalf("staging PORT = %v, want 9090", v) |
| + } |
| + if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" { |
| + t.Fatalf("environments = %v, want [staging]", out["environments"]) |
| + } |
| + |
| + // The default bundle is untouched by an environment-scoped write. |
| + status, out = getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET default = %d %v", status, out) |
| + } |
| + if vars, _ := out["vars"].([]any); len(vars) != 0 { |
| + t.Fatalf("default bundle vars = %v, want empty — staging's write must not land here", out["vars"]) |
| + } |
| + if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" { |
| + t.Fatalf("environments on the default bundle = %v, want [staging]", out["environments"]) |
| + } |
| + // The exec_delivery field is app-level: even asking for a named bundle, |
| + // it is never reported from one. |
| + if _, present := out["exec_delivery"]; present { |
| + t.Fatalf("exec_delivery = %v, want absent", out["exec_delivery"]) |
| + } |
| +} |
| + |
| +// TestAPIConfigAuditNamesTheBundle: an environment-scoped write audits under |
| +// that environment's name, so an operator reading rows can tell which bundle |
| +// moved without the row carrying a single key or value. |
| +func TestAPIConfigAuditNamesTheBundle(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-audit-bundle") |
| + |
| + if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "environment": "staging", |
| + "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}}, |
| + }, token); status != http.StatusOK { |
| + t.Fatalf("PUT staging = %d %v", status, out) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action != "config" { |
| + continue |
| + } |
| + found = true |
| + if !strings.Contains(e.Detail, "staging") { |
| + t.Fatalf("config audit detail = %q, want the bundle named", e.Detail) |
| + } |
| + if strings.Contains(e.Detail, "PORT") || strings.Contains(e.Detail, "9090") { |
| + t.Fatalf("config audit detail = %q, want counts only — never keys or values", e.Detail) |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a config row", events) |
| + } |
| +} |
| + |
| +// ---- PUT: the refusal matrix ------------------------------------------------ |
| + |
| +// TestAPIConfigPutRefusals is spec §2's refusal list, one case each, asserting |
| +// the STATUS and that the error text is specific enough to act on — plus the |
| +// property underneath all of them: a refused PUT writes NOTHING. Validation |
| +// runs to completion before the first store call, so no case here can leave a |
| +// half-applied bundle (or mint a phantom one) behind. |
| +func TestAPIConfigPutRefusals(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-refusals") |
| + _, _, envelope := sealedFor(t, "irrelevant") |
| + |
| + cases := []struct { |
| + name string |
| + body map[string]any |
| + status int |
| + errSub string |
| + }{ |
| + {"empty request", map[string]any{}, http.StatusBadRequest, "nothing to do"}, |
| + {"environment default", map[string]any{ |
| + "environment": "default", |
| + "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}}, |
| + }, http.StatusBadRequest, "default"}, |
| + {"invalid environment name", map[string]any{ |
| + "environment": "Staging Prod", |
| + "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}}, |
| + }, http.StatusBadRequest, "environment name"}, |
| + {"invalid var name", map[string]any{ |
| + "set": map[string]any{"9BAD": map[string]any{"kind": "env", "value": "1"}}, |
| + }, http.StatusBadRequest, "9BAD"}, |
| + {"unknown kind", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "sekret", "value": "1"}}, |
| + }, http.StatusBadRequest, "kind"}, |
| + {"env value with a newline", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "env", "value": "one\ntwo"}}, |
| + }, http.StatusBadRequest, "A"}, |
| + {"env var carrying an envelope", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1", "envelope": envelope}}, |
| + }, http.StatusBadRequest, "envelope"}, |
| + {"secret with a garbage envelope", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": "not base64 at all!!"}}, |
| + }, http.StatusBadRequest, "envelope"}, |
| + {"secret with a truncated envelope", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope[:20]}}, |
| + }, http.StatusBadRequest, "envelope"}, |
| + {"secret carrying a plaintext value", map[string]any{ |
| + "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope, "value": "hunter2"}}, |
| + }, http.StatusBadRequest, "value"}, |
| + {"bad exec_delivery value", map[string]any{"exec_delivery": "x"}, http.StatusBadRequest, "exec_delivery"}, |
| + {"exec_delivery with an environment", map[string]any{ |
| + "environment": "staging", |
| + "exec_delivery": "file", |
| + }, http.StatusBadRequest, "app-level"}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + status, out := putJSON(t, configURL(fx, "titogo"), c.body, token) |
| + if status != c.status { |
| + t.Fatalf("%s = %d %v, want %d", c.name, status, out, c.status) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, c.errSub) { |
| + t.Fatalf("%s error = %q, want it to name %q", c.name, msg, c.errSub) |
| + } |
| + }) |
| + } |
| + |
| + // Nothing above may have reached the store: no default bundle, no |
| + // staging bundle, and no generation object. |
| + if _, found := storedManifest(t, fx, "titogo"); found { |
| + t.Fatal("a config manifest exists after nothing but refused PUTs — validation must run before any write") |
| + } |
| + names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(names) != 0 { |
| + t.Fatalf("environment bundles = %v after nothing but refused PUTs, want none", names) |
| + } |
| + if etag := genEtag(t, fx, "titogo"); etag != "" { |
| + t.Fatalf("generation object exists (%q) after nothing but refused PUTs, want none", etag) |
| + } |
| +} |
| + |
| +// TestAPIConfigPutSecretPlaintextNeverStored: the refusal that matters most. |
| +// A secret var carrying a `value` is refused outright rather than stored with |
| +// the plaintext quietly dropped — a caller who sent one has to learn their |
| +// plaintext crossed the wire, and a future edit that "helpfully" kept it would |
| +// be the console holding a secret in the clear. |
| +func TestAPIConfigPutSecretPlaintextNeverStored(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-secret-plaintext") |
| + _, _, envelope := sealedFor(t, "irrelevant") |
| + const leaked = "plaintext-that-must-not-be-stored" |
| + |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"BANK_KEY": map[string]any{ |
| + "kind": "secret", "envelope": envelope, "value": leaked}}, |
| + }, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("secret with a value = %d %v, want 400", status, out) |
| + } |
| + if msg := str(t, out, "error"); strings.Contains(msg, leaked) { |
| + t.Fatalf("the refusal echoes the plaintext back: %q", msg) |
| + } |
| + if _, found := storedManifest(t, fx, "titogo"); found { |
| + t.Fatal("a manifest was written for a refused secret-with-value PUT") |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range events { |
| + if strings.Contains(e.Detail, leaked) { |
| + t.Fatalf("audit row leaks the refused plaintext: %q", e.Detail) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIConfigUnsetOnlyNonexistentBundle: unsetting from a bundle that does |
| +// not exist is a 409, not a silent create — creating it would mint an empty |
| +// manifest, which by this package's own contract means "configured, then |
| +// fully unset" and converges every one of that app's env files to empty |
| +// (applyAt's CRITICAL 2 refusal, reached through the door). |
| +func TestAPIConfigUnsetOnlyNonexistentBundle(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-unset-nobundle") |
| + |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "unset": []string{"NOPE"}, |
| + }, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("unset-only on a nonexistent bundle = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "nothing to unset") { |
| + t.Fatalf("error = %q, want appconfig's own refusal text", msg) |
| + } |
| + if _, found := storedManifest(t, fx, "titogo"); found { |
| + t.Fatal("a phantom manifest was minted by a refused unset-only PUT") |
| + } |
| + |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "environment": "staging", |
| + "unset": []string{"NOPE"}, |
| + }, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("unset-only on a nonexistent environment bundle = %d %v, want 409", status, out) |
| + } |
| + names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(names) != 0 { |
| + t.Fatalf("environment bundles = %v, want none — a phantom bundle satisfies the route -environment rail", names) |
| + } |
| +} |
| + |
| +// ---- exec-delivery ---------------------------------------------------------- |
| + |
| +// TestAPIConfigExecDelivery: the issue #18 opt-in through the door — "file" |
| +// shows up on the default bundle, "off" clears the field entirely (absent, |
| +// not empty), and both audit as their own row so the change is visible to an |
| +// operator reading the trail. |
| +func TestAPIConfigExecDelivery(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-execdelivery") |
| + |
| + // A set alongside it: exec_delivery rides on the same PUT the CLI would |
| + // otherwise need two calls for, and both halves land. |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}, |
| + "exec_delivery": "file", |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT exec_delivery file = %d %v", status, out) |
| + } |
| + status, out = getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile { |
| + t.Fatalf("exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile) |
| + } |
| + if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" { |
| + t.Fatalf("PORT = %v, want 8080 — the set half must land too", v) |
| + } |
| + |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"exec_delivery": "off"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("PUT exec_delivery off = %d %v", status, out) |
| + } |
| + status, out = getJSON(t, configURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if _, present := out["exec_delivery"]; present { |
| + t.Fatalf("exec_delivery = %v after off, want the field absent", out["exec_delivery"]) |
| + } |
| + if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" { |
| + t.Fatalf("PORT = %v after exec_delivery off, want it untouched", v) |
| + } |
| + |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var on, off bool |
| + for _, e := range events { |
| + if e.Action != "exec-delivery" { |
| + continue |
| + } |
| + if strings.Contains(e.Detail, "file") { |
| + on = true |
| + } |
| + if strings.Contains(e.Detail, "off") { |
| + off = true |
| + } |
| + if !strings.Contains(e.Detail, "(cli)") { |
| + t.Fatalf("exec-delivery audit detail = %q, want it to name the CLI", e.Detail) |
| + } |
| + } |
| + if !on || !off { |
| + t.Fatalf("events = %v, want an exec-delivery row for each of file and off", events) |
| + } |
| +} |
| + |
| +// ---- sync ------------------------------------------------------------------- |
| + |
| +// TestAPIConfigSync: `carlos env sync`'s door moves the generation etag — |
| +// which is the entire mechanism the agent's fast loop watches — and audits, |
| +// because a sync makes instances converge (and exec-backed ones restart), |
| +// which an operator reading rows must be able to see. |
| +func TestAPIConfigSync(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-sync") |
| + |
| + if etag := genEtag(t, fx, "titogo"); etag != "" { |
| + t.Fatalf("generation object exists (%q) before anything wrote config", etag) |
| + } |
| + status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("sync = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "requested" { |
| + t.Fatalf("sync body = %v, want result requested", out) |
| + } |
| + first := genEtag(t, fx, "titogo") |
| + if first == "" { |
| + t.Fatal("no generation object after a sync request") |
| + } |
| + if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("second sync = %d %v", status, out) |
| + } |
| + if second := genEtag(t, fx, "titogo"); second == first { |
| + t.Fatalf("generation etag %q did not move on the second sync", second) |
| + } |
| + |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "config-sync" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a config-sync row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// ---- rate limiting ---------------------------------------------------------- |
| + |
| +// TestAPIConfigWritesAreRateLimited: both mutating doors pass the same write |
| +// budget promote/rollback/ledgers pass, keyed on the ADDRESS — and the READ |
| +// door does not, because an exhausted write budget must not blind a CLI to |
| +// what it already wrote. |
| +func TestAPIConfigWritesAreRateLimited(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-ratelimit") |
| + |
| + limited := false |
| + for i := 0; i < cliWriteBurst+3; i++ { |
| + status, _ := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}, |
| + }, token) |
| + if status == http.StatusTooManyRequests { |
| + limited = true |
| + break |
| + } |
| + } |
| + if !limited { |
| + t.Fatalf("PUT was never rate-limited in %d attempts", cliWriteBurst+3) |
| + } |
| + // Same limiter, same key: an exhausted address cannot switch door. |
| + if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusTooManyRequests { |
| + t.Fatalf("sync on an exhausted budget = %d %v, want 429", status, out) |
| + } |
| + if status, out := getJSON(t, configURL(fx, "titogo"), token); status != http.StatusOK { |
| + t.Fatalf("GET on an exhausted write budget = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestAPIConfigGetBadEnvironment: the query-parameter half of the environment |
| +// rule — "default" and a malformed name are refused on the READ door too, |
| +// rather than being resolved to some other bundle or reaching the store. |
| +func TestAPIConfigGetBadEnvironment(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-get-badenv") |
| + |
| + status, out := getJSON(t, configURL(fx, "titogo")+"?environment=default", token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("GET ?environment=default = %d %v, want 400", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "default") { |
| + t.Fatalf("error = %q, want it to name the rule", msg) |
| + } |
| + status, out = getJSON(t, configURL(fx, "titogo")+"?environment=NOT%20valid", token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("GET ?environment=NOT valid = %d %v, want 400", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "environment name") { |
| + t.Fatalf("error = %q, want it to name the shape rule", msg) |
| + } |
| +} |
| + |
| +// TestAPIConfigPutLargeEnvelope: the PUT door's own body cap |
| +// (configPutMaxBytes), not readJSON's 8KiB, governs a config write — a |
| +// sealed envelope runs ~4/3 the size of its plaintext, so one long base64 |
| +// credential passes 8KiB on its own and must still land. The over-cap case |
| +// answers an honest 413, not "malformed JSON body": the caller's fix is |
| +// nothing like a JSON fix. |
| +func TestAPIConfigPutLargeEnvelope(t *testing.T) { |
| + fx, token := configFixture(t, "code-config-large") |
| + |
| + // ~16KiB of single-line plaintext (ValidVarValue-legal), well past the |
| + // old shared cap once sealed and base64'd. |
| + _, priv, envelope := sealedFor(t, strings.Repeat("a", 16<<10)) |
| + status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"BIG_KEY": map[string]any{"kind": "secret", "envelope": envelope}}, |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("large envelope PUT = %d %v, want 200", status, out) |
| + } |
| + m, found := storedManifest(t, fx, "titogo") |
| + if !found || m.Vars["BIG_KEY"].Envelope != envelope { |
| + t.Fatal("large envelope did not land verbatim") |
| + } |
| + // And it still opens: the door relayed, never rewrote. |
| + pk, err := appconfig.ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := appconfig.Open(pk, m.Vars["BIG_KEY"].Envelope) |
| + if err != nil || len(got) != 16<<10 { |
| + t.Fatalf("stored envelope does not open to the plaintext: %v", err) |
| + } |
| + |
| + // Over the cap: refused at read time, before validation could even see |
| + // it, with the size's own status. |
| + status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| + "set": map[string]any{"TOO_BIG": map[string]any{"kind": "secret", "envelope": strings.Repeat("A", int(configPutMaxBytes)+1024)}}, |
| + }, token) |
| + if status != http.StatusRequestEntityTooLarge { |
| + t.Fatalf("over-cap PUT = %d %v, want 413", status, out) |
| + } |
| + if m, _ := storedManifest(t, fx, "titogo"); m.Vars != nil { |
| + if _, ok := m.Vars["TOO_BIG"]; ok { |
| + t.Fatal("over-cap PUT wrote to the bucket") |
| + } |
| + } |
| +} |