carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
12import (
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.
29func 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.
49func 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.
77func 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.
91func 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.
98func 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).
113func 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.
123func 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.
141func 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.
165func 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.
204func 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.
228func 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.
258func 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).
277func 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.
307func 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).
419func 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.
450func 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.
482func 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.
537func 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.
575func 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.
663func 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).
697func 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.
735func 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.
803func 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.
848func 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.
876func 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.
901func 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.
947func 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