| 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() |
| +} |