// End-to-end acceptance test for issues #43/#44: `carlos env`/`carlos // secrets` working through the console API with `carlos auth login` alone, // and `carlos env sync` touching the fast-convergence generation object. // Spec: docs/superpowers/specs/2026-08-04-config-console-api-design.md §6. // // Follows TestE2ECLIThroughConsole's shape exactly (auth_e2e_test.go): a // REAL console.Server (DirStore, a real ed25519 release signing key — no // hand-rolled httptest handlers standing in for it), a device-flow login // landing a real bearer token in a temp credential file, and then — with NO // store env set and NO CARLOS_SECRETS_PUBKEY either (the console-served // pubkey path is the point of issue #43) — the real `carlos env`/`carlos // secrets` run* functions (appconfig_cli.go) driven in-process, their // effects verified against the console's own config door and, for the // sealed secret, the raw manifest bytes straight from the DirStore. // // appconfig_cli.go's run* functions print through fmt.Printf/os.Stdout // directly, not through cli.go's `stdout` package var (the seam // captureStdout swaps) — ship/promote/channels/ledgers are the only verbs // wired to that seam today. So happy-path assertions here read the // console's own response (or the DirStore) as ground truth, exactly what // the spec's §6 bullets ask for ("assert ... that PORT=8080 is visible", // "secrets list shows the key name", "the generation object's etag moved"), // with one exception (env unset's "unset N of M" line) captured via a raw // os.Pipe swap of the real os.Stdout, since that specific line is the one // piece of this flow the spec pins as printed text. package main import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/release" ) // runCapturingRealStdout swaps the process's real os.Stdout for the length // of fn, synchronously: fn runs to completion (writing whatever it likes // into the pipe's buffer — comfortably under the OS pipe buffer's size for // one CLI confirmation line), THEN the write end closes and the whole pipe // is drained. Unlike a background-goroutine copy, there is no race between // "did the copy see this byte yet" and the caller's read of the result: // nothing reads until fn has already returned and the pipe is closed. // // This is a different seam from captureStdout (apimode_test.go), which // swaps cli.go's `stdout` package var — appconfig_cli.go's run* functions // write straight to os.Stdout instead, so that seam doesn't reach them. func runCapturingRealStdout(t *testing.T, fn func()) string { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatal(err) } orig := os.Stdout os.Stdout = w fn() os.Stdout = orig w.Close() out, err := io.ReadAll(r) if err != nil { t.Fatal(err) } return string(out) } // TestConfigE2EThroughConsole drives the whole config-console-api feature // (issues #43, #44) end to end: login, `env set`, `secrets set` with no // CARLOS_SECRETS_PUBKEY in the terminal's own environment (the console // serves its own), `env unset`, `env sync`, and a membership refusal for a // token that belongs to nobody on the account. func TestConfigE2EThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) // The point of issue #43 is that `secrets set` needs nothing but a // login — no CARLOS_SECRETS_PUBKEY in this terminal's own env either, // so the fallback to the console's served key (Config.SecretsPub) is // what actually gets exercised, not secretsPubKeyAPI's env-var branch. t.Setenv("CARLOS_SECRETS_PUBKEY", "") const acctFlag = "ops" // resolves to console.OpsAccountID / release.OpsAccount ("bab") const app = "titogo" tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) // A real appconfig secrets keypair — the private half stays in this // test (standing in for CARLOS_SECRETS_KEY on a real box), the public // half is what the console will SERVE (Config.SecretsPub), never hold // the private half at all. secPrivB64, secPubB64, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } secPriv, err := appconfig.ParsePrivateKey(secPrivB64, "e2e") if err != nil { t.Fatal(err) } // The unrelated release-signing keypair every console needs to boot, // exactly as TestE2ECLIThroughConsole sets one up — config doors don't // check it (apiconfig.go's package comment: "no signing-key check on // any door here"), but console.NewServer still wants a valid one. seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin Registry: filepath.Join(tmp, "registry.db"), Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", SecretsPub: secPubB64, // the console-served path issue #43 exists for }, cst) if err != nil { t.Fatal(err) } const addr = "paul@keymail.dev" if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { t.Fatal(err) } // --- device flow: start -> approve -> poll, to a real token on disk ---- token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } // sess is used only for the assertions this test makes directly against // the console (config GET, and the negative case below) — never for the // mutations under test, which go through the real run* functions. sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr} // --- (1) `env set PORT=8080` through the real CLI path ------------------ runEnvSet([]string{"-app", app, "PORT=8080"}) cfg, err := sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "") if err != nil { t.Fatalf("config GET after env set: %v", err) } var sawPort bool for _, v := range cfg.Vars { if v.Key == "PORT" { sawPort = true if v.Kind != "env" || v.Value != "8080" { t.Fatalf("PORT var = %+v, want kind=env value=8080", v) } } } if !sawPort { t.Fatalf("config GET vars = %+v, want PORT to be visible", cfg.Vars) } // --- (2) `secrets set BANK_KEY=hunter2-e2e`, no CARLOS_SECRETS_PUBKEY --- // // secretsPubKeyAPI (appconfig_cli.go) falls back to the console's served // key exactly because CARLOS_SECRETS_PUBKEY is unset in this test's own // env (t.Setenv above) — this is the console-served path, not the env // var one. runSecretsSet([]string{"-app", app, "BANK_KEY=hunter2-e2e"}) cfg, err = sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "") if err != nil { t.Fatalf("config GET after secrets set: %v", err) } var sawBankKey bool for _, v := range cfg.Vars { if v.Key == "BANK_KEY" { sawBankKey = true if v.Kind != "secret" { t.Fatalf("BANK_KEY var = %+v, want kind=secret", v) } if v.Value != "" { t.Fatalf("BANK_KEY var carries a value over the wire: %+v — secret values must be structurally absent", v) } } } if !sawBankKey { t.Fatalf("config GET vars = %+v, want BANK_KEY (name only) to be visible", cfg.Vars) } // secrets list (CLI) shows only the key name. listOut := runCapturingRealStdout(t, func() { runSecretsList([]string{"-app", app}) }) if !strings.Contains(listOut, "BANK_KEY") { t.Fatalf("secrets list stdout = %q, want it to name BANK_KEY", listOut) } if strings.Contains(listOut, "hunter2-e2e") { t.Fatalf("secrets list stdout = %q, leaked the plaintext value", listOut) } // Read the manifest straight from the DirStore: the envelope Opens with // the test's own private key to EXACTLY the plaintext, and the raw // stored bytes never contain the plaintext (the console only ever saw // ciphertext — this is the whole point of issue #43's threat model). raw, _, err := relStore.Get(context.Background(), appconfig.Key(release.OpsAccount, app)) if err != nil { t.Fatalf("read manifest from the store: %v", err) } if bytes.Contains(raw, []byte("hunter2-e2e")) { t.Fatalf("raw stored manifest contains the secret plaintext:\n%s", raw) } var m appconfig.Manifest if err := json.Unmarshal(raw, &m); err != nil { t.Fatalf("decode stored manifest: %v", err) } bankVar, ok := m.Vars["BANK_KEY"] if !ok || bankVar.Kind != "secret" || bankVar.Envelope == "" { t.Fatalf("stored manifest BANK_KEY = %+v, want a sealed secret envelope", bankVar) } plaintext, err := appconfig.Open(secPriv, appconfig.Binding{Account: release.OpsAccount, App: app}, bankVar.Envelope) if err != nil { t.Fatalf("Open the stored envelope with the test's private key: %v", err) } if string(plaintext) != "hunter2-e2e" { t.Fatalf("opened plaintext = %q, want %q", plaintext, "hunter2-e2e") } // --- (3) `env unset PORT` ----------------------------------------------- unsetOut := runCapturingRealStdout(t, func() { runEnvUnset([]string{"-app", app, "PORT"}) }) if !strings.Contains(unsetOut, "unset 1 of 1") { t.Fatalf("env unset stdout = %q, want it to say \"unset 1 of 1\"", unsetOut) } cfg, err = sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "") if err != nil { t.Fatalf("config GET after env unset: %v", err) } for _, v := range cfg.Vars { if v.Key == "PORT" { t.Fatalf("config GET vars = %+v, want PORT gone after unset", cfg.Vars) } } // --- (4) `env sync` moves the generation object's etag ------------------ genKey := appconfig.GenKey(release.OpsAccount, app) _, beforeEtag, err := relStore.Get(context.Background(), genKey) if err != nil { // Every write path above (Apply via env set/secrets set/env unset) // already touches the generation object as a side effect // (generation.go's touchLogged), so by this point in the test it // must exist. t.Fatalf("generation object before sync: %v", err) } runEnvSync([]string{"-app", app}) _, afterEtag, err := relStore.Get(context.Background(), genKey) if err != nil { t.Fatalf("generation object after sync: %v", err) } if afterEtag == beforeEtag { t.Fatalf("generation etag unchanged by env sync: before=%s after=%s", beforeEtag, afterEtag) } // --- (5) negative: a token for an address that is NOT a member --------- // // runEnvSet itself log.Fatals on this error (os.Exit — nothing an // in-process test could recover from), so the refusal is asserted at // the apiSession level instead — the exact call runEnvSet's API-mode // branch makes, against the same real server, with a token for an // address that was never added to the ops account. Same technique // TestE2ECLIThroughConsole uses for the stable-promote refusal. const outsider = "mallory@keymail.dev" outsiderToken, _ := e2eDeviceLogin(t, ts.URL, cst, outsider, "e2e@mallory") outsiderSess := &apiSession{ c: newConsoleClient(ts.URL, outsiderToken), console: ts.URL, address: outsider, } if _, err := outsiderSess.configApply(context.Background(), apiAcct{Seg: acctFlag}, app, apiConfigApply{Set: map[string]appconfig.Var{"X": {Kind: "env", Value: "1"}}}); err == nil { t.Fatal("configApply for a non-member address must refuse") } else if !strings.Contains(err.Error(), "not found: check --account and --app") { t.Fatalf("non-member refusal = %q, want the membership message", err.Error()) } }