package cli import ( "bytes" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "sync/atomic" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" ) func TestRootHelpDoesNotShowDaemon(t *testing.T) { out, _, err := executeCLI(t, Deps{}, "--help") if err != nil { t.Fatal(err) } if strings.Contains(out, "\n daemon") { t.Fatalf("hidden daemon command leaked into help:\n%s", out) } for _, want := range []string{"start", "stop", "status", "doctor", "completion", "version"} { if !strings.Contains(out, want) { t.Fatalf("help missing %q:\n%s", want, out) } } } func TestCommandsRejectUnexpectedArgs(t *testing.T) { for _, args := range [][]string{ {"daemon", "extra"}, {"start", "extra"}, {"stop", "extra"}, {"status", "extra"}, {"doctor", "extra"}, {"version", "extra"}, } { t.Run(strings.Join(args, " "), func(t *testing.T) { _, _, err := executeCLI(t, Deps{}, args...) if err == nil { t.Fatal("expected usage error") } if got := ExitCode(err); got != 2 { t.Fatalf("ExitCode(%v) = %d, want 2", err, got) } }) } } func TestVersionEmitsCLIInvocationBestEffort(t *testing.T) { cfg := setConfigEnv(t) called := make(chan string, 1) if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } if _, _, err := executeCLI(t, Deps{ HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.Path == "/internal/telemetry/cli-invoked" { called <- req.URL.Path return jsonResponse(http.StatusAccepted, ""), nil } return jsonResponse(http.StatusNotFound, ""), nil })}, ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, }, "version"); err != nil { t.Fatal(err) } select { case path := <-called: if path != "/internal/telemetry/cli-invoked" { t.Fatalf("telemetry path = %q, want /internal/telemetry/cli-invoked", path) } default: t.Fatal("version did not emit CLI invocation") } } func TestUsageErrorEmitsCLIUsageTelemetryBestEffort(t *testing.T) { cfg := setConfigEnv(t) called := make(chan string, 1) if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } deps := Deps{ HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.Path == "/internal/telemetry/cli-usage-error" { called <- req.URL.Path return jsonResponse(http.StatusAccepted, ""), nil } return jsonResponse(http.StatusNotFound, ""), nil })}, ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, } err := executeWithDeps(deps, []string{"status", "extra"}) if err == nil { t.Fatal("expected usage error") } select { case path := <-called: if path != "/internal/telemetry/cli-usage-error" { t.Fatalf("telemetry path = %q, want /internal/telemetry/cli-usage-error", path) } default: t.Fatal("usage error did not emit CLI usage telemetry") } } func TestStatusStoppedJSON(t *testing.T) { setConfigEnv(t) out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "status", "--json") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"state": "stopped"`) { t.Fatalf("status did not report stopped:\n%s", out) } if strings.Contains(out, "startedAt") { t.Fatalf("stopped JSON should omit startedAt:\n%s", out) } } func TestStartReturnsExistingReadyDaemon(t *testing.T) { cfg := setConfigEnv(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/healthz": _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) case "/readyz": _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) default: http.NotFound(w, r) } })) defer srv.Close() port := serverPort(t, srv.URL) if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } var started bool out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, StartProcess: func(processStartConfig) error { started = true return nil }, Now: func() time.Time { return time.Unix(110, 0).UTC() }, }, "start", "--json") if err != nil { t.Fatal(err) } if started { t.Fatal("start should not spawn when daemon is already ready") } if !strings.Contains(out, `"state": "ready"`) { t.Fatalf("start did not report ready:\n%s", out) } } func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) { cfg := setConfigEnv(t) var spawned atomic.Bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !spawned.Load() { _, _ = fmt.Fprintf(w, `{"status":"ok","service":"not-ao","pid":4242}`) return } switch r.URL.Path { case "/healthz": _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) case "/readyz": _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) default: http.NotFound(w, r) } })) defer srv.Close() port := serverPort(t, srv.URL) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() }, StartProcess: func(processStartConfig) error { info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) } if info != nil { t.Fatalf("stale run-file was not removed before spawn: %#v", info) } spawned.Store(true) if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil { t.Fatal(err) } return nil }, Now: func() time.Time { return time.Unix(120, 0).UTC() }, }, "start", "--json") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"state": "ready"`) { t.Fatalf("start did not report ready after clearing stale run-file:\n%s", out) } } func TestStartEmitsCLIInvocationAfterReady(t *testing.T) { cfg := setConfigEnv(t) var spawned atomic.Bool called := make(chan string, 1) port := 3001 out, _, err := executeCLI(t, Deps{ HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.Path { case "/healthz": if !spawned.Load() { return jsonResponse(http.StatusOK, `{"status":"ok","service":"not-ao","pid":4242}`), nil } return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil case "/readyz": if !spawned.Load() { return jsonResponse(http.StatusOK, `{"status":"not_ready","service":"not-ao","pid":4242}`), nil } return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil case "/internal/telemetry/cli-invoked": called <- req.URL.Path return jsonResponse(http.StatusAccepted, ""), nil default: return jsonResponse(http.StatusNotFound, ""), nil } })}, ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, StartProcess: func(processStartConfig) error { spawned.Store(true) return runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}) }, Now: func() time.Time { return time.Unix(120, 0).UTC() }, }, "start", "--json") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"state": "ready"`) { t.Fatalf("start did not report ready:\n%s", out) } select { case path := <-called: if path != "/internal/telemetry/cli-invoked" { t.Fatalf("telemetry path = %q, want /internal/telemetry/cli-invoked", path) } default: t.Fatal("start did not emit CLI invocation after readiness") } } func TestStopRemovesStaleRunFile(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "stop", "--json") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"state": "stopped"`) { t.Fatalf("stop did not report stopped:\n%s", out) } info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) } if info != nil { t.Fatalf("stale run-file was not removed: %#v", info) } } func TestStopDoesNotShutdownUnverifiedReusedPID(t *testing.T) { cfg := setConfigEnv(t) shutdownCalled := make(chan struct{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/healthz": _, _ = w.Write([]byte(`{"status":"ok"}`)) case "/readyz": _, _ = w.Write([]byte(`{"status":"ready"}`)) case "/shutdown": shutdownCalled <- struct{}{} http.Error(w, "unexpected shutdown", http.StatusInternalServerError) default: http.NotFound(w, r) } })) defer srv.Close() if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, }, "stop", "--json") if err != nil { t.Fatal(err) } select { case <-shutdownCalled: t.Fatal("stop requested shutdown from a process whose health probe did not prove AO daemon ownership") default: } if !strings.Contains(out, `"state": "stopped"`) { t.Fatalf("stop did not report stopped:\n%s", out) } info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) } if info != nil { t.Fatalf("unverified run-file was not removed: %#v", info) } } func TestStopUsesShutdownEndpoint(t *testing.T) { cfg := setConfigEnv(t) shutdownCalled := make(chan struct{}, 1) var shutdownSeen atomic.Bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/healthz": _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) case "/readyz": _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) case "/shutdown": if err := runfile.Remove(cfg.runFile); err != nil { t.Fatal(err) } shutdownSeen.Store(true) shutdownCalled <- struct{}{} _, _ = fmt.Fprintf(w, `{"status":"shutting_down","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) default: http.NotFound(w, r) } })) defer srv.Close() if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { if pid != os.Getpid() { return false } return !shutdownSeen.Load() }, }, "stop", "--json") if err != nil { t.Fatal(err) } select { case <-shutdownCalled: default: t.Fatal("stop did not call daemon shutdown endpoint") } if !strings.Contains(out, `"state": "stopped"`) { t.Fatalf("stop did not report stopped:\n%s", out) } } func TestStatusKeepsLiveProbeFailureUnhealthy(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, }, "status", "--json") if err != nil { t.Fatal(err) } if !strings.Contains(out, `"state": "unhealthy"`) { t.Fatalf("status should keep live probe failures unhealthy:\n%s", out) } info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) } if info == nil { t.Fatal("live probe failure should not remove run-file") } } func TestStopRefusesUnverifiedLivePID(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } _, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, }, "stop", "--json") if err == nil { t.Fatal("stop should fail when daemon ownership cannot be verified") } info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) } if info == nil { t.Fatal("unverified live PID should remain tracked") } } func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { t.Fatal(err) } var started bool _, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, StartProcess: func(processStartConfig) error { started = true return nil }, }, "start", "--timeout", "1ns", "--json") if err == nil { t.Fatal("start should fail instead of spawning over a live unverified PID") } if started { t.Fatal("start spawned while run-file PID was still alive") } } type testConfig struct { runFile string dataDir string } func setConfigEnv(t *testing.T) testConfig { t.Helper() dir := t.TempDir() cfg := testConfig{ runFile: filepath.Join(dir, "running.json"), dataDir: filepath.Join(dir, "data"), } t.Setenv("AO_RUN_FILE", cfg.runFile) t.Setenv("AO_DATA_DIR", cfg.dataDir) t.Setenv("AO_PORT", "3001") t.Setenv("AO_REQUEST_TIMEOUT", "") t.Setenv("AO_SHUTDOWN_TIMEOUT", "") return cfg } func executeCLI(t *testing.T, deps Deps, args ...string) (string, string, error) { t.Helper() var out, errOut bytes.Buffer deps.Out = &out deps.Err = &errOut if deps.Sleep == nil { deps.Sleep = func(time.Duration) {} } cmd := NewRootCommand(deps) cmd.SetArgs(args) err := cmd.Execute() return out.String(), errOut.String(), err } func serverPort(t *testing.T, raw string) int { t.Helper() u, err := url.Parse(raw) if err != nil { t.Fatal(err) } _, portRaw, err := net.SplitHostPort(u.Host) if err != nil { t.Fatal(err) } port, err := strconv.Atoi(portRaw) if err != nil { t.Fatal(err) } return port } func closedPort(t *testing.T) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() _, portRaw, err := net.SplitHostPort(ln.Addr().String()) if err != nil { t.Fatal(err) } port, err := strconv.Atoi(portRaw) if err != nil { t.Fatal(err) } return port } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func jsonResponse(status int, body string) *http.Response { if body == "" { body = "{}" } return &http.Response{ StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), } }