Files
AetherForge/server/internal/api/ws_beacon_integration_test.go
AetherForge 7b2d41cda8
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Expand P2 test coverage: mining chain, spread lanes, path forge, WS/beacon, E2E onion, file handling
2026-06-07 04:58:55 -07:00

568 lines
16 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/gorilla/websocket"
)
// TestIntegrationAuthStatsTickStatsBatchEndToEnd verifies auth → stats tick →
// coalesced stats_batch delivery to a dashboard WebSocket client.
func TestIntegrationAuthStatsTickStatsBatchEndToEnd(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type batchResult struct {
updates []map[string]interface{}
err string
}
batchCh := make(chan batchResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
batchCh <- batchResult{err: err.Error()}
return
}
if msg.Type != "stats_batch" {
continue
}
var body struct {
Updates []json.RawMessage `json:"updates"`
}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
batchCh <- batchResult{err: parseErr.Error()}
return
}
updates := make([]map[string]interface{}, 0, len(body.Updates))
for _, raw := range body.Updates {
var u map[string]interface{}
if json.Unmarshal(raw, &u) != nil {
continue
}
updates = append(updates, u)
}
batchCh <- batchResult{updates: updates}
return
}
}()
agentID := "auth-stats-agent"
conn := connectTestAgent(t, hub, agentID)
statsPayload, _ := json.Marshal(map[string]interface{}{
"hashrate_15s": 42.0,
"hashrate_1m": 40.0,
"hashrate_15m": 38.0,
"shares_submitted": 3,
"shares_accepted": 2,
"cpu_usage_pct": 11.0,
"memory_usage_pct": 22.0,
"uptime_seconds": 120,
"mining_hashrate": 42.0,
"lotl_tier": "cpu_inprocess",
"parent_agent_id": "parent-abc",
"spread_generation": 1,
"spread_strain": "#112233",
})
if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil {
t.Fatal(err)
}
stopStatsBatchTimer(hub)
hub.flushStatsBatch()
select {
case r := <-batchCh:
if r.err != "" {
t.Fatalf("dashboard did not receive stats_batch: %s", r.err)
}
if len(r.updates) != 1 {
t.Fatalf("expected 1 update, got %d: %+v", len(r.updates), r.updates)
}
u := r.updates[0]
if u["agent_id"] != agentID {
t.Errorf("agent_id = %v", u["agent_id"])
}
if u["hashrate_15s"] != 42.0 {
t.Errorf("hashrate_15s = %v", u["hashrate_15s"])
}
if u["mining_hashrate"] != 42.0 {
t.Errorf("mining_hashrate = %v", u["mining_hashrate"])
}
if u["lotl_tier"] != "cpu_inprocess" {
t.Errorf("lotl_tier = %v", u["lotl_tier"])
}
if u["parent_agent_id"] != "parent-abc" {
t.Errorf("parent_agent_id = %v", u["parent_agent_id"])
}
if u["spread_generation"] != float64(1) {
t.Errorf("spread_generation = %v", u["spread_generation"])
}
if u["spread_strain"] != "#112233" {
t.Errorf("spread_strain = %v", u["spread_strain"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for stats_batch after auth+stats tick")
}
agent, err := database.GetAgent(agentID)
if err != nil {
t.Fatal(err)
}
if agent.Status != "online" {
t.Errorf("agent status = %q, want online", agent.Status)
}
if agent.Hashrate15s != 42.0 {
t.Errorf("db hashrate_15s = %v", agent.Hashrate15s)
}
}
// TestIntegrationBeaconRegistrationHeartbeatLifecycle covers HTTPS beacon
// registration, heartbeat reachability, queued command delivery, and result relay.
func TestIntegrationBeaconRegistrationHeartbeatLifecycle(t *testing.T) {
resetAuthState(t)
const secret = "beacon-lifecycle-secret"
SetAgentPathSecret(secret)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
hub.SetFleetSecret(secret)
resetWSAuthUsers(t, testAuthUser, testAuthPass)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
beaconHandler := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeacon))
beaconResultHandler := basicAuthMiddleware(http.HandlerFunc(hub.HandleAgentBeaconResult))
postBeacon := func(agentID, hostname string, hashrate float64) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(map[string]interface{}{
"agent_id": agentID,
"hostname": hostname,
"version": "1.0",
"stats": map[string]interface{}{
"hashrate_15s": hashrate,
"hashrate_1m": hashrate,
"hashrate_15m": hashrate,
},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon", bytes.NewReader(body))
req.Header.Set("X-Fleet-Secret", secret)
rec := httptest.NewRecorder()
beaconHandler.ServeHTTP(rec, req)
return rec
}
agentID := "beacon-new-agent"
rec := postBeacon(agentID, "BEACON-HOST", 55.0)
if rec.Code != http.StatusOK {
t.Fatalf("first beacon: %d %s", rec.Code, rec.Body.String())
}
agent, err := database.GetAgent(agentID)
if err != nil {
t.Fatal(err)
}
if agent.Name != "BEACON-HOST" {
t.Errorf("registered name = %q, want BEACON-HOST", agent.Name)
}
if !hub.isAgentBeaconReachable(agentID) {
t.Fatal("agent should be beacon-reachable after first heartbeat")
}
rec = postBeacon(agentID, "BEACON-HOST", 60.0)
if rec.Code != http.StatusOK {
t.Fatalf("second beacon heartbeat: %d", rec.Code)
}
if !hub.EnqueueBeaconCommand(agentID, "pause", nil) {
t.Fatal("enqueue pause should succeed while beacon reachable")
}
rec = postBeacon(agentID, "BEACON-HOST", 65.0)
if rec.Code != http.StatusOK {
t.Fatalf("beacon with commands: %d", rec.Code)
}
var beaconResp beaconResponse
if err := json.Unmarshal(rec.Body.Bytes(), &beaconResp); err != nil {
t.Fatal(err)
}
if len(beaconResp.Commands) != 1 || beaconResp.Commands[0].Action != "pause" {
t.Fatalf("expected pause command, got %+v", beaconResp.Commands)
}
type cmdResult struct {
body map[string]interface{}
err string
}
resultCh := make(chan cmdResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
resultCh <- cmdResult{err: err.Error()}
return
}
if msg.Type != "command_result" {
continue
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
resultCh <- cmdResult{err: parseErr.Error()}
return
}
if body["transport"] != "https_beacon" {
continue
}
resultCh <- cmdResult{body: body}
return
}
}()
resultBody, _ := json.Marshal(map[string]interface{}{
"agent_id": agentID,
"action": "pause",
"success": true,
"message": "paused via beacon",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/beacon/result", bytes.NewReader(resultBody))
req.Header.Set("X-Fleet-Secret", secret)
rec = httptest.NewRecorder()
beaconResultHandler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("beacon result: %d %s", rec.Code, rec.Body.String())
}
select {
case r := <-resultCh:
if r.err != "" {
t.Fatalf("dashboard did not receive beacon command_result: %s", r.err)
}
if r.body["agent_id"] != agentID {
t.Errorf("agent_id = %v", r.body["agent_id"])
}
if r.body["action"] != "pause" {
t.Errorf("action = %v", r.body["action"])
}
if r.body["message"] != "paused via beacon" {
t.Errorf("message = %v", r.body["message"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for beacon command_result broadcast")
}
}
// TestIntegrationBeaconClearsOnWSReconnect verifies beacon transport state is
// cleared when the agent reconnects over WebSocket.
func TestIntegrationBeaconClearsOnWSReconnect(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
_ = database.UpsertAgent(&models.Agent{ID: "beacon-ws-agent", Name: "host", Status: "offline"})
hub := NewWSHub(database)
hub.MarkBeaconSeen("beacon-ws-agent")
_ = hub.EnqueueBeaconCommand("beacon-ws-agent", "resume", nil)
if !hub.isAgentBeaconReachable("beacon-ws-agent") {
t.Fatal("expected beacon reachable before WS auth")
}
connectTestAgent(t, hub, "beacon-ws-agent")
if hub.isAgentBeaconReachable("beacon-ws-agent") {
t.Fatal("beacon state should be cleared after WS reconnect")
}
if len(hub.dequeueBeaconCommands("beacon-ws-agent")) != 0 {
t.Fatal("beacon command queue should be empty after WS reconnect")
}
}
// TestIntegrationCommandDispatchExecShellRoundTrip sends exec_shell over WS and
// verifies the agent receives the framed command payload.
func TestIntegrationCommandDispatchExecShellRoundTrip(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
agentID := "exec-shell-agent"
conn := connectTestAgent(t, hub, agentID)
type agentCmdResult struct {
cmd Message
err string
}
cmdCh := make(chan agentCmdResult, 1)
go func() {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var cmd Message
if err := conn.ReadJSON(&cmd); err != nil {
cmdCh <- agentCmdResult{err: err.Error()}
return
}
if cmd.Type != "command" {
continue
}
cmdCh <- agentCmdResult{cmd: cmd}
return
}
}()
const shellCmd = "echo integration-exec"
if err := hub.SendAgentCommand(agentID, "exec_shell", map[string]interface{}{
"command": shellCmd,
}); err != nil {
t.Fatal(err)
}
select {
case r := <-cmdCh:
if r.err != "" {
t.Fatalf("agent did not receive command: %s", r.err)
}
var payload map[string]interface{}
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != "exec_shell" {
t.Errorf("action = %v", payload["action"])
}
if payload["command"] != shellCmd {
t.Errorf("command = %v", payload["command"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for exec_shell command")
}
}
// TestIntegrationCommandDispatchMiningDiagnosticsRoundTrip sends
// mining_diagnostics and verifies the agent receives it.
func TestIntegrationCommandDispatchMiningDiagnosticsRoundTrip(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
agentID := "mining-diag-agent"
conn := connectTestAgent(t, hub, agentID)
type agentCmdResult struct {
cmd Message
err string
}
cmdCh := make(chan agentCmdResult, 1)
go func() {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var cmd Message
if err := conn.ReadJSON(&cmd); err != nil {
cmdCh <- agentCmdResult{err: err.Error()}
return
}
if cmd.Type != "command" {
continue
}
cmdCh <- agentCmdResult{cmd: cmd}
return
}
}()
if err := hub.SendAgentCommand(agentID, "mining_diagnostics", nil); err != nil {
t.Fatal(err)
}
select {
case r := <-cmdCh:
if r.err != "" {
t.Fatalf("agent did not receive command: %s", r.err)
}
var payload map[string]interface{}
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != "mining_diagnostics" {
t.Errorf("action = %v", payload["action"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for mining_diagnostics command")
}
}
// TestIntegrationAISnapshotRequestFlow sends ai_snapshot_request over WS and
// verifies an ai_snapshot reply is cached in hub telemetry for Fleet AI.
func TestIntegrationAISnapshotRequestFlow(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
agentID := "ai-snapshot-agent"
conn := connectIntelAgent(t, hub, agentID, nil)
pushStuckAgentTelemetry(t, conn)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
hub.mu.RLock()
tel := hub.agentLiveTelemetry[agentID]
hub.mu.RUnlock()
if tel != nil {
if stuck, _ := tel["stuck"].(bool); stuck {
return
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatal("ai_snapshot telemetry never cached in hub")
}
// TestIntegrationAgentDisconnectCleanup verifies WS disconnect clears live hub
// state, marks the agent offline, and broadcasts agent_offline to dashboards.
func TestIntegrationAgentDisconnectCleanup(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
agentID := "disconnect-cleanup-agent"
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)
}
authAgentConn(t, conn, map[string]interface{}{
"agent_id": agentID,
"hostname": "cleanup-host",
"version": "1.0",
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if hub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if !hub.isAgentConnected(agentID) {
t.Fatal("agent should be connected after auth")
}
logPayload, _ := json.Marshal(map[string]interface{}{"content": "tail-line", "lines": 1})
if err := conn.WriteJSON(Message{Type: "log_tail", Payload: logPayload}); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
if got := hub.GetAgentLog(agentID); got != "tail-line" {
t.Fatalf("log tail = %q", got)
}
offlineCh := make(chan map[string]interface{}, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "agent_offline" {
continue
}
var body map[string]interface{}
if json.Unmarshal(msg.Payload, &body) != nil {
continue
}
if body["agent_id"] == agentID {
offlineCh <- body
return
}
}
}()
_ = conn.Close()
waitDeadline := time.Now().Add(3 * time.Second)
for time.Now().Before(waitDeadline) {
if !hub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if hub.isAgentConnected(agentID) {
t.Fatal("agent should be disconnected after conn close")
}
if hub.GetAgentLog(agentID) != "" {
t.Fatal("agent log cache should be cleared on disconnect")
}
select {
case body := <-offlineCh:
if body["agent_id"] != agentID {
t.Errorf("offline agent_id = %v", body["agent_id"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for agent_offline broadcast")
}
agent, err := database.GetAgent(agentID)
if err != nil {
t.Fatal(err)
}
if agent.Status != "offline" {
t.Errorf("db status = %q, want offline", agent.Status)
}
}