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, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, []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) } } // The GET names the account sqid a secret for this app is bound to — the // CLI may only know the account by name — and only while the deployment // writes bound (v2) envelopes: its presence is what makes the CLI seal v2, // so with CONSOLE_SECRETS_ENVELOPE_V2 off (the default) it must be absent // and the CLI keeps writing v1 that every box can open (discussion #220). func TestAPIConfigGetNamesTheSecretsAccountOnlyWhenV2IsOn(t *testing.T) { fx, token := configFixture(t, "code-config-secrets-account") t.Cleanup(func() { appconfig.SetBoundSealing(false) }) appconfig.SetBoundSealing(false) status, out := getJSON(t, configURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET = %d %v", status, out) } if _, ok := out["secrets_account"]; ok { t.Fatalf("secrets_account served with bound sealing off: %v", out["secrets_account"]) } appconfig.SetBoundSealing(true) status, out = getJSON(t, configURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET = %d %v", status, out) } if got := str(t, out, "secrets_account"); got != release.OpsAccount { t.Fatalf("secrets_account = %q, want %q", got, release.OpsAccount) } } // ---- 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, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, 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"]) } // No exec_delivery anywhere: nothing set it, and the field is omitted // when a bundle is silent on it rather than reported as empty. 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"}, // Issue #195: "file" IS accepted with an environment now (see // TestAPIConfigExecDeliveryAtEnvironmentScope). What each scope // refuses is the OTHER scope's clearing word, because the two mean // different things: "off" stops delivery, "inherit" hands the // question back to the app default. {"exec_delivery off with an environment", map[string]any{ "environment": "staging", "exec_delivery": "off", }, http.StatusBadRequest, "inherit"}, {"exec_delivery inherit without an environment", map[string]any{ "exec_delivery": "inherit", }, http.StatusBadRequest, "needs an environment"}, } 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, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, 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") } } } // TestAPIConfigExecDeliveryAtEnvironmentScope is issue #195 through the // door: the flag lands in the NAMED bundle, the default bundle stays // silent, and "inherit" clears it. The point of the whole change is that // opting one bundle in must be readable as exactly that — so a GET for the // default bundle must not report a named bundle's flag, and vice versa. func TestAPIConfigExecDeliveryAtEnvironmentScope(t *testing.T) { fx, token := configFixture(t, "code-config-execdelivery-env") status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "set": map[string]any{"WALLET": map[string]any{"kind": "env", "value": "sealed"}}, "exec_delivery": "file", }, token) if status != http.StatusOK { t.Fatalf("PUT environment=relay exec_delivery=file = %d %v", status, out) } status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) if status != http.StatusOK { t.Fatalf("GET relay = %d %v", status, out) } if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile { t.Fatalf("relay exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile) } // The default bundle never learned about it — the fleet-wide switch // issue #195 is about was exactly this leaking upward. status, out = getJSON(t, configURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET default = %d %v", status, out) } if _, present := out["exec_delivery"]; present { t.Fatalf("default bundle exec_delivery = %v, want absent — an environment-scoped opt-in must not read as an app-level one", out["exec_delivery"]) } // "inherit" clears the named bundle's field without touching its vars. status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "exec_delivery": "inherit", }, token) if status != http.StatusOK { t.Fatalf("PUT environment=relay exec_delivery=inherit = %d %v", status, out) } status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) if status != http.StatusOK { t.Fatalf("GET relay = %d %v", status, out) } if _, present := out["exec_delivery"]; present { t.Fatalf("relay exec_delivery = %v after inherit, want the field absent", out["exec_delivery"]) } if v := varsByKey(t, out)["WALLET"]; str(t, v, "value") != "sealed" { t.Fatalf("WALLET = %v after inherit, want it untouched", v) } // The audit trail records the caller's own word and the bundle it // applied to: "off" and "inherit" both store "", so a row built from // the stored value would lose which scope was acted on. events, err := fx.st.Events(OpsAccountID, "titogo", 20) if err != nil { t.Fatal(err) } var joined string for _, e := range events { joined += e.Detail + "\n" } if !strings.Contains(joined, "exec-delivery file (relay)") { t.Fatalf("audit trail = %q, want a row naming the value and the bundle", joined) } if !strings.Contains(joined, "exec-delivery inherit (relay)") { t.Fatalf("audit trail = %q, want the clear recorded as \"inherit\", not as \"off\"", joined) } }