package main import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/release" ) // buildCarlos builds the real binary, the way TestStatusCLI does, so these // tests exercise the actual command line an operator types rather than the // functions underneath it. // // The binary is built once per test process (sharedCarlos), not once per // test: linking it takes seconds and about 135 tests wanted it. func buildCarlos(t *testing.T) string { t.Helper() return sharedCarlos(t) } // The shared binary's state. moduleRoot, carlosBuildEnv and carlosTmpParent // are set by TestMain, after isolateTempRoot and before any test can // t.Chdir or t.Setenv, because the build has to run in the module, with the // real HOME and Go cache and the isolated temp root, whichever test asks // first. carlosDir is made by the build itself, in carlosTmpParent, so a run // that never asks for the binary makes nothing (a re-executed test binary, // like the child of TestQueryCleansUpWhenStdoutIsClosed, runs TestMain too // and exits without cleanup). TestMain removes it when the tests return. var ( moduleRoot string carlosBuildEnv []string carlosTmpParent string carlosDir string carlosBuilds atomic.Int32 sharedCarlosState carlosShared ) // carlosShared is the one build for the whole test process: one Once, one // path, one error. The external test package (package main_test) reaches it // through export_test.go, so there is no second copy to disagree with. type carlosShared struct { once sync.Once path string err error } func (s *carlosShared) get(build func() (string, error)) (string, error) { s.once.Do(func() { s.path, s.err = build() }) return s.path, s.err } // sharedCarlos returns the path of the carlos binary, built from the module // on the first call and reused by every later one in this test process. The // build is lazy so a run that never needs the binary never pays for it. // // The file is read-only by contract: it is shared by every test, so a test // that writes to it, replaces it or moves it breaks the ones that run after. // It is mode 0555 to make that fail loudly. A test that needs a binary of // its own to modify or swap must copy it to its own directory first. A build // failure is kept, and fails every caller with the compiler's output. func sharedCarlos(t testing.TB) string { t.Helper() goBin, err := exec.LookPath("go") if err != nil { t.Skip("go toolchain not found; skipping CLI subprocess test") } path, err := sharedCarlosState.get(func() (string, error) { return buildSharedCarlos(goBin) }) if err != nil { t.Fatalf("build carlos: %v", err) } return path } // buildSharedCarlos is the build itself, run once. The directory goes under // GOTMPDIR when that is set, as t.TempDir() does, so the 120 MB file lands // wherever the tests' other scratch does. func buildSharedCarlos(goBin string) (string, error) { carlosBuilds.Add(1) dir, err := os.MkdirTemp(carlosTmpParent, "carlos-bin-") if err != nil { return "", fmt.Errorf("no directory for the shared binary: %w", err) } carlosDir = dir bin := filepath.Join(dir, "carlos") build := exec.Command(goBin, "build", "-o", bin, ".") build.Dir = moduleRoot build.Env = carlosBuildEnv if out, err := build.CombinedOutput(); err != nil { return "", fmt.Errorf("%v\n%s", err, out) } if err := os.Chmod(bin, 0o555); err != nil { return "", err } return bin, nil } // TestAuthCLI drives `carlos auth` as a subprocess against a fake console: // login stores a credential the file system can be checked for, whoami reads // it back, and logout revokes it. func TestAuthCLI(t *testing.T) { bin := buildCarlos(t) fc := newFakeConsole(t) home := t.TempDir() creds := filepath.Join(home, ".carlos", "credentials") // carlosTestEnv, never os.Environ(): openStore prefers an inherited // CARLOS_DEPLOYMENT_BUCKET over any dir store, so blank it (and the AWS // profile) so no child can reach a real bucket. env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "AWS_PROFILE=", "CARLOS_CREDENTIALS="+creds, "HOME="+home, ) run := func(args ...string) (string, error) { cmd := exec.Command(bin, args...) cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = home out, err := cmd.CombinedOutput() return string(out), err } // A login with no console anywhere now lands on the BUILT-IN default — // the production flagship — which this test must never talk to. Prove // the default chain is consulted without crossing the network: a config // default in the scratch home points at the fake console, and login // with no -console follows it. if err := os.MkdirAll(filepath.Join(home, ".carlos"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(home, ".carlos", "config"), []byte(`{"console": "`+fc.ts.URL+`"}`+"\n"), 0o644); err != nil { t.Fatal(err) } fc.approved.Store(true) out, err := run("auth", "login", "-label", "e2e@box") if err != nil { t.Fatalf("carlos auth login via config default: %v\n%s", err, out) } if !strings.Contains(out, fc.ts.URL) { t.Fatalf("login did not follow the config default to the fake console:\n%s", out) } out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box") if err != nil { t.Fatalf("carlos auth login: %v\n%s", err, out) } if !strings.Contains(out, "WXYZ-4823") || !strings.Contains(out, "paul@keymail.dev") { t.Fatalf("login output:\n%s", out) } if strings.Contains(out, "cli-token") { t.Fatalf("login printed the bearer token:\n%s", out) } fi, err := os.Stat(creds) if err != nil { t.Fatalf("credentials file: %v", err) } if fi.Mode().Perm() != 0o600 { t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm()) } // whoami with no -console picks the only credential there is. out, err = run("auth", "whoami") if err != nil { t.Fatalf("carlos auth whoami: %v\n%s", err, out) } if !strings.Contains(out, "paul@keymail.dev") { t.Fatalf("whoami output:\n%s", out) } out, err = run("auth", "logout") if err != nil { t.Fatalf("carlos auth logout: %v\n%s", err, out) } if !fc.revoked.Load() { t.Fatal("logout did not revoke at the console") } if out, err := run("auth", "whoami"); err == nil { t.Fatalf("whoami after logout exited 0:\n%s", out) } } // TestAccountsCreateNeedsAnOwner is the regression this whole feature exists // for: an account with no owner is one nobody can open in the console, so the // CLI refuses to mint one and names both ways to fix it. func TestAccountsCreateNeedsAnOwner(t *testing.T) { bin := buildCarlos(t) tmp := t.TempDir() env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "AWS_PROFILE=", "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"), "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), "CARLOS_DATA_DIR="+tmp, "HOME="+tmp, ) cmd := exec.Command(bin, "accounts", "create", "-name", "orphan") cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("ownerless accounts create exited 0:\n%s", out) } for _, want := range []string{"carlos auth login", "-owner"} { if !strings.Contains(string(out), want) { t.Fatalf("refusal does not name %q:\n%s", want, out) } } // Nothing was minted: a refusal must not leave a half-made account behind. if keys, err := release.NewDirStore(filepath.Join(tmp, "store")).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 { t.Fatalf("a refused create left %v behind (%v)", keys, err) } } // A non-slug name is refused before anything is minted — this is the third // (and only bucket-writing) door, so a shape mistake here must not reach // domains.MintAccount at all. -owner is supplied so the refusal is // unambiguously about the NAME, not the ownerless-account path above. func TestAccountsCreateRefusesANonSlugName(t *testing.T) { bin := buildCarlos(t) tmp := t.TempDir() storeDir := filepath.Join(tmp, "store") env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_DATA_DIR="+tmp, ) cmd := exec.Command(bin, "accounts", "create", "-name", "Paul's Stuff!", "-owner", "paul@keymail.dev") cmd.Env = env // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("non-slug accounts create exited 0:\n%s", out) } if !strings.Contains(string(out), "not a usable account name") { t.Fatalf("refusal does not name the shape rule:\n%s", out) } // Nothing was minted: a rejected name must not burn an id or leave a // half-made account in the bucket. if keys, err := release.NewDirStore(storeDir).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 { t.Fatalf("a refused create left %v behind (%v)", keys, err) } } // The headless path: -owner names the owner outright, no login, no browser, // and the address lands in the manifest every box materializes identity from. func TestAccountsCreateWithOwnerFlag(t *testing.T) { bin := buildCarlos(t) tmp := t.TempDir() storeDir := filepath.Join(tmp, "store") cmd := exec.Command(bin, "accounts", "create", "-name", "kass", "-owner", "paul@keymail.dev") cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "AWS_PROFILE=", "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), "CARLOS_DATA_DIR="+tmp, "HOME="+tmp, ) // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos accounts create --owner: %v\n%s", err, out) } if !strings.Contains(string(out), "paul@keymail.dev") { t.Fatalf("create output does not name the owner:\n%s", out) } store := release.NewDirStore(storeDir) keys, err := store.List(t.Context(), "accounts/") if err != nil || len(keys) != 1 { t.Fatalf("account manifests = %v (%v), want exactly one", keys, err) } body, _, err := store.Get(t.Context(), keys[0]) if err != nil { t.Fatal(err) } var m domains.AccountManifest if err := json.Unmarshal(body, &m); err != nil { t.Fatal(err) } if m.Owner != "paul@keymail.dev" || m.Name != "kass" { t.Fatalf("manifest = %+v, want kass owned by paul", m) } } // The headless path mints through the same allocator as the console, so it // steps over a reserved sqid too (#285): with 1167 the highest account in the // bucket, the next account is 1169 ("dew"), never 1168 ("dev"). func TestAccountsCreateSkipsAReservedSqid(t *testing.T) { bin := buildCarlos(t) tmp := t.TempDir() storeDir := filepath.Join(tmp, "store") store := release.NewDirStore(storeDir) if _, err := store.Put(t.Context(), domains.AccountManifestKey(domains.Sqid(1167)), []byte(`{"id":1167,"sqid":"`+domains.Sqid(1167)+`","name":"neighbour","created_at":"2026-08-02T09:00:00Z"}`), ""); err != nil { t.Fatal(err) } cmd := exec.Command(bin, "accounts", "create", "-name", "shop", "-owner", "owner@example.com") // openStore prefers CARLOS_DEPLOYMENT_BUCKET over CARLOS_DEPLOYMENT_DIR, so // an inherited bucket (plus AWS credentials) would mint a real account. // Blank both, so the child can only reach the dir store. cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "AWS_PROFILE=", "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_DATA_DIR="+tmp, ) // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos accounts create --owner: %v\n%s", err, out) } if _, _, err := store.Get(t.Context(), domains.AccountManifestKey("dev")); err == nil { t.Fatalf("accounts create minted the reserved sqid dev:\n%s", out) } body, _, err := store.Get(t.Context(), domains.AccountManifestKey("dew")) if err != nil { t.Fatalf("no manifest at accounts/dew/ (%v):\n%s", err, out) } var m domains.AccountManifest if err := json.Unmarshal(body, &m); err != nil || m.ID != 1169 || m.Name != "shop" { t.Fatalf("manifest = %+v (%v), want shop at id 1169", m, err) } } // A padded name is trimmed BEFORE it is validated or minted — the gate and // the write must agree on what "the name" is. Before this fix, -name " // kass " passed ValidAccountName on the trimmed copy but handed the // untrimmed original to MintAccount, landing padding in the manifest // through the one edge that writes straight to the bucket. func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) { bin := buildCarlos(t) tmp := t.TempDir() storeDir := filepath.Join(tmp, "store") cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev") cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "AWS_PROFILE=", "CARLOS_DEPLOYMENT_DIR="+storeDir, "CARLOS_DATA_DIR="+tmp, ) // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos accounts create --name ' kass ': %v\n%s", err, out) } store := release.NewDirStore(storeDir) keys, err := store.List(t.Context(), "accounts/") if err != nil || len(keys) != 1 { t.Fatalf("account manifests = %v (%v), want exactly one", keys, err) } body, _, err := store.Get(t.Context(), keys[0]) if err != nil { t.Fatal(err) } var m domains.AccountManifest if err := json.Unmarshal(body, &m); err != nil { t.Fatal(err) } if m.Name != "kass" { t.Fatalf("manifest name = %q, want the trimmed %q", m.Name, "kass") } } // Logged in, no -owner: the account is created THROUGH the console, so the // person at the terminal is its owner from the first instant. func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) { bin := buildCarlos(t) fc := newFakeConsole(t) tmp := t.TempDir() creds := filepath.Join(tmp, "credentials") f := &credentialFile{Version: 1, Credentials: []credential{{ Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(), }}} raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } if err := os.WriteFile(creds, raw, 0o600); err != nil { t.Fatal(err) } cmd := exec.Command(bin, "accounts", "create", "-name", "kass") cmd.Env = carlosTestEnv(t, // No deployment store of any kind: the console owns the bucket write // on this path, and a laptop has none configured. Blanked rather than // left out, so an inherited bucket (or dir) can never be reached. "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=", "AWS_PROFILE=", "CARLOS_CREDENTIALS="+creds, "CARLOS_DATA_DIR="+tmp, "HOME="+tmp, ) // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. cmd.Dir = tmp out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("carlos accounts create (logged in): %v\n%s", err, out) } for _, want := range []string{"bag", "paul@keymail.dev", "(you)"} { if !strings.Contains(string(out), want) { t.Fatalf("create output missing %q:\n%s", want, out) } } } // ---- Task 6: the whole chain, end to end ----------------------------------- // e2ePostJSON is a bare, unauthenticated JSON POST — the shape authLogin's // own start/poll calls make (auth.go). func e2ePostJSON(t *testing.T, u string, body any) map[string]any { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out map[string]any if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } return out } // e2eDeviceLogin drives `carlos auth login`'s three HTTP legs — POST // /auth/cli/start, POST /auth/cli/approve, POST /auth/cli/poll — against a // REAL console.Server, the exact wire protocol authLogin (auth.go) speaks. // // The one leg this does NOT reproduce is the human's own trip through // Keymail: internal/console/authcli_test.go's TestCLILoginEndToEnd already // drives that ceremony end to end (a fake Keymail, PKCE, the passkey // step-up redirect) inside package console, so redoing it here would only // be the same assertion through a slower path — this test's job is what // happens AFTER a token exists, not the ceremony that mints one. So this // mints an already-signed-in, already-stepped-up session straight through // the Store's own exported primitives — CreateSession + RecordStepUp are // exactly what a completed Keymail round trip leaves behind — and then // drives approve/poll for real, over real HTTP, against the real handlers. func e2eDeviceLogin(t *testing.T, consoleURL string, cst *console.Store, addr, label string) (token string, expiresAt time.Time) { t.Helper() start := e2ePostJSON(t, consoleURL+"/auth/cli/start", map[string]string{"label": label}) userCode, _ := start["user_code"].(string) deviceCode, _ := start["device_code"].(string) if userCode == "" || deviceCode == "" { t.Fatalf("auth/cli/start = %v, want both codes", start) } now := time.Now() raw, err := cst.CreateSession(addr, "", now) if err != nil { t.Fatalf("mint session: %v", err) } if err := cst.RecordStepUp(raw, addr, now, now); err != nil { t.Fatalf("record step-up: %v", err) } form := url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {raw}} req, err := http.NewRequest(http.MethodPost, consoleURL+"/auth/cli/approve", strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", "carlos_console_session="+raw) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("approve: %v", err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Approved") { t.Fatalf("approve = %d:\n%s", resp.StatusCode, body) } poll := e2ePostJSON(t, consoleURL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}) if poll["status"] != "approved" { t.Fatalf("poll after approval = %v, want approved", poll) } token, _ = poll["token"].(string) if token == "" { t.Fatalf("poll approved with no token: %v", poll) } expiresAt = time.Now().Add(90 * 24 * time.Hour) if es, _ := poll["expires_at"].(string); es != "" { if parsed, err := time.Parse(time.RFC3339, es); err == nil { expiresAt = parsed } } return token, expiresAt } // TestE2ECLIThroughConsole is the acceptance test for the whole feature: a // REAL console.Server (a dir store, a real ed25519 signing key — no hand- // rolled httptest handlers standing in for it) fronted by an httptest // listener, a device-flow login against it landing a real bearer token in a // temp credential file, and then — with NO store env set at all, exactly a // developer's laptop — every API-mode verb driven through its real cli.go // entry point: ship, promote, channels, a refused stable promote, and a // rollback. func TestE2ECLIThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) 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() }) // The origin must equal the listener's own URL, which only exists after // the listener starts — same indirection internal/console's own fixture // (server_test.go) uses. 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", }, 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, "titogo", 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) } // --- ship: a 1KiB linux-arm64 binary, through the console -------------- dir := t.TempDir() bin := filepath.Join(dir, "carlos") payload := bytes.Repeat([]byte{0x42}, 1024) if err := os.WriteFile(bin, payload, 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin}) if !strings.Contains(buf.String(), "shipped titogo abc1234") { t.Fatalf("ship stdout = %q, want the shipped line", buf.String()) } sum := sha256.Sum256(payload) wantHash := hex.EncodeToString(sum[:]) m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo", "abc1234") if err != nil { t.Fatalf("GetManifest after ship: %v", err) } art, ok := m.Artifacts["linux-arm64"] if !ok || art.SHA256 != wantHash || art.Size != int64(len(payload)) { t.Fatalf("shipped manifest artifact = %+v, want sha256 %s size %d", art, wantHash, len(payload)) } // Identical re-ship: 200, not 201 — the noop-parity line, not the // ordinary "shipped" one. buf.Reset() runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin}) if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(buf.String(), want) { t.Fatalf("re-ship stdout = %q, want %q", buf.String(), want) } // --- promote to canary/e2e: pointer moves, audit row names the CLI ----- buf.Reset() runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"}) if want := "promoted titogo abc1234 -> canary/e2e"; !strings.Contains(buf.String(), want) { t.Fatalf("promote stdout = %q, want %q", buf.String(), want) } cp, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") if err != nil || cp.Version != "abc1234" { t.Fatalf("channel canary/e2e = %+v %v, want version abc1234", cp, err) } events, err := cst.Events(console.OpsAccountID, "titogo", 10) if err != nil { t.Fatal(err) } foundAudit := false for _, e := range events { if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") { foundAudit = true } } if !foundAudit { t.Fatalf("audit events = %+v, want a promote row naming (cli)", events) } // A repeat promote of the same version onto the same channel is a // no-op — the same parity line the direct path prints (Task 5's // noop-parity amendment). buf.Reset() runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"}) if want := "titogo abc1234 is already on canary/e2e — nothing to do"; !strings.Contains(buf.String(), want) { t.Fatalf("repeat promote stdout = %q, want %q", buf.String(), want) } // --- channels: canary/e2e and its version show up ----------------------- buf.Reset() runChannels([]string{"-app", "titogo", "-account", "ops"}) if out := buf.String(); !strings.Contains(out, "canary/e2e") || !strings.Contains(out, "abc1234") { t.Fatalf("channels stdout = %q, want canary/e2e and abc1234", out) } // --- promote to stable: refused, naming the console; pointer untouched - // // runPromote 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 runPromote's API-mode branch // makes, against the same real server, over the same credential. sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr} if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false, false); err == nil { t.Fatal("promote to stable must refuse") } else { if !strings.Contains(err.Error(), ts.URL) { t.Fatalf("stable refusal %q does not name the console URL", err) } if !strings.Contains(err.Error(), "passkey") { t.Fatalf("stable refusal %q does not carry the console's refusal text", err) } } if _, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "stable"); err == nil { t.Fatal("stable has a pointer — the refused promote must not have created one") } // --- a second ship+promote, then roll back ------------------------------ // // A SECOND terminal, because this is the test's third ship and the // console's ship budget is cliShipBurst — 2 per token per minute // (internal/console/authcli.go). A third upload on the same token is a // 429, and runShip log.Fatals on it, which takes the whole package down // with no named failure. Logging in again is exactly what a person with // two machines does, and ship's budget is deliberately keyed on the // TOKEN rather than the address so that it works: see // TestPersonShipBudgetStaysKeyedByToken. token2, expires2 := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@second-terminal") credFile.put(credential{Console: ts.URL, Address: addr, Token: token2, ExpiresAt: expires2}) if err := credFile.save(); err != nil { t.Fatal(err) } payload2 := bytes.Repeat([]byte{0x43}, 1024) bin2 := filepath.Join(dir, "carlos-def5678") if err := os.WriteFile(bin2, payload2, 0o755); err != nil { t.Fatal(err) } buf.Reset() runShip([]string{"-app", "titogo", "-account", "ops", "-version", "def5678", "-target", "linux-arm64", bin2}) runPromote([]string{"-app", "titogo", "-account", "ops", "def5678", "canary/e2e"}) cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") if err != nil || cp.Version != "def5678" { t.Fatalf("channel canary/e2e after second promote = %+v %v, want def5678", cp, err) } buf.Reset() runRollback([]string{"-app", "titogo", "-account", "ops", "canary/e2e"}) if want := "rolled back titogo canary/e2e"; !strings.Contains(buf.String(), want) { t.Fatalf("rollback stdout = %q, want %q", buf.String(), want) } cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") if err != nil || cp.Version != "abc1234" { t.Fatalf("channel canary/e2e after rollback = %+v %v, want back to abc1234", cp, err) } checkNoRegistry() }