carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "strings"
18 "sync"
19 "sync/atomic"
20 "testing"
21 "time"
22
23 "github.com/carlosframework/platform/internal/console"
24 "github.com/carlosframework/platform/internal/domains"
25 "github.com/carlosframework/platform/internal/release"
26)
27
28// buildCarlos builds the real binary, the way TestStatusCLI does, so these
29// tests exercise the actual command line an operator types rather than the
30// functions underneath it.
31//
32// The binary is built once per test process (sharedCarlos), not once per
33// test: linking it takes seconds and about 135 tests wanted it.
34func buildCarlos(t *testing.T) string {
35 t.Helper()
36 return sharedCarlos(t)
37}
38
39// The shared binary's state. moduleRoot, carlosBuildEnv and carlosTmpParent
40// are set by TestMain, after isolateTempRoot and before any test can
41// t.Chdir or t.Setenv, because the build has to run in the module, with the
42// real HOME and Go cache and the isolated temp root, whichever test asks
43// first. carlosDir is made by the build itself, in carlosTmpParent, so a run
44// that never asks for the binary makes nothing (a re-executed test binary,
45// like the child of TestQueryCleansUpWhenStdoutIsClosed, runs TestMain too
46// and exits without cleanup). TestMain removes it when the tests return.
47var (
48 moduleRoot string
49 carlosBuildEnv []string
50 carlosTmpParent string
51 carlosDir string
52
53 carlosBuilds atomic.Int32
54 sharedCarlosState carlosShared
55)
56
57// carlosShared is the one build for the whole test process: one Once, one
58// path, one error. The external test package (package main_test) reaches it
59// through export_test.go, so there is no second copy to disagree with.
60type carlosShared struct {
61 once sync.Once
62 path string
63 err error
64}
65
66func (s *carlosShared) get(build func() (string, error)) (string, error) {
67 s.once.Do(func() { s.path, s.err = build() })
68 return s.path, s.err
69}
70
71// sharedCarlos returns the path of the carlos binary, built from the module
72// on the first call and reused by every later one in this test process. The
73// build is lazy so a run that never needs the binary never pays for it.
74//
75// The file is read-only by contract: it is shared by every test, so a test
76// that writes to it, replaces it or moves it breaks the ones that run after.
77// It is mode 0555 to make that fail loudly. A test that needs a binary of
78// its own to modify or swap must copy it to its own directory first. A build
79// failure is kept, and fails every caller with the compiler's output.
80func sharedCarlos(t testing.TB) string {
81 t.Helper()
82 goBin, err := exec.LookPath("go")
83 if err != nil {
84 t.Skip("go toolchain not found; skipping CLI subprocess test")
85 }
86 path, err := sharedCarlosState.get(func() (string, error) { return buildSharedCarlos(goBin) })
87 if err != nil {
88 t.Fatalf("build carlos: %v", err)
89 }
90 return path
91}
92
93// buildSharedCarlos is the build itself, run once. The directory goes under
94// GOTMPDIR when that is set, as t.TempDir() does, so the 120 MB file lands
95// wherever the tests' other scratch does.
96func buildSharedCarlos(goBin string) (string, error) {
97 carlosBuilds.Add(1)
98 dir, err := os.MkdirTemp(carlosTmpParent, "carlos-bin-")
99 if err != nil {
100 return "", fmt.Errorf("no directory for the shared binary: %w", err)
101 }
102 carlosDir = dir
103 bin := filepath.Join(dir, "carlos")
104 build := exec.Command(goBin, "build", "-o", bin, ".")
105 build.Dir = moduleRoot
106 build.Env = carlosBuildEnv
107 if out, err := build.CombinedOutput(); err != nil {
108 return "", fmt.Errorf("%v\n%s", err, out)
109 }
110 if err := os.Chmod(bin, 0o555); err != nil {
111 return "", err
112 }
113 return bin, nil
114}
115
116// TestAuthCLI drives `carlos auth` as a subprocess against a fake console:
117// login stores a credential the file system can be checked for, whoami reads
118// it back, and logout revokes it.
119func TestAuthCLI(t *testing.T) {
120 bin := buildCarlos(t)
121 fc := newFakeConsole(t)
122 home := t.TempDir()
123 creds := filepath.Join(home, ".carlos", "credentials")
124 // carlosTestEnv, never os.Environ(): openStore prefers an inherited
125 // CARLOS_DEPLOYMENT_BUCKET over any dir store, so blank it (and the AWS
126 // profile) so no child can reach a real bucket.
127 env := carlosTestEnv(t,
128 "CARLOS_DEPLOYMENT_BUCKET=",
129 "AWS_PROFILE=",
130 "CARLOS_CREDENTIALS="+creds,
131 "HOME="+home,
132 )
133
134 run := func(args ...string) (string, error) {
135 cmd := exec.Command(bin, args...)
136 cmd.Env = env
137 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
138 cmd.Dir = home
139 out, err := cmd.CombinedOutput()
140 return string(out), err
141 }
142
143 // A login with no console anywhere now lands on the BUILT-IN default —
144 // the production flagship — which this test must never talk to. Prove
145 // the default chain is consulted without crossing the network: a config
146 // default in the scratch home points at the fake console, and login
147 // with no -console follows it.
148 if err := os.MkdirAll(filepath.Join(home, ".carlos"), 0o755); err != nil {
149 t.Fatal(err)
150 }
151 if err := os.WriteFile(filepath.Join(home, ".carlos", "config"),
152 []byte(`{"console": "`+fc.ts.URL+`"}`+"\n"), 0o644); err != nil {
153 t.Fatal(err)
154 }
155 fc.approved.Store(true)
156 out, err := run("auth", "login", "-label", "e2e@box")
157 if err != nil {
158 t.Fatalf("carlos auth login via config default: %v\n%s", err, out)
159 }
160 if !strings.Contains(out, fc.ts.URL) {
161 t.Fatalf("login did not follow the config default to the fake console:\n%s", out)
162 }
163 out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box")
164 if err != nil {
165 t.Fatalf("carlos auth login: %v\n%s", err, out)
166 }
167 if !strings.Contains(out, "WXYZ-4823") || !strings.Contains(out, "paul@keymail.dev") {
168 t.Fatalf("login output:\n%s", out)
169 }
170 if strings.Contains(out, "cli-token") {
171 t.Fatalf("login printed the bearer token:\n%s", out)
172 }
173 fi, err := os.Stat(creds)
174 if err != nil {
175 t.Fatalf("credentials file: %v", err)
176 }
177 if fi.Mode().Perm() != 0o600 {
178 t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm())
179 }
180
181 // whoami with no -console picks the only credential there is.
182 out, err = run("auth", "whoami")
183 if err != nil {
184 t.Fatalf("carlos auth whoami: %v\n%s", err, out)
185 }
186 if !strings.Contains(out, "paul@keymail.dev") {
187 t.Fatalf("whoami output:\n%s", out)
188 }
189
190 out, err = run("auth", "logout")
191 if err != nil {
192 t.Fatalf("carlos auth logout: %v\n%s", err, out)
193 }
194 if !fc.revoked.Load() {
195 t.Fatal("logout did not revoke at the console")
196 }
197 if out, err := run("auth", "whoami"); err == nil {
198 t.Fatalf("whoami after logout exited 0:\n%s", out)
199 }
200}
201
202// TestAccountsCreateNeedsAnOwner is the regression this whole feature exists
203// for: an account with no owner is one nobody can open in the console, so the
204// CLI refuses to mint one and names both ways to fix it.
205func TestAccountsCreateNeedsAnOwner(t *testing.T) {
206 bin := buildCarlos(t)
207 tmp := t.TempDir()
208 env := carlosTestEnv(t,
209 "CARLOS_DEPLOYMENT_BUCKET=",
210 "AWS_PROFILE=",
211 "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"),
212 "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
213 "CARLOS_DATA_DIR="+tmp,
214 "HOME="+tmp,
215 )
216 cmd := exec.Command(bin, "accounts", "create", "-name", "orphan")
217 cmd.Env = env
218 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
219 cmd.Dir = tmp
220 out, err := cmd.CombinedOutput()
221 if err == nil {
222 t.Fatalf("ownerless accounts create exited 0:\n%s", out)
223 }
224 for _, want := range []string{"carlos auth login", "-owner"} {
225 if !strings.Contains(string(out), want) {
226 t.Fatalf("refusal does not name %q:\n%s", want, out)
227 }
228 }
229 // Nothing was minted: a refusal must not leave a half-made account behind.
230 if keys, err := release.NewDirStore(filepath.Join(tmp, "store")).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 {
231 t.Fatalf("a refused create left %v behind (%v)", keys, err)
232 }
233}
234
235// A non-slug name is refused before anything is minted — this is the third
236// (and only bucket-writing) door, so a shape mistake here must not reach
237// domains.MintAccount at all. -owner is supplied so the refusal is
238// unambiguously about the NAME, not the ownerless-account path above.
239func TestAccountsCreateRefusesANonSlugName(t *testing.T) {
240 bin := buildCarlos(t)
241 tmp := t.TempDir()
242 storeDir := filepath.Join(tmp, "store")
243 env := carlosTestEnv(t,
244 "CARLOS_DEPLOYMENT_DIR="+storeDir,
245 "CARLOS_DATA_DIR="+tmp,
246 )
247 cmd := exec.Command(bin, "accounts", "create", "-name", "Paul's Stuff!", "-owner", "paul@keymail.dev")
248 cmd.Env = env
249 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
250 cmd.Dir = tmp
251 out, err := cmd.CombinedOutput()
252 if err == nil {
253 t.Fatalf("non-slug accounts create exited 0:\n%s", out)
254 }
255 if !strings.Contains(string(out), "not a usable account name") {
256 t.Fatalf("refusal does not name the shape rule:\n%s", out)
257 }
258 // Nothing was minted: a rejected name must not burn an id or leave a
259 // half-made account in the bucket.
260 if keys, err := release.NewDirStore(storeDir).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 {
261 t.Fatalf("a refused create left %v behind (%v)", keys, err)
262 }
263}
264
265// The headless path: -owner names the owner outright, no login, no browser,
266// and the address lands in the manifest every box materializes identity from.
267func TestAccountsCreateWithOwnerFlag(t *testing.T) {
268 bin := buildCarlos(t)
269 tmp := t.TempDir()
270 storeDir := filepath.Join(tmp, "store")
271 cmd := exec.Command(bin, "accounts", "create", "-name", "kass", "-owner", "paul@keymail.dev")
272 cmd.Env = carlosTestEnv(t,
273 "CARLOS_DEPLOYMENT_BUCKET=",
274 "AWS_PROFILE=",
275 "CARLOS_DEPLOYMENT_DIR="+storeDir,
276 "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
277 "CARLOS_DATA_DIR="+tmp,
278 "HOME="+tmp,
279 )
280 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
281 cmd.Dir = tmp
282 out, err := cmd.CombinedOutput()
283 if err != nil {
284 t.Fatalf("carlos accounts create --owner: %v\n%s", err, out)
285 }
286 if !strings.Contains(string(out), "paul@keymail.dev") {
287 t.Fatalf("create output does not name the owner:\n%s", out)
288 }
289 store := release.NewDirStore(storeDir)
290 keys, err := store.List(t.Context(), "accounts/")
291 if err != nil || len(keys) != 1 {
292 t.Fatalf("account manifests = %v (%v), want exactly one", keys, err)
293 }
294 body, _, err := store.Get(t.Context(), keys[0])
295 if err != nil {
296 t.Fatal(err)
297 }
298 var m domains.AccountManifest
299 if err := json.Unmarshal(body, &m); err != nil {
300 t.Fatal(err)
301 }
302 if m.Owner != "paul@keymail.dev" || m.Name != "kass" {
303 t.Fatalf("manifest = %+v, want kass owned by paul", m)
304 }
305}
306
307// The headless path mints through the same allocator as the console, so it
308// steps over a reserved sqid too (#285): with 1167 the highest account in the
309// bucket, the next account is 1169 ("dew"), never 1168 ("dev").
310func TestAccountsCreateSkipsAReservedSqid(t *testing.T) {
311 bin := buildCarlos(t)
312 tmp := t.TempDir()
313 storeDir := filepath.Join(tmp, "store")
314 store := release.NewDirStore(storeDir)
315 if _, err := store.Put(t.Context(), domains.AccountManifestKey(domains.Sqid(1167)),
316 []byte(`{"id":1167,"sqid":"`+domains.Sqid(1167)+`","name":"neighbour","created_at":"2026-08-02T09:00:00Z"}`), ""); err != nil {
317 t.Fatal(err)
318 }
319 cmd := exec.Command(bin, "accounts", "create", "-name", "shop", "-owner", "owner@example.com")
320 // openStore prefers CARLOS_DEPLOYMENT_BUCKET over CARLOS_DEPLOYMENT_DIR, so
321 // an inherited bucket (plus AWS credentials) would mint a real account.
322 // Blank both, so the child can only reach the dir store.
323 cmd.Env = carlosTestEnv(t,
324 "CARLOS_DEPLOYMENT_BUCKET=",
325 "AWS_PROFILE=",
326 "CARLOS_DEPLOYMENT_DIR="+storeDir,
327 "CARLOS_DATA_DIR="+tmp,
328 )
329 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
330 cmd.Dir = tmp
331 out, err := cmd.CombinedOutput()
332 if err != nil {
333 t.Fatalf("carlos accounts create --owner: %v\n%s", err, out)
334 }
335 if _, _, err := store.Get(t.Context(), domains.AccountManifestKey("dev")); err == nil {
336 t.Fatalf("accounts create minted the reserved sqid dev:\n%s", out)
337 }
338 body, _, err := store.Get(t.Context(), domains.AccountManifestKey("dew"))
339 if err != nil {
340 t.Fatalf("no manifest at accounts/dew/ (%v):\n%s", err, out)
341 }
342 var m domains.AccountManifest
343 if err := json.Unmarshal(body, &m); err != nil || m.ID != 1169 || m.Name != "shop" {
344 t.Fatalf("manifest = %+v (%v), want shop at id 1169", m, err)
345 }
346}
347
348// A padded name is trimmed BEFORE it is validated or minted — the gate and
349// the write must agree on what "the name" is. Before this fix, -name "
350// kass " passed ValidAccountName on the trimmed copy but handed the
351// untrimmed original to MintAccount, landing padding in the manifest
352// through the one edge that writes straight to the bucket.
353func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) {
354 bin := buildCarlos(t)
355 tmp := t.TempDir()
356 storeDir := filepath.Join(tmp, "store")
357 cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev")
358 cmd.Env = carlosTestEnv(t,
359 "CARLOS_DEPLOYMENT_BUCKET=",
360 "AWS_PROFILE=",
361 "CARLOS_DEPLOYMENT_DIR="+storeDir,
362 "CARLOS_DATA_DIR="+tmp,
363 )
364 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
365 cmd.Dir = tmp
366 out, err := cmd.CombinedOutput()
367 if err != nil {
368 t.Fatalf("carlos accounts create --name ' kass ': %v\n%s", err, out)
369 }
370 store := release.NewDirStore(storeDir)
371 keys, err := store.List(t.Context(), "accounts/")
372 if err != nil || len(keys) != 1 {
373 t.Fatalf("account manifests = %v (%v), want exactly one", keys, err)
374 }
375 body, _, err := store.Get(t.Context(), keys[0])
376 if err != nil {
377 t.Fatal(err)
378 }
379 var m domains.AccountManifest
380 if err := json.Unmarshal(body, &m); err != nil {
381 t.Fatal(err)
382 }
383 if m.Name != "kass" {
384 t.Fatalf("manifest name = %q, want the trimmed %q", m.Name, "kass")
385 }
386}
387
388// Logged in, no -owner: the account is created THROUGH the console, so the
389// person at the terminal is its owner from the first instant.
390func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
391 bin := buildCarlos(t)
392 fc := newFakeConsole(t)
393 tmp := t.TempDir()
394 creds := filepath.Join(tmp, "credentials")
395 f := &credentialFile{Version: 1, Credentials: []credential{{
396 Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
397 CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
398 }}}
399 raw, err := json.Marshal(f)
400 if err != nil {
401 t.Fatal(err)
402 }
403 if err := os.WriteFile(creds, raw, 0o600); err != nil {
404 t.Fatal(err)
405 }
406 cmd := exec.Command(bin, "accounts", "create", "-name", "kass")
407 cmd.Env = carlosTestEnv(t,
408 // No deployment store of any kind: the console owns the bucket write
409 // on this path, and a laptop has none configured. Blanked rather than
410 // left out, so an inherited bucket (or dir) can never be reached.
411 "CARLOS_DEPLOYMENT_BUCKET=",
412 "CARLOS_DEPLOYMENT_DIR=",
413 "AWS_PROFILE=",
414 "CARLOS_CREDENTIALS="+creds,
415 "CARLOS_DATA_DIR="+tmp,
416 "HOME="+tmp,
417 )
418 // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
419 cmd.Dir = tmp
420 out, err := cmd.CombinedOutput()
421 if err != nil {
422 t.Fatalf("carlos accounts create (logged in): %v\n%s", err, out)
423 }
424 for _, want := range []string{"bag", "paul@keymail.dev", "(you)"} {
425 if !strings.Contains(string(out), want) {
426 t.Fatalf("create output missing %q:\n%s", want, out)
427 }
428 }
429}
430
431// ---- Task 6: the whole chain, end to end -----------------------------------
432
433// e2ePostJSON is a bare, unauthenticated JSON POST — the shape authLogin's
434// own start/poll calls make (auth.go).
435func e2ePostJSON(t *testing.T, u string, body any) map[string]any {
436 t.Helper()
437 raw, err := json.Marshal(body)
438 if err != nil {
439 t.Fatal(err)
440 }
441 req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
442 if err != nil {
443 t.Fatal(err)
444 }
445 req.Header.Set("Content-Type", "application/json")
446 resp, err := http.DefaultClient.Do(req)
447 if err != nil {
448 t.Fatal(err)
449 }
450 defer resp.Body.Close()
451 var out map[string]any
452 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
453 t.Fatal(err)
454 }
455 return out
456}
457
458// e2eDeviceLogin drives `carlos auth login`'s three HTTP legs — POST
459// /auth/cli/start, POST /auth/cli/approve, POST /auth/cli/poll — against a
460// REAL console.Server, the exact wire protocol authLogin (auth.go) speaks.
461//
462// The one leg this does NOT reproduce is the human's own trip through
463// Keymail: internal/console/authcli_test.go's TestCLILoginEndToEnd already
464// drives that ceremony end to end (a fake Keymail, PKCE, the passkey
465// step-up redirect) inside package console, so redoing it here would only
466// be the same assertion through a slower path — this test's job is what
467// happens AFTER a token exists, not the ceremony that mints one. So this
468// mints an already-signed-in, already-stepped-up session straight through
469// the Store's own exported primitives — CreateSession + RecordStepUp are
470// exactly what a completed Keymail round trip leaves behind — and then
471// drives approve/poll for real, over real HTTP, against the real handlers.
472func e2eDeviceLogin(t *testing.T, consoleURL string, cst *console.Store, addr, label string) (token string, expiresAt time.Time) {
473 t.Helper()
474 start := e2ePostJSON(t, consoleURL+"/auth/cli/start", map[string]string{"label": label})
475 userCode, _ := start["user_code"].(string)
476 deviceCode, _ := start["device_code"].(string)
477 if userCode == "" || deviceCode == "" {
478 t.Fatalf("auth/cli/start = %v, want both codes", start)
479 }
480
481 now := time.Now()
482 raw, err := cst.CreateSession(addr, "", now)
483 if err != nil {
484 t.Fatalf("mint session: %v", err)
485 }
486 if err := cst.RecordStepUp(raw, addr, now, now); err != nil {
487 t.Fatalf("record step-up: %v", err)
488 }
489
490 form := url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {raw}}
491 req, err := http.NewRequest(http.MethodPost, consoleURL+"/auth/cli/approve", strings.NewReader(form.Encode()))
492 if err != nil {
493 t.Fatal(err)
494 }
495 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
496 req.Header.Set("Cookie", "carlos_console_session="+raw)
497 resp, err := http.DefaultClient.Do(req)
498 if err != nil {
499 t.Fatalf("approve: %v", err)
500 }
501 body, _ := io.ReadAll(resp.Body)
502 resp.Body.Close()
503 if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Approved") {
504 t.Fatalf("approve = %d:\n%s", resp.StatusCode, body)
505 }
506
507 poll := e2ePostJSON(t, consoleURL+"/auth/cli/poll", map[string]string{"device_code": deviceCode})
508 if poll["status"] != "approved" {
509 t.Fatalf("poll after approval = %v, want approved", poll)
510 }
511 token, _ = poll["token"].(string)
512 if token == "" {
513 t.Fatalf("poll approved with no token: %v", poll)
514 }
515 expiresAt = time.Now().Add(90 * 24 * time.Hour)
516 if es, _ := poll["expires_at"].(string); es != "" {
517 if parsed, err := time.Parse(time.RFC3339, es); err == nil {
518 expiresAt = parsed
519 }
520 }
521 return token, expiresAt
522}
523
524// TestE2ECLIThroughConsole is the acceptance test for the whole feature: a
525// REAL console.Server (a dir store, a real ed25519 signing key — no hand-
526// rolled httptest handlers standing in for it) fronted by an httptest
527// listener, a device-flow login against it landing a real bearer token in a
528// temp credential file, and then — with NO store env set at all, exactly a
529// developer's laptop — every API-mode verb driven through its real cli.go
530// entry point: ship, promote, channels, a refused stable promote, and a
531// rollback.
532func TestE2ECLIThroughConsole(t *testing.T) {
533 useTempCredentials(t)
534 clearDeploymentEnv(t)
535 checkNoRegistry := requireNoRegistryTouch(t)
536
537 tmp := t.TempDir()
538 relStore := release.NewDirStore(filepath.Join(tmp, "store"))
539 seed, pubB64, err := release.GenerateKey()
540 if err != nil {
541 t.Fatal(err)
542 }
543 priv, err := release.ParseSigningKey(seed, "e2e")
544 if err != nil {
545 t.Fatal(err)
546 }
547 pub, err := release.ParsePublicKey(pubB64, "e2e")
548 if err != nil {
549 t.Fatal(err)
550 }
551
552 cst, err := console.Open(filepath.Join(tmp, "console.db"))
553 if err != nil {
554 t.Fatal(err)
555 }
556 t.Cleanup(func() { cst.Close() })
557
558 // The origin must equal the listener's own URL, which only exists after
559 // the listener starts — same indirection internal/console's own fixture
560 // (server_test.go) uses.
561 var srv *console.Server
562 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
563 srv.ServeHTTP(w, r)
564 }))
565 t.Cleanup(ts.Close)
566
567 srv, err = console.NewServer(console.Config{
568 Origin: ts.URL,
569 Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
570 Registry: filepath.Join(tmp, "registry.db"),
571 Releases: relStore,
572 ReleasePub: pub,
573 Key: priv,
574 AppScheme: "http",
575 }, cst)
576 if err != nil {
577 t.Fatal(err)
578 }
579
580 const addr = "paul@keymail.dev"
581 if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
582 t.Fatal(err)
583 }
584 if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
585 t.Fatal(err)
586 }
587
588 // --- device flow: start -> approve -> poll, to a real token on disk ----
589 token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
590 credFile, err := loadCredentials()
591 if err != nil {
592 t.Fatal(err)
593 }
594 credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
595 if err := credFile.save(); err != nil {
596 t.Fatal(err)
597 }
598
599 // --- ship: a 1KiB linux-arm64 binary, through the console --------------
600 dir := t.TempDir()
601 bin := filepath.Join(dir, "carlos")
602 payload := bytes.Repeat([]byte{0x42}, 1024)
603 if err := os.WriteFile(bin, payload, 0o755); err != nil {
604 t.Fatal(err)
605 }
606
607 buf := captureStdout(t)
608 runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin})
609 if !strings.Contains(buf.String(), "shipped titogo abc1234") {
610 t.Fatalf("ship stdout = %q, want the shipped line", buf.String())
611 }
612
613 sum := sha256.Sum256(payload)
614 wantHash := hex.EncodeToString(sum[:])
615 m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo", "abc1234")
616 if err != nil {
617 t.Fatalf("GetManifest after ship: %v", err)
618 }
619 art, ok := m.Artifacts["linux-arm64"]
620 if !ok || art.SHA256 != wantHash || art.Size != int64(len(payload)) {
621 t.Fatalf("shipped manifest artifact = %+v, want sha256 %s size %d", art, wantHash, len(payload))
622 }
623
624 // Identical re-ship: 200, not 201 — the noop-parity line, not the
625 // ordinary "shipped" one.
626 buf.Reset()
627 runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin})
628 if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(buf.String(), want) {
629 t.Fatalf("re-ship stdout = %q, want %q", buf.String(), want)
630 }
631
632 // --- promote to canary/e2e: pointer moves, audit row names the CLI -----
633 buf.Reset()
634 runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"})
635 if want := "promoted titogo abc1234 -> canary/e2e"; !strings.Contains(buf.String(), want) {
636 t.Fatalf("promote stdout = %q, want %q", buf.String(), want)
637 }
638 cp, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
639 if err != nil || cp.Version != "abc1234" {
640 t.Fatalf("channel canary/e2e = %+v %v, want version abc1234", cp, err)
641 }
642 events, err := cst.Events(console.OpsAccountID, "titogo", 10)
643 if err != nil {
644 t.Fatal(err)
645 }
646 foundAudit := false
647 for _, e := range events {
648 if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") {
649 foundAudit = true
650 }
651 }
652 if !foundAudit {
653 t.Fatalf("audit events = %+v, want a promote row naming (cli)", events)
654 }
655
656 // A repeat promote of the same version onto the same channel is a
657 // no-op — the same parity line the direct path prints (Task 5's
658 // noop-parity amendment).
659 buf.Reset()
660 runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"})
661 if want := "titogo abc1234 is already on canary/e2e — nothing to do"; !strings.Contains(buf.String(), want) {
662 t.Fatalf("repeat promote stdout = %q, want %q", buf.String(), want)
663 }
664
665 // --- channels: canary/e2e and its version show up -----------------------
666 buf.Reset()
667 runChannels([]string{"-app", "titogo", "-account", "ops"})
668 if out := buf.String(); !strings.Contains(out, "canary/e2e") || !strings.Contains(out, "abc1234") {
669 t.Fatalf("channels stdout = %q, want canary/e2e and abc1234", out)
670 }
671
672 // --- promote to stable: refused, naming the console; pointer untouched -
673 //
674 // runPromote itself log.Fatals on this error (os.Exit — nothing an
675 // in-process test could recover from), so the refusal is asserted at the
676 // apiSession level instead: the exact call runPromote's API-mode branch
677 // makes, against the same real server, over the same credential.
678 sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
679 if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false, false); err == nil {
680 t.Fatal("promote to stable must refuse")
681 } else {
682 if !strings.Contains(err.Error(), ts.URL) {
683 t.Fatalf("stable refusal %q does not name the console URL", err)
684 }
685 if !strings.Contains(err.Error(), "passkey") {
686 t.Fatalf("stable refusal %q does not carry the console's refusal text", err)
687 }
688 }
689 if _, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "stable"); err == nil {
690 t.Fatal("stable has a pointer — the refused promote must not have created one")
691 }
692
693 // --- a second ship+promote, then roll back ------------------------------
694 //
695 // A SECOND terminal, because this is the test's third ship and the
696 // console's ship budget is cliShipBurst — 2 per token per minute
697 // (internal/console/authcli.go). A third upload on the same token is a
698 // 429, and runShip log.Fatals on it, which takes the whole package down
699 // with no named failure. Logging in again is exactly what a person with
700 // two machines does, and ship's budget is deliberately keyed on the
701 // TOKEN rather than the address so that it works: see
702 // TestPersonShipBudgetStaysKeyedByToken.
703 token2, expires2 := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@second-terminal")
704 credFile.put(credential{Console: ts.URL, Address: addr, Token: token2, ExpiresAt: expires2})
705 if err := credFile.save(); err != nil {
706 t.Fatal(err)
707 }
708
709 payload2 := bytes.Repeat([]byte{0x43}, 1024)
710 bin2 := filepath.Join(dir, "carlos-def5678")
711 if err := os.WriteFile(bin2, payload2, 0o755); err != nil {
712 t.Fatal(err)
713 }
714 buf.Reset()
715 runShip([]string{"-app", "titogo", "-account", "ops", "-version", "def5678", "-target", "linux-arm64", bin2})
716 runPromote([]string{"-app", "titogo", "-account", "ops", "def5678", "canary/e2e"})
717 cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
718 if err != nil || cp.Version != "def5678" {
719 t.Fatalf("channel canary/e2e after second promote = %+v %v, want def5678", cp, err)
720 }
721
722 buf.Reset()
723 runRollback([]string{"-app", "titogo", "-account", "ops", "canary/e2e"})
724 if want := "rolled back titogo canary/e2e"; !strings.Contains(buf.String(), want) {
725 t.Fatalf("rollback stdout = %q, want %q", buf.String(), want)
726 }
727 cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
728 if err != nil || cp.Version != "abc1234" {
729 t.Fatalf("channel canary/e2e after rollback = %+v %v, want back to abc1234", cp, err)
730 }
731
732 checkNoRegistry()
733}
734