224 lines
6.4 KiB
Go
224 lines
6.4 KiB
Go
package httpd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func TestHealthProbes(t *testing.T) {
|
|
router := newTestRouter(config.Config{}, discardLogger(), nil)
|
|
srv := httptest.NewServer(router)
|
|
defer srv.Close()
|
|
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
for _, path := range []string{"/healthz", "/readyz"} {
|
|
resp, err := client.Get(srv.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
|
t.Errorf("GET %s Content-Type = %q, want JSON", path, ct)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHealthProbesIncludeDaemonIdentity(t *testing.T) {
|
|
router := newTestRouter(config.Config{}, discardLogger(), nil)
|
|
srv := httptest.NewServer(router)
|
|
defer srv.Close()
|
|
|
|
wantExe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantCWD, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
for _, path := range []string{"/healthz", "/readyz"} {
|
|
resp, err := client.Get(srv.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var body struct {
|
|
ExecutablePath string `json:"executablePath"`
|
|
WorkingDirectory string `json:"workingDirectory"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode %s: %v", path, err)
|
|
}
|
|
if body.ExecutablePath != wantExe {
|
|
t.Errorf("GET %s executablePath = %q, want %q", path, body.ExecutablePath, wantExe)
|
|
}
|
|
if body.WorkingDirectory != wantCWD {
|
|
t.Errorf("GET %s workingDirectory = %q, want %q", path, body.WorkingDirectory, wantCWD)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestServerLifecycle exercises the full Run loop: bind an ephemeral port,
|
|
// publish running.json, serve a request, then cancel the context and confirm a
|
|
// clean shutdown that removes the handshake file.
|
|
func TestServerLifecycle(t *testing.T) {
|
|
runPath := filepath.Join(t.TempDir(), "running.json")
|
|
cfg := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: 0, // let the OS pick a free port — no conflict with a real daemon
|
|
ShutdownTimeout: 5 * time.Second,
|
|
RunFilePath: runPath,
|
|
}
|
|
|
|
srv, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- srv.Run(ctx) }()
|
|
|
|
// Wait for the handshake file to confirm the server is up.
|
|
base := "http://" + srv.Addr().String()
|
|
waitForHealth(t, base)
|
|
|
|
info, err := runfile.Read(runPath)
|
|
if err != nil {
|
|
t.Fatalf("read run-file: %v", err)
|
|
}
|
|
if info == nil {
|
|
t.Fatal("run-file not written while server running")
|
|
}
|
|
if info.Port == 0 {
|
|
t.Error("run-file recorded port 0; want the actual bound port")
|
|
}
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-runErr:
|
|
if err != nil {
|
|
t.Fatalf("Run returned error on graceful shutdown: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("Run did not return after context cancel")
|
|
}
|
|
|
|
if after, _ := runfile.Read(runPath); after != nil {
|
|
t.Error("run-file still present after shutdown; want it removed")
|
|
}
|
|
}
|
|
|
|
func TestServerShutdownEndpoint(t *testing.T) {
|
|
runPath := filepath.Join(t.TempDir(), "running.json")
|
|
cfg := config.Config{
|
|
Host: "127.0.0.1",
|
|
Port: 0,
|
|
ShutdownTimeout: 5 * time.Second,
|
|
RunFilePath: runPath,
|
|
}
|
|
|
|
srv, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- srv.Run(context.Background()) }()
|
|
|
|
base := "http://" + srv.Addr().String()
|
|
waitForHealth(t, base)
|
|
|
|
resp, err := http.Post(base+"/shutdown", "application/json", nil)
|
|
if err != nil {
|
|
t.Fatalf("POST /shutdown: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
t.Fatalf("POST /shutdown = %d, want 202", resp.StatusCode)
|
|
}
|
|
|
|
select {
|
|
case err := <-runErr:
|
|
if err != nil {
|
|
t.Fatalf("Run returned error on shutdown endpoint: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("Run did not return after shutdown endpoint")
|
|
}
|
|
|
|
if after, _ := runfile.Read(runPath); after != nil {
|
|
t.Error("run-file still present after shutdown endpoint; want it removed")
|
|
}
|
|
}
|
|
|
|
func waitForHealth(t *testing.T, base string) {
|
|
t.Helper()
|
|
// Per-request timeout so a stalled connect or hung handshake doesn't park
|
|
// the test for the full Go test timeout; the outer deadline only bounds
|
|
// the polling loop, not any single GET.
|
|
client := &http.Client{Timeout: 500 * time.Millisecond}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := client.Get(base + "/healthz")
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
return
|
|
}
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("server did not become healthy within timeout")
|
|
}
|
|
|
|
// TestNewFallsBackOnPortConflict confirms that when the configured port is
|
|
// already held, the constructor binds an ephemeral port instead of failing, so
|
|
// the desktop supervisor never gets stuck on "daemon not ready".
|
|
func TestNewFallsBackOnPortConflict(t *testing.T) {
|
|
cfg := config.Config{Host: "127.0.0.1", Port: 0, RunFilePath: filepath.Join(t.TempDir(), "r.json")}
|
|
|
|
first, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{})
|
|
if err != nil {
|
|
t.Fatalf("first New: %v", err)
|
|
}
|
|
defer first.listen.Close()
|
|
|
|
// Request the exact port the first server took; the second server should
|
|
// fall back to a different, ephemeral port rather than error out.
|
|
conflict := config.Config{Host: "127.0.0.1", Port: first.boundPort(), RunFilePath: cfg.RunFilePath}
|
|
second, err := NewWithDeps(conflict, discardLogger(), nil, APIDeps{})
|
|
if err != nil {
|
|
t.Fatalf("New on an already-bound port = %v, want ephemeral fallback", err)
|
|
}
|
|
defer second.listen.Close()
|
|
|
|
if second.boundPort() == first.boundPort() {
|
|
t.Fatalf("second server bound the same port %d; want a fallback port", second.boundPort())
|
|
}
|
|
if second.boundPort() == 0 {
|
|
t.Fatal("second server bound port 0; want a real fallback port")
|
|
}
|
|
}
|