Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
451 lines
13 KiB
Go
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)
|
|
}
|
|
}
|