carlos / platform Public

cli+console: e2e — a credential is enough to ship and promote

Task 6: the acceptance test for the CLI-through-console-API feature —
a real console.Server (dir store, real signing key, no hand-rolled
httptest handlers), a device-flow login against it landing a real
bearer token on disk, then ship/promote/channels/rollback driven
through their real cli.go entry points with no store env set at all.
Covers the noop-parity lines and the stable-promote refusal (asserted
at the apiSession level, since runPromote's own error path is
log.Fatal). README gets a short login → ship → promote section for
developers, noting CI/operators keep the env-var direct path.
Paul Campbell 32c4e9b46def0b149c65a97a06aef3c9c6970748 parent c57b194
2 files changed, +317 −0
  • README.md +19 −0
  • auth_e2e_test.go +298 −0
diff --git a/README.md b/README.md
index fabd0c7b9..51136f1cc 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,25 @@ origin), expires in 90 days, and is listed with a revoke button at
carlos auth whoami # who this terminal is, per the console
carlos auth logout # revokes the token, then forgets it
+**Shipping from a logged-in laptop.** Once you're logged in, `ship`,
+`promote`, `rollback` and `channels` speak through the console instead of the
+bucket directly — no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's
+machine at all:
+
+ carlos ship -app blog -version $(git rev-parse --short HEAD) -target linux-arm64 blog-linux-arm64
+ carlos promote -app blog abc1234 canary/my-branch
+ carlos channels -app blog
+
+Canary and edge promote exactly as before; **promoting onto `stable` is
+refused** — the console names itself in the refusal, because moving `stable`
+needs a passkey step-up only a browser can do. Finish it at the URL the
+refusal prints.
+
+CI and operators keep the existing path unchanged: set
+`CARLOS_DEPLOYMENT_BUCKET` (or `_DIR`) and every verb goes straight at the
+bucket, exactly as before — logging in is for a human at a laptop, never a box
+or a pipeline.
+
**On a box, or in CI, don't log in.** There is no browser and no human, and a
long-lived token sitting on a box is a standing credential nobody is watching.
Name the owner outright instead — it needs no identity of its own and takes
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index 2fadb189f..967e34ef3 100644
--- a/auth_e2e_test.go
+++ b/auth_e2e_test.go
@@ -1,7 +1,15 @@
package main
import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -9,6 +17,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/console"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/release"
)
@@ -197,3 +206,292 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
}
}
}
+
+// ---- 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(), "ops", "titogo", "abc1234", "stable"); 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 ------------------------------
+ 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()
+}