Files
pad/internal/cli/bootstrap_test.go
xarmian 665f1918a7 feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988) (#761)
* feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988)

Adds non-interactive flags to `pad auth setup` and `pad init` so agents
running inside Claude Code or other non-TTY environments can bootstrap a
fresh Pad instance without hitting interactive prompts that block forever.

- New flags --email, --name, --password on both commands; all three must
  be supplied together when any one is present (clear error naming the
  missing flag otherwise). Checked before the remote-mode guard so the
  headless path works on any server host — the loopback gate is enforced
  server-side.
- runHeadlessSetup() drives the existing POST /api/v1/auth/bootstrap
  endpoint directly, saves credentials, and respects --format json (emits
  a LoginResponse-shaped object with user + token). Already-initialized
  conflict produces a structured JSON error object under --format json.
- `pad init` slots headless bootstrap into the bootstrap step only; the
  rest of init (config, workspace creation, skill install) continues.
- Hardens readPassword() with an early non-TTY guard (generic message).
- Hardens promptAndBootstrap() with a bootstrap-specific non-TTY guard
  (points at --email/--name/--password flags) so --cli-prompt on a pipe
  exits immediately rather than blocking.
- Extends `pad init` non-TTY error message to mention the new flags.
- Five new tests in cmd/pad/setup_headless_test.go covering success,
  missing-flag validation, non-TTY guard, already-initialized conflict,
  and the full init flow including workspace creation.

NOTE: --password is visible in process listings (inherent to flag-based
injection). Env-var bootstrap (PAD_ADMIN_*) is the tracked follow-up.

Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr

* fix(cli): thread bootstrap token, factor shared core, restore readPassword fallback

Round-1 codex findings:

1. Bootstrap token not sent on headless path (BLOCKER)
   Add BootstrapWithToken(email, name, password, token) to cli.Client that
   sets X-Bootstrap-Token when token is non-empty. Export ReadBootstrapToken
   from internal/cli/bootstrap.go (was readBootstrapToken) so cmd/pad can
   call it. Extract doHeadlessBootstrap(cfg, client, email, name, password)
   as the shared core for both setupCmd and padInitCmd: reads the on-disk
   token best-effort (absent → empty → loopback gate still covers that case),
   calls BootstrapWithToken, saves credentials, sets auth token on client.
   Both headless paths now go through this single function — no divergence.

2. readPassword bufio fallback removed by accident (REGRESSION)
   Restore the pre-round-1 bufio fallback in readPassword so piped-password
   flows (e.g. pad auth login --interactive in CI) keep working. The bootstrap
   wedge is already prevented by the top-of-promptAndBootstrap TTY guard; the
   generic readPassword fallback is only reached by non-bootstrap callers.

3. Shared core (CLEANUP)
   padInitCmd now calls doHeadlessBootstrap instead of duplicating Bootstrap +
   saveCredentials + SetAuthToken. The --format json asymmetry (init vs setup)
   is resolved by design: pad init is a multi-step flow; for machine-readable
   bootstrap output agents should use `pad auth setup --email … --format json`.
   Documented in the inline comment on the headless branch in padInitCmd.

Tests added: TestHeadlessSetupSendsBootstrapToken, TestHeadlessSetupNoTokenFileOK,
TestReadPasswordFallback. Update internal/cli/bootstrap_test.go for the rename.

Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr

* BUG-988 round-2: surface token-read errors, wrap 403 with hint, rescope readPassword test

doHeadlessBootstrap: distinguish os.ErrNotExist (absent token → best-effort
empty, proceed without header) from other read errors (permissions, etc.
→ surface with the file path so operators can diagnose rather than silently
hitting a confusing 403). Wrap 403/forbidden from BootstrapWithToken with an
actionable multi-bullet hint covering loopback gate, token-file path, and
PAD_BYPASS_SETUP_TOKEN.

ReadBootstrapToken (internal/cli/bootstrap.go): add %w to the ErrNotExist
branch so errors.Is(err, os.ErrNotExist) propagates to callers; existing
tests and --cli-prompt hint text preserved.

TestReadPasswordFallback → TestReadPasswordBufioFallback: rescoped to assert
readPassword isolation only; added comment citing BUG-1886 (pre-existing
doInteractiveLogin double-bufio.Reader bug). BUG-1886 filed in docapp.
2026-06-24 10:50:33 -04:00

