carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// End-to-end acceptance test for issues #43/#44: `carlos env`/`carlos
2// secrets` working through the console API with `carlos auth login` alone,
3// and `carlos env sync` touching the fast-convergence generation object.
4// Spec: docs/superpowers/specs/2026-08-04-config-console-api-design.md §6.
5//
6// Follows TestE2ECLIThroughConsole's shape exactly (auth_e2e_test.go): a
7// REAL console.Server (DirStore, a real ed25519 release signing key — no
8// hand-rolled httptest handlers standing in for it), a device-flow login
9// landing a real bearer token in a temp credential file, and then — with NO
10// store env set and NO CARLOS_SECRETS_PUBKEY either (the console-served
11// pubkey path is the point of issue #43) — the real `carlos env`/`carlos
12// secrets` run* functions (appconfig_cli.go) driven in-process, their
13// effects verified against the console's own config door and, for the
14// sealed secret, the raw manifest bytes straight from the DirStore.
15//
16// appconfig_cli.go's run* functions print through fmt.Printf/os.Stdout
17// directly, not through cli.go's `stdout` package var (the seam
18// captureStdout swaps) — ship/promote/channels/ledgers are the only verbs
19// wired to that seam today. So happy-path assertions here read the
20// console's own response (or the DirStore) as ground truth, exactly what
21// the spec's §6 bullets ask for ("assert ... that PORT=8080 is visible",
22// "secrets list shows the key name", "the generation object's etag moved"),
23// with one exception (env unset's "unset N of M" line) captured via a raw
24// os.Pipe swap of the real os.Stdout, since that specific line is the one
25// piece of this flow the spec pins as printed text.
26package main
27
28import (
29 "bytes"
30 "context"
31 "encoding/json"
32 "io"
33 "net/http"
34 "net/http/httptest"
35 "os"
36 "path/filepath"
37 "strings"
38 "testing"
39 "time"
40
41 "github.com/carlosframework/platform/internal/appconfig"
42 "github.com/carlosframework/platform/internal/console"
43 "github.com/carlosframework/platform/internal/release"
44)
45
46// runCapturingRealStdout swaps the process's real os.Stdout for the length
47// of fn, synchronously: fn runs to completion (writing whatever it likes
48// into the pipe's buffer — comfortably under the OS pipe buffer's size for
49// one CLI confirmation line), THEN the write end closes and the whole pipe
50// is drained. Unlike a background-goroutine copy, there is no race between
51// "did the copy see this byte yet" and the caller's read of the result:
52// nothing reads until fn has already returned and the pipe is closed.
53//
54// This is a different seam from captureStdout (apimode_test.go), which
55// swaps cli.go's `stdout` package var — appconfig_cli.go's run* functions
56// write straight to os.Stdout instead, so that seam doesn't reach them.
57func runCapturingRealStdout(t *testing.T, fn func()) string {
58 t.Helper()
59 r, w, err := os.Pipe()
60 if err != nil {
61 t.Fatal(err)
62 }
63 orig := os.Stdout
64 os.Stdout = w
65 fn()
66 os.Stdout = orig
67 w.Close()
68 out, err := io.ReadAll(r)
69 if err != nil {
70 t.Fatal(err)
71 }
72 return string(out)
73}
74
75// TestConfigE2EThroughConsole drives the whole config-console-api feature
76// (issues #43, #44) end to end: login, `env set`, `secrets set` with no
77// CARLOS_SECRETS_PUBKEY in the terminal's own environment (the console
78// serves its own), `env unset`, `env sync`, and a membership refusal for a
79// token that belongs to nobody on the account.
80func TestConfigE2EThroughConsole(t *testing.T) {
81 useTempCredentials(t)
82 clearDeploymentEnv(t)
83 // The point of issue #43 is that `secrets set` needs nothing but a
84 // login — no CARLOS_SECRETS_PUBKEY in this terminal's own env either,
85 // so the fallback to the console's served key (Config.SecretsPub) is
86 // what actually gets exercised, not secretsPubKeyAPI's env-var branch.
87 t.Setenv("CARLOS_SECRETS_PUBKEY", "")
88
89 const acctFlag = "ops" // resolves to console.OpsAccountID / release.OpsAccount ("bab")
90 const app = "titogo"
91
92 tmp := t.TempDir()
93 relStore := release.NewDirStore(filepath.Join(tmp, "store"))
94
95 // A real appconfig secrets keypair — the private half stays in this
96 // test (standing in for CARLOS_SECRETS_KEY on a real box), the public
97 // half is what the console will SERVE (Config.SecretsPub), never hold
98 // the private half at all.
99 secPrivB64, secPubB64, err := appconfig.GenerateKey()
100 if err != nil {
101 t.Fatal(err)
102 }
103 secPriv, err := appconfig.ParsePrivateKey(secPrivB64, "e2e")
104 if err != nil {
105 t.Fatal(err)
106 }
107
108 // The unrelated release-signing keypair every console needs to boot,
109 // exactly as TestE2ECLIThroughConsole sets one up — config doors don't
110 // check it (apiconfig.go's package comment: "no signing-key check on
111 // any door here"), but console.NewServer still wants a valid one.
112 seed, pubB64, err := release.GenerateKey()
113 if err != nil {
114 t.Fatal(err)
115 }
116 priv, err := release.ParseSigningKey(seed, "e2e")
117 if err != nil {
118 t.Fatal(err)
119 }
120 pub, err := release.ParsePublicKey(pubB64, "e2e")
121 if err != nil {
122 t.Fatal(err)
123 }
124
125 cst, err := console.Open(filepath.Join(tmp, "console.db"))
126 if err != nil {
127 t.Fatal(err)
128 }
129 t.Cleanup(func() { cst.Close() })
130
131 var srv *console.Server
132 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133 srv.ServeHTTP(w, r)
134 }))
135 t.Cleanup(ts.Close)
136
137 srv, err = console.NewServer(console.Config{
138 Origin: ts.URL,
139 Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
140 Registry: filepath.Join(tmp, "registry.db"),
141 Releases: relStore,
142 ReleasePub: pub,
143 Key: priv,
144 AppScheme: "http",
145 SecretsPub: secPubB64, // the console-served path issue #43 exists for
146 }, cst)
147 if err != nil {
148 t.Fatal(err)
149 }
150
151 const addr = "paul@keymail.dev"
152 if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
153 t.Fatal(err)
154 }
155 if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil {
156 t.Fatal(err)
157 }
158
159 // --- device flow: start -> approve -> poll, to a real token on disk ----
160 token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
161 credFile, err := loadCredentials()
162 if err != nil {
163 t.Fatal(err)
164 }
165 credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
166 if err := credFile.save(); err != nil {
167 t.Fatal(err)
168 }
169
170 // sess is used only for the assertions this test makes directly against
171 // the console (config GET, and the negative case below) — never for the
172 // mutations under test, which go through the real run* functions.
173 sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
174
175 // --- (1) `env set PORT=8080` through the real CLI path ------------------
176 runEnvSet([]string{"-app", app, "PORT=8080"})
177
178 cfg, err := sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "")
179 if err != nil {
180 t.Fatalf("config GET after env set: %v", err)
181 }
182 var sawPort bool
183 for _, v := range cfg.Vars {
184 if v.Key == "PORT" {
185 sawPort = true
186 if v.Kind != "env" || v.Value != "8080" {
187 t.Fatalf("PORT var = %+v, want kind=env value=8080", v)
188 }
189 }
190 }
191 if !sawPort {
192 t.Fatalf("config GET vars = %+v, want PORT to be visible", cfg.Vars)
193 }
194
195 // --- (2) `secrets set BANK_KEY=hunter2-e2e`, no CARLOS_SECRETS_PUBKEY ---
196 //
197 // secretsPubKeyAPI (appconfig_cli.go) falls back to the console's served
198 // key exactly because CARLOS_SECRETS_PUBKEY is unset in this test's own
199 // env (t.Setenv above) — this is the console-served path, not the env
200 // var one.
201 runSecretsSet([]string{"-app", app, "BANK_KEY=hunter2-e2e"})
202
203 cfg, err = sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "")
204 if err != nil {
205 t.Fatalf("config GET after secrets set: %v", err)
206 }
207 var sawBankKey bool
208 for _, v := range cfg.Vars {
209 if v.Key == "BANK_KEY" {
210 sawBankKey = true
211 if v.Kind != "secret" {
212 t.Fatalf("BANK_KEY var = %+v, want kind=secret", v)
213 }
214 if v.Value != "" {
215 t.Fatalf("BANK_KEY var carries a value over the wire: %+v — secret values must be structurally absent", v)
216 }
217 }
218 }
219 if !sawBankKey {
220 t.Fatalf("config GET vars = %+v, want BANK_KEY (name only) to be visible", cfg.Vars)
221 }
222 // secrets list (CLI) shows only the key name.
223 listOut := runCapturingRealStdout(t, func() {
224 runSecretsList([]string{"-app", app})
225 })
226 if !strings.Contains(listOut, "BANK_KEY") {
227 t.Fatalf("secrets list stdout = %q, want it to name BANK_KEY", listOut)
228 }
229 if strings.Contains(listOut, "hunter2-e2e") {
230 t.Fatalf("secrets list stdout = %q, leaked the plaintext value", listOut)
231 }
232
233 // Read the manifest straight from the DirStore: the envelope Opens with
234 // the test's own private key to EXACTLY the plaintext, and the raw
235 // stored bytes never contain the plaintext (the console only ever saw
236 // ciphertext — this is the whole point of issue #43's threat model).
237 raw, _, err := relStore.Get(context.Background(), appconfig.Key(release.OpsAccount, app))
238 if err != nil {
239 t.Fatalf("read manifest from the store: %v", err)
240 }
241 if bytes.Contains(raw, []byte("hunter2-e2e")) {
242 t.Fatalf("raw stored manifest contains the secret plaintext:\n%s", raw)
243 }
244 var m appconfig.Manifest
245 if err := json.Unmarshal(raw, &m); err != nil {
246 t.Fatalf("decode stored manifest: %v", err)
247 }
248 bankVar, ok := m.Vars["BANK_KEY"]
249 if !ok || bankVar.Kind != "secret" || bankVar.Envelope == "" {
250 t.Fatalf("stored manifest BANK_KEY = %+v, want a sealed secret envelope", bankVar)
251 }
252 plaintext, err := appconfig.Open(secPriv, appconfig.Binding{Account: release.OpsAccount, App: app}, bankVar.Envelope)
253 if err != nil {
254 t.Fatalf("Open the stored envelope with the test's private key: %v", err)
255 }
256 if string(plaintext) != "hunter2-e2e" {
257 t.Fatalf("opened plaintext = %q, want %q", plaintext, "hunter2-e2e")
258 }
259
260 // --- (3) `env unset PORT` -----------------------------------------------
261 unsetOut := runCapturingRealStdout(t, func() {
262 runEnvUnset([]string{"-app", app, "PORT"})
263 })
264 if !strings.Contains(unsetOut, "unset 1 of 1") {
265 t.Fatalf("env unset stdout = %q, want it to say \"unset 1 of 1\"", unsetOut)
266 }
267 cfg, err = sess.configGet(context.Background(), apiAcct{Seg: acctFlag}, app, "")
268 if err != nil {
269 t.Fatalf("config GET after env unset: %v", err)
270 }
271 for _, v := range cfg.Vars {
272 if v.Key == "PORT" {
273 t.Fatalf("config GET vars = %+v, want PORT gone after unset", cfg.Vars)
274 }
275 }
276
277 // --- (4) `env sync` moves the generation object's etag ------------------
278 genKey := appconfig.GenKey(release.OpsAccount, app)
279 _, beforeEtag, err := relStore.Get(context.Background(), genKey)
280 if err != nil {
281 // Every write path above (Apply via env set/secrets set/env unset)
282 // already touches the generation object as a side effect
283 // (generation.go's touchLogged), so by this point in the test it
284 // must exist.
285 t.Fatalf("generation object before sync: %v", err)
286 }
287 runEnvSync([]string{"-app", app})
288 _, afterEtag, err := relStore.Get(context.Background(), genKey)
289 if err != nil {
290 t.Fatalf("generation object after sync: %v", err)
291 }
292 if afterEtag == beforeEtag {
293 t.Fatalf("generation etag unchanged by env sync: before=%s after=%s", beforeEtag, afterEtag)
294 }
295
296 // --- (5) negative: a token for an address that is NOT a member ---------
297 //
298 // runEnvSet itself log.Fatals on this error (os.Exit — nothing an
299 // in-process test could recover from), so the refusal is asserted at
300 // the apiSession level instead — the exact call runEnvSet's API-mode
301 // branch makes, against the same real server, with a token for an
302 // address that was never added to the ops account. Same technique
303 // TestE2ECLIThroughConsole uses for the stable-promote refusal.
304 const outsider = "mallory@keymail.dev"
305 outsiderToken, _ := e2eDeviceLogin(t, ts.URL, cst, outsider, "e2e@mallory")
306 outsiderSess := &apiSession{
307 c: newConsoleClient(ts.URL, outsiderToken),
308 console: ts.URL,
309 address: outsider,
310 }
311 if _, err := outsiderSess.configApply(context.Background(), apiAcct{Seg: acctFlag}, app,
312 apiConfigApply{Set: map[string]appconfig.Var{"X": {Kind: "env", Value: "1"}}}); err == nil {
313 t.Fatal("configApply for a non-member address must refuse")
314 } else if !strings.Contains(err.Error(), "not found: check --account and --app") {
315 t.Fatalf("non-member refusal = %q, want the membership message", err.Error())
316 }
317}
318