From d010292333a4264912b665f23d7f0c8605c882b9 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 30 May 2026 23:16:50 -0700 Subject: [PATCH] feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat --- PROBLEMS.md | 37 +- agent/client/client.go | 1 + agent/client/posture_types.go | 14 +- agent/client/posture_unix.go | 77 ++ agent/client/posture_windows.go | 123 ++- agent/client/protocol.go | 1 + server/config.go | 196 ++-- server/config_test.go | 219 ++++- server/internal/api/config_handler.go | 18 +- server/internal/api/config_handler_test.go | 208 ++++ server/internal/api/fleet_handler_test.go | 893 ++++++++++++++++++ server/internal/api/websocket.go | 9 + server/internal/maintenance/retention.go | 12 +- server/internal/maintenance/retention_test.go | 307 ++++++ server/internal/models/agent.go | 15 +- server/maintenance | 21 + server/web/src/context/WebSocketProvider.tsx | 1 + server/web/src/pages/BuilderPage.test.tsx | 157 +++ server/web/src/pages/BuilderPage.tsx | 24 +- server/web/src/pages/CruciblePage.css | 28 + server/web/src/pages/CruciblePage.tsx | 61 +- server/web/src/pages/SettingsPage.test.tsx | 148 +++ server/web/src/pages/SettingsPage.tsx | 4 +- server/web/src/test/fixtures.ts | 48 +- server/web/src/types/index.ts | 8 + server/web/src/types/ws.ts | 3 +- 26 files changed, 2499 insertions(+), 134 deletions(-) create mode 100644 server/internal/api/config_handler_test.go create mode 100644 server/internal/api/fleet_handler_test.go create mode 100644 server/internal/maintenance/retention_test.go create mode 100644 server/maintenance create mode 100644 server/web/src/pages/BuilderPage.test.tsx create mode 100644 server/web/src/pages/SettingsPage.test.tsx 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( + + + + + + ); +} + +describe('formatBytes', () => { + it('formats sub-kilobyte values as bytes', () => { + expect(formatBytes(512)).toBe('512 B'); + }); + + it('formats kilobytes and megabytes with two decimals', () => { + expect(formatBytes(2048)).toBe('2.00 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB'); + }); + + it('formats gigabytes and terabytes', () => { + expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.00 GB'); + expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024)).toBe('2.00 TB'); + }); +}); + +describe('BuilderPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); + vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); + vi.spyOn(api, 'listBuilds').mockResolvedValue([]); + vi.spyOn(api, 'buildAgent').mockResolvedValue({ + success: true, + file_name: 'worker-1.exe', + file_size: 4 * 1024 * 1024, + download_url: '/api/v1/builds/test/download', + fusion_enabled: false, + obfuscated: false, + signed: false, + }); + vi.spyOn(api, 'listBlueprints').mockResolvedValue([]); + vi.spyOn(api, 'saveBlueprint').mockResolvedValue({ name: 'my-blueprint', size: 1024, created_at: '' }); + vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + cleanup(); + }); + + it('shows loading hero before defaults arrive', () => { + renderBuilder(); + expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument(); + expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument(); + }); + + it('renders forge form with identity field labels after load', async () => { + renderBuilder(); + expect(await screen.findByRole('heading', { level: 2, name: 'Quick Forge' })).toBeInTheDocument(); + expect(screen.getByText('INSTALLER FORGE')).toBeInTheDocument(); + expect(screen.getByText('Worker Name')).toBeInTheDocument(); + expect(screen.getByText('Control Endpoint')).toBeInTheDocument(); + expect(screen.getByText('XMR Wallet Address')).toBeInTheDocument(); + expect(api.getConfig).toHaveBeenCalled(); + expect(api.getServerInfo).toHaveBeenCalled(); + expect(api.listBuilds).toHaveBeenCalled(); + }); + + it('surfaces load failure when server info cannot be fetched', async () => { + vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline')); + renderBuilder(); + expect( + await screen.findByText('Failed to load server info — is the control server running?') + ).toBeInTheDocument(); + }); + + it('shows LAN endpoint chips from server info', async () => { + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + expect(screen.getByRole('button', { name: mockServerInfo.suggested_url! })).toBeInTheDocument(); + }); + + it('switches to advanced mode and reveals pool configuration', async () => { + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' })); + expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument(); + expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument(); + expect(screen.getByText('Pool Host')).toBeInTheDocument(); + }); + + it('submits forge when preflight passes', async () => { + const buildSpy = vi.spyOn(api, 'buildAgent'); + renderBuilder(); + await screen.findByRole('button', { name: /FORGE INSTALLER/i }); + await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i })); + await waitFor(() => { + expect(buildSpy).toHaveBeenCalled(); + }); + expect(await screen.findByText('worker-1.exe')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument(); + }); + + it('blocks forge when preflight has errors (empty wallet)', async () => { + renderBuilder(); + const wallet = await screen.findByDisplayValue(/^4A+/); + fireEvent.change(wallet, { target: { value: '' } }); + const forgeBtn = screen.getByRole('button', { name: /FIX .* ERROR/i }); + expect(forgeBtn).toBeDisabled(); + expect(screen.getByText('PREFLIGHT CROSS-CHECK')).toBeInTheDocument(); + }); + + it('persists simple vs advanced mode in localStorage', async () => { + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' })); + expect(localStorage.getItem('aetherforge-forge-mode')).toBe('advanced'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Simple' })); + expect(localStorage.getItem('aetherforge-forge-mode')).toBe('simple'); + }); + + it('loads blueprint list when Load Blueprint is clicked', async () => { + vi.spyOn(api, 'listBlueprints').mockResolvedValue([ + { name: 'office-preset', size: 2048, created_at: '2026-05-30T12:00:00Z' }, + ]); + renderBuilder(); + await screen.findByRole('button', { name: /Load Blueprint/i }); + await userEvent.setup().click(screen.getByRole('button', { name: /Load Blueprint/i })); + expect(await screen.findByText('Saved Blueprints')).toBeInTheDocument(); + expect(screen.getByText('office-preset')).toBeInTheDocument(); + expect(api.listBlueprints).toHaveBeenCalled(); + }); + + it('shows fusion deliverable hint when Fusion card is selected', async () => { + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await userEvent.setup().click( + screen.getByRole('button', { name: /Hide miner in any file/i }) + ); + expect( + screen.getByText(/Fusion selected — drop your files below and forge/i) + ).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 487bc56..0d568fc 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -33,7 +33,7 @@ import { } from '../help/fusionMedia'; import './Pages.css'; -function formatBytes(n: number): string { +export function formatBytes(n: number): string { if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB']; let v = n / 1024; @@ -635,7 +635,7 @@ export default function BuilderPage() { fusionPrepFile, ]); - if (loadingDefaults || !form) { + if (loadingDefaults) { return (
@@ -649,6 +649,22 @@ export default function BuilderPage() { ); } + if (!form) { + return ( +
+
+
+

INSTALLER FORGE

+

The Forge

+
+
+ +

{error || 'Failed to load forge defaults from server.'}

+
+
+ ); + } + const installPreview = previewInstallPath({ install_base: form.install_base, install_custom_base: form.install_custom_base, @@ -946,7 +962,7 @@ export default function BuilderPage() { 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`} - placeholder="4... or 8... (95–106 characters)" + placeholder="4... or 8... (90–106 characters)" value={form.wallet} onChange={(e) => updateField('wallet', e.target.value)} required @@ -954,7 +970,7 @@ export default function BuilderPage() { /> {form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (

- 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.

)} diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css index 4f161e9..e7e1871 100644 --- a/server/web/src/pages/CruciblePage.css +++ b/server/web/src/pages/CruciblePage.css @@ -226,6 +226,34 @@ 50% { opacity: 0.45; } } +/* ── T1007 service row ─────────────────────────────────────────────────────── */ +.cn-services { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + border-top: 1px solid rgba(255,255,255,0.05); + padding-top: 4px; +} + +.cn-svc { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.6rem; + font-family: var(--font-tech); + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.05em; + cursor: default; +} + +.cn-svc .svc-dot { font-size: 0.55rem; } + +.cn-svc.svc-run { color: #00ff88; background: rgba(0,255,136,0.07); } +.cn-svc.svc-stop { color: #888; background: rgba(128,128,128,0.07); text-decoration: line-through; opacity: 0.7; } +.cn-svc.svc-unk { color: #555; background: rgba(80,80,80,0.05); } + /* ── Row: Groups + Actions ───────────────────────────────────────────── */ .crucible-row { diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index 6974aa5..e08c293 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; import { api } from '../api/client'; -import type { Agent } from '../types'; +import type { Agent, AgentService } from '../types'; import NeonCard from '../components/NeonCard/NeonCard'; import { formatHashrate } from '../help/fleetFilters'; import './CruciblePage.css'; @@ -78,6 +78,15 @@ function postureTooltip(agent: Agent): string { if (agent.reboot_pending !== undefined) { lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`); } + if (agent.services?.length) { + lines.push('──────────────────────'); + lines.push('Services (T1007):'); + for (const svc of agent.services) { + const icon = svc.status === 'running' ? '●' : svc.status === 'stopped' ? '○' : '—'; + const st = svc.start_type !== 'unknown' ? ` [${svc.start_type}]` : ''; + lines.push(` ${icon} ${svc.display_name ?? svc.name}${st}`); + } + } return lines.join('\n'); } @@ -96,6 +105,42 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null { return null; // no badge when not pending — cleaner UI } +// ── Service helpers (T1007) ──────────────────────────────────────────────── + +// Human-readable label for well-known service names +const SVC_LABELS: Record = { + sshd: 'SSH', + ssh: 'SSH', + 'openssh ssh server': 'SSH', + cloudflared: 'CF Tunnel', + wuauserv: 'WU', + windefend: 'Defender', + ufw: 'UFW', + fail2ban: 'Fail2Ban', +}; + +function svcLabel(svc: AgentService): string { + return SVC_LABELS[svc.name.toLowerCase()] ?? svc.display_name ?? svc.name; +} + +function svcDot(status: string): string { + if (status === 'running') return '●'; + if (status === 'stopped') return '○'; + return '—'; +} + +function svcDotClass(status: string): string { + if (status === 'running') return 'svc-run'; + if (status === 'stopped') return 'svc-stop'; + return 'svc-unk'; +} + +// Only surface services that are interesting to show (skip self-service clutter) +const IMPORTANT_SVCS = new Set(['sshd', 'ssh', 'openssh ssh server', 'cloudflared', 'wuauserv', 'windefend']); +function importantServices(svcs: AgentService[]): AgentService[] { + return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running'); +} + function platformIcon(platform?: string): string { if (!platform) return '⬡'; const p = platform.toLowerCase(); @@ -472,6 +517,20 @@ export default function CruciblePage() {
ADMIN
)}
+ {a.services && a.services.length > 0 && ( +
+ {importantServices(a.services).map(svc => ( + + {svcDot(svc.status)} + {svcLabel(svc)} + + ))} +
+ )} ); diff --git a/server/web/src/pages/SettingsPage.test.tsx b/server/web/src/pages/SettingsPage.test.tsx new file mode 100644 index 0000000..6a0d4cc --- /dev/null +++ b/server/web/src/pages/SettingsPage.test.tsx @@ -0,0 +1,148 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SettingsPage, { deepMerge } from './SettingsPage'; +import { mockServerConfig, mockServerInfo } from '../test/fixtures'; +import { api } from '../api/client'; +import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth'; + +function renderSettings() { + return render(); +} + +describe('deepMerge', () => { + it('preserves base keys not present in override', () => { + const base = { a: 1, nested: { keep: true, old: 'x' } }; + const result = deepMerge(base, { nested: { old: 'y' } }); + expect(result.a).toBe(1); + expect(result.nested.keep).toBe(true); + expect(result.nested.old).toBe('y'); + }); + + it('replaces scalar values from override', () => { + const base = { port: 8989, pool: { host: 'a', port: 3333 } }; + const result = deepMerge(base, { port: 8080, pool: { host: 'b' } }); + expect(result.port).toBe(8080); + expect(result.pool.host).toBe('b'); + expect(result.pool.port).toBe(3333); + }); +}); + +describe('SettingsPage (Calibrate)', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); + vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); + vi.spyOn(api, 'updateConfig').mockImplementation(async (cfg) => cfg as ReturnType); + vi.spyOn(api, 'createUser').mockResolvedValue({ success: true }); + }); + + afterEach(() => { + cleanup(); + }); + + it('shows loading state then Calibrate heading', async () => { + renderSettings(); + expect(screen.getByRole('heading', { level: 1, name: 'Calibrate' })).toBeInTheDocument(); + expect(screen.getByText('Loading server calibration...')).toBeInTheDocument(); + expect(await screen.findByText('CONTROL SERVER · LOCAL HOST')).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 1, name: 'Calibrate' })).toHaveLength(1); + }); + + it('renders key section headings and labels', async () => { + renderSettings(); + expect(await screen.findByText('Control Server')).toBeInTheDocument(); + expect(screen.getByText('Upstream Pool')).toBeInTheDocument(); + expect(screen.getByText('Fleet Payout Wallet')).toBeInTheDocument(); + expect(screen.getByText('Access Control')).toBeInTheDocument(); + expect(screen.getByText('Fleet Security')).toBeInTheDocument(); + expect(screen.getByText('Listen Port')).toBeInTheDocument(); + expect(screen.getByText(/Public URL \(LAN\)/i)).toBeInTheDocument(); + expect(screen.getByText(/XMR Address/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('8080')).toBeInTheDocument(); + }); + + it('shows detected LAN endpoints banner', async () => { + renderSettings(); + expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument(); + expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument(); + expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument(); + }); + + it('saves calibration via updateConfig API', async () => { + const updateSpy = vi.spyOn(api, 'updateConfig'); + renderSettings(); + await screen.findByRole('button', { name: 'Save Calibration' }); + await userEvent.setup().click(screen.getByRole('button', { name: 'Save Calibration' })); + await waitFor(() => { + expect(updateSpy).toHaveBeenCalled(); + }); + expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument(); + }); + + it('applies best defaults to public URL from server info', async () => { + vi.spyOn(api, 'getConfig').mockResolvedValue( + mockServerConfig({ server: { public_url: '' } }) + ); + renderSettings(); + await screen.findByRole('button', { name: 'Use best defaults' }); + const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement; + expect(publicUrlInput.value).toBe(''); + await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' })); + expect( + await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.') + ).toBeInTheDocument(); + expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url); + }); + + it('stores browser session credentials', async () => { + renderSettings(); + const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement; + const user = userEvent.setup(); + const sessionUser = within(accessSection).getAllByPlaceholderText('admin')[0]; + const sessionPass = accessSection.querySelectorAll('input[type="password"]')[0] as HTMLInputElement; + await user.type(sessionUser, 'admin'); + await user.type(sessionPass, 'secret-pass'); + await user.click(within(accessSection).getByRole('button', { name: 'Save session login' })); + expect(getStoredAuth()).toBeTruthy(); + expect(await screen.findByText(/Session login saved/i)).toBeInTheDocument(); + }); + + it('clears browser session on logout', async () => { + setStoredAuth('admin', 'secret-pass'); + renderSettings(); + await screen.findByText('Session active'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Clear session' })); + expect(getStoredAuth()).toBeNull(); + expect(await screen.findByText('Session login cleared.')).toBeInTheDocument(); + clearStoredAuth(); + }); + + it('adds dashboard user via createUser API', async () => { + const createSpy = vi.spyOn(api, 'createUser'); + renderSettings(); + const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement; + const user = userEvent.setup(); + const newUserInput = within(accessSection).getAllByPlaceholderText('admin')[1]; + const newPassInput = within(accessSection).getByPlaceholderText('••••••••'); + await user.type(newUserInput, 'operator'); + await user.type(newPassInput, 'op-pass-123'); + await user.click(within(accessSection).getByRole('button', { name: 'Add User' })); + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith('operator', 'op-pass-123'); + }); + expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument(); + }); + + it('describes first-run admin credentials in Access Control help', async () => { + renderSettings(); + expect( + await screen.findByText(/first server start, credentials are printed once in the server console/i) + ).toBeInTheDocument(); + expect(screen.getByText(/admin/i)).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index e7ec58e..262780d 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -7,7 +7,7 @@ import NeonCard from '../components/NeonCard/NeonCard'; import './Pages.css'; /** Recursively merge `override` into `base`, preserving keys not in `override`. */ -function deepMerge(base: T, override: Partial): T { +export function deepMerge(base: T, override: Partial): T { const result = { ...base } as T; for (const key in override) { const val = override[key]; @@ -378,7 +378,7 @@ export default function SettingsPage() {

Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.

- updateField('wallet.address', e.target.value)} /> diff --git a/server/web/src/test/fixtures.ts b/server/web/src/test/fixtures.ts index 9c5cfea..7c6773c 100644 --- a/server/web/src/test/fixtures.ts +++ b/server/web/src/test/fixtures.ts @@ -1,4 +1,50 @@ -import type { Agent, Share, ServerInfo } from '../types'; +import type { Agent, Share, ServerConfig, ServerInfo } from '../types'; + +const VALID_XMR_WALLET = '4' + 'A'.repeat(94); + +export function mockServerConfig(overrides: Partial = {}): ServerConfig { + return { + port: 8080, + data_dir: './data', + pool: { + host: 'pool.supportxmr.com', + port: 3333, + use_tls: false, + password: 'x', + ...(overrides.pool ?? {}), + }, + wallet: { + address: VALID_XMR_WALLET, + payment_id: '', + ...(overrides.wallet ?? {}), + }, + server: { + public_url: 'http://192.168.1.5:8080', + stats_retention_hours: 168, + build_retention_days: 30, + pool_reconnect_seconds: 30, + websocket_ping_seconds: 30, + max_agents: 256, + max_build_size_mb: 150, + log_agent_connections: true, + log_share_submissions: false, + log_pool_traffic: false, + strict_wallet_validation: false, + dashboard_subtitle: 'security is just an emotion', + open_firewall_on_start: true, + obfuscate_default: false, + sign_enabled: false, + ...(overrides.server ?? {}), + }, + alerts: { + offline_threshold_minutes: 5, + hashrate_drop_threshold_pct: 50, + rejection_rate_threshold_pct: 5, + ...(overrides.alerts ?? {}), + }, + ...overrides, + }; +} export function mockAgent(overrides: Partial = {}): Agent { return { diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 8caa8f2..2a0e6e5 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -37,6 +37,14 @@ export interface Agent { pending_updates?: number; // -1 = unknown reboot_pending?: boolean; agent_elevated?: boolean; + services?: AgentService[]; +} + +export interface AgentService { + name: string; + display_name?: string; + status: 'running' | 'stopped' | 'not_found'; + start_type: 'auto' | 'manual' | 'disabled' | 'unknown'; } export interface AgentCapabilities { diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index b91ea8b..73a2624 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -1,4 +1,4 @@ -import type { Agent } from '../types'; +import type { Agent, AgentService } from '../types'; /** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */ export interface WSDashboardInit { @@ -31,6 +31,7 @@ export interface WSStatsUpdate { pending_updates?: number; reboot_pending?: boolean; agent_elevated?: boolean; + services?: AgentService[]; } export interface WSCommandResult {