| 1 | package console |
| 2 | |
| 3 | // The CLI's config API surface (apiconfig.go): `carlos env` and `carlos |
| 4 | // secrets` reading and writing through the console instead of the bucket. |
| 5 | // The properties that matter, in the order they'd hurt if lost: a secret's |
| 6 | // PLAINTEXT is never anywhere near this console and its ENVELOPE never comes |
| 7 | // back out of it, an unknown account / unknown app / someone else's account |
| 8 | // are one byte-identical 404 on all three doors, every refusal happens before |
| 9 | // anything is written, and the audit trail records counts rather than |
| 10 | // content. |
| 11 | |
| 12 | import ( |
| 13 | "bytes" |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "io" |
| 17 | "net/http" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/carlosframework/platform/internal/appconfig" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | ) |
| 25 | |
| 26 | // putJSON is postJSON's PUT twin — the config write door is the only PUT in |
| 27 | // this API, so it gets its own helper rather than a method parameter on the |
| 28 | // existing ones. |
| 29 | func putJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) { |
| 30 | t.Helper() |
| 31 | raw, err := json.Marshal(body) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw)) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | req.Header.Set("Content-Type", "application/json") |
| 40 | if bearer != "" { |
| 41 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 42 | } |
| 43 | return doJSON(t, req) |
| 44 | } |
| 45 | |
| 46 | // putRaw is putJSON's byte-exact cousin, for the gate assertions: two |
| 47 | // refusals have to be IDENTICAL, not merely equal key-by-key, and |
| 48 | // http.NotFound's body is not JSON at all. |
| 49 | func putRaw(t *testing.T, u string, body any, bearer string) (int, string) { |
| 50 | t.Helper() |
| 51 | raw, err := json.Marshal(body) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(raw)) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | req.Header.Set("Content-Type", "application/json") |
| 60 | if bearer != "" { |
| 61 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 62 | } |
| 63 | res, err := http.DefaultClient.Do(req) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | defer res.Body.Close() |
| 68 | out, err := io.ReadAll(res.Body) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | return res.StatusCode, string(out) |
| 73 | } |
| 74 | |
| 75 | // configFixture is a console with titogo claimed by the ops account and a |
| 76 | // member token for it — the shape every test below starts from. |
| 77 | func configFixture(t *testing.T, code string) (*consoleFixture, string) { |
| 78 | t.Helper() |
| 79 | fx := newConsoleFixture(t) |
| 80 | now := time.Now() |
| 81 | if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| 88 | } |
| 89 | |
| 90 | // configURL is the config door for one app in the fixture's account. |
| 91 | func configURL(fx *consoleFixture, app string) string { |
| 92 | return fx.ts.URL + "/api/cli/apps/bab/" + app + "/config" |
| 93 | } |
| 94 | |
| 95 | // storedManifest reads an app's config manifest straight out of the bucket — |
| 96 | // the check that a secret's envelope arrived VERBATIM, and the check that a |
| 97 | // refused write left nothing behind. found==false means no object at all. |
| 98 | func storedManifest(t *testing.T, fx *consoleFixture, app string) (appconfig.Manifest, bool) { |
| 99 | t.Helper() |
| 100 | raw, _, err := fx.rel.Get(context.Background(), appconfig.Key(release.OpsAccount, app)) |
| 101 | if err != nil { |
| 102 | return appconfig.Manifest{}, false |
| 103 | } |
| 104 | var m appconfig.Manifest |
| 105 | if err := json.Unmarshal(raw, &m); err != nil { |
| 106 | t.Fatalf("stored manifest %s: %v", app, err) |
| 107 | } |
| 108 | return m, true |
| 109 | } |
| 110 | |
| 111 | // genEtag is the app's generation-object etag, "" when the object does not |
| 112 | // exist yet — the signal the agent's fast loop watches (issue #44). |
| 113 | func genEtag(t *testing.T, fx *consoleFixture, app string) string { |
| 114 | t.Helper() |
| 115 | _, etag, err := fx.rel.Get(context.Background(), appconfig.GenKey(release.OpsAccount, app)) |
| 116 | if err != nil { |
| 117 | return "" |
| 118 | } |
| 119 | return etag |
| 120 | } |
| 121 | |
| 122 | // varsByKey indexes a GET response's vars array by key name. |
| 123 | func varsByKey(t *testing.T, out map[string]any) map[string]map[string]any { |
| 124 | t.Helper() |
| 125 | vars, ok := out["vars"].([]any) |
| 126 | if !ok { |
| 127 | t.Fatalf("response has no vars array: %v", out) |
| 128 | } |
| 129 | byKey := map[string]map[string]any{} |
| 130 | for _, v := range vars { |
| 131 | m, _ := v.(map[string]any) |
| 132 | byKey[str(t, m, "key")] = m |
| 133 | } |
| 134 | return byKey |
| 135 | } |
| 136 | |
| 137 | // sealedFor mints a secrets keypair and seals plaintext with it, returning |
| 138 | // the base64 public key, the base64 private key and the envelope — a REAL |
| 139 | // envelope from the real primitive, so CheckEnvelope's acceptance here means |
| 140 | // what it means in production. |
| 141 | func sealedFor(t *testing.T, plaintext string) (pub, priv, envelope string) { |
| 142 | t.Helper() |
| 143 | priv, pub, err := appconfig.GenerateKey() |
| 144 | if err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | pk, err := appconfig.ParsePublicKey(pub, "test") |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | envelope, err = appconfig.Seal(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, []byte(plaintext)) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | return pub, priv, envelope |
| 156 | } |
| 157 | |
| 158 | // ---- the gate --------------------------------------------------------------- |
| 159 | |
| 160 | // TestAPIConfigGateIsByteIdentical: on ALL THREE doors, a token that belongs |
| 161 | // to no account, an account segment that doesn't exist, and an app the |
| 162 | // account has never claimed are one and the same 404 — byte-identical to |
| 163 | // http.NotFound's own output, so probing this API teaches a token holder |
| 164 | // nothing about which accounts or apps exist. |
| 165 | func TestAPIConfigGateIsByteIdentical(t *testing.T) { |
| 166 | fx, token := configFixture(t, "code-config-gate") |
| 167 | eve := cliToken(t, fx, "eve@keymail.dev", "code-config-gate-eve") |
| 168 | |
| 169 | doors := []struct { |
| 170 | name string |
| 171 | call func(u, bearer string) (int, string) |
| 172 | }{ |
| 173 | {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }}, |
| 174 | {"PUT", func(u, bearer string) (int, string) { |
| 175 | return putRaw(t, u, map[string]any{"unset": []string{"X"}}, bearer) |
| 176 | }}, |
| 177 | {"sync", func(u, bearer string) (int, string) { |
| 178 | return postRaw(t, u+"/sync", map[string]any{}, bearer) |
| 179 | }}, |
| 180 | } |
| 181 | for _, d := range doors { |
| 182 | t.Run(d.name, func(t *testing.T) { |
| 183 | // (a) a member of nothing, against a real account and a real app. |
| 184 | statusA, bodyA := d.call(configURL(fx, "titogo"), eve) |
| 185 | // (b) a member, against an account that does not exist. |
| 186 | statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/config", token) |
| 187 | // (c) a member of the account, against an app it never claimed. |
| 188 | statusC, bodyC := d.call(configURL(fx, "ghost-app"), token) |
| 189 | if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| 190 | t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC) |
| 191 | } |
| 192 | if bodyA != bodyB || bodyB != bodyC { |
| 193 | t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC) |
| 194 | } |
| 195 | if bodyA != "404 page not found\n" { |
| 196 | t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA) |
| 197 | } |
| 198 | }) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // TestAPIConfigNoToken: every door answers the same 401 cliUser always gives |
| 203 | // — before it can matter what account or app was named. |
| 204 | func TestAPIConfigNoToken(t *testing.T) { |
| 205 | fx, _ := configFixture(t, "code-config-notoken") |
| 206 | |
| 207 | status, out := getJSON(t, configURL(fx, "titogo"), "") |
| 208 | if status != http.StatusUnauthorized || !strings.Contains(str(t, out, "error"), "not signed in") { |
| 209 | t.Fatalf("GET with no token = %d %v, want 401 and the cliUser sentence", status, out) |
| 210 | } |
| 211 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"unset": []string{"X"}}, "") |
| 212 | if status != http.StatusUnauthorized { |
| 213 | t.Fatalf("PUT with no token = %d %v, want 401", status, out) |
| 214 | } |
| 215 | status, out = postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, "") |
| 216 | if status != http.StatusUnauthorized { |
| 217 | t.Fatalf("sync with no token = %d %v, want 401", status, out) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // ---- GET -------------------------------------------------------------------- |
| 222 | |
| 223 | // TestAPIConfigGetEmpty: an app that has never been configured answers as an |
| 224 | // empty DEFAULT bundle rather than a 404 — the same "never configured is not |
| 225 | // an error" convention appconfig.Get itself keeps. environments is present |
| 226 | // and empty (a client reads it without testing for absence); exec_delivery |
| 227 | // and secrets_pubkey are absent, not empty strings. |
| 228 | func TestAPIConfigGetEmpty(t *testing.T) { |
| 229 | fx, token := configFixture(t, "code-config-get-empty") |
| 230 | |
| 231 | status, out := getJSON(t, configURL(fx, "titogo"), token) |
| 232 | if status != http.StatusOK { |
| 233 | t.Fatalf("GET = %d %v", status, out) |
| 234 | } |
| 235 | if str(t, out, "bundle") != appconfig.DefaultBundle { |
| 236 | t.Fatalf("bundle = %v, want %q", out["bundle"], appconfig.DefaultBundle) |
| 237 | } |
| 238 | envs, present := out["environments"].([]any) |
| 239 | if !present || len(envs) != 0 { |
| 240 | t.Fatalf("environments = %v, want a present, empty list", out["environments"]) |
| 241 | } |
| 242 | vars, present := out["vars"].([]any) |
| 243 | if !present || len(vars) != 0 { |
| 244 | t.Fatalf("vars = %v, want a present, empty list", out["vars"]) |
| 245 | } |
| 246 | if _, present := out["exec_delivery"]; present { |
| 247 | t.Fatalf("exec_delivery = %v, want the field absent when unset", out["exec_delivery"]) |
| 248 | } |
| 249 | if _, present := out["secrets_pubkey"]; present { |
| 250 | t.Fatalf("secrets_pubkey = %v, want it absent when the console serves none", out["secrets_pubkey"]) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // TestAPIConfigGetSecretsPubkey: a console configured with |
| 255 | // CARLOS_SECRETS_PUBKEY serves it — the whole point being that `carlos |
| 256 | // secrets set` can seal with `carlos auth login` and nothing else. It is |
| 257 | // public material: it only ever encrypts. |
| 258 | func TestAPIConfigGetSecretsPubkey(t *testing.T) { |
| 259 | fx, token := configFixture(t, "code-config-pubkey") |
| 260 | pub, _, _ := sealedFor(t, "irrelevant") |
| 261 | fx.srv.cfg.SecretsPub = pub |
| 262 | |
| 263 | status, out := getJSON(t, configURL(fx, "titogo"), token) |
| 264 | if status != http.StatusOK { |
| 265 | t.Fatalf("GET = %d %v", status, out) |
| 266 | } |
| 267 | if str(t, out, "secrets_pubkey") != pub { |
| 268 | t.Fatalf("secrets_pubkey = %v, want the console's own %q", out["secrets_pubkey"], pub) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // The GET names the account sqid a secret for this app is bound to — the |
| 273 | // CLI may only know the account by name — and only while the deployment |
| 274 | // writes bound (v2) envelopes: its presence is what makes the CLI seal v2, |
| 275 | // so with CONSOLE_SECRETS_ENVELOPE_V2 off (the default) it must be absent |
| 276 | // and the CLI keeps writing v1 that every box can open (discussion #220). |
| 277 | func TestAPIConfigGetNamesTheSecretsAccountOnlyWhenV2IsOn(t *testing.T) { |
| 278 | fx, token := configFixture(t, "code-config-secrets-account") |
| 279 | t.Cleanup(func() { appconfig.SetBoundSealing(false) }) |
| 280 | |
| 281 | appconfig.SetBoundSealing(false) |
| 282 | status, out := getJSON(t, configURL(fx, "titogo"), token) |
| 283 | if status != http.StatusOK { |
| 284 | t.Fatalf("GET = %d %v", status, out) |
| 285 | } |
| 286 | if _, ok := out["secrets_account"]; ok { |
| 287 | t.Fatalf("secrets_account served with bound sealing off: %v", out["secrets_account"]) |
| 288 | } |
| 289 | |
| 290 | appconfig.SetBoundSealing(true) |
| 291 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 292 | if status != http.StatusOK { |
| 293 | t.Fatalf("GET = %d %v", status, out) |
| 294 | } |
| 295 | if got := str(t, out, "secrets_account"); got != release.OpsAccount { |
| 296 | t.Fatalf("secrets_account = %q, want %q", got, release.OpsAccount) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | // ---- PUT: the round trips --------------------------------------------------- |
| 301 | |
| 302 | // TestAPIConfigPutRoundTrip is the property the whole feature rests on: an |
| 303 | // env var round-trips in full, and a secret goes IN as an envelope, lands in |
| 304 | // the bucket verbatim, opens with the private key the console has never |
| 305 | // seen — and NEVER comes back out of the API, not as a value and not as an |
| 306 | // envelope. The audit trail carries neither. |
| 307 | func TestAPIConfigPutRoundTrip(t *testing.T) { |
| 308 | fx, token := configFixture(t, "code-config-roundtrip") |
| 309 | const plaintext = "s3cr3t-plaintext-never-relayed" |
| 310 | _, priv, envelope := sealedFor(t, plaintext) |
| 311 | |
| 312 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 313 | "set": map[string]any{ |
| 314 | "PORT": map[string]any{"kind": "env", "value": "8080"}, |
| 315 | "BANK_KEY": map[string]any{"kind": "secret", "envelope": envelope}, |
| 316 | }, |
| 317 | }, token) |
| 318 | if status != http.StatusOK { |
| 319 | t.Fatalf("PUT = %d %v", status, out) |
| 320 | } |
| 321 | if str(t, out, "result") != "ok" { |
| 322 | t.Fatalf("PUT body = %v, want result ok", out) |
| 323 | } |
| 324 | if n, _ := out["removed"].(float64); n != 0 { |
| 325 | t.Fatalf("removed = %v, want 0 (nothing was unset)", out["removed"]) |
| 326 | } |
| 327 | |
| 328 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 329 | if status != http.StatusOK { |
| 330 | t.Fatalf("GET = %d %v", status, out) |
| 331 | } |
| 332 | byKey := varsByKey(t, out) |
| 333 | port, ok := byKey["PORT"] |
| 334 | if !ok { |
| 335 | t.Fatalf("vars = %v, want PORT", out["vars"]) |
| 336 | } |
| 337 | if str(t, port, "kind") != "env" || str(t, port, "value") != "8080" { |
| 338 | t.Fatalf("PORT = %v, want kind env value 8080", port) |
| 339 | } |
| 340 | bank, ok := byKey["BANK_KEY"] |
| 341 | if !ok { |
| 342 | t.Fatalf("vars = %v, want BANK_KEY", out["vars"]) |
| 343 | } |
| 344 | if str(t, bank, "kind") != "secret" { |
| 345 | t.Fatalf("BANK_KEY = %v, want kind secret", bank) |
| 346 | } |
| 347 | // Structurally absent, not empty: there is no field here for a secret's |
| 348 | // content to occupy in the first place. |
| 349 | if _, present := bank["value"]; present { |
| 350 | t.Fatalf("BANK_KEY = %v, want NO value field at all", bank) |
| 351 | } |
| 352 | if _, present := bank["envelope"]; present { |
| 353 | t.Fatalf("BANK_KEY = %v, want NO envelope field at all — the console must not relay ciphertext back", bank) |
| 354 | } |
| 355 | // And the raw response body, in case a future shape smuggles it elsewhere. |
| 356 | _, rawBody := getRaw(t, configURL(fx, "titogo"), token) |
| 357 | if strings.Contains(rawBody, envelope) || strings.Contains(rawBody, plaintext) { |
| 358 | t.Fatalf("GET body carries the secret: %q", rawBody) |
| 359 | } |
| 360 | |
| 361 | // The bucket holds the envelope byte-for-byte, and it still opens. |
| 362 | m, found := storedManifest(t, fx, "titogo") |
| 363 | if !found { |
| 364 | t.Fatal("no config manifest in the bucket after a successful PUT") |
| 365 | } |
| 366 | if m.Vars["BANK_KEY"].Envelope != envelope { |
| 367 | t.Fatalf("stored envelope = %q, want the exact bytes sent", m.Vars["BANK_KEY"].Envelope) |
| 368 | } |
| 369 | if m.Vars["BANK_KEY"].Value != "" { |
| 370 | t.Fatalf("stored secret carries a plaintext value %q, want none", m.Vars["BANK_KEY"].Value) |
| 371 | } |
| 372 | pk, err := appconfig.ParsePrivateKey(priv, "test") |
| 373 | if err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | opened, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, m.Vars["BANK_KEY"].Envelope) |
| 377 | if err != nil { |
| 378 | t.Fatalf("stored envelope does not open: %v", err) |
| 379 | } |
| 380 | if string(opened) != plaintext { |
| 381 | t.Fatalf("opened secret = %q, want %q", opened, plaintext) |
| 382 | } |
| 383 | if m.Vars["PORT"].Value != "8080" { |
| 384 | t.Fatalf("stored PORT = %+v, want value 8080", m.Vars["PORT"]) |
| 385 | } |
| 386 | |
| 387 | // The generation object moved: this write is visible to the agent's fast |
| 388 | // loop rather than waiting on the hourly sweep (issue #44). |
| 389 | if genEtag(t, fx, "titogo") == "" { |
| 390 | t.Fatal("no generation object after a successful config write") |
| 391 | } |
| 392 | |
| 393 | // Audit: counts and the bundle, never content. |
| 394 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 395 | if err != nil { |
| 396 | t.Fatal(err) |
| 397 | } |
| 398 | found = false |
| 399 | for _, e := range events { |
| 400 | if strings.Contains(e.Detail, plaintext) || strings.Contains(e.Detail, envelope) { |
| 401 | t.Fatalf("audit row leaks the secret: %q", e.Detail) |
| 402 | } |
| 403 | if e.Action == "config" && strings.Contains(e.Detail, "(cli)") { |
| 404 | found = true |
| 405 | if !strings.Contains(e.Detail, "2 set") || !strings.Contains(e.Detail, appconfig.DefaultBundle) { |
| 406 | t.Fatalf("config audit detail = %q, want the counts and the bundle", e.Detail) |
| 407 | } |
| 408 | } |
| 409 | } |
| 410 | if !found { |
| 411 | t.Fatalf("events = %v, want a config row whose detail names the CLI", events) |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | // TestAPIConfigPutRemovedCount: `removed` is how many keys were ACTUALLY |
| 416 | // there, not how many the caller listed — the CLI prints it, so an |
| 417 | // argument-count answer would tell an operator they removed something they |
| 418 | // didn't (2026-08-02 review, MINOR 9, on the wire too). |
| 419 | func TestAPIConfigPutRemovedCount(t *testing.T) { |
| 420 | fx, token := configFixture(t, "code-config-removed") |
| 421 | |
| 422 | if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 423 | "set": map[string]any{ |
| 424 | "A": map[string]any{"kind": "env", "value": "1"}, |
| 425 | "B": map[string]any{"kind": "env", "value": "2"}, |
| 426 | }, |
| 427 | }, token); status != http.StatusOK { |
| 428 | t.Fatalf("seed PUT = %d %v", status, out) |
| 429 | } |
| 430 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 431 | "unset": []string{"A", "NEVER_SET"}, |
| 432 | }, token) |
| 433 | if status != http.StatusOK { |
| 434 | t.Fatalf("unset PUT = %d %v", status, out) |
| 435 | } |
| 436 | if n, _ := out["removed"].(float64); n != 1 { |
| 437 | t.Fatalf("removed = %v, want 1 (A was there, NEVER_SET was not)", out["removed"]) |
| 438 | } |
| 439 | m, _ := storedManifest(t, fx, "titogo") |
| 440 | if _, still := m.Vars["A"]; still { |
| 441 | t.Fatalf("A survived the unset: %v", m.Vars) |
| 442 | } |
| 443 | if _, gone := m.Vars["B"]; !gone { |
| 444 | t.Fatalf("B was removed by an unset that never named it: %v", m.Vars) |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | // TestAPIConfigPutSetAndUnsetTogether: one PUT carrying both halves is ONE |
| 449 | // CAS write (appconfig.Apply), so it can never half-apply. |
| 450 | func TestAPIConfigPutSetAndUnsetTogether(t *testing.T) { |
| 451 | fx, token := configFixture(t, "code-config-both") |
| 452 | |
| 453 | if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 454 | "set": map[string]any{"OLD": map[string]any{"kind": "env", "value": "1"}}, |
| 455 | }, token); status != http.StatusOK { |
| 456 | t.Fatalf("seed PUT = %d %v", status, out) |
| 457 | } |
| 458 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 459 | "set": map[string]any{"NEW": map[string]any{"kind": "env", "value": "2"}}, |
| 460 | "unset": []string{"OLD"}, |
| 461 | }, token) |
| 462 | if status != http.StatusOK { |
| 463 | t.Fatalf("mixed PUT = %d %v", status, out) |
| 464 | } |
| 465 | if n, _ := out["removed"].(float64); n != 1 { |
| 466 | t.Fatalf("removed = %v, want 1", out["removed"]) |
| 467 | } |
| 468 | m, _ := storedManifest(t, fx, "titogo") |
| 469 | if _, still := m.Vars["OLD"]; still { |
| 470 | t.Fatalf("OLD survived: %v", m.Vars) |
| 471 | } |
| 472 | if m.Vars["NEW"].Value != "2" { |
| 473 | t.Fatalf("NEW = %+v, want value 2", m.Vars["NEW"]) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // TestAPIConfigNamedEnvironment: a named bundle round-trips at its own |
| 478 | // sibling key, does not bleed into the default bundle, and shows up in |
| 479 | // `environments` — which is what powers `carlos env environments` without a |
| 480 | // fourth door. An environment that has never been written answers as an |
| 481 | // empty bundle, matching appconfig's own convention for an absent config. |
| 482 | func TestAPIConfigNamedEnvironment(t *testing.T) { |
| 483 | fx, token := configFixture(t, "code-config-env") |
| 484 | |
| 485 | // Absent bundle: an empty answer, not a 404 and not an error. |
| 486 | status, out := getJSON(t, configURL(fx, "titogo")+"?environment=staging", token) |
| 487 | if status != http.StatusOK { |
| 488 | t.Fatalf("GET absent environment = %d %v, want 200", status, out) |
| 489 | } |
| 490 | if str(t, out, "bundle") != "staging" { |
| 491 | t.Fatalf("bundle = %v, want staging", out["bundle"]) |
| 492 | } |
| 493 | if vars, _ := out["vars"].([]any); len(vars) != 0 { |
| 494 | t.Fatalf("vars = %v, want empty for a bundle that doesn't exist yet", out["vars"]) |
| 495 | } |
| 496 | |
| 497 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 498 | "environment": "staging", |
| 499 | "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}}, |
| 500 | }, token) |
| 501 | if status != http.StatusOK { |
| 502 | t.Fatalf("PUT staging = %d %v", status, out) |
| 503 | } |
| 504 | |
| 505 | status, out = getJSON(t, configURL(fx, "titogo")+"?environment=staging", token) |
| 506 | if status != http.StatusOK { |
| 507 | t.Fatalf("GET staging = %d %v", status, out) |
| 508 | } |
| 509 | if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "9090" { |
| 510 | t.Fatalf("staging PORT = %v, want 9090", v) |
| 511 | } |
| 512 | if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" { |
| 513 | t.Fatalf("environments = %v, want [staging]", out["environments"]) |
| 514 | } |
| 515 | |
| 516 | // The default bundle is untouched by an environment-scoped write. |
| 517 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 518 | if status != http.StatusOK { |
| 519 | t.Fatalf("GET default = %d %v", status, out) |
| 520 | } |
| 521 | if vars, _ := out["vars"].([]any); len(vars) != 0 { |
| 522 | t.Fatalf("default bundle vars = %v, want empty — staging's write must not land here", out["vars"]) |
| 523 | } |
| 524 | if envs, _ := out["environments"].([]any); len(envs) != 1 || envs[0] != "staging" { |
| 525 | t.Fatalf("environments on the default bundle = %v, want [staging]", out["environments"]) |
| 526 | } |
| 527 | // No exec_delivery anywhere: nothing set it, and the field is omitted |
| 528 | // when a bundle is silent on it rather than reported as empty. |
| 529 | if _, present := out["exec_delivery"]; present { |
| 530 | t.Fatalf("exec_delivery = %v, want absent", out["exec_delivery"]) |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | // TestAPIConfigAuditNamesTheBundle: an environment-scoped write audits under |
| 535 | // that environment's name, so an operator reading rows can tell which bundle |
| 536 | // moved without the row carrying a single key or value. |
| 537 | func TestAPIConfigAuditNamesTheBundle(t *testing.T) { |
| 538 | fx, token := configFixture(t, "code-config-audit-bundle") |
| 539 | |
| 540 | if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 541 | "environment": "staging", |
| 542 | "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "9090"}}, |
| 543 | }, token); status != http.StatusOK { |
| 544 | t.Fatalf("PUT staging = %d %v", status, out) |
| 545 | } |
| 546 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 547 | if err != nil { |
| 548 | t.Fatal(err) |
| 549 | } |
| 550 | found := false |
| 551 | for _, e := range events { |
| 552 | if e.Action != "config" { |
| 553 | continue |
| 554 | } |
| 555 | found = true |
| 556 | if !strings.Contains(e.Detail, "staging") { |
| 557 | t.Fatalf("config audit detail = %q, want the bundle named", e.Detail) |
| 558 | } |
| 559 | if strings.Contains(e.Detail, "PORT") || strings.Contains(e.Detail, "9090") { |
| 560 | t.Fatalf("config audit detail = %q, want counts only — never keys or values", e.Detail) |
| 561 | } |
| 562 | } |
| 563 | if !found { |
| 564 | t.Fatalf("events = %v, want a config row", events) |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | // ---- PUT: the refusal matrix ------------------------------------------------ |
| 569 | |
| 570 | // TestAPIConfigPutRefusals is spec §2's refusal list, one case each, asserting |
| 571 | // the STATUS and that the error text is specific enough to act on — plus the |
| 572 | // property underneath all of them: a refused PUT writes NOTHING. Validation |
| 573 | // runs to completion before the first store call, so no case here can leave a |
| 574 | // half-applied bundle (or mint a phantom one) behind. |
| 575 | func TestAPIConfigPutRefusals(t *testing.T) { |
| 576 | fx, token := configFixture(t, "code-config-refusals") |
| 577 | _, _, envelope := sealedFor(t, "irrelevant") |
| 578 | |
| 579 | cases := []struct { |
| 580 | name string |
| 581 | body map[string]any |
| 582 | status int |
| 583 | errSub string |
| 584 | }{ |
| 585 | {"empty request", map[string]any{}, http.StatusBadRequest, "nothing to do"}, |
| 586 | {"environment default", map[string]any{ |
| 587 | "environment": "default", |
| 588 | "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}}, |
| 589 | }, http.StatusBadRequest, "default"}, |
| 590 | {"invalid environment name", map[string]any{ |
| 591 | "environment": "Staging Prod", |
| 592 | "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1"}}, |
| 593 | }, http.StatusBadRequest, "environment name"}, |
| 594 | {"invalid var name", map[string]any{ |
| 595 | "set": map[string]any{"9BAD": map[string]any{"kind": "env", "value": "1"}}, |
| 596 | }, http.StatusBadRequest, "9BAD"}, |
| 597 | {"unknown kind", map[string]any{ |
| 598 | "set": map[string]any{"A": map[string]any{"kind": "sekret", "value": "1"}}, |
| 599 | }, http.StatusBadRequest, "kind"}, |
| 600 | {"env value with a newline", map[string]any{ |
| 601 | "set": map[string]any{"A": map[string]any{"kind": "env", "value": "one\ntwo"}}, |
| 602 | }, http.StatusBadRequest, "A"}, |
| 603 | {"env var carrying an envelope", map[string]any{ |
| 604 | "set": map[string]any{"A": map[string]any{"kind": "env", "value": "1", "envelope": envelope}}, |
| 605 | }, http.StatusBadRequest, "envelope"}, |
| 606 | {"secret with a garbage envelope", map[string]any{ |
| 607 | "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": "not base64 at all!!"}}, |
| 608 | }, http.StatusBadRequest, "envelope"}, |
| 609 | {"secret with a truncated envelope", map[string]any{ |
| 610 | "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope[:20]}}, |
| 611 | }, http.StatusBadRequest, "envelope"}, |
| 612 | {"secret carrying a plaintext value", map[string]any{ |
| 613 | "set": map[string]any{"A": map[string]any{"kind": "secret", "envelope": envelope, "value": "hunter2"}}, |
| 614 | }, http.StatusBadRequest, "value"}, |
| 615 | {"bad exec_delivery value", map[string]any{"exec_delivery": "x"}, http.StatusBadRequest, "exec_delivery"}, |
| 616 | // Issue #195: "file" IS accepted with an environment now (see |
| 617 | // TestAPIConfigExecDeliveryAtEnvironmentScope). What each scope |
| 618 | // refuses is the OTHER scope's clearing word, because the two mean |
| 619 | // different things: "off" stops delivery, "inherit" hands the |
| 620 | // question back to the app default. |
| 621 | {"exec_delivery off with an environment", map[string]any{ |
| 622 | "environment": "staging", |
| 623 | "exec_delivery": "off", |
| 624 | }, http.StatusBadRequest, "inherit"}, |
| 625 | {"exec_delivery inherit without an environment", map[string]any{ |
| 626 | "exec_delivery": "inherit", |
| 627 | }, http.StatusBadRequest, "needs an environment"}, |
| 628 | } |
| 629 | for _, c := range cases { |
| 630 | t.Run(c.name, func(t *testing.T) { |
| 631 | status, out := putJSON(t, configURL(fx, "titogo"), c.body, token) |
| 632 | if status != c.status { |
| 633 | t.Fatalf("%s = %d %v, want %d", c.name, status, out, c.status) |
| 634 | } |
| 635 | if msg := str(t, out, "error"); !strings.Contains(msg, c.errSub) { |
| 636 | t.Fatalf("%s error = %q, want it to name %q", c.name, msg, c.errSub) |
| 637 | } |
| 638 | }) |
| 639 | } |
| 640 | |
| 641 | // Nothing above may have reached the store: no default bundle, no |
| 642 | // staging bundle, and no generation object. |
| 643 | if _, found := storedManifest(t, fx, "titogo"); found { |
| 644 | t.Fatal("a config manifest exists after nothing but refused PUTs — validation must run before any write") |
| 645 | } |
| 646 | names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| 647 | if err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | if len(names) != 0 { |
| 651 | t.Fatalf("environment bundles = %v after nothing but refused PUTs, want none", names) |
| 652 | } |
| 653 | if etag := genEtag(t, fx, "titogo"); etag != "" { |
| 654 | t.Fatalf("generation object exists (%q) after nothing but refused PUTs, want none", etag) |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | // TestAPIConfigPutSecretPlaintextNeverStored: the refusal that matters most. |
| 659 | // A secret var carrying a `value` is refused outright rather than stored with |
| 660 | // the plaintext quietly dropped — a caller who sent one has to learn their |
| 661 | // plaintext crossed the wire, and a future edit that "helpfully" kept it would |
| 662 | // be the console holding a secret in the clear. |
| 663 | func TestAPIConfigPutSecretPlaintextNeverStored(t *testing.T) { |
| 664 | fx, token := configFixture(t, "code-config-secret-plaintext") |
| 665 | _, _, envelope := sealedFor(t, "irrelevant") |
| 666 | const leaked = "plaintext-that-must-not-be-stored" |
| 667 | |
| 668 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 669 | "set": map[string]any{"BANK_KEY": map[string]any{ |
| 670 | "kind": "secret", "envelope": envelope, "value": leaked}}, |
| 671 | }, token) |
| 672 | if status != http.StatusBadRequest { |
| 673 | t.Fatalf("secret with a value = %d %v, want 400", status, out) |
| 674 | } |
| 675 | if msg := str(t, out, "error"); strings.Contains(msg, leaked) { |
| 676 | t.Fatalf("the refusal echoes the plaintext back: %q", msg) |
| 677 | } |
| 678 | if _, found := storedManifest(t, fx, "titogo"); found { |
| 679 | t.Fatal("a manifest was written for a refused secret-with-value PUT") |
| 680 | } |
| 681 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 682 | if err != nil { |
| 683 | t.Fatal(err) |
| 684 | } |
| 685 | for _, e := range events { |
| 686 | if strings.Contains(e.Detail, leaked) { |
| 687 | t.Fatalf("audit row leaks the refused plaintext: %q", e.Detail) |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | // TestAPIConfigUnsetOnlyNonexistentBundle: unsetting from a bundle that does |
| 693 | // not exist is a 409, not a silent create — creating it would mint an empty |
| 694 | // manifest, which by this package's own contract means "configured, then |
| 695 | // fully unset" and converges every one of that app's env files to empty |
| 696 | // (applyAt's CRITICAL 2 refusal, reached through the door). |
| 697 | func TestAPIConfigUnsetOnlyNonexistentBundle(t *testing.T) { |
| 698 | fx, token := configFixture(t, "code-config-unset-nobundle") |
| 699 | |
| 700 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 701 | "unset": []string{"NOPE"}, |
| 702 | }, token) |
| 703 | if status != http.StatusConflict { |
| 704 | t.Fatalf("unset-only on a nonexistent bundle = %d %v, want 409", status, out) |
| 705 | } |
| 706 | if msg := str(t, out, "error"); !strings.Contains(msg, "nothing to unset") { |
| 707 | t.Fatalf("error = %q, want appconfig's own refusal text", msg) |
| 708 | } |
| 709 | if _, found := storedManifest(t, fx, "titogo"); found { |
| 710 | t.Fatal("a phantom manifest was minted by a refused unset-only PUT") |
| 711 | } |
| 712 | |
| 713 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 714 | "environment": "staging", |
| 715 | "unset": []string{"NOPE"}, |
| 716 | }, token) |
| 717 | if status != http.StatusConflict { |
| 718 | t.Fatalf("unset-only on a nonexistent environment bundle = %d %v, want 409", status, out) |
| 719 | } |
| 720 | names, err := appconfig.ListEnvironments(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| 721 | if err != nil { |
| 722 | t.Fatal(err) |
| 723 | } |
| 724 | if len(names) != 0 { |
| 725 | t.Fatalf("environment bundles = %v, want none — a phantom bundle satisfies the route --environment rail", names) |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | // ---- exec-delivery ---------------------------------------------------------- |
| 730 | |
| 731 | // TestAPIConfigExecDelivery: the issue #18 opt-in through the door — "file" |
| 732 | // shows up on the default bundle, "off" clears the field entirely (absent, |
| 733 | // not empty), and both audit as their own row so the change is visible to an |
| 734 | // operator reading the trail. |
| 735 | func TestAPIConfigExecDelivery(t *testing.T) { |
| 736 | fx, token := configFixture(t, "code-config-execdelivery") |
| 737 | |
| 738 | // A set alongside it: exec_delivery rides on the same PUT the CLI would |
| 739 | // otherwise need two calls for, and both halves land. |
| 740 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 741 | "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}, |
| 742 | "exec_delivery": "file", |
| 743 | }, token) |
| 744 | if status != http.StatusOK { |
| 745 | t.Fatalf("PUT exec_delivery file = %d %v", status, out) |
| 746 | } |
| 747 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 748 | if status != http.StatusOK { |
| 749 | t.Fatalf("GET = %d %v", status, out) |
| 750 | } |
| 751 | if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile { |
| 752 | t.Fatalf("exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile) |
| 753 | } |
| 754 | if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" { |
| 755 | t.Fatalf("PORT = %v, want 8080 — the set half must land too", v) |
| 756 | } |
| 757 | |
| 758 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{"exec_delivery": "off"}, token) |
| 759 | if status != http.StatusOK { |
| 760 | t.Fatalf("PUT exec_delivery off = %d %v", status, out) |
| 761 | } |
| 762 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 763 | if status != http.StatusOK { |
| 764 | t.Fatalf("GET = %d %v", status, out) |
| 765 | } |
| 766 | if _, present := out["exec_delivery"]; present { |
| 767 | t.Fatalf("exec_delivery = %v after off, want the field absent", out["exec_delivery"]) |
| 768 | } |
| 769 | if v := varsByKey(t, out)["PORT"]; str(t, v, "value") != "8080" { |
| 770 | t.Fatalf("PORT = %v after exec_delivery off, want it untouched", v) |
| 771 | } |
| 772 | |
| 773 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 774 | if err != nil { |
| 775 | t.Fatal(err) |
| 776 | } |
| 777 | var on, off bool |
| 778 | for _, e := range events { |
| 779 | if e.Action != "exec-delivery" { |
| 780 | continue |
| 781 | } |
| 782 | if strings.Contains(e.Detail, "file") { |
| 783 | on = true |
| 784 | } |
| 785 | if strings.Contains(e.Detail, "off") { |
| 786 | off = true |
| 787 | } |
| 788 | if !strings.Contains(e.Detail, "(cli)") { |
| 789 | t.Fatalf("exec-delivery audit detail = %q, want it to name the CLI", e.Detail) |
| 790 | } |
| 791 | } |
| 792 | if !on || !off { |
| 793 | t.Fatalf("events = %v, want an exec-delivery row for each of file and off", events) |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | // ---- sync ------------------------------------------------------------------- |
| 798 | |
| 799 | // TestAPIConfigSync: `carlos env sync`'s door moves the generation etag — |
| 800 | // which is the entire mechanism the agent's fast loop watches — and audits, |
| 801 | // because a sync makes instances converge (and exec-backed ones restart), |
| 802 | // which an operator reading rows must be able to see. |
| 803 | func TestAPIConfigSync(t *testing.T) { |
| 804 | fx, token := configFixture(t, "code-config-sync") |
| 805 | |
| 806 | if etag := genEtag(t, fx, "titogo"); etag != "" { |
| 807 | t.Fatalf("generation object exists (%q) before anything wrote config", etag) |
| 808 | } |
| 809 | status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token) |
| 810 | if status != http.StatusOK { |
| 811 | t.Fatalf("sync = %d %v", status, out) |
| 812 | } |
| 813 | if str(t, out, "result") != "requested" { |
| 814 | t.Fatalf("sync body = %v, want result requested", out) |
| 815 | } |
| 816 | first := genEtag(t, fx, "titogo") |
| 817 | if first == "" { |
| 818 | t.Fatal("no generation object after a sync request") |
| 819 | } |
| 820 | if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusOK { |
| 821 | t.Fatalf("second sync = %d %v", status, out) |
| 822 | } |
| 823 | if second := genEtag(t, fx, "titogo"); second == first { |
| 824 | t.Fatalf("generation etag %q did not move on the second sync", second) |
| 825 | } |
| 826 | |
| 827 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 828 | if err != nil { |
| 829 | t.Fatal(err) |
| 830 | } |
| 831 | found := false |
| 832 | for _, e := range events { |
| 833 | if e.Action == "config-sync" && strings.Contains(e.Detail, "(cli)") { |
| 834 | found = true |
| 835 | } |
| 836 | } |
| 837 | if !found { |
| 838 | t.Fatalf("events = %v, want a config-sync row whose detail names the CLI", events) |
| 839 | } |
| 840 | } |
| 841 | |
| 842 | // ---- rate limiting ---------------------------------------------------------- |
| 843 | |
| 844 | // TestAPIConfigWritesAreRateLimited: both mutating doors pass the same write |
| 845 | // budget promote/rollback/ledgers pass, keyed on the ADDRESS — and the READ |
| 846 | // door does not, because an exhausted write budget must not blind a CLI to |
| 847 | // what it already wrote. |
| 848 | func TestAPIConfigWritesAreRateLimited(t *testing.T) { |
| 849 | fx, token := configFixture(t, "code-config-ratelimit") |
| 850 | |
| 851 | limited := false |
| 852 | for i := 0; i < cliWriteBurst+3; i++ { |
| 853 | status, _ := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 854 | "set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}, |
| 855 | }, token) |
| 856 | if status == http.StatusTooManyRequests { |
| 857 | limited = true |
| 858 | break |
| 859 | } |
| 860 | } |
| 861 | if !limited { |
| 862 | t.Fatalf("PUT was never rate-limited in %d attempts", cliWriteBurst+3) |
| 863 | } |
| 864 | // Same limiter, same key: an exhausted address cannot switch door. |
| 865 | if status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token); status != http.StatusTooManyRequests { |
| 866 | t.Fatalf("sync on an exhausted budget = %d %v, want 429", status, out) |
| 867 | } |
| 868 | if status, out := getJSON(t, configURL(fx, "titogo"), token); status != http.StatusOK { |
| 869 | t.Fatalf("GET on an exhausted write budget = %d %v, want 200", status, out) |
| 870 | } |
| 871 | } |
| 872 | |
| 873 | // TestAPIConfigGetBadEnvironment: the query-parameter half of the environment |
| 874 | // rule — "default" and a malformed name are refused on the READ door too, |
| 875 | // rather than being resolved to some other bundle or reaching the store. |
| 876 | func TestAPIConfigGetBadEnvironment(t *testing.T) { |
| 877 | fx, token := configFixture(t, "code-config-get-badenv") |
| 878 | |
| 879 | status, out := getJSON(t, configURL(fx, "titogo")+"?environment=default", token) |
| 880 | if status != http.StatusBadRequest { |
| 881 | t.Fatalf("GET ?environment=default = %d %v, want 400", status, out) |
| 882 | } |
| 883 | if msg := str(t, out, "error"); !strings.Contains(msg, "default") { |
| 884 | t.Fatalf("error = %q, want it to name the rule", msg) |
| 885 | } |
| 886 | status, out = getJSON(t, configURL(fx, "titogo")+"?environment=NOT%20valid", token) |
| 887 | if status != http.StatusBadRequest { |
| 888 | t.Fatalf("GET ?environment=NOT valid = %d %v, want 400", status, out) |
| 889 | } |
| 890 | if msg := str(t, out, "error"); !strings.Contains(msg, "environment name") { |
| 891 | t.Fatalf("error = %q, want it to name the shape rule", msg) |
| 892 | } |
| 893 | } |
| 894 | |
| 895 | // TestAPIConfigPutLargeEnvelope: the PUT door's own body cap |
| 896 | // (configPutMaxBytes), not readJSON's 8KiB, governs a config write — a |
| 897 | // sealed envelope runs ~4/3 the size of its plaintext, so one long base64 |
| 898 | // credential passes 8KiB on its own and must still land. The over-cap case |
| 899 | // answers an honest 413, not "malformed JSON body": the caller's fix is |
| 900 | // nothing like a JSON fix. |
| 901 | func TestAPIConfigPutLargeEnvelope(t *testing.T) { |
| 902 | fx, token := configFixture(t, "code-config-large") |
| 903 | |
| 904 | // ~16KiB of single-line plaintext (ValidVarValue-legal), well past the |
| 905 | // old shared cap once sealed and base64'd. |
| 906 | _, priv, envelope := sealedFor(t, strings.Repeat("a", 16<<10)) |
| 907 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 908 | "set": map[string]any{"BIG_KEY": map[string]any{"kind": "secret", "envelope": envelope}}, |
| 909 | }, token) |
| 910 | if status != http.StatusOK { |
| 911 | t.Fatalf("large envelope PUT = %d %v, want 200", status, out) |
| 912 | } |
| 913 | m, found := storedManifest(t, fx, "titogo") |
| 914 | if !found || m.Vars["BIG_KEY"].Envelope != envelope { |
| 915 | t.Fatal("large envelope did not land verbatim") |
| 916 | } |
| 917 | // And it still opens: the door relayed, never rewrote. |
| 918 | pk, err := appconfig.ParsePrivateKey(priv, "test") |
| 919 | if err != nil { |
| 920 | t.Fatal(err) |
| 921 | } |
| 922 | got, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, m.Vars["BIG_KEY"].Envelope) |
| 923 | if err != nil || len(got) != 16<<10 { |
| 924 | t.Fatalf("stored envelope does not open to the plaintext: %v", err) |
| 925 | } |
| 926 | |
| 927 | // Over the cap: refused at read time, before validation could even see |
| 928 | // it, with the size's own status. |
| 929 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 930 | "set": map[string]any{"TOO_BIG": map[string]any{"kind": "secret", "envelope": strings.Repeat("A", int(configPutMaxBytes)+1024)}}, |
| 931 | }, token) |
| 932 | if status != http.StatusRequestEntityTooLarge { |
| 933 | t.Fatalf("over-cap PUT = %d %v, want 413", status, out) |
| 934 | } |
| 935 | if m, _ := storedManifest(t, fx, "titogo"); m.Vars != nil { |
| 936 | if _, ok := m.Vars["TOO_BIG"]; ok { |
| 937 | t.Fatal("over-cap PUT wrote to the bucket") |
| 938 | } |
| 939 | } |
| 940 | } |
| 941 | |
| 942 | // TestAPIConfigExecDeliveryAtEnvironmentScope is issue #195 through the |
| 943 | // door: the flag lands in the NAMED bundle, the default bundle stays |
| 944 | // silent, and "inherit" clears it. The point of the whole change is that |
| 945 | // opting one bundle in must be readable as exactly that — so a GET for the |
| 946 | // default bundle must not report a named bundle's flag, and vice versa. |
| 947 | func TestAPIConfigExecDeliveryAtEnvironmentScope(t *testing.T) { |
| 948 | fx, token := configFixture(t, "code-config-execdelivery-env") |
| 949 | |
| 950 | status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 951 | "environment": "relay", |
| 952 | "set": map[string]any{"WALLET": map[string]any{"kind": "env", "value": "sealed"}}, |
| 953 | "exec_delivery": "file", |
| 954 | }, token) |
| 955 | if status != http.StatusOK { |
| 956 | t.Fatalf("PUT environment=relay exec_delivery=file = %d %v", status, out) |
| 957 | } |
| 958 | |
| 959 | status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) |
| 960 | if status != http.StatusOK { |
| 961 | t.Fatalf("GET relay = %d %v", status, out) |
| 962 | } |
| 963 | if str(t, out, "exec_delivery") != appconfig.ExecDeliveryFile { |
| 964 | t.Fatalf("relay exec_delivery = %v, want %q", out["exec_delivery"], appconfig.ExecDeliveryFile) |
| 965 | } |
| 966 | |
| 967 | // The default bundle never learned about it — the fleet-wide switch |
| 968 | // issue #195 is about was exactly this leaking upward. |
| 969 | status, out = getJSON(t, configURL(fx, "titogo"), token) |
| 970 | if status != http.StatusOK { |
| 971 | t.Fatalf("GET default = %d %v", status, out) |
| 972 | } |
| 973 | if _, present := out["exec_delivery"]; present { |
| 974 | 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"]) |
| 975 | } |
| 976 | |
| 977 | // "inherit" clears the named bundle's field without touching its vars. |
| 978 | status, out = putJSON(t, configURL(fx, "titogo"), map[string]any{ |
| 979 | "environment": "relay", |
| 980 | "exec_delivery": "inherit", |
| 981 | }, token) |
| 982 | if status != http.StatusOK { |
| 983 | t.Fatalf("PUT environment=relay exec_delivery=inherit = %d %v", status, out) |
| 984 | } |
| 985 | status, out = getJSON(t, configURL(fx, "titogo")+"?environment=relay", token) |
| 986 | if status != http.StatusOK { |
| 987 | t.Fatalf("GET relay = %d %v", status, out) |
| 988 | } |
| 989 | if _, present := out["exec_delivery"]; present { |
| 990 | t.Fatalf("relay exec_delivery = %v after inherit, want the field absent", out["exec_delivery"]) |
| 991 | } |
| 992 | if v := varsByKey(t, out)["WALLET"]; str(t, v, "value") != "sealed" { |
| 993 | t.Fatalf("WALLET = %v after inherit, want it untouched", v) |
| 994 | } |
| 995 | |
| 996 | // The audit trail records the caller's own word and the bundle it |
| 997 | // applied to: "off" and "inherit" both store "", so a row built from |
| 998 | // the stored value would lose which scope was acted on. |
| 999 | events, err := fx.st.Events(OpsAccountID, "titogo", 20) |
| 1000 | if err != nil { |
| 1001 | t.Fatal(err) |
| 1002 | } |
| 1003 | var joined string |
| 1004 | for _, e := range events { |
| 1005 | joined += e.Detail + "\n" |
| 1006 | } |
| 1007 | if !strings.Contains(joined, "exec-delivery file (relay)") { |
| 1008 | t.Fatalf("audit trail = %q, want a row naming the value and the bundle", joined) |
| 1009 | } |
| 1010 | if !strings.Contains(joined, "exec-delivery inherit (relay)") { |
| 1011 | t.Fatalf("audit trail = %q, want the clear recorded as \"inherit\", not as \"off\"", joined) |
| 1012 | } |
| 1013 | } |
| 1014 | |