feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack

This commit is contained in:
AetherForge
2026-05-30 23:11:32 -07:00
parent 9232f4c448
commit 4207f6c21b
43 changed files with 4359 additions and 180 deletions

View File

@@ -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/<title>/`; 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 95106 but validator accepts 90106; 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 |
| H5H8 | 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 (B1B42, C1C6, H1H8, 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 |

View File

@@ -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})
}
}

View File

@@ -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, ""
}

View File

@@ -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, ""
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -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)
}
}

View File

@@ -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":

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
)

View File

@@ -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']);
});
});

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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
);
});
});
});

View File

@@ -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.',
});
}

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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 {

View File

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

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -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">

View File

@@ -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',
};

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -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');
});
});

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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" }]
}

View File

@@ -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'],
],
},
});