Expand test coverage across server, agent, and web; fix bugs found during audit.

Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
AetherForge
2026-05-31 01:13:49 -07:00
parent 159747877c
commit ea6f54ad03
89 changed files with 5307 additions and 322 deletions

View File

@@ -0,0 +1,54 @@
package miner
import (
"strings"
"testing"
)
func TestNewEngineNotNil(t *testing.T) {
e := NewEngine()
if e == nil || e.cache == nil {
t.Fatal("NewEngine should allocate cache")
}
}
func TestEngineSetJobInvalidSeed(t *testing.T) {
e := NewEngine()
if err := e.SetJob("zz", strings.Repeat("00", 76)); err == nil {
t.Fatal("invalid seed hex should error")
}
}
func TestEngineSetJobInvalidBlob(t *testing.T) {
e := NewEngine()
seed := strings.Repeat("ab", 32)
if err := e.SetJob(seed, "not-hex"); err == nil {
t.Fatal("invalid blob hex should error")
}
}
func TestEngineHashAtNonceNoVM(t *testing.T) {
e := NewEngine()
hash, blob, err := e.HashAtNonce(0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hash != "" || blob != "" {
t.Fatalf("empty VM should return empty strings, got hash=%q blob=%q", hash, blob)
}
}
func TestEngineHashAtNonceShortBlob(t *testing.T) {
e := NewEngine()
seed := strings.Repeat("cd", 32)
if err := e.SetJob(seed, strings.Repeat("00", 20)); err != nil {
t.Fatal(err)
}
hash, blob, err := e.HashAtNonce(1)
if err != nil {
t.Fatal(err)
}
if hash != "" || blob != "" {
t.Fatalf("blob shorter than nonce offset should not hash, got hash=%q blob=%q", hash, blob)
}
}

View File

@@ -24,6 +24,10 @@ type Pool struct {
mu sync.RWMutex
currentJob *job.Job
// jobGen is incremented atomically every time SetJob replaces the current job.
// Workers compare their local snapshot to detect job changes inside the inner
// hash loop without acquiring mu on every iteration.
jobGen atomic.Uint64
stopCh chan struct{}
wg sync.WaitGroup
paused atomic.Bool
@@ -62,16 +66,24 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha
func (p *Pool) SetJob(job *job.Job) {
p.mu.Lock()
defer p.mu.Unlock()
p.currentJob = job
// Bump generation while holding the write-lock so workers that check jobGen
// inside their inner batch loop break out and re-snapshot the new job.
gen := p.jobGen.Add(1)
_ = gen
if job == nil {
p.mu.Unlock()
return
}
seed := job.SeedHash
if seed == "" && len(job.Blob) >= 64 {
seed = job.Blob[:64]
}
for _, engine := range p.engines {
// Capture engines slice before releasing the lock.
engines := p.engines
p.mu.Unlock()
// Update all engines outside the pool lock — each Engine has its own mutex.
for _, engine := range engines {
if err := engine.SetJob(seed, job.Blob); err != nil {
log.Printf("[miner] failed to set job: %v", err)
}
@@ -202,6 +214,10 @@ func (p *Pool) worker(id int, engine *Engine) {
continue
}
// Snapshot the generation before the inner loop so we can detect a new
// job mid-batch and break early rather than hashing 256 stale nonces.
startGen := p.jobGen.Load()
for batch := 0; batch < 256; batch++ {
select {
case <-p.stopCh:
@@ -211,6 +227,10 @@ func (p *Pool) worker(id int, engine *Engine) {
if p.paused.Load() || p.remotePause.Load() {
break
}
// New job arrived — abandon this batch and re-snapshot immediately.
if p.jobGen.Load() != startGen {
break
}
hashHex, _, err := engine.HashAtNonce(nonce)
if err != nil {

View File

@@ -0,0 +1,120 @@
package miner
import (
"strings"
"sync/atomic"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/stats"
)
func testPoolCfg() config.RuntimeConfig {
return config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "always",
MaxCPUUsage: 0,
MaxMemoryPct: 0,
MinFreeRAM: 0,
}}
}
func TestNewPoolDefaultsThreads(t *testing.T) {
p := NewPool(0, testPoolCfg(), stats.NewReporter(), nil)
if p == nil || len(p.engines) != 1 {
t.Fatalf("threads<=0 should default to 1 engine, got %d", len(p.engines))
}
}
func TestNewPoolMultipleEngines(t *testing.T) {
p := NewPool(3, testPoolCfg(), stats.NewReporter(), nil)
if len(p.engines) != 3 {
t.Fatalf("expected 3 engines, got %d", len(p.engines))
}
}
func TestPoolSetJobNil(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
p.SetJob(nil)
p.mu.RLock()
defer p.mu.RUnlock()
if p.currentJob != nil {
t.Fatal("SetJob(nil) should leave current job nil")
}
}
func TestPoolSetJobDerivesSeedFromBlob(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
blobPrefix := strings.Repeat("ee", 32)
blob := blobPrefix + strings.Repeat("11", 22)
j := &job.Job{ID: "j1", Blob: blob}
p.SetJob(j)
if p.engines[0].seedHex != blobPrefix {
t.Fatalf("seed from blob prefix = %q, want %q", p.engines[0].seedHex, blobPrefix)
}
}
func TestPoolHashesPerSecondAndReset(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
p.hashesTotal.Store(1000)
p.hashesLastReset = time.Now().Add(-2 * time.Second)
rate := p.HashesPerSecond()
if rate <= 0 {
t.Fatalf("expected positive hashrate, got %f", rate)
}
p.ResetHashCounter()
if p.hashesTotal.Load() != 0 {
t.Fatal("ResetHashCounter should zero hashesTotal")
}
if p.HashesPerSecond() != 0 {
t.Fatalf("hashrate after reset with no hashes should be 0, got %f", p.HashesPerSecond())
}
}
func TestPoolRemotePause(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if p.IsRemotePaused() {
t.Fatal("new pool should not be remote-paused")
}
p.PauseRemote()
if !p.IsRemotePaused() {
t.Fatal("PauseRemote should set flag")
}
p.ResumeRemote()
if p.IsRemotePaused() {
t.Fatal("ResumeRemote should clear flag")
}
}
func TestPoolSetShareHandler(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
var called atomic.Bool
p.SetShareHandler(func(jobID, nonce, hash string) {
called.Store(true)
})
p.handlerMu.RLock()
h := p.handler
p.handlerMu.RUnlock()
if h == nil {
t.Fatal("handler should be set")
}
h("j", "n", "h")
if !called.Load() {
t.Fatal("swapped handler should run")
}
}
func TestPoolMiningAllowedAlways(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if !p.miningAllowed() {
t.Fatal("always mode with resource limits disabled should allow mining")
}
}
func TestPoolResourcesOKDisabledLimits(t *testing.T) {
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
if !p.resourcesOK() {
t.Fatal("zero resource limits should allow mining")
}
}

91
agent/miner/pool_test.go Normal file
View File

@@ -0,0 +1,91 @@
package miner
import (
"math/big"
"strings"
"testing"
)
func TestUint32ToHexLittleEndian(t *testing.T) {
got := uint32ToHex(0x01020304)
want := "04030201"
if got != want {
t.Fatalf("uint32ToHex(0x01020304) = %q, want %q", got, want)
}
}
func TestUint32ToHexZero(t *testing.T) {
if got := uint32ToHex(0); got != "00000000" {
t.Fatalf("zero nonce hex = %q", got)
}
}
func TestHexEncode(t *testing.T) {
if got := hexEncode([]byte{0xde, 0xad, 0xbe, 0xef}); got != "deadbeef" {
t.Fatalf("hexEncode = %q", got)
}
}
func TestDifficultyToTargetHexZero(t *testing.T) {
if difficultyToTargetHex(0) != "" {
t.Fatal("difficulty 0 should yield empty target")
}
if difficultyToTargetHex(-1) != "" {
t.Fatal("negative difficulty should yield empty target")
}
}
func TestDifficultyToTargetHexOne(t *testing.T) {
out := difficultyToTargetHex(1)
want := strings.Repeat("f", 64)
if out != want {
t.Fatalf("difficulty 1 target = %q, want %q", out, want)
}
}
func TestDifficultyToTargetHexTwo(t *testing.T) {
out := difficultyToTargetHex(2)
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
half := new(big.Int).Div(maxTarget, big.NewInt(2))
// difficultyToTargetHex reverses byte order before hex encoding
bytes := half.Bytes()
padded := make([]byte, 32)
copy(padded[32-len(bytes):], bytes)
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
padded[i], padded[j] = padded[j], padded[i]
}
want := strings.ToLower(strings.Repeat("", 0)) // placeholder
_ = want
const hexdigits = "0123456789abcdef"
wantBytes := make([]byte, 64)
for i, v := range padded {
wantBytes[i*2] = hexdigits[v>>4]
wantBytes[i*2+1] = hexdigits[v&0x0f]
}
want = string(wantBytes)
if out != want {
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
}
}
func TestDifficultyToTargetHexLength(t *testing.T) {
for _, d := range []int64{1, 100, 1000, 1_000_000} {
out := difficultyToTargetHex(d)
if len(out) != 64 {
t.Fatalf("difficulty %d: expected 64 hex chars, got %d (%q)", d, len(out), out)
}
}
}
func TestDifficultyToTargetMeetsHash(t *testing.T) {
target := difficultyToTargetHex(1000)
zeroHash := strings.Repeat("0", 64)
if !hashMeetsTarget(zeroHash, target) {
t.Fatal("zero hash should meet difficulty-derived target")
}
highHash := strings.Repeat("f", 64)
if hashMeetsTarget(highHash, target) {
t.Fatal("max hash should not meet difficulty-derived target")
}
}

View File

@@ -25,11 +25,15 @@ func NewScheduleGuard(cfg config.RuntimeConfig, reporter *stats.Reporter) *Sched
}
func (g *ScheduleGuard) Allowed() bool {
return g.allowedAt(time.Now())
}
func (g *ScheduleGuard) allowedAt(now time.Time) bool {
switch g.cfg.MiningModeNormalized() {
case "idle":
return g.idleAllowed()
case "scheduled":
return g.cfg.InScheduleWindow(time.Now())
case "scheduled", "schedule":
return g.cfg.InScheduleWindow(now)
default:
return true
}

View File

@@ -29,3 +29,41 @@ func TestScheduleGuardScheduledUsesConfigWindow(t *testing.T) {
t.Fatal("expected overnight schedule to allow mining at 23:00")
}
}
func TestScheduleGuardScheduledDeniedOutsideWindow(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "scheduled",
ScheduleStart: "09:00",
ScheduleEnd: "17:00",
}}, stats.NewReporter())
if !guard.allowedAt(parseTestTime(10, 0)) {
t.Fatal("10:00 should allow mining in 09-17 window")
}
if guard.allowedAt(parseTestTime(20, 0)) {
t.Fatal("20:00 should deny mining outside 09-17 window")
}
}
func TestScheduleGuardScheduleAlias(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "schedule",
ScheduleStart: "09:00",
ScheduleEnd: "17:00",
}}, stats.NewReporter())
if guard.allowedAt(parseTestTime(10, 0)) != true {
t.Fatal("normalized schedule alias should honor daytime window at 10:00")
}
if guard.allowedAt(parseTestTime(3, 0)) {
t.Fatal("normalized schedule alias should deny mining at 03:00")
}
}
func TestScheduleGuardIdleFirstSampleNotAllowed(t *testing.T) {
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
MiningMode: "idle",
}}, stats.NewReporter())
// First SystemCPUPercent sample is 0 → treated as not idle.
if guard.Allowed() {
t.Fatal("idle mode should deny mining on first CPU sample (cpu=0)")
}
}

