Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators can remove machines from Crucible and dashboard rosters with honest messaging that deletion is registry-only; bulk select, oath ledger entries, and Go/Vitest coverage included.
1125 lines
35 KiB
Go
1125 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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)
|
|
case http.MethodDelete:
|
|
r.Delete(pattern, handler)
|
|
default:
|
|
panic("unsupported method " + method)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func testAgentClientIP(agentID string) string {
|
|
var sum int
|
|
for i, c := range agentID {
|
|
sum += int(c) * (i + 1)
|
|
}
|
|
return fmt.Sprintf("10.42.%d.%d", (sum%250)+1, ((sum/250)%250)+1)
|
|
}
|
|
|
|
func waitForHubAgents(t *testing.T, hub *WSHub, agentIDs ...string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
allConnected := true
|
|
for _, id := range agentIDs {
|
|
if !hub.isAgentConnected(id) {
|
|
allConnected = false
|
|
break
|
|
}
|
|
}
|
|
if allConnected {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("agents not connected: %v", agentIDs)
|
|
}
|
|
|
|
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")
|
|
hdr := http.Header{"X-Forwarded-For": {testAgentClientIP(agentID)}}
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, hdr)
|
|
if err != nil {
|
|
t.Fatalf("dial agent ws: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
authAgentConn(t, conn, map[string]interface{}{
|
|
"agent_id": agentID,
|
|
"hostname": "test-host",
|
|
"version": "1.0",
|
|
})
|
|
|
|
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.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, 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 TestFleetPostAlertTestUnconfigured(t *testing.T) {
|
|
evaluator := alerts.NewEvaluator(nil, func() alerts.Thresholds { return alerts.Thresholds{} },
|
|
func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil)
|
|
fh := NewFleetHandler(nil, NewWSHub(nil), NewAIHandler(nil), nil, evaluator, pool.Config{}, "")
|
|
rec := httptest.NewRecorder()
|
|
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
var body map[string]struct {
|
|
Sent bool `json:"sent"`
|
|
Error *string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["telegram"].Sent || body["smtp"].Sent {
|
|
t.Fatalf("expected no sends, got %+v", body)
|
|
}
|
|
if body["telegram"].Error == nil || *body["telegram"].Error != "not configured" {
|
|
t.Fatalf("telegram: %+v", body["telegram"])
|
|
}
|
|
}
|
|
|
|
func TestFleetPostAlertTestNilEvaluator(t *testing.T) {
|
|
fh, _, _, _ := newTestFleetHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
var body map[string]struct {
|
|
Error *string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["telegram"].Error == nil || *body["telegram"].Error != "alert evaluator not configured" {
|
|
t.Fatalf("got %+v", body["telegram"])
|
|
}
|
|
}
|
|
|
|
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 TestFleetGetXMRPriceRetriesOn429(t *testing.T) {
|
|
attempts := 0
|
|
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
|
attempts++
|
|
rec := httptest.NewRecorder()
|
|
if attempts < 2 {
|
|
rec.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = rec.Write([]byte(`{"error":"rate limit"}`))
|
|
return rec.Result(), nil
|
|
}
|
|
rec.Header().Set("Content-Type", "application/json")
|
|
_, _ = rec.Write([]byte(`{"monero":{"usd":123.45}}`))
|
|
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("expected 200 after retry, got %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
if attempts < 2 {
|
|
t.Fatalf("expected retry on 429, attempts=%d", attempts)
|
|
}
|
|
}
|
|
|
|
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":"get_log"}`))
|
|
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %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"] != false || body["error"] != "agent not connected" {
|
|
t.Fatalf("unexpected body: %v", body)
|
|
}
|
|
})
|
|
|
|
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())
|
|
}
|
|
|
|
cmd := readAgentWSMessage(t, conn, "command")
|
|
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)
|
|
}
|
|
|
|
cmd := readAgentWSMessage(t, conn, "command")
|
|
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 TestFleetPostBulkCommandPowerManagementMeta(t *testing.T) {
|
|
fh, _, ws, _ := newTestFleetHandler(t)
|
|
connectTestAgent(t, ws, "pm-agent")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
|
strings.NewReader(`{"agent_ids":["pm-agent"],"action":"pause"}`))
|
|
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["category"] != "power_management" {
|
|
t.Fatalf("category = %v", body["category"])
|
|
}
|
|
if body["label"] != "Power down hashing (fleet health job)" {
|
|
t.Fatalf("label = %v", body["label"])
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cmd := readAgentWSMessage(t, conn, "command")
|
|
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)
|
|
}
|
|
}
|
|
|
|
func readDashboardWSMessage(t *testing.T, conn *websocket.Conn, wantType string) Message {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
var msg Message
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
t.Fatalf("read %s: %v", wantType, err)
|
|
}
|
|
if msg.Type == wantType {
|
|
return msg
|
|
}
|
|
}
|
|
t.Fatalf("timed out waiting for %s", wantType)
|
|
return Message{}
|
|
}
|
|
|
|
func TestFleetDeleteAgentSuccess(t *testing.T) {
|
|
fh, database, ws, _ := newTestFleetHandler(t)
|
|
agent := &models.Agent{ID: "del-agent-1", Name: "rig", Status: "offline", LastSeen: time.Now()}
|
|
if err := database.UpsertAgent(agent); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dashConn := connectTestDashboard(t, ws)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/agents/del-agent-1", nil)
|
|
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).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)
|
|
}
|
|
if _, err := database.GetAgent("del-agent-1"); err == nil {
|
|
t.Fatal("expected agent removed from database")
|
|
}
|
|
|
|
removed := readDashboardWSMessage(t, dashConn, "agent_removed")
|
|
var payload map[string]string
|
|
if err := json.Unmarshal(removed.Payload, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["agent_id"] != "del-agent-1" {
|
|
t.Fatalf("agent_id = %q", payload["agent_id"])
|
|
}
|
|
}
|
|
|
|
func TestFleetDeleteAgentEmptyID(t *testing.T) {
|
|
fh, _, _, _ := newTestFleetHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
fh.DeleteAgent(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFleetDeleteAgentDisconnectsOnline(t *testing.T) {
|
|
fh, database, ws, _ := newTestFleetHandler(t)
|
|
agentID := "del-online"
|
|
connectTestAgent(t, ws, agentID)
|
|
if !ws.isAgentConnected(agentID) {
|
|
t.Fatal("agent should be connected")
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/agents/"+agentID, nil)
|
|
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
if ws.isAgentConnected(agentID) {
|
|
t.Fatal("agent should be disconnected after delete")
|
|
}
|
|
if _, err := database.GetAgent(agentID); err == nil {
|
|
t.Fatal("expected agent removed from database")
|
|
}
|
|
}
|
|
|
|
func TestFleetBulkDeleteAgents(t *testing.T) {
|
|
fh, database, ws, _ := newTestFleetHandler(t)
|
|
for _, id := range []string{"bulk-del-a", "bulk-del-b", "bulk-del-missing"} {
|
|
if id == "bulk-del-missing" {
|
|
continue
|
|
}
|
|
agent := &models.Agent{ID: id, Name: id, Status: "offline", LastSeen: time.Now()}
|
|
if err := database.UpsertAgent(agent); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
connectTestAgent(t, ws, "bulk-del-a")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete",
|
|
strings.NewReader(`{"ids":["bulk-del-a","bulk-del-b","bulk-del-missing"]}`))
|
|
fh.BulkDeleteAgents(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["deleted"].(float64) != 2 {
|
|
t.Fatalf("deleted count = %v", body["deleted"])
|
|
}
|
|
if _, err := database.GetAgent("bulk-del-a"); err == nil {
|
|
t.Fatal("bulk-del-a should be gone")
|
|
}
|
|
if _, err := database.GetAgent("bulk-del-b"); err == nil {
|
|
t.Fatal("bulk-del-b should be gone")
|
|
}
|
|
}
|
|
|
|
func TestFleetBulkDeleteAgentsRequiresIDs(t *testing.T) {
|
|
fh, _, _, _ := newTestFleetHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete", strings.NewReader(`{"ids":[]}`))
|
|
fh.BulkDeleteAgents(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|