Files
zeekayandHanzo Dev 0a22533cda control-plane: route the HTTP surface on zip v1.10.0
Move the control-plane's REST/UI surface off gin's router and onto
github.com/zap-proto/zip v1.10.0 (over zap-proto/http v0.3.0). zip now owns
routing, the middleware chain and the transport; all 130 routes are registered
natively via App.Get/Post/Put/Patch/Delete and App.Group.

The route table is unchanged: 130 method+path pairs before and after, verified
by diff. The only spelling change is the wildcard leaf, "/ui/*filepath" ->
"/ui/*", which matches the same paths.

Handlers are fronted rather than rewritten. ~24k lines of gin handler bodies
are this service's public API and a hand port of that volume cannot be proven
equivalent, so internal/server/ginbridge.go provides the one seam:

  ginHandler(h)     wraps a gin.HandlerFunc, copying the matched zip route's
                    params onto the gin context so c.Param(...) still works.
  ginChain(mws...)  runs the gin middleware stack (CORS, access log, timeout,
                    API-key auth) unmodified, so CORS origin echoing and the
                    auth abort bodies stay byte-for-byte identical.

The net/http adaptor underneath supports buffered, flushed and hijacked
responses, so the SSE and WebSocket routes keep streaming through the same
seam rather than being rewritten badly.

Two things that would otherwise silently break the API:

  - Start listens on "http://:PORT". A bare address binds zip's default ZAP
    transport, which would change the wire protocol of the REST surface.
  - The NoRoute fallback is All("/+"), not All("/*"). "/*" also matches "/"
    and shadowed the root redirect, turning it into a 404.

Coverage: routes_test.go pins the exact 130-route table and the NoRoute
semantics; ginbridge_test.go pins params, status, body, CORS echo, auth abort
and SSE through the bridge. Both pass under the default and "embedded" build
tags. A package TestMain sets gin's mode once, removing the pre-existing
gin.SetMode data race between parallel tests in this package.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 11:48:06 -07:00