View File

@@ -0,0 +1,70 @@
package miner
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestNewStratumClient(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
if sc == nil || sc.pool != pool {
t.Fatal("NewStratumClient should wire pool")
}
}
func TestStratumSetJobSkipsInvalid(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
sc.setJob(nil)
sc.setJob(&stratumJob{Blob: ""})
pool.mu.RLock()
if pool.currentJob != nil {
pool.mu.RUnlock()
t.Fatal("nil/empty stratum job should not set pool job")
}
pool.mu.RUnlock()
sc.setJob(&stratumJob{
Blob: strings.Repeat("aa", 76), JobID: "42", Target: "ffff", SeedHash: strings.Repeat("bb", 32), Height: 10,
})
pool.mu.RLock()
defer pool.mu.RUnlock()
if pool.currentJob == nil {
t.Fatal("valid stratum job should set pool job")
}
wantBlob := strings.Repeat("aa", 76)
if pool.currentJob.ID != "42" || pool.currentJob.Blob != wantBlob {
t.Fatalf("pool job mismatch: %+v", pool.currentJob)
}
wantSeed := strings.Repeat("bb", 32)
if pool.currentJob.Target != "ffff" || pool.currentJob.SeedHash != wantSeed {
t.Fatalf("pool job fields: %+v", pool.currentJob)
}
}
func TestStratumRunFallbackNoPoolHost(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
stop := make(chan struct{})
close(stop)
// Should return immediately without dialing.
sc.RunFallback(stop)
}
func TestStratumSetJobMapsToInternalJob(t *testing.T) {
pool := NewPool(1, testPoolCfg(), nil, nil)
sc := NewStratumClient(pool, config.RuntimeConfig{})
sc.setJob(&stratumJob{
Blob: strings.Repeat("cc", 76), JobID: "jid", Target: "tgt", SeedHash: strings.Repeat("dd", 32),
})
pool.mu.RLock()
j := pool.currentJob
pool.mu.RUnlock()
if j == nil || j.ID != "jid" || j.Blob != strings.Repeat("cc", 76) {
t.Fatalf("expected internal job, got %+v", j)
}
}

