feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat

This commit is contained in:
AetherForge
2026-05-30 23:16:50 -07:00
parent 4207f6c21b
commit d010292333
26 changed files with 2499 additions and 134 deletions

View File

@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"crypto-miner-server/internal/db"
)
@@ -37,23 +38,34 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func writeConfigJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// GET /api/v1/config
func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request) {
configJSON := h.config.GetConfigJSON()
w.Header().Set("Content-Type", "application/json")
w.Write(configJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(configJSON)
}
// PUT /api/v1/config
func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
writeConfigJSONError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := h.config.UpdateConfigFromJSON(body); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
status := http.StatusInternalServerError
if strings.HasPrefix(err.Error(), "invalid config:") {
status = http.StatusBadRequest
}
writeConfigJSONError(w, status, err.Error())
return
}

View File

@@ -0,0 +1,208 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"crypto-miner-server/internal/db"
)
// stubConfigProvider implements ConfigProvider for handler unit tests.
type stubConfigProvider struct {
configJSON json.RawMessage
updateErr error
updated json.RawMessage
}
func (s *stubConfigProvider) GetConfigJSON() json.RawMessage {
if len(s.configJSON) == 0 {
return json.RawMessage(`{"port":8989,"pool":{"host":"pool.example.com","port":3333,"use_tls":true}}`)
}
return s.configJSON
}
func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
if s.updateErr != nil {
return s.updateErr
}
s.updated = append(json.RawMessage(nil), data...)
s.configJSON = append(json.RawMessage(nil), data...)
return nil
}
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)
}
func TestNewConfigHandler(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
if h == nil || h.config == nil || h.db == nil {
t.Fatal("NewConfigHandler returned incomplete handler")
}
}
func TestConfigHandlerServeHTTP_MethodNotAllowed(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
for _, method := range []string{http.MethodPost, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/config", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("%s: expected 405, got %d", method, rec.Code)
}
}
}
func TestConfigHandlerGetConfig(t *testing.T) {
stub := &stubConfigProvider{
configJSON: json.RawMessage(`{"port":9001,"wallet":{"address":"48abc"}}`),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("expected JSON content-type, got %q", ct)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["port"].(float64) != 9001 {
t.Fatalf("unexpected config body: %v", body)
}
}
func TestConfigHandlerPutConfig_SuccessReturnsUpdated(t *testing.T) {
stub := &stubConfigProvider{
configJSON: json.RawMessage(`{"port":8989}`),
}
h := newTestConfigHandler(t, stub)
payload := `{"port":9100,"pool":{"host":"new.pool","port":4444,"use_tls":false}}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(payload))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status=%d body=%s", rec.Code, rec.Body.String())
}
if string(stub.updated) != payload {
t.Fatalf("provider did not receive payload: %q", stub.updated)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["port"].(float64) != 9100 {
t.Fatalf("response not updated: %v", body)
}
}
func TestConfigHandlerPutConfig_InvalidJSON(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{not json`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatalf("error body not valid JSON: %s", rec.Body.String())
}
if errBody["error"] != "Invalid JSON" {
t.Fatalf("unexpected error: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_InvalidConfigBadRequest(t *testing.T) {
stub := &stubConfigProvider{
updateErr: fmt.Errorf("invalid config: unexpected EOF"),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid config, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestConfigHandlerPutConfig_SaveErrorInternalServerError(t *testing.T) {
stub := &stubConfigProvider{
updateErr: errors.New("failed to save config: disk full"),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatal(err)
}
if !strings.Contains(errBody["error"], "disk full") {
t.Fatalf("unexpected error message: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_ErrorJSONEscapesQuotes(t *testing.T) {
stub := &stubConfigProvider{
updateErr: errors.New(`failed: say "hello"`),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatalf("malformed JSON error response: %s", rec.Body.String())
}
if errBody["error"] != `failed: say "hello"` {
t.Fatalf("unexpected escaped error: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_EmptyBodyInvalidJSON(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", nil)
req.Body = io.NopCloser(bytes.NewReader(nil))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty body, got %d", rec.Code)
}
}
func TestConfigProviderInterface(t *testing.T) {
var _ ConfigProvider = (*stubConfigProvider)(nil)
}

View File

@@ -0,0 +1,893 @@
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) {
t.Helper()
xmrPriceMu.Lock()
xmrPriceCache = nil
xmrPriceMu.Unlock()
t.Cleanup(func() {
xmrPriceMu.Lock()
xmrPriceCache = nil
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}
}, 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) {
resetXMRPriceCache(t)
fetchedAt := time.Now().Add(-2 * time.Minute)
xmrPriceMu.Lock()
xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
xmrPriceMu.Unlock()
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) != 165.5 || body["source"] != "coingecko" {
t.Fatalf("unexpected body: %v", body)
}
}
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)
}
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) {
resetXMRPriceCache(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) {
resetXMRPriceCache(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 := *fh
bad.ws = nil
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 := *fh
bad.ws = nil
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)
}
}

View File

@@ -532,6 +532,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
Services []struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"`
StartType string `json:"start_type"`
} `json:"services,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
@@ -598,6 +604,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.AgentElevated != nil {
broadcast["agent_elevated"] = *stats.AgentElevated
}
if len(stats.Services) > 0 {
broadcast["services"] = stats.Services
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":