Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
888 lines
28 KiB
Go
888 lines
28 KiB
Go
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)
|
|
}
|
|
}
|