| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 34 | func 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. |
| 47 | var ( |
| 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. |
| 60 | type carlosShared struct { |
| 61 | once sync.Once |
| 62 | path string |
| 63 | err error |
| 64 | } |
| 65 | |
| 66 | func (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. |
| 80 | func 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. |
| 96 | func 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. |
| 119 | func 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. |
| 205 | func 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. |
| 239 | func 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. |
| 267 | func 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"). |
| 310 | func 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. |
| 353 | func 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. |
| 390 | func 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). |
| 435 | func 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. |
| 472 | func 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. |
| 532 | func 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 | |