384 lines
14 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/config"
)
// fakeSessionServer returns an httptest.Server whose /api/v1/auth/session
// handler responds with the SessionResponse produced by `gen` (called once
// per request so tests can flip setup_required mid-poll). On any other
// path it 404s — caller is responsible for not hitting other endpoints.
func fakeSessionServer(t *testing.T, gen func() SessionResponse) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/auth/session" {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(gen())
}))
t.Cleanup(srv.Close)
return srv
}
// newTestConfig points DataDir at a fresh tmp dir and BrowserURL at the
// given test server. Most tests want both wired up together.
func newTestConfig(t *testing.T, browserURL string) *config.Config {
t.Helper()
cfg := config.DefaultConfig()
cfg.DataDir = t.TempDir()
// BrowserURL prefers cfg.URL when set, which sidesteps the
// host-rewriting branch and gives us a stable URL to assert on.
cfg.URL = browserURL
return cfg
}
// writeBootstrapToken drops a token file at <dataDir>/.bootstrap-token in
// the same shape EnsureBootstrapToken would produce — token + trailing
// newline, mode 0600 — so ReadBootstrapToken sees what it expects.
func writeBootstrapToken(t *testing.T, dataDir, token string) {
t.Helper()
path := filepath.Join(dataDir, bootstrapTokenFilename)
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
t.Fatalf("write bootstrap token: %v", err)
}
}
// TestRunBrowserBootstrap_Idempotent confirms the helper is a no-op when
// the server already reports setup_required: false — important for
// `pad init` against a server where setup is already done. Should not
// touch the token file.
func TestRunBrowserBootstrap_Idempotent(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
return SessionResponse{Authenticated: true, SetupRequired: false}
})
cfg := newTestConfig(t, srv.URL)
client := NewClientFromURL(srv.URL)
// Deliberately do NOT write a token file. If the helper short-circuits
// correctly on setup_required: false, it should never need to read it.
if err := RunBrowserBootstrap(context.Background(), client, cfg, ""); err != nil {
t.Fatalf("RunBrowserBootstrap returned %v on already-bootstrapped server, want nil", err)
}
}
// TestRunBrowserBootstrap_LogsTokenSuccess covers the happy path:
// setup_required: true with a valid token on disk, server flips to
// setup_required: false on the second poll, helper returns nil.
func TestRunBrowserBootstrap_LogsTokenSuccess(t *testing.T) {
withFastPolling(t, 10*time.Millisecond, 5*time.Second)
var calls atomic.Int32
srv := fakeSessionServer(t, func() SessionResponse {
// First call (initial CheckSession): setup_required: true.
// Subsequent polls: setup_required: false on the 2nd poll-tick to
// keep the test fast.
n := calls.Add(1)
if n <= 2 {
return SessionResponse{SetupRequired: true, SetupMethod: "logs_token"}
}
return SessionResponse{Authenticated: false, SetupRequired: false}
})
cfg := newTestConfig(t, srv.URL)
writeBootstrapToken(t, cfg.DataDir, "test-token-abc")
client := NewClientFromURL(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := RunBrowserBootstrap(ctx, client, cfg, "")
if err != nil {
t.Fatalf("RunBrowserBootstrap: %v", err)
}
// Sanity: we should have made at least 2 calls (initial + at least one
// poll). The exact count depends on tick timing but 2 is the floor.
if calls.Load() < 2 {
t.Fatalf("expected at least 2 session calls, got %d", calls.Load())
}
}
// TestRunBrowserBootstrap_OpenMode covers PAD_BYPASS_SETUP_TOKEN=true:
// no token file required, helper should still print a /setup URL and
// poll to completion.
func TestRunBrowserBootstrap_OpenMode(t *testing.T) {
withFastPolling(t, 10*time.Millisecond, 5*time.Second)
var calls atomic.Int32
srv := fakeSessionServer(t, func() SessionResponse {
n := calls.Add(1)
if n <= 2 {
return SessionResponse{SetupRequired: true, SetupMethod: "open"}
}
return SessionResponse{SetupRequired: false}
})
cfg := newTestConfig(t, srv.URL)
// Deliberately no token file — open mode shouldn't need one.
client := NewClientFromURL(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := RunBrowserBootstrap(ctx, client, cfg, ""); err != nil {
t.Fatalf("RunBrowserBootstrap (open mode): %v", err)
}
}
// TestRunBrowserBootstrap_TokenMissing — server says setup_method=logs_token
// but the file isn't on disk. Helper should fail loudly with a path-aware
// error directing the user at --cli-prompt.
func TestRunBrowserBootstrap_TokenMissing(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
return SessionResponse{SetupRequired: true, SetupMethod: "logs_token"}
})
cfg := newTestConfig(t, srv.URL)
// No writeBootstrapToken — file is absent.
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with missing token, want error")
}
msg := err.Error()
if !strings.Contains(msg, "--cli-prompt") {
t.Errorf("error %q should mention --cli-prompt fallback", msg)
}
if !strings.Contains(msg, bootstrapTokenFilename) {
t.Errorf("error %q should mention the token filename %q for operator guidance", msg, bootstrapTokenFilename)
}
}
// TestRunBrowserBootstrap_TokenEmpty — file exists but is empty (0 bytes
// or only whitespace). EnsureBootstrapToken would never produce this, but
// a corrupted file or partial write deserves a clear error rather than
// silently building a `#token=` URL with an empty token.
func TestRunBrowserBootstrap_TokenEmpty(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
return SessionResponse{SetupRequired: true, SetupMethod: "logs_token"}
})
cfg := newTestConfig(t, srv.URL)
writeBootstrapToken(t, cfg.DataDir, " ")
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with empty token, want error")
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("error %q should call out empty token", err.Error())
}
}
// TestRunBrowserBootstrap_LocalCLIMethod — the server failed to provision
// a bootstrap token (read-only DataDir, etc.) and the bypass flag isn't
// set, so setup_method=local_cli. Helper can't drive the browser flow;
// must fail with a --cli-prompt hint.
func TestRunBrowserBootstrap_LocalCLIMethod(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
return SessionResponse{SetupRequired: true, SetupMethod: "local_cli"}
})
cfg := newTestConfig(t, srv.URL)
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with setup_method=local_cli, want error")
}
if !strings.Contains(err.Error(), "--cli-prompt") {
t.Errorf("error %q should mention --cli-prompt fallback", err.Error())
}
}
// TestRunBrowserBootstrap_UnknownMethod — newer server speaking a method
// this CLI doesn't know about. Helper should bail with a clear "this CLI
// may be older than the server" error rather than silently mishandling it.
func TestRunBrowserBootstrap_UnknownMethod(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
return SessionResponse{SetupRequired: true, SetupMethod: "future_method"}
})
cfg := newTestConfig(t, srv.URL)
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with unknown setup_method, want error")
}
if !strings.Contains(err.Error(), "future_method") {
t.Errorf("error %q should echo back the unknown method for diagnosis", err.Error())
}
}
// withFastPolling shrinks the helper's tick interval and timeout to
// millisecond scale for the duration of a test, then restores them.
// Required for asserting on the timeout-error branch (5-min default
// would make the test hang forever) and for keeping the happy-path
// assertions fast.
func withFastPolling(t *testing.T, interval, timeout time.Duration) {
t.Helper()
prevInterval := bootstrapPollInterval
prevTimeout := bootstrapPollTimeout
bootstrapPollInterval = interval
bootstrapPollTimeout = timeout
t.Cleanup(func() {
bootstrapPollInterval = prevInterval
bootstrapPollTimeout = prevTimeout
})
}
// TestRunBrowserBootstrap_TimeoutFires asserts the helper's own internal
// timeout branch surfaces a friendly, actionable error (not just a context
// error). Distinct from the ContextCancelled test below: that one drives
// cancellation from the caller's context; this one fires the helper's
// 5-minute (here: 100ms) safety timer.
func TestRunBrowserBootstrap_TimeoutFires(t *testing.T) {
withFastPolling(t, 10*time.Millisecond, 100*time.Millisecond)
srv := fakeSessionServer(t, func() SessionResponse {
// Server never finishes setup — forces the helper into its timeout.
return SessionResponse{SetupRequired: true, SetupMethod: "logs_token"}
})
cfg := newTestConfig(t, srv.URL)
writeBootstrapToken(t, cfg.DataDir, "tok")
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil after internal timeout, want error")
}
// Should be the friendly "timed out" error, NOT context.DeadlineExceeded
// — the latter would mean the helper conflated caller cancellation with
// its own internal timer (the bug the prior round caught).
if errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v: helper conflated its own timeout with caller-ctx cancellation", err)
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("err = %q, want a 'timed out' message", err.Error())
}
if !strings.Contains(err.Error(), "--cli-prompt") {
t.Errorf("err = %q should mention --cli-prompt fallback", err.Error())
}
}
// TestRunBrowserBootstrap_ContextCancelled — operator hits Ctrl+C while
// the helper is polling. The caller's context is cancelled; helper should
// return ctx.Err() promptly so the outer command can exit cleanly.
func TestRunBrowserBootstrap_ContextCancelled(t *testing.T) {
srv := fakeSessionServer(t, func() SessionResponse {
// Always say setup-required, so the helper polls forever absent
// cancellation.
return SessionResponse{SetupRequired: true, SetupMethod: "logs_token"}
})
cfg := newTestConfig(t, srv.URL)
writeBootstrapToken(t, cfg.DataDir, "tok")
client := NewClientFromURL(srv.URL)
// Cancel after 100ms — well inside the 2s tick interval, so we're
// asserting the select branch fires on ctx.Done(), not on a stale
// tick.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
err := RunBrowserBootstrap(ctx, client, cfg, "")
elapsed := time.Since(start)
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil after ctx cancellation, want error")
}
// Should propagate the context error verbatim (DeadlineExceeded here).
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want context.DeadlineExceeded", err)
}
// And it should return promptly — within roughly the tick interval.
// 3s gives generous headroom on a slow CI box.
if elapsed > 3*time.Second {
t.Errorf("RunBrowserBootstrap took %s to honour ctx cancellation, want <3s", elapsed)
}
}
// TestBuildBootstrapURL_LogsTokenFragment locks down the token-in-fragment
// shape: /setup#token=<TOKEN>. The fragment-not-query choice is load-
// bearing — fragments aren't sent to the server, never appear in access
// logs, and /setup scrubs them from the address bar before paint. A
// regression here that quietly switched to ?token= would defeat all of
// that.
func TestBuildBootstrapURL_LogsTokenFragment(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
writeBootstrapToken(t, cfg.DataDir, "abc123")
got, err := buildBootstrapURL(cfg, "logs_token", "")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
want := "http://example.test:7777/setup#token=abc123"
if got != want {
t.Errorf("buildBootstrapURL = %q, want %q", got, want)
}
}
// TestBuildBootstrapURL_NextBeforeFragment locks down the BUG-1843 handoff
// shape: the next= query param MUST sit before the #token fragment.
// A query placed after the fragment would be swallowed by it and never
// reach the /setup page's searchParams, breaking the post-bootstrap
// redirect to the CLI-authorize page. The path is URL-escaped so the
// leading slash round-trips intact.
func TestBuildBootstrapURL_NextBeforeFragment(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
writeBootstrapToken(t, cfg.DataDir, "abc123")
got, err := buildBootstrapURL(cfg, "logs_token", "/auth/cli/XYZ")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ#token=abc123"
if got != want {
t.Errorf("buildBootstrapURL = %q, want %q", got, want)
}
}
// TestBuildBootstrapURL_OpenModeNext — in open mode (no token) the next=
// query still attaches, with no fragment.
func TestBuildBootstrapURL_OpenModeNext(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
got, err := buildBootstrapURL(cfg, "open", "/auth/cli/XYZ")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ"
if got != want {
t.Errorf("buildBootstrapURL = %q, want %q", got, want)
}
}
// TestReadBootstrapToken_TrimsTrailingNewline — EnsureBootstrapToken
// writes "<token>\n", so a faithful read must strip the trailing newline
// (and any incidental whitespace). Otherwise the URL would carry a stray
// %0A and the server's constant-time compare would reject it.
func TestReadBootstrapToken_TrimsTrailingNewline(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, bootstrapTokenFilename), []byte("hello-token\n"), 0600); err != nil {
t.Fatalf("write token: %v", err)
}
got, err := ReadBootstrapToken(dir)
if err != nil {
t.Fatalf("ReadBootstrapToken: %v", err)
}
if got != "hello-token" {
t.Errorf("ReadBootstrapToken = %q, want %q", got, "hello-token")
}
}