| new file mode 100644 |
| index 000000000..aa09fa8e8 |
| --- /dev/null |
| +++ b/config_e2e_test.go |
| @@ -0,0 +1,317 @@ |
| +// 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(), 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(), 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, 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(), 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(), 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()) |
| + } |
| +} |