Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
@@ -140,8 +140,14 @@ func (h *AIHandler) HandleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ─── Decide ───────────────────────────────────
|
||||
|
||||
// decideRequest carries the agent state for an AI decision cycle.
|
||||
// OllamaEndpoint and Model are intentionally ignored on the server side —
|
||||
// the engine endpoint is set server-side when the agent authenticates via WS
|
||||
// to prevent SSRF via caller-supplied URLs.
|
||||
type decideRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
// OllamaEndpoint is accepted in the payload for forward-compat but NEVER used;
|
||||
// the server uses only the endpoint set during WS authentication.
|
||||
OllamaEndpoint string `json:"ollama_endpoint,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ollama.AgentState
|
||||
@@ -159,16 +165,14 @@ func (h *AIHandler) handleDecide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create engine for this agent
|
||||
// Only use the engine that was registered when the agent authenticated via
|
||||
// WebSocket — do NOT create a new engine from caller-supplied OllamaEndpoint,
|
||||
// which would allow SSRF by pointing the server at an internal URL.
|
||||
engine := h.GetEngine(req.AgentID)
|
||||
if engine == nil {
|
||||
// Create engine on first request
|
||||
h.SetEngineForAgent(req.AgentID, req.OllamaEndpoint, req.Model)
|
||||
engine = h.GetEngine(req.AgentID)
|
||||
}
|
||||
|
||||
if engine == nil {
|
||||
http.Error(w, "failed to create AI engine", http.StatusInternalServerError)
|
||||
// Agent has not yet authenticated via WebSocket; reject to prevent
|
||||
// unauthenticated callers from triggering outbound Ollama requests.
|
||||
http.Error(w, "agent not registered — authenticate via WebSocket first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -180,11 +180,12 @@ func TestAIHandleDecideSuccess(t *testing.T) {
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
// Engine must be pre-registered via WS auth (caller-supplied endpoint is ignored to prevent SSRF).
|
||||
h.SetEngineForAgent("agent-decide-ok", srv.URL, "test-model")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-ok",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "test-model",
|
||||
"hostname": "host1",
|
||||
"agent_id": "agent-decide-ok",
|
||||
"hostname": "host1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -220,9 +221,11 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
|
||||
// Engine must be pre-registered; caller-supplied endpoint is ignored (SSRF prevention).
|
||||
h.SetEngineForAgent("agent-decide-fail", srv.URL, "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-fail",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"agent_id": "agent-decide-fail",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -251,25 +254,27 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
|
||||
// TestAIHandleDecideRejectsUnregisteredAgent verifies that decide returns 403
|
||||
// when the agent has not first authenticated via WebSocket. This prevents SSRF
|
||||
// by ensuring the server never initiates an outbound Ollama request to a
|
||||
// caller-supplied URL.
|
||||
func TestAIHandleDecideRejectsUnregisteredAgent(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("idle", nil)
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-new",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"agent_id": "agent-not-in-ws",
|
||||
"ollama_endpoint": "http://internal-server/v1/chat",
|
||||
"model": "m1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unregistered agent should get 403, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if h.GetEngine("agent-new") == nil {
|
||||
t.Fatal("engine should exist after first decide")
|
||||
// Engine must NOT be created from the caller-supplied URL.
|
||||
if h.GetEngine("agent-not-in-ws") != nil {
|
||||
t.Fatal("engine should NOT be created from caller-supplied endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// ConfigHandler handles GET/PUT for server configuration settings
|
||||
type ConfigHandler struct {
|
||||
db *db.Database
|
||||
config ConfigProvider
|
||||
}
|
||||
|
||||
@@ -20,11 +17,8 @@ type ConfigProvider interface {
|
||||
UpdateConfigFromJSON(data json.RawMessage) error
|
||||
}
|
||||
|
||||
func NewConfigHandler(database *db.Database, cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
db: database,
|
||||
config: cp,
|
||||
}
|
||||
func NewConfigHandler(cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{config: cp}
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// stubConfigProvider implements ConfigProvider for handler unit tests.
|
||||
@@ -39,17 +37,12 @@ func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
|
||||
func newTestConfigHandler(t *testing.T, cp ConfigProvider) *ConfigHandler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return NewConfigHandler(database, cp)
|
||||
return NewConfigHandler(cp)
|
||||
}
|
||||
|
||||
func TestNewConfigHandler(t *testing.T) {
|
||||
h := newTestConfigHandler(t, &stubConfigProvider{})
|
||||
if h == nil || h.config == nil || h.db == nil {
|
||||
if h == nil || h.config == nil {
|
||||
t.Fatal("NewConfigHandler returned incomplete handler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ type xmrPriceEntry struct {
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
xmrPriceMu sync.Mutex
|
||||
xmrPriceCache *xmrPriceEntry
|
||||
xmrPriceTTL = 10 * time.Minute
|
||||
)
|
||||
const xmrPriceTTL = 10 * time.Minute
|
||||
|
||||
type FleetHandler struct {
|
||||
db *db.Database
|
||||
@@ -37,6 +33,10 @@ type FleetHandler struct {
|
||||
alerts *alerts.Evaluator
|
||||
defaultPool pool.Config
|
||||
|
||||
// XMR price cache — per-handler so multiple routers in one process stay isolated.
|
||||
xmrPriceMu sync.Mutex
|
||||
xmrPriceCache *xmrPriceEntry
|
||||
|
||||
// Real-earnings cache (avoids hammering the pool API)
|
||||
earningsMu sync.Mutex
|
||||
earningsCache map[string]*poolEarningsCache
|
||||
@@ -87,11 +87,11 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
|
||||
// GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes.
|
||||
// Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully.
|
||||
func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
xmrPriceMu.Lock()
|
||||
if xmrPriceCache != nil && time.Since(xmrPriceCache.fetchedAt) < xmrPriceTTL {
|
||||
usd := xmrPriceCache.USD
|
||||
at := xmrPriceCache.fetchedAt
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Lock()
|
||||
if f.xmrPriceCache != nil && time.Since(f.xmrPriceCache.fetchedAt) < xmrPriceTTL {
|
||||
usd := f.xmrPriceCache.USD
|
||||
at := f.xmrPriceCache.fetchedAt
|
||||
f.xmrPriceMu.Unlock()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
"fetched_at": at.UTC().Format(time.RFC3339),
|
||||
@@ -99,7 +99,7 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Unlock()
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
|
||||
@@ -118,9 +118,9 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
usd := raw["monero"]["usd"]
|
||||
|
||||
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = entry
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Lock()
|
||||
f.xmrPriceCache = entry
|
||||
f.xmrPriceMu.Unlock()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
|
||||
@@ -43,15 +43,15 @@ func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
}
|
||||
|
||||
func resetXMRPriceCache(t *testing.T) {
|
||||
func resetXMRPriceCache(t *testing.T, fh *FleetHandler) {
|
||||
t.Helper()
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = nil
|
||||
xmrPriceMu.Unlock()
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = nil
|
||||
fh.xmrPriceMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = nil
|
||||
xmrPriceMu.Unlock()
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = nil
|
||||
fh.xmrPriceMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -270,13 +270,12 @@ func TestFleetGetAIActivityWithEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceCacheHit(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
fetchedAt := time.Now().Add(-2 * time.Minute)
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
|
||||
xmrPriceMu.Unlock()
|
||||
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
resetXMRPriceCache(t, fh)
|
||||
fetchedAt := time.Now().Add(-2 * time.Minute)
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
|
||||
fh.xmrPriceMu.Unlock()
|
||||
rec := httptest.NewRecorder()
|
||||
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -292,7 +291,6 @@ func TestFleetGetXMRPriceCacheHit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Host, "coingecko.com") {
|
||||
t.Fatalf("unexpected host %s", req.URL.Host)
|
||||
@@ -319,7 +317,6 @@ func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
})
|
||||
@@ -333,7 +330,6 @@ func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceParseError(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type mockConfigProvider struct {
|
||||
@@ -31,6 +39,7 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
const testFleetSecret = "test-fleet-secret-integration"
|
||||
|
||||
func seedTestUsers(t *testing.T, dataDir string) {
|
||||
t.Helper()
|
||||
@@ -44,7 +53,7 @@ func seedTestUsers(t *testing.T, dataDir string) {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
@@ -57,7 +66,7 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
@@ -68,11 +77,95 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes).
|
||||
func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.Header.Set("X-Fleet-Secret", secret)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func insertTestBuild(t *testing.T, database *db.Database, dataDir, buildID, platform, fileName string) {
|
||||
t.Helper()
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binPath := filepath.Join(buildDir, fileName)
|
||||
if err := os.WriteFile(binPath, []byte("fake-binary-"+platform), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "worker", ServerURL: "http://localhost:8989", Wallet: "48x",
|
||||
FilePath: binPath, FileName: fileName, Platform: platform, CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func startRouterServer(t *testing.T, router http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func connectAgentViaRouter(t *testing.T, router http.Handler, agentID string) (*websocket.Conn, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := startRouterServer(t, router)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
authPayload, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"hostname": "integration-host",
|
||||
"version": "1.0",
|
||||
})
|
||||
if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
|
||||
t.Fatalf("send auth: %v", err)
|
||||
}
|
||||
var resp Message
|
||||
if err := conn.ReadJSON(&resp); err != nil {
|
||||
t.Fatalf("read auth_response: %v", err)
|
||||
}
|
||||
if resp.Type != "auth_response" {
|
||||
t.Fatalf("expected auth_response, got %q", resp.Type)
|
||||
}
|
||||
return conn, srv
|
||||
}
|
||||
|
||||
func TestHealthIsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -89,7 +182,7 @@ func TestHealthIsPublic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -99,7 +192,7 @@ func TestConfigRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -110,7 +203,7 @@ func TestConfigWithValidAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -120,7 +213,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -138,13 +231,14 @@ func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestArtifactDownloadRejectsTraversal(t *testing.T) {
|
||||
router, dataDir := newTestRouter(t)
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
buildID := "test-build-id"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
@@ -153,7 +247,7 @@ func TestArtifactDownloadRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSPAServesIndex(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -179,7 +273,7 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -189,3 +283,437 @@ func TestStatsLimitCapped(t *testing.T) {
|
||||
t.Fatalf("limit cap caused server error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationServerInfo(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/server/info", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["port"]; !ok {
|
||||
t.Fatalf("expected port in server info: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDashboardStats(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/dashboard/stats", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationGetAgentNotFound(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/missing-agent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentLog(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
agentID := "log-agent"
|
||||
connectTestAgent(t, wsHub, agentID)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/"+agentID+"/log", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["agent_id"] != agentID {
|
||||
t.Fatalf("unexpected agent_id: %v", body["agent_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentCommandOffline(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
|
||||
[]byte(`{"action":"pause"}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for offline agent, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentMeta(t *testing.T) {
|
||||
router, _, database, _ := newTestRouter(t)
|
||||
agent := &models.Agent{ID: "meta-rig", Name: "rig", Status: "offline", LastSeen: time.Now()}
|
||||
if err := database.UpsertAgent(agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/agents/meta-rig/meta",
|
||||
[]byte(`{"notes":"integration test","tags":["lab"]}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBulkCommandPartialFailure(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
onlineID := "bulk-online"
|
||||
connectTestAgent(t, wsHub, onlineID)
|
||||
|
||||
payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/bulk-command", []byte(payload))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != true {
|
||||
t.Fatalf("expected partial success true, got %v", body)
|
||||
}
|
||||
if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
|
||||
t.Fatalf("sent/failed counts: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationFleetReadEndpoints(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
paths := []string{
|
||||
"/api/v1/alerts",
|
||||
"/api/v1/pools/status",
|
||||
"/api/v1/ai/activity",
|
||||
"/api/v1/earnings/estimate",
|
||||
"/api/v1/shares",
|
||||
"/api/v1/builds",
|
||||
}
|
||||
for _, path := range paths {
|
||||
rec := serveAuthed(t, router, http.MethodGet, path, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationMarketXMR(t *testing.T) {
|
||||
setMockHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.String(), "coingecko") {
|
||||
return nil, errors.New("unexpected url")
|
||||
}
|
||||
body := `{"monero":{"usd":165.5}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}))
|
||||
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/market/xmr", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["usd"].(float64) != 165.5 {
|
||||
t.Fatalf("unexpected price: %v", body["usd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuildsLifecycle(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
buildID := "lifecycle-build"
|
||||
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodPut, "/api/v1/builds/"+buildID+"/pin", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("pin status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/pin", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unpin status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/"+buildID, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPutConfig(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/config", []byte(`{"port":9090}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "9090") {
|
||||
t.Fatalf("expected updated config: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuilderRoutes(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/builder/build", []byte(`{}`))
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatal("builder/build route not registered")
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/estimate", []byte("not-multipart"))
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatal("builder/estimate route not registered")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("estimate without multipart expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builder/cancel/no-such-token", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBlueprintsCRUD(t *testing.T) {
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
|
||||
saveBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "integration-preset",
|
||||
"data": map[string]interface{}{"threads": 2},
|
||||
})
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", saveBody)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dataDir, "blueprints", "integration-preset.json")
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
t.Fatalf("blueprint file missing: %v", err)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/integration-preset", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints?name=integration-preset", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBlueprintErrors(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", []byte(`{"name":"","data":{}}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty name expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/missing-preset", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing blueprint expected 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("delete without name expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAIRoutes(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
// Agent routes require fleet secret (not Basic Auth).
|
||||
SetAgentPathSecret(testFleetSecret)
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
// decide: missing agent_id → 400.
|
||||
rec := serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/decide", testFleetSecret, []byte(`{"worker_name":"x"}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("decide missing agent_id expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// report and heartbeat require fleet secret; they don't need a pre-registered engine.
|
||||
body, _ := json.Marshal(map[string]string{"agent_id": "report-agent", "tool": "sleep", "output": "ok"})
|
||||
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/report", testFleetSecret, body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("report status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(map[string]string{"agent_id": "hb-agent", "status": "alive"})
|
||||
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/heartbeat", testFleetSecret, hb)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("heartbeat status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentRoutesFleetSecret(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
SetAgentPathSecret("integration-fleet-secret")
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat",
|
||||
bytes.NewReader([]byte(`{"agent_id":"a","status":"alive"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing fleet secret expected 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Fleet-Secret", "integration-fleet-secret")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid fleet secret expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDropperVariants(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("no builds expected 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
insertTestBuild(t, database, dataDir, "win-drop", "windows", "worker.exe")
|
||||
req = httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("windows build expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/get?os=linux", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("linux with only windows build falls back to latest, expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0)")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("UA-detected windows expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/install.sh", "/install.ps1"} {
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "forge.local:8989"
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuildUninstallNotFound(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
buildID := "uninstall-build"
|
||||
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds/"+buildID+"/uninstall", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing uninstall script expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketDashboard(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
srv := startRouterServer(t, router)
|
||||
|
||||
badURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard"
|
||||
_, resp, err := websocket.DefaultDialer.Dial(badURL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected dial failure without token")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got err=%v status=%v", err, resp)
|
||||
}
|
||||
|
||||
goodURL := badURL + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(goodURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial with token: %v status=%v", err, resp)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("read init: %v", err)
|
||||
}
|
||||
if msg.Type != "init" {
|
||||
t.Fatalf("expected init, got %q", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketAgentBadSecret(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
wsHub.SetFleetSecret("ws-fleet-secret")
|
||||
t.Cleanup(func() { wsHub.SetFleetSecret("") })
|
||||
|
||||
srv := startRouterServer(t, router)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "bad-secret-agent", "fleet_secret": "wrong", "hostname": "host",
|
||||
})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != false {
|
||||
t.Fatalf("expected auth failure with bad fleet secret, got %+v", body)
|
||||
}
|
||||
if wsHub.isAgentConnected("bad-secret-agent") {
|
||||
t.Fatal("agent should not register with bad fleet secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
agentID := "router-cmd-agent"
|
||||
connectAgentViaRouter(t, router, agentID)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if wsHub.isAgentConnected(agentID) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !wsHub.isAgentConnected(agentID) {
|
||||
t.Fatal("agent not connected via router ws")
|
||||
}
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command",
|
||||
[]byte(`{"action":"pause"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,10 +216,11 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
path := r.URL.Path
|
||||
|
||||
// Health check and download endpoints are always open.
|
||||
// Health check and one-liner installer endpoints are always open.
|
||||
// NOTE: build download/artifact routes are intentionally NOT in this list —
|
||||
// they require fleet-secret or Basic Auth (see isDownload block below).
|
||||
if path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -227,22 +228,44 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
// Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret
|
||||
// in the X-Fleet-Secret header instead of Basic auth. This ensures only
|
||||
// legitimately forged agents can call these endpoints.
|
||||
// A missing or empty fleet secret is always rejected — the server auto-
|
||||
// generates one at startup so this state should never occur in production.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
secret := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
if secret != "" {
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if secret == "" {
|
||||
http.Error(w, "server not ready: fleet secret not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Secret is empty (first run before config save) or matched — allow through.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Build download/artifact/uninstall routes: accept fleet secret OR Basic Auth.
|
||||
// This lets forged agents self-upgrade (they have the fleet secret baked in)
|
||||
// while still requiring credentials for unauthenticated callers.
|
||||
isDownload := strings.HasPrefix(path, "/api/v1/builds/") &&
|
||||
(strings.HasSuffix(path, "/download") ||
|
||||
strings.Contains(path, "/artifact/") ||
|
||||
strings.HasSuffix(path, "/uninstall"))
|
||||
if isDownload {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
secret := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if secret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Fall through to Basic Auth below.
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
|
||||
@@ -86,7 +86,9 @@ func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
|
||||
// TestBasicAuthMiddlewareBuildDownloadRequiresAuth verifies that build download
|
||||
// routes are no longer publicly accessible — they require fleet secret or Basic Auth.
|
||||
func TestBasicAuthMiddlewareBuildDownloadRequiresAuth(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -95,12 +97,25 @@ func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
|
||||
"/api/v1/builds/abc/download",
|
||||
"/api/v1/builds/abc/artifact/worker.exe",
|
||||
}
|
||||
for _, path := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s without auth should be 401, got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Fleet secret should grant access.
|
||||
SetAgentPathSecret("test-secret-abc")
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
req.Header.Set("X-Fleet-Secret", "test-secret-abc")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s should be public, got %d", path, rec.Code)
|
||||
t.Fatalf("%s with fleet secret should be 200, got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +227,7 @@ func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterPostUsersValidation(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
@@ -224,7 +239,7 @@ func TestRouterPostUsersValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterPostUsersSuccess(t *testing.T) {
|
||||
router, dataDir := newTestRouter(t)
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
|
||||
@@ -250,7 +265,7 @@ func TestRouterPostUsersSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -261,7 +276,7 @@ func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretSuccess(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
SetRotateSecretFn(func() (string, error) {
|
||||
return "new-secret-token-xyz", nil
|
||||
})
|
||||
@@ -284,7 +299,7 @@ func TestRouterRotateSecretSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterBuilderCancelNotFound(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -294,7 +309,10 @@ func TestRouterBuilderCancelNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterBuildDownloadNoAuth(t *testing.T) {
|
||||
// TestRouterBuildDownloadAuth verifies that build download routes require either
|
||||
// the fleet secret (X-Fleet-Secret header) or Basic Auth — they are no longer
|
||||
// publicly accessible without credentials.
|
||||
func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
database, err := db.New(dataDir)
|
||||
@@ -319,25 +337,50 @@ func TestRouterBuildDownloadNoAuth(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const testSecret = "test-fleet-secret-12345"
|
||||
SetAgentPathSecret(testSecret)
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil)
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
// 1. Unauthenticated → 401.
|
||||
req := httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated download should be 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. Valid fleet secret → 200.
|
||||
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
req.Header.Set("X-Fleet-Secret", testSecret)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download should be public, got %d body=%s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("fleet-secret download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Basic Auth → 200.
|
||||
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("basic-auth download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
for _, path := range []string{"/install.sh", "/install.ps1"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "forge.local:8989"
|
||||
@@ -353,7 +396,7 @@ func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -376,7 +419,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
|
||||
Reference in New Issue
Block a user