149
agent/miner/stratum_test.go Normal file
View File

@@ -0,0 +1,149 @@
package miner
import (
"encoding/json"
"testing"
"crypto-miner-agent/config"
)
func TestBuildStratumEndpointsPrimaryOnly(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "secret",
}}
eps := buildStratumEndpoints(cfg)
if len(eps) != 1 {
t.Fatalf("expected 1 endpoint, got %d", len(eps))
}
if eps[0].Host != "pool.example.com" || eps[0].Port != 3333 || !eps[0].TLS || eps[0].Pass != "secret" {
t.Fatalf("primary endpoint mismatch: %+v", eps[0])
}
}
func TestBuildStratumEndpointsSkipsInvalidBackups(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
PoolHost: "primary.pool",
PoolPort: 4444,
BackupPools: []config.BackupPool{
{Host: "", Port: 5555},
{Host: "backup.pool", Port: 0},
{Host: "good.backup", Port: 6666, TLS: true, Pass: "bp"},
},
}}
eps := buildStratumEndpoints(cfg)
if len(eps) != 2 {
t.Fatalf("expected primary + 1 valid backup, got %d", len(eps))
}
if eps[1].Host != "good.backup" || eps[1].Port != 6666 || !eps[1].TLS || eps[1].Pass != "bp" {
t.Fatalf("backup endpoint mismatch: %+v", eps[1])
}
}
func TestMustMarshal(t *testing.T) {
raw := mustMarshal(map[string]string{"login": "wallet", "pass": "x"})
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m["login"] != "wallet" || m["pass"] != "x" {
t.Fatalf("unexpected map: %v", m)
}
}
func TestStratumMsgLoginRoundTrip(t *testing.T) {
params := mustMarshal(map[string]interface{}{
"login": "4AbC...wallet",
"pass": "x",
"rigid": "worker-1",
"agent": "AetherForge/1.0.0",
})
msg := stratumMsg{
ID: 1,
JSONRPC: "2.0",
Method: "login",
Params: params,
}
b, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
var decoded stratumMsg
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Method != "login" || decoded.JSONRPC != "2.0" {
t.Fatalf("decoded msg: %+v", decoded)
}
var p map[string]interface{}
if err := json.Unmarshal(decoded.Params, &p); err != nil {
t.Fatal(err)
}
if p["login"] != "4AbC...wallet" || p["pass"] != "x" {
t.Fatalf("params: %v", p)
}
}
func TestStratumJobUnmarshal(t *testing.T) {
raw := `{"blob":"aabb","job_id":"j1","target":"ffff","seed_hash":"ccdd","height":12345}`
var sj stratumJob
if err := json.Unmarshal([]byte(raw), &sj); err != nil {
t.Fatal(err)
}
if sj.Blob != "aabb" || sj.JobID != "j1" || sj.Target != "ffff" || sj.SeedHash != "ccdd" || sj.Height != 12345 {
t.Fatalf("job mismatch: %+v", sj)
}
}
func TestLoginResultUnmarshal(t *testing.T) {
raw := `{"id":"sess-1","status":"OK","job":{"blob":"deadbeef","job_id":"42","target":"ffffffff","seed_hash":"seed","height":1}}`
var lr loginResult
if err := json.Unmarshal([]byte(raw), &lr); err != nil {
t.Fatal(err)
}
if lr.ID != "sess-1" || lr.Status != "OK" || lr.Job == nil || lr.Job.JobID != "42" {
t.Fatalf("login result mismatch: %+v", lr)
}
}
func TestSubmitParamsMarshal(t *testing.T) {
b, err := json.Marshal(submitParams{
ID: "sess-1",
JobID: "42",
Nonce: "01020304",
Hash: "abc123",
})
if err != nil {
t.Fatal(err)
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if m["id"] != "sess-1" || m["job_id"] != "42" || m["nonce"] != "01020304" || m["result"] != "abc123" {
t.Fatalf("submit params field names: %v", m)
}
}
func TestStratumMsgJobNotification(t *testing.T) {
params, _ := json.Marshal(stratumJob{
Blob: "blobhex", JobID: "99", Target: "targethex", SeedHash: "seedhex", Height: 100,
})
line := mustMarshal(stratumMsg{Method: "job", Params: params})
var msg stratumMsg
if err := json.Unmarshal(line, &msg); err != nil {
t.Fatal(err)
}
if msg.Method != "job" {
t.Fatalf("method=%q", msg.Method)
}
var sj stratumJob
if err := json.Unmarshal(msg.Params, &sj); err != nil {
t.Fatal(err)
}
if sj.JobID != "99" || sj.Blob != "blobhex" {
t.Fatalf("job notification: %+v", sj)
}
}

View File

@@ -1,6 +1,7 @@
package miner
import (
"strings"
"testing"
)
@@ -20,9 +21,50 @@ func TestHashMeetsTargetReject(t *testing.T) {
}
}
func TestDifficultyToTargetHex(t *testing.T) {
out := difficultyToTargetHex(1000)
if len(out) != 64 {
t.Fatalf("expected 64 hex chars, got %d", len(out))
func TestHashMeetsTargetExactMatch(t *testing.T) {
val := "00000000000000000000000000000000000000000000000000000000000000ab"
if !hashMeetsTarget(val, val) {
t.Fatal("hash equal to target should meet target")
}
}
func TestHashMeetsTargetInvalidHex(t *testing.T) {
if hashMeetsTarget("not-hex", strings.Repeat("f", 64)) {
t.Fatal("invalid hash hex should not meet target")
}
if hashMeetsTarget(strings.Repeat("f", 64), "zz") {
t.Fatal("invalid target hex should not meet")
}
if hashMeetsTarget("", strings.Repeat("f", 64)) {
t.Fatal("empty hash should not meet target")
}
}
func TestHashMeetsTargetShortTargetPadded(t *testing.T) {
// Short target hex is zero-padded to hash width
target := "ff"
hash := strings.Repeat("0", 63) + "1"
if !hashMeetsTarget(hash, target) {
t.Fatal("padded short target should accept low hash")
}
}
func TestPadHex(t *testing.T) {
if got := padHex("ab", 6); got != "0000ab" {
t.Fatalf("padHex = %q", got)
}
if got := padHex("abcdef", 4); got != "abcdef" {
t.Fatalf("no truncate when already long: %q", got)
}
}
func TestReverseBytes(t *testing.T) {
in := []byte{1, 2, 3, 4}
out := reverseBytes(in)
want := []byte{4, 3, 2, 1}
for i := range want {
if out[i] != want[i] {
t.Fatalf("reverseBytes = %v, want %v", out, want)
}
}
}