Files
viettranx 0494721af2 test(gateway,http): add unit tests for server/methods/handlers
Add server and RPC handler tests:
- gateway/ratelimit_test.go: rate limiter pure unit
- gateway/event_filter_test.go: event routing logic
- gateway/server_test.go: handleHealth, tokenAuth, checkOrigin, desktopCORS
- gateway/methods/sessions_test.go: sessions RPC handlers
- gateway/methods/skills_test.go: skills RPC handlers
- gateway/methods/cron_test.go: cron RPC handlers
- http/files_path_security_test.go: path traversal, workspace boundary, auth
- http/auth_helpers_test.go: extractBearerToken, tokenMatch, extractUserID/AgentID
2026-04-11 21:22:23 +07:00

297 lines
8.2 KiB
Go

package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// minimalServer builds a Server with only the fields needed for HTTP-level tests.
// No agent.Router, no store — these are set to nil so BuildMux can be called only for
// the specific routes we are testing.
func minimalServer(t *testing.T) *Server {
t.Helper()
cfg := &config.Config{}
// Build a no-op EventPublisher stub so NewServer doesn't panic.
s := &Server{
cfg: cfg,
clients: make(map[string]*Client),
}
s.rateLimiter = NewRateLimiter(0, 5)
s.upgrader.CheckOrigin = s.checkOrigin
return s
}
// ---- handleHealth ----
func TestHandleHealth_Returns200WithProtocolVersion(t *testing.T) {
s := minimalServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.handleHealth(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
body := w.Body.String()
if body == "" {
t.Error("expected non-empty body")
}
// Must contain protocol version field.
if !containsSubstr(body, "protocol") {
t.Errorf("body %q missing 'protocol' key", body)
}
if !containsSubstr(body, "ok") {
t.Errorf("body %q missing 'ok' status", body)
}
}
func TestHandleHealth_ContentTypeJSON(t *testing.T) {
s := minimalServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.handleHealth(w, req)
ct := w.Result().Header.Get("Content-Type")
if ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
// ---- tokenAuthMiddleware ----
func TestTokenAuthMiddleware_ValidToken_PassesThrough(t *testing.T) {
token := "super-secret"
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := tokenAuthMiddleware(token, next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler should have been called with valid token")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestTokenAuthMiddleware_WrongToken_Returns401(t *testing.T) {
token := "correct-token"
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := tokenAuthMiddleware(token, next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestTokenAuthMiddleware_MissingAuthHeader_Returns401(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := tokenAuthMiddleware("some-token", next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestTokenAuthMiddleware_NonBearerScheme_Returns401(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := tokenAuthMiddleware("token123", next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // Base64, not Bearer
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// ---- clientIP ----
func TestClientIP_XRealIPHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "1.2.3.4")
req.RemoteAddr = "127.0.0.1:9999"
if got := clientIP(req); got != "1.2.3.4" {
t.Errorf("clientIP = %q, want 1.2.3.4", got)
}
}
func TestClientIP_XForwardedForHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "5.6.7.8, 9.10.11.12")
req.RemoteAddr = "127.0.0.1:9999"
if got := clientIP(req); got != "5.6.7.8" {
t.Errorf("clientIP = %q, want 5.6.7.8 (first in chain)", got)
}
}
func TestClientIP_FallbackRemoteAddr(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.100:12345"
if got := clientIP(req); got != "192.168.1.100" {
t.Errorf("clientIP = %q, want 192.168.1.100", got)
}
}
// ---- checkOrigin ----
func TestCheckOrigin_NoAllowedOriginsConfigured_AllowsAll(t *testing.T) {
s := minimalServer(t)
s.cfg.Gateway.AllowedOrigins = nil
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Origin", "https://evil.example.com")
if !s.checkOrigin(req) {
t.Error("should allow any origin when AllowedOrigins is empty")
}
}
func TestCheckOrigin_EmptyOriginHeader_AlwaysAllowed(t *testing.T) {
s := minimalServer(t)
s.cfg.Gateway.AllowedOrigins = []string{"https://app.example.com"}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
// No Origin header → non-browser client
if !s.checkOrigin(req) {
t.Error("empty Origin header should always be allowed (CLI / SDK clients)")
}
}
func TestCheckOrigin_MatchingOrigin_Allowed(t *testing.T) {
s := minimalServer(t)
s.cfg.Gateway.AllowedOrigins = []string{"https://app.example.com", "https://dashboard.example.com"}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Origin", "https://dashboard.example.com")
if !s.checkOrigin(req) {
t.Error("matching origin should be allowed")
}
}
func TestCheckOrigin_WildcardAllowsAll(t *testing.T) {
s := minimalServer(t)
s.cfg.Gateway.AllowedOrigins = []string{"*"}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Origin", "https://any-origin.example.com")
if !s.checkOrigin(req) {
t.Error("wildcard origin should allow all")
}
}
func TestCheckOrigin_UnknownOrigin_Rejected(t *testing.T) {
s := minimalServer(t)
s.cfg.Gateway.AllowedOrigins = []string{"https://allowed.example.com"}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Origin", "https://evil.example.com")
if s.checkOrigin(req) {
t.Error("unknown origin should be rejected")
}
}
// ---- desktopCORS ----
func TestDesktopCORS_SetsHeaders(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := desktopCORS(next)
req := httptest.NewRequest(http.MethodGet, "/v1/agents", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Errorf("CORS origin header missing")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestDesktopCORS_OptionsRequest_Returns204(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Should not be called for OPTIONS
t.Error("next handler called for OPTIONS — should short-circuit")
})
handler := desktopCORS(next)
req := httptest.NewRequest(http.MethodOptions, "/v1/agents", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("OPTIONS status = %d, want 204", w.Code)
}
}
// ---- isOwnerID ----
func TestIsOwnerID_MatchesConfiguredOwner(t *testing.T) {
if !isOwnerID("alice", []string{"alice", "bob"}) {
t.Error("alice should be recognized as owner")
}
}
func TestIsOwnerID_EmptyUserID_NotOwner(t *testing.T) {
if isOwnerID("", []string{"alice"}) {
t.Error("empty user ID should never be owner")
}
}
func TestIsOwnerID_EmptyOwnerList_OnlySystemIsOwner(t *testing.T) {
if !isOwnerID("system", nil) {
t.Error("'system' should be default owner when no owner IDs configured")
}
if isOwnerID("admin", nil) {
t.Error("non-system user should not be owner when no owner IDs configured")
}
}
func TestIsOwnerID_UnknownUser_NotOwner(t *testing.T) {
if isOwnerID("charlie", []string{"alice", "bob"}) {
t.Error("charlie is not in owner list")
}
}
// ---- helpers ----
func containsSubstr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}