package api import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" ) // Numeric constants from fleet_handler.go (guard against silent drift). const ( fleetEarningsCacheTTL = 5 * time.Minute fleetXMRPriceTTL = 10 * time.Minute fleetNetworkHashrate = 3_000_000_000.0 fleetDailyEmissionXMR = 432.0 fleetPiconeroPerXMR = 1e12 fleetGetLogTailLines = 300 ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func setMockHTTPTransport(t *testing.T, fn roundTripFunc) { t.Helper() orig := http.DefaultTransport http.DefaultTransport = fn t.Cleanup(func() { http.DefaultTransport = orig }) } func resetXMRPriceCache(t *testing.T, fh *FleetHandler) { t.Helper() fh.xmrPriceMu.Lock() fh.xmrPriceCache = nil fh.xmrPriceMu.Unlock() t.Cleanup(func() { fh.xmrPriceMu.Lock() fh.xmrPriceCache = nil fh.xmrPriceMu.Unlock() }) } func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AIHandler) { t.Helper() database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) ws := NewWSHub(database) ai := NewAIHandler(database) fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{}) return fh, database, ws, ai } func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handler { r := chi.NewRouter() switch method { case http.MethodGet: r.Get(pattern, handler) case http.MethodPost: r.Post(pattern, handler) case http.MethodPut: r.Put(pattern, handler) default: panic("unsupported method " + method) } return r } func connectTestAgent(t *testing.T, hub *WSHub, agentID string) *websocket.Conn { t.Helper() srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS)) t.Cleanup(srv.Close) wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") 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": "test-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) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if hub.isAgentConnected(agentID) { return conn } time.Sleep(10 * time.Millisecond) } t.Fatal("agent not connected after auth") return nil } func TestFleetConstants(t *testing.T) { if fleetEarningsCacheTTL != earningsCacheTTL || fleetXMRPriceTTL != xmrPriceTTL || fleetNetworkHashrate != 3_000_000_000.0 || fleetDailyEmissionXMR != 432.0 { t.Fatal("fleet_handler constants drifted from documented values") } out := EstimateXMRPerDay(fleetNetworkHashrate) if out["network_hashrate"].(float64) != fleetNetworkHashrate { t.Fatalf("network_hashrate want %v got %v", fleetNetworkHashrate, out["network_hashrate"]) } xmr, ok := out["xmr_per_day"].(float64) if !ok || xmr != fleetDailyEmissionXMR { t.Fatalf("at network hashrate want xmr_per_day=%v got %v", fleetDailyEmissionXMR, xmr) } } func TestFleetNewFleetHandler(t *testing.T) { fh, database, ws, ai := newTestFleetHandler(t) if fh.db != database || fh.ws != ws || fh.ai != ai { t.Fatal("NewFleetHandler did not wire dependencies") } if fh.earningsCache != nil { t.Fatal("expected nil earnings cache at init") } } func TestFleetGetAlertsNilEvaluator(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body []alerts.AlertEvent if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 { t.Fatalf("expected empty alerts array, got %s", rec.Body.String()) } } func TestFleetGetAlertsWithEvaluator(t *testing.T) { database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) agent := &models.Agent{ ID: "alert-agent", Name: "offline-rig", Status: "offline", LastSeen: time.Now().Add(-30 * time.Minute), } if err := database.UpsertAgent(agent); err != nil { t.Fatal(err) } evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { return alerts.Thresholds{OfflineMinutes: 5} }, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil) evaluator.RunOnce() fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}) rec := httptest.NewRecorder() fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body []alerts.AlertEvent if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if len(body) != 1 || body[0].Type != "offline" { t.Fatalf("expected offline alert, got %+v", body) } } func TestFleetGetPoolStatusNilManager(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" { var body []pool.PoolStatus if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 { t.Fatalf("expected empty pool status, got %s", rec.Body.String()) } } } func TestFleetGetPoolStatusWithManager(t *testing.T) { pm := pool.NewManager(nil, nil) fh, _, _, _ := newTestFleetHandler(t) fh.pools = pm rec := httptest.NewRecorder() fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body []pool.PoolStatus if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if len(body) != 0 { t.Fatalf("expected empty list from manager with no pools, got %d", len(body)) } } func TestFleetGetAIActivityNilHandler(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body []AIActivityEntry if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 { t.Fatalf("expected empty activity, got %s", rec.Body.String()) } } func TestFleetGetAIActivityWithEntries(t *testing.T) { fh, _, _, ai := newTestFleetHandler(t) ai.recordActivity(AIActivityEntry{ AgentID: "agent-x", LastAction: "decide", LastTool: "ok", }) rec := httptest.NewRecorder() fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body []AIActivityEntry if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if len(body) != 1 || body[0].AgentID != "agent-x" { t.Fatalf("unexpected activity: %+v", body) } } func TestFleetGetXMRPriceCacheHit(t *testing.T) { 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 { 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 || body["source"] != "coingecko" { t.Fatalf("unexpected body: %v", body) } } func TestFleetGetXMRPriceFetchSuccess(t *testing.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) } rec := httptest.NewRecorder() rec.Header().Set("Content-Type", "application/json") _, _ = rec.Write([]byte(`{"monero":{"usd":200.25}}`)) return rec.Result(), nil }) fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/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) != 200.25 { t.Fatalf("unexpected usd: %v", body["usd"]) } } func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) { setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { return nil, errors.New("network down") }) fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d body %s", rec.Code, rec.Body.String()) } } func TestFleetGetXMRPriceParseError(t *testing.T) { setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { rec := httptest.NewRecorder() rec.WriteHeader(http.StatusOK) _, _ = rec.Write([]byte(`not-json`)) return rec.Result(), nil }) fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil)) if rec.Code != http.StatusBadGateway { t.Fatalf("expected 502, got %d body %s", rec.Code, rec.Body.String()) } } func TestFleetEstimateXMRPerDay(t *testing.T) { zero := EstimateXMRPerDay(0) if zero["xmr_per_day"].(float64) != 0 { t.Fatalf("zero hashrate should yield 0 xmr, got %v", zero["xmr_per_day"]) } if zero["usd_per_day"] != nil { t.Fatal("usd_per_day should be nil without price feed") } atNetwork := EstimateXMRPerDay(fleetNetworkHashrate) if atNetwork["xmr_per_day"].(float64) != fleetDailyEmissionXMR { t.Fatalf("full network share want %v got %v", fleetDailyEmissionXMR, atNetwork["xmr_per_day"]) } half := EstimateXMRPerDay(fleetNetworkHashrate / 2) wantHalf := fleetDailyEmissionXMR / 2 if half["xmr_per_day"].(float64) != wantHalf { t.Fatalf("half network want %v got %v", wantHalf, half["xmr_per_day"]) } } func TestFleetParseFloatQuery(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/?hashrate=123.45&bad=abc", nil) if got := parseFloatQuery(req, "hashrate", 0); got != 123.45 { t.Fatalf("hashrate want 123.45 got %v", got) } if got := parseFloatQuery(req, "missing", 9); got != 9 { t.Fatalf("missing key want default 9 got %v", got) } if got := parseFloatQuery(req, "bad", 7); got != 7 { t.Fatalf("invalid float want default 7 got %v", got) } } func TestFleetGetEarningsEstimateOnly(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000000", nil) fh.GetEarnings(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["source"] != nil { t.Fatalf("expected estimate-only response, got source=%v", body["source"]) } if body["hashrate"].(float64) != 1_000_000 { t.Fatalf("hashrate not parsed: %v", body["hashrate"]) } } func TestFleetGetEarningsWalletFromBuild(t *testing.T) { fh, database, _, _ := newTestFleetHandler(t) wallet := "48buildwalletaddress0000000000000000000000000000000000000000000000000000000000" if err := database.InsertBuild(&models.BuildRecord{ ID: "build-earn-1", Wallet: wallet, CreatedAt: time.Now(), }); err != nil { t.Fatal(err) } setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { if !strings.Contains(req.URL.Host, "supportxmr.com") { return nil, errors.New("unexpected host") } rec := httptest.NewRecorder() rec.Header().Set("Content-Type", "application/json") _, _ = rec.Write([]byte(`{ "amtDue": 1000000000000, "amtPaid": 2000000000000, "totalHashes": 999, "hashRate": 5000, "lastPaymentTs": 1609459200, "lastPayment": 500000000000 }`)) return rec.Result(), nil }) rec := httptest.NewRecorder() fh.GetEarnings(rec, httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000", 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["source"] != "pool_api" { t.Fatalf("expected pool_api source, got %v", body["source"]) } if body["pending_xmr"].(float64) != 1.0 { t.Fatalf("pending_xmr want 1.0 got %v", body["pending_xmr"]) } if body["paid_xmr"].(float64) != 2.0 { t.Fatalf("paid_xmr want 2.0 got %v", body["paid_xmr"]) } if body["last_payment_xmr"].(float64) != 0.5 { t.Fatalf("last_payment_xmr want 0.5 got %v", body["last_payment_xmr"]) } } func TestFleetGetEarningsPoolAPIFallback(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { return nil, errors.New("pool unreachable") }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=48fallback&hashrate=2000000", nil) fh.GetEarnings(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var body map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["source"] != nil { t.Fatalf("expected estimate fallback without source, got %v", body["source"]) } if body["hashrate"].(float64) != 2_000_000 { t.Fatalf("hashrate %v", body["hashrate"]) } } func TestFleetGetEarningsEstimateDelegates(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) req := httptest.NewRequest(http.MethodGet, "/earnings/estimate?hashrate=500000", nil) recDirect := httptest.NewRecorder() fh.GetEarnings(recDirect, req) recDelegate := httptest.NewRecorder() fh.GetEarningsEstimate(recDelegate, req) if recDirect.Body.String() != recDelegate.Body.String() { t.Fatalf("GetEarningsEstimate should delegate to GetEarnings\ndirect: %s\ndelegate: %s", recDirect.Body.String(), recDelegate.Body.String()) } } func TestFleetFetchPoolEarningsCache(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) cached := map[string]interface{}{"pending_xmr": 3.14} fh.earningsMu.Lock() fh.earningsCache = map[string]*poolEarningsCache{ "cached-wallet": {data: cached, fetchedAt: time.Now()}, } fh.earningsMu.Unlock() got, err := fh.fetchPoolEarnings("cached-wallet") if err != nil { t.Fatal(err) } if got["pending_xmr"].(float64) != 3.14 { t.Fatalf("cache miss or wrong data: %v", got) } } func TestFleetFetchPoolEarningsHTTPError(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { rec := httptest.NewRecorder() rec.WriteHeader(http.StatusNotFound) return rec.Result(), nil }) if _, err := fh.fetchPoolEarnings("missing-wallet"); err == nil { t.Fatal("expected error for non-200 pool response") } } func TestFleetGetAgentLogNilWS(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) fh.ws = nil rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/agents/a1/log", nil) fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } } func TestFleetGetAgentLogContentAndRefresh(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) ws.mu.Lock() ws.agentLogs["agent-log-1"] = "line1\nline2" ws.mu.Unlock() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/agents/agent-log-1/log?refresh=1", nil) fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req) 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["content"] != "line1\nline2" { t.Fatalf("content %v", body["content"]) } } func TestFleetPostAgentCommandErrors(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) t.Run("nil ws", func(t *testing.T) { bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } }) t.Run("invalid json", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) t.Run("missing action", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) t.Run("agent not connected", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command", strings.NewReader(`{"action":"pause"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body %s", rec.Code, rec.Body.String()) } }) t.Run("broadcast all no agents", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/all/command", strings.NewReader(`{"action":"pause"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var body map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["success"] != false || body["error"] != "no connected agents" { t.Fatalf("unexpected body: %v", body) } }) _ = ws } func TestFleetPostAgentCommandSuccess(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) agentID := "cmd-agent-1" conn := connectTestAgent(t, ws, agentID) rec := httptest.NewRecorder() body := `{"action":"get_log","tail_lines":50,"command":"whoami","path":"C:\\","data":"x"}` req := httptest.NewRequest(http.MethodPost, "/agents/"+agentID+"/command", strings.NewReader(body)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } var cmd Message if err := conn.ReadJSON(&cmd); err != nil { t.Fatalf("read command: %v", err) } if cmd.Type != "command" { t.Fatalf("expected command message, got %q", cmd.Type) } var payload map[string]interface{} if err := json.Unmarshal(cmd.Payload, &payload); err != nil { t.Fatal(err) } if payload["action"] != "get_log" || payload["tail_lines"].(float64) != 50 { t.Fatalf("unexpected payload: %v", payload) } } func TestFleetPostAgentCommandBroadcastAll(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) conn := connectTestAgent(t, ws, "broadcast-agent") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/all/command", strings.NewReader(`{"action":"resume"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var cmd Message if err := conn.ReadJSON(&cmd); err != nil { t.Fatalf("read broadcast command: %v", err) } if cmd.Type != "command" { t.Fatalf("expected command, got %q", cmd.Type) } } func TestFleetPutAgentMetaErrors(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) t.Run("not found", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/agents/missing/meta", strings.NewReader(`{"notes":"n","tags":["a"]}`)) fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } }) t.Run("invalid body", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/agents/a1/meta", errReader{}) fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) t.Run("empty id", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{"notes":"n"}`)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) fh.PutAgentMeta(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) } func TestFleetPutAgentMetaSuccess(t *testing.T) { fh, database, _, _ := newTestFleetHandler(t) agent := &models.Agent{ID: "meta-agent", Name: "rig", Status: "offline", LastSeen: time.Now()} if err := database.UpsertAgent(agent); err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/agents/meta-agent/meta", strings.NewReader(`{"notes":"lab box","tags":["gpu","win"]}`)) fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req) 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("unexpected body: %v", body) } agentObj := body["agent"].(map[string]interface{}) if agentObj["notes"] != "lab box" { t.Fatalf("notes not saved: %v", agentObj["notes"]) } } func TestFleetPostBulkCommandErrors(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) t.Run("nil ws", func(t *testing.T) { bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`)) bad.PostBulkCommand(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } }) t.Run("invalid body", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{`)) fh.PostBulkCommand(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) t.Run("missing action", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{"agent_ids":["a"]}`)) fh.PostBulkCommand(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } }) t.Run("missing agent ids", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{"agent_ids":[],"action":"pause"}`)) fh.PostBulkCommand(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var body map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["success"] != false { t.Fatalf("expected success false, got %v", body) } }) } func TestFleetPostBulkCommandPartialSuccess(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) onlineID := "bulk-online" connectTestAgent(t, ws, onlineID) rec := httptest.NewRecorder() payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}` req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(payload)) fh.PostBulkCommand(rec, req) 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 TestFleetMinHelper(t *testing.T) { if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 { t.Fatal("min helper wrong") } } // errReader reuse from ai_handler_test.go for invalid JSON bodies. var _ io.ReadCloser = errReader{} func TestFleetGetLogRefreshUsesTailConstant(t *testing.T) { fh, _, ws, _ := newTestFleetHandler(t) agentID := "tail-agent" conn := connectTestAgent(t, ws, agentID) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/agents/"+agentID+"/log?refresh=1", nil) fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } var cmd Message if err := conn.ReadJSON(&cmd); err != nil { t.Fatalf("read get_log command: %v", err) } var payload map[string]interface{} if err := json.Unmarshal(cmd.Payload, &payload); err != nil { t.Fatal(err) } if payload["action"] != "get_log" { t.Fatalf("action %v", payload["action"]) } if int(payload["tail_lines"].(float64)) != fleetGetLogTailLines { t.Fatalf("tail_lines want %d got %v", fleetGetLogTailLines, payload["tail_lines"]) } } func TestFleetFetchPoolEarningsInvalidJSON(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) { rec := httptest.NewRecorder() rec.WriteHeader(http.StatusOK) _, _ = rec.Write([]byte(`{`)) return rec.Result(), nil }) if _, err := fh.fetchPoolEarnings("bad-json-wallet"); err == nil { t.Fatal("expected unmarshal error") } } func TestFleetGetEarningsWithWalletQueryParam(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) fh.earningsMu.Lock() fh.earningsCache = map[string]*poolEarningsCache{ "query-wallet": { data: map[string]interface{}{"paid_xmr": 9.99}, fetchedAt: time.Now(), }, } fh.earningsMu.Unlock() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=query-wallet", nil) fh.GetEarnings(rec, req) var body map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["paid_xmr"].(float64) != 9.99 || body["source"] != "pool_api" { t.Fatalf("unexpected merged body: %v", body) } } func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) { fh, _, _, _ := newTestFleetHandler(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"action":"pause"}`)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) fh.PostAgentCommand(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty id, got %d", rec.Code) } }