319 lines
8.8 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/hanzoai/agents/control-plane/internal/storage"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func TestCheckStorageHealthOverride(t *testing.T) {
srv := &HanzoAgentsServer{
storageHealthOverride: func(context.Context) gin.H {
return gin.H{"status": "healthy"}
},
}
result := srv.checkStorageHealth(context.Background())
if status, ok := result["status"].(string); !ok || status != "healthy" {
t.Fatalf("expected healthy status, got %+v", result)
}
}
func TestCheckStorageHealthWithoutStorage(t *testing.T) {
srv := &HanzoAgentsServer{}
result := srv.checkStorageHealth(context.Background())
if status, ok := result["status"].(string); !ok || status != "healthy" {
t.Fatalf("expected default healthy status when storage nil, got %+v", result)
}
}
func TestCheckStorageHealthContextError(t *testing.T) {
srv := &HanzoAgentsServer{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := srv.checkStorageHealth(ctx)
if status, ok := result["status"].(string); !ok || status != "unhealthy" {
t.Fatalf("expected unhealthy status for cancelled context, got %+v", result)
}
}
type fakeCache struct {
mu sync.RWMutex
store map[string]string
setErr error
getErr error
}
func newFakeCache() *fakeCache {
return &fakeCache{store: make(map[string]string)}
}
func (c *fakeCache) Set(key string, value interface{}, ttl time.Duration) error {
if c.setErr != nil {
return c.setErr
}
c.mu.Lock()
defer c.mu.Unlock()
if s, ok := value.(string); ok {
c.store[key] = s
}
return nil
}
func (c *fakeCache) Get(key string, dest interface{}) error {
if c.getErr != nil {
return c.getErr
}
c.mu.RLock()
defer c.mu.RUnlock()
if ptr, ok := dest.(*string); ok {
*ptr = c.store[key]
}
return nil
}
func (c *fakeCache) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.store, key)
return nil
}
func (c *fakeCache) Exists(key string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
_, ok := c.store[key]
return ok
}
func (c *fakeCache) Subscribe(channel string) (<-chan storage.CacheMessage, error) {
return nil, nil
}
func (c *fakeCache) Publish(channel string, message interface{}) error {
return nil
}
func TestCheckCacheHealthHealthy(t *testing.T) {
cache := newFakeCache()
srv := &HanzoAgentsServer{cache: cache}
result := srv.checkCacheHealth(context.Background())
if status, ok := result["status"].(string); !ok || status != "healthy" {
t.Fatalf("expected healthy cache status, got %+v", result)
}
}
func TestCheckCacheHealthSetError(t *testing.T) {
cache := newFakeCache()
cache.setErr = context.DeadlineExceeded
srv := &HanzoAgentsServer{cache: cache}
result := srv.checkCacheHealth(context.Background())
if status, ok := result["status"].(string); !ok || status != "unhealthy" {
t.Fatalf("expected unhealthy cache status, got %+v", result)
}
}
func TestCheckCacheHealthGetError(t *testing.T) {
cache := newFakeCache()
cache.getErr = context.DeadlineExceeded
srv := &HanzoAgentsServer{cache: cache}
result := srv.checkCacheHealth(context.Background())
if status, ok := result["status"].(string); !ok || status != "unhealthy" {
t.Fatalf("expected unhealthy cache status, got %+v", result)
}
}
func TestHealthCheckHandlerHealthy(t *testing.T) {
srv := &HanzoAgentsServer{
storageHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} },
cacheHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} },
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
c.Request = req
srv.healthCheckHandler(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 status, got %d", w.Code)
}
var payload map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if payload["status"] != "healthy" {
t.Fatalf("expected response status healthy, got %+v", payload)
}
}
func TestHealthCheckHandlerCacheOptional(t *testing.T) {
srv := &HanzoAgentsServer{
storageHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} },
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
c.Request = req
srv.healthCheckHandler(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 status, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
checks := payload["checks"].(map[string]any)
cacheCheck := checks["cache"].(map[string]any)
if cacheCheck["message"] != "cache not configured (optional)" {
t.Fatalf("expected optional cache message, got %+v", cacheCheck)
}
}
func TestHealthCheckHandlerUnhealthyStorage(t *testing.T) {
srv := &HanzoAgentsServer{
storageHealthOverride: func(context.Context) gin.H { return gin.H{"status": "unhealthy"} },
cacheHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} },
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
c.Request = req
srv.healthCheckHandler(c)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 status, got %d", w.Code)
}
}
func TestHealthCheckHandlerWithoutStorage(t *testing.T) {
srv := &HanzoAgentsServer{}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
c.Request = req
srv.healthCheckHandler(c)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 status, got %d", w.Code)
}
}
func TestGenerateHanzoAgentsServerIDDeterministic(t *testing.T) {
dir1 := filepath.Join("/tmp", "hanzo-agents-test-1")
dir2 := filepath.Join("/tmp", "hanzo-agents-test-2")
id1 := generateHanzoAgentsServerID(dir1)
id1Again := generateHanzoAgentsServerID(dir1)
id2 := generateHanzoAgentsServerID(dir2)
if id1 != id1Again {
t.Fatal("expected deterministic ID for same path")
}
if id1 == id2 {
t.Fatal("expected different IDs for different paths")
}
}
func TestUnregisterAgentFromMonitoring_NoNodeID(t *testing.T) {
srv := &HanzoAgentsServer{}
router := gin.New()
router.DELETE("/nodes/:node_id/monitoring", srv.unregisterAgentFromMonitoring)
req := httptest.NewRequest(http.MethodDelete, "/nodes//monitoring", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("expected 400 status, got %d", resp.Code)
}
}
func TestUnregisterAgentFromMonitoring_NoMonitor(t *testing.T) {
srv := &HanzoAgentsServer{}
router := gin.New()
router.DELETE("/nodes/:node_id/monitoring", srv.unregisterAgentFromMonitoring)
req := httptest.NewRequest(http.MethodDelete, "/nodes/node-1/monitoring", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 status, got %d", resp.Code)
}
}
func TestSyncPackagesFromRegistry(t *testing.T) {
storage := newStubPackageStorage()
hanzoAgentsHome := t.TempDir()
pkgDir := filepath.Join(hanzoAgentsHome, "packages", "mypkg")
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
t.Fatalf("failed to create package dir: %v", err)
}
packageContent := []byte(`name: Test Package\nversion: 1.0.0`)
if err := os.WriteFile(filepath.Join(pkgDir, "hanzo-agents-package.yaml"), packageContent, 0o644); err != nil {
t.Fatalf("failed to write hanzo-agents-package.yaml: %v", err)
}
installedContent := []byte("installed:\n test-package:\n name: Test Package\n version: \"1.0.0\"\n description: Test description\n path: \"" + pkgDir + "\"\n source: local\n")
if err := os.WriteFile(filepath.Join(hanzoAgentsHome, "installed.yaml"), installedContent, 0o644); err != nil {
t.Fatalf("failed to write installed.yaml: %v", err)
}
var reg InstallationRegistry
if data, err := os.ReadFile(filepath.Join(hanzoAgentsHome, "installed.yaml")); err == nil {
_ = yaml.Unmarshal(data, &reg)
}
if len(reg.Installed) == 0 {
t.Fatal("expected registry to contain installed package")
}
if err := SyncPackagesFromRegistry(hanzoAgentsHome, storage); err != nil {
t.Fatalf("SyncPackagesFromRegistry returned error: %v", err)
}
if len(storage.getCalls) == 0 {
t.Fatalf("expected GetAgentPackage to be called, got %d", len(storage.getCalls))
}
}
func TestSyncPackagesFromRegistryMissingFile(t *testing.T) {
storage := newStubPackageStorage()
hanzoAgentsHome := t.TempDir()
if err := SyncPackagesFromRegistry(hanzoAgentsHome, storage); err != nil {
t.Fatalf("expected nil error when registry file missing, got %v", err)
}
if len(storage.packages) != 0 {
t.Fatalf("expected no packages to be stored, found %d", len(storage.packages))
}
}