feat(api): HTTP API REST+SSE para gestion remota de agentes (issue 0128)
Nuevo paquete internal/api con servidor HTTP stdlib (sin gin/echo):
- Auth Bearer via AGENTS_API_KEY con subtle.ConstantTimeCompare
- REST: GET /health (sin auth), GET/POST /agents, /agents/{id}, /{id}/{start,stop,restart,logs}
- SSE: /sse/status (broadcast diffs cada 2s) y /sse/agents/{id}/logs (tail -f)
- Pubsub in-memory (TODO: NATS cuando haya 2do cliente)
- Tail de logfiles: retroalimenta ultimos 50KB + poll 200ms para streaming
Integracion en cmd/launcher/main.go:
- Flag --api-port (0=desactivado, 8487 en produccion)
- Flag --api-key (override de AGENTS_API_KEY env var)
- Si apiPort>0 y sin clave, WARN y deshabilita en vez de fallar
Systemd unit en systemd/agents_and_robots.service:
- Restart=always (no on-failure — evita que exit limpio mate el service)
- EnvironmentFile para AGENTS_API_KEY y demas tokens
- WorkingDirectory=/home/ubuntu/CodeProyects/agents_and_robots
app.md v0.2.0:
- port: 8487, health_endpoint: /health (fix drift anterior donde era null)
- e2e_checks: build, tests, smoke_health, smoke_auth
- Documentacion Traefik+DNS pendiente humano post-merge
Tests: 12 tests unitarios en internal/api (auth, health, bus, agents, logs)
Smoke: /health 200, /agents sin auth 401, /agents con key 200 — verificado local
Co-Authored-By: fn-constructor (agent)
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/shell/process"
|
||||
)
|
||||
|
||||
// newTestServer creates a Server with a real (temp-dir) Manager and a test API key.
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
mgr := process.NewManager(dir+"/run", dir+"/agents/*/config.yaml", "")
|
||||
return New(mgr, "test-key-abcd1234", 0, nil)
|
||||
}
|
||||
|
||||
// --- Auth tests ---
|
||||
|
||||
func TestAuthMissingHeader(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler called despite missing auth")
|
||||
})).ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthWrongKey(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler called despite wrong key")
|
||||
})).ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCorrectKey(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key-abcd1234")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
called := false
|
||||
s.auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(w, req)
|
||||
|
||||
if !called {
|
||||
t.Fatal("handler not called with valid key")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Health endpoint ---
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["status"] != "ok" {
|
||||
t.Errorf("expected status=ok, got %q", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- List agents ---
|
||||
|
||||
func TestListAgentsEmpty(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /agents", s.auth(http.HandlerFunc(s.handleListAgents)))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key-abcd1234")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
// With empty agents dir, should return empty JSON array.
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed != "null" && trimmed != "[]" {
|
||||
// Accept both null and [] for empty slice serialisation.
|
||||
t.Logf("body: %s (acceptable)", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bus tests ---
|
||||
|
||||
func TestBusSubscribePublish(t *testing.T) {
|
||||
b := NewBus()
|
||||
ch := b.Subscribe("test")
|
||||
defer b.Unsubscribe("test", ch)
|
||||
|
||||
b.Publish("test", "hello")
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev != "hello" {
|
||||
t.Fatalf("expected 'hello', got %v", ev)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusUnsubscribe(t *testing.T) {
|
||||
b := NewBus()
|
||||
ch := b.Subscribe("test")
|
||||
b.Unsubscribe("test", ch)
|
||||
|
||||
// After unsubscribe, channel should be closed.
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if ok {
|
||||
t.Fatal("channel should be closed after unsubscribe")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("channel not closed after unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusMultipleSubscribers(t *testing.T) {
|
||||
b := NewBus()
|
||||
ch1 := b.Subscribe("x")
|
||||
ch2 := b.Subscribe("x")
|
||||
defer b.Unsubscribe("x", ch1)
|
||||
defer b.Unsubscribe("x", ch2)
|
||||
|
||||
b.Publish("x", 42)
|
||||
|
||||
for _, ch := range []<-chan Event{ch1, ch2} {
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev != 42 {
|
||||
t.Fatalf("expected 42, got %v", ev)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Get agent not found ---
|
||||
|
||||
func TestGetAgentNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /agents/{id}", s.auth(http.HandlerFunc(s.handleGetAgent)))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/nonexistent", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key-abcd1234")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stop agent not running ---
|
||||
|
||||
func TestStopAgentNotRunning(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /agents/{id}/stop", s.auth(http.HandlerFunc(s.handleStopAgent)))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/ghost/stop", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key-abcd1234")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// stop returns Conflict when the agent is not running
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logs endpoint ---
|
||||
|
||||
func TestLogsEndpointNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /agents/{id}/logs", s.auth(http.HandlerFunc(s.handleAgentLogs)))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/nonexistent/logs?n=10", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key-abcd1234")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractBearerToken ---
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"Bearer abc123", "abc123"},
|
||||
{"bearer abc123", ""}, // case sensitive
|
||||
{"Basic abc123", ""},
|
||||
{"", ""},
|
||||
{"Bearer ", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
got := extractBearerToken(req)
|
||||
if got != tc.want {
|
||||
t.Errorf("header=%q: got=%q want=%q", tc.header, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user