feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
@@ -3,22 +3,278 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/ollama"
|
||||
)
|
||||
|
||||
func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
// Numeric constants from ai_handler.go (guard against silent drift).
|
||||
const (
|
||||
aiReportBufferCap = 1000
|
||||
aiHeartbeatIntervalSeconds = 60
|
||||
aiReasoningTruncateLen = 120
|
||||
aiReportOutputTruncateLen = 200
|
||||
aiEngineIdleTTL = time.Hour
|
||||
aiActivityIdleTTL = 24 * time.Hour
|
||||
aiCleanupTickerInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
func newTestAIHandler(t *testing.T) *AIHandler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return NewAIHandler(database)
|
||||
}
|
||||
|
||||
h := NewAIHandler(database)
|
||||
func mockOllamaChatServer(t *testing.T, content string, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if status != http.StatusOK {
|
||||
_, _ = w.Write([]byte("ollama down"))
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]string{"content": content},
|
||||
"done": true,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func ollamaDecideContent(reasoning string, tools []ollama.ToolCall) string {
|
||||
resp := ollama.DecideResponse{Reasoning: reasoning, ToolCalls: tools}
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestAIConstants(t *testing.T) {
|
||||
if aiReportBufferCap != 1000 || aiHeartbeatIntervalSeconds != 60 ||
|
||||
aiReasoningTruncateLen != 120 || aiReportOutputTruncateLen != 200 ||
|
||||
aiEngineIdleTTL != time.Hour || aiActivityIdleTTL != 24*time.Hour ||
|
||||
aiCleanupTickerInterval != 10*time.Minute {
|
||||
t.Fatal("ai_handler constants drifted from documented values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAINewAIHandler(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
if h.db == nil || h.engines == nil || h.activity == nil {
|
||||
t.Fatal("NewAIHandler did not initialize fields")
|
||||
}
|
||||
if cap(h.reports) != aiReportBufferCap {
|
||||
t.Fatalf("reports slice cap want %d got %d", aiReportBufferCap, cap(h.reports))
|
||||
}
|
||||
if len(h.ActivitySnapshot()) != 0 {
|
||||
t.Fatal("expected empty activity snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITruncateStr(t *testing.T) {
|
||||
if truncateStr("short", aiReasoningTruncateLen) != "short" {
|
||||
t.Fatal("short string should be unchanged")
|
||||
}
|
||||
long := strings.Repeat("x", aiReasoningTruncateLen+10)
|
||||
out := truncateStr(long, aiReasoningTruncateLen)
|
||||
if len(out) != aiReasoningTruncateLen+3 || !strings.HasSuffix(out, "...") {
|
||||
t.Fatalf("truncate want len %d with ellipsis, got %q", aiReasoningTruncateLen+3, out)
|
||||
}
|
||||
if truncateStr("", 5) != "" {
|
||||
t.Fatal("empty string")
|
||||
}
|
||||
if truncateStr("exact", 5) != "exact" {
|
||||
t.Fatal("exact length boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentAndGetRemove(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, ollamaDecideContent("ok", nil), http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-a", srv.URL, "test-model")
|
||||
if h.GetEngine("agent-a") == nil {
|
||||
t.Fatal("GetEngine returned nil after SetEngineForAgent")
|
||||
}
|
||||
h.RemoveEngine("agent-a")
|
||||
if h.GetEngine("agent-a") != nil {
|
||||
t.Fatal("GetEngine should be nil after RemoveEngine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentDefaults(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var gotModel string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
gotModel = req.Model
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": map[string]string{"content": ollamaDecideContent("ok", nil)},
|
||||
"done": true,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-defaults", srv.URL, "")
|
||||
eng := h.GetEngine("agent-defaults")
|
||||
if eng == nil {
|
||||
t.Fatal("engine missing")
|
||||
}
|
||||
_, err := eng.Decide(&ollama.AgentState{AgentID: "agent-defaults"})
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if gotModel != "llama3.2" {
|
||||
t.Fatalf("default model want llama3.2 got %q", gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader(`{"worker_name":"x"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "agent_id") {
|
||||
t.Fatalf("missing agent_id status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideSuccess(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("all good", []ollama.ToolCall{{
|
||||
Tool: "check_miner", Args: map[string]string{"process_name": "xmrig"}, Reason: "verify",
|
||||
}})
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-ok",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "test-model",
|
||||
"hostname": "host1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp ollama.DecideResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "check_miner" {
|
||||
t.Fatalf("unexpected tool calls: %+v", resp.ToolCalls)
|
||||
}
|
||||
|
||||
snap := h.ActivitySnapshot()
|
||||
var found bool
|
||||
for _, e := range snap {
|
||||
if e.AgentID == "agent-decide-ok" && e.LastAction == "check_miner" && e.ToolCallCount == 1 {
|
||||
found = true
|
||||
if len(e.LastReasoning) > aiReasoningTruncateLen+3 {
|
||||
t.Fatalf("reasoning should be truncated to %d", aiReasoningTruncateLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("activity not recorded: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-fail",
|
||||
"ollama_endpoint": srv.URL,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fallback should return 200, got %d", w.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["error"] == nil {
|
||||
t.Fatal("expected error field")
|
||||
}
|
||||
tools, ok := out["tool_calls"].([]interface{})
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("expected sleep fallback tool_calls, got %v", out["tool_calls"])
|
||||
}
|
||||
first, _ := tools[0].(map[string]interface{})
|
||||
if first["tool"] != "sleep" {
|
||||
t.Fatalf("expected sleep tool, got %v", first["tool"])
|
||||
}
|
||||
args, _ := first["args"].(map[string]interface{})
|
||||
if args["seconds"] != "120" {
|
||||
t.Fatalf("fallback sleep seconds want 120 got %v", args["seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("idle", nil)
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-new",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "m1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if h.GetEngine("agent-new") == nil {
|
||||
t.Fatal("engine should exist after first decide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportSingleAndArray(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
single := ollama.Report{
|
||||
AgentID: "agent-1",
|
||||
@@ -54,6 +310,166 @@ func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", errReader{})
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("read error status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", strings.NewReader("not-json"))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid json status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportBufferCap(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
for i := 0; i < aiReportBufferCap+5; i++ {
|
||||
r := ollama.Report{AgentID: "cap-agent", Tool: "sleep", Success: true}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("report %d status %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
n := len(h.reports)
|
||||
h.mu.RUnlock()
|
||||
if n != aiReportBufferCap {
|
||||
t.Fatalf("reports len want %d got %d", aiReportBufferCap, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportEventBroadcaster(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var events []AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
events = append(events, e)
|
||||
})
|
||||
|
||||
r := ollama.Report{AgentID: "evt-agent", Tool: "upload_log", Success: true, Output: "done"}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if len(events) != 1 || events[0].AgentID != "evt-agent" || events[0].LastTool != "upload_log" {
|
||||
t.Fatalf("broadcaster events: %+v", events)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&resp)
|
||||
if resp["received"].(float64) != 1 || resp["success"] != true {
|
||||
t.Fatalf("response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleHeartbeat(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader(`{"status":"alive"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing agent_id status %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(heartbeatRequest{AgentID: "hb-agent", Status: "alive", Message: "ok"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["success"] != true {
|
||||
t.Fatalf("success: %v", out["success"])
|
||||
}
|
||||
interval, ok := out["interval"].(float64)
|
||||
if !ok || int(interval) != aiHeartbeatIntervalSeconds {
|
||||
t.Fatalf("interval want %d got %v", aiHeartbeatIntervalSeconds, out["interval"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIRecordActivityMergeAndBroadcast(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
h.activity["merge-agent"] = AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastReportAt: now,
|
||||
LastSuccess: true,
|
||||
ToolCallCount: 3,
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var broadcasted AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
broadcasted = e
|
||||
})
|
||||
|
||||
h.recordActivity(AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastDecideAt: now.Add(time.Minute),
|
||||
LastAction: "sleep",
|
||||
LastTool: "sleep",
|
||||
LastReasoning: "rest",
|
||||
ToolCallCount: 0, // zero must not wipe previous count
|
||||
})
|
||||
|
||||
h.mu.RLock()
|
||||
prev := h.activity["merge-agent"]
|
||||
h.mu.RUnlock()
|
||||
if prev.ToolCallCount != 3 {
|
||||
t.Fatalf("zero ToolCallCount should not clear prior count, got %d", prev.ToolCallCount)
|
||||
}
|
||||
if prev.LastAction != "sleep" || prev.LastReasoning != "rest" {
|
||||
t.Fatalf("merged activity: %+v", prev)
|
||||
}
|
||||
if broadcasted.AgentID != "merge-agent" {
|
||||
t.Fatalf("broadcast: %+v", broadcasted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIActivitySnapshot(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
h.mu.Lock()
|
||||
h.activity["a"] = AIActivityEntry{AgentID: "a"}
|
||||
h.activity["b"] = AIActivityEntry{AgentID: "b"}
|
||||
h.mu.Unlock()
|
||||
snap := h.ActivitySnapshot()
|
||||
if len(snap) != 2 {
|
||||
t.Fatalf("want 2 entries got %d", len(snap))
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read([]byte) (int, error) { return 0, errors.New("read fail") }
|
||||
func (errReader) Close() error { return nil }
|
||||
|
||||
// errReader must satisfy io.ReadCloser for request body
|
||||
var _ io.ReadCloser = errReader{}
|
||||
|
||||
func TestEstimateXMRPerDay(t *testing.T) {
|
||||
out := EstimateXMRPerDay(3_000_000_000)
|
||||
xmr, ok := out["xmr_per_day"].(float64)
|
||||
|
||||
72
server/internal/api/auth_test.go
Normal file
72
server/internal/api/auth_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckPasswordBcryptAndLegacy(t *testing.T) {
|
||||
hashed, err := hashPassword("secret123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !checkPassword(hashed, "secret123") {
|
||||
t.Fatal("bcrypt hash should match correct password")
|
||||
}
|
||||
if checkPassword(hashed, "wrong") {
|
||||
t.Fatal("bcrypt hash should reject wrong password")
|
||||
}
|
||||
if !checkPassword("plainlegacy", "plainlegacy") {
|
||||
t.Fatal("legacy plain-text should match")
|
||||
}
|
||||
if checkPassword("plainlegacy", "other") {
|
||||
t.Fatal("legacy plain-text should reject mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBcryptHash(t *testing.T) {
|
||||
h, _ := hashPassword("x")
|
||||
if !isBcryptHash(h) {
|
||||
t.Fatal("expected bcrypt prefix")
|
||||
}
|
||||
if isBcryptHash("plaintext") {
|
||||
t.Fatal("plaintext should not look like bcrypt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCacheHitAndMiss(t *testing.T) {
|
||||
user, pass := "cacheuser", "cachepass"
|
||||
if authCacheHit(user, pass) {
|
||||
t.Fatal("cache should miss before set")
|
||||
}
|
||||
authCacheSet(user, pass)
|
||||
if !authCacheHit(user, pass) {
|
||||
t.Fatal("cache should hit after set")
|
||||
}
|
||||
if authCacheHit(user, "wrong") {
|
||||
t.Fatal("different password should miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomPasswordLength(t *testing.T) {
|
||||
pw := generateRandomPassword()
|
||||
if len(pw) != 20 {
|
||||
t.Fatalf("expected 20-char hex password, got len %d (%q)", len(pw), pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentForgeConfigDefaults(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if cfg.poolHostOrDefault("fallback.host") != "fallback.host" {
|
||||
t.Fatal("empty pool host should use fallback")
|
||||
}
|
||||
if cfg.poolPortOrDefault(3333) != 3333 {
|
||||
t.Fatal("zero pool port should use fallback")
|
||||
}
|
||||
cfg = AgentForgeConfig{PoolHost: "pool.example.com", PoolPort: 443}
|
||||
if cfg.poolHostOrDefault("fallback") != "pool.example.com" {
|
||||
t.Fatal("explicit pool host should win")
|
||||
}
|
||||
if cfg.poolPortOrDefault(3333) != 443 {
|
||||
t.Fatal("explicit pool port should win")
|
||||
}
|
||||
}
|
||||
91
server/internal/api/handlers_test.go
Normal file
91
server/internal/api/handlers_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return NewHandler(database)
|
||||
}
|
||||
|
||||
func TestHealthCheckReturnsOK(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HealthCheck(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("unexpected body: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentsEmptyArray(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAgents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
|
||||
var agents []json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agents) != 0 {
|
||||
t.Fatalf("expected empty list, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatsLimitCap(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusInternalServerError {
|
||||
t.Fatalf("limit cap caused 500: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentSharesLimitCap(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/shares?limit=999999", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRecentShares(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentNotFound(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Get("/agents/{id}", h.GetAgent)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,26 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
|
||||
func seedTestUsers(t *testing.T, dataDir string) {
|
||||
t.Helper()
|
||||
usersPath := filepath.Join(dataDir, "users.json")
|
||||
data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal users: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(usersPath, data, 0600); err != nil {
|
||||
t.Fatalf("write users.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
@@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) {
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -164,7 +181,7 @@ func indexOf(s, sub string) int {
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit
|
||||
|
||||
86
server/internal/api/server_info_test.go
Normal file
86
server/internal/api/server_info_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePort(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"8989", 8989},
|
||||
{"80", 80},
|
||||
{"abc", 8989},
|
||||
{"", 8989},
|
||||
{"0", 8989},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := parsePort(tc.in); got != tc.want {
|
||||
t.Fatalf("parsePort(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLoopbackHost(t *testing.T) {
|
||||
if !isLoopbackHost("localhost") {
|
||||
t.Fatal("localhost should be loopback")
|
||||
}
|
||||
if !isLoopbackHost("127.0.0.1") {
|
||||
t.Fatal("127.0.0.1 should be loopback")
|
||||
}
|
||||
if isLoopbackHost("192.168.1.1") {
|
||||
t.Fatal("192.168.1.1 should not be loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
if itoa(8989) != "8989" {
|
||||
t.Fatalf("itoa(8989) = %q", itoa(8989))
|
||||
}
|
||||
if itoa(0) != "0" {
|
||||
t.Fatalf("itoa(0) = %q", itoa(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerInfoJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Port != 8989 {
|
||||
t.Fatalf("port = %d, want 8989", info.Port)
|
||||
}
|
||||
if info.WebSocketURL == "" || info.SuggestedURL == "" {
|
||||
t.Fatalf("missing URLs: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerInfoPublicURLOverride(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "https://forge.example.com:443")
|
||||
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.SuggestedURL != "https://forge.example.com:443" {
|
||||
t.Fatalf("override not applied: %q", info.SuggestedURL)
|
||||
}
|
||||
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" {
|
||||
t.Fatalf("ws url = %q", info.WebSocketURL)
|
||||
}
|
||||
}
|
||||
@@ -519,8 +519,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
}
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.SSHAvailable != nil {
|
||||
broadcast["ssh_available"] = *stats.SSHAvailable
|
||||
}
|
||||
if stats.PostureScore != nil {
|
||||
broadcast["posture_score"] = *stats.PostureScore
|
||||
}
|
||||
if stats.DefenderEnabled != nil {
|
||||
broadcast["defender_enabled"] = *stats.DefenderEnabled
|
||||
}
|
||||
if stats.DefenderRTP != nil {
|
||||
broadcast["defender_rtp"] = *stats.DefenderRTP
|
||||
}
|
||||
if len(stats.AVProducts) > 0 {
|
||||
broadcast["av_products"] = stats.AVProducts
|
||||
}
|
||||
if stats.FirewallDomain != nil {
|
||||
broadcast["firewall_domain"] = *stats.FirewallDomain
|
||||
}
|
||||
if stats.FirewallPrivate != nil {
|
||||
broadcast["firewall_private"] = *stats.FirewallPrivate
|
||||
}
|
||||
if stats.FirewallPublic != nil {
|
||||
broadcast["firewall_public"] = *stats.FirewallPublic
|
||||
}
|
||||
if stats.LastPatchDays != nil {
|
||||
broadcast["last_patch_days"] = *stats.LastPatchDays
|
||||
}
|
||||
if stats.LastPatch != nil {
|
||||
broadcast["last_patch"] = *stats.LastPatch
|
||||
}
|
||||
if stats.PendingUpdates != nil {
|
||||
broadcast["pending_updates"] = *stats.PendingUpdates
|
||||
}
|
||||
if stats.RebootPending != nil {
|
||||
broadcast["reboot_pending"] = *stats.RebootPending
|
||||
}
|
||||
if stats.AgentElevated != nil {
|
||||
broadcast["agent_elevated"] = *stats.AgentElevated
|
||||
}
|
||||
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
|
||||
|
||||
case "submit_share":
|
||||
|
||||
76
server/internal/db/agent_meta_test.go
Normal file
76
server/internal/db/agent_meta_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeTagsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"brackets", "[]", 0},
|
||||
{"whitespace", " [] ", 0},
|
||||
{"valid", `["a","b"]`, 2},
|
||||
{"invalid json", "{not-json", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := decodeTags(tc.raw)
|
||||
if len(got) != tc.want {
|
||||
t.Fatalf("decodeTags(%q) len=%d want %d (%v)", tc.raw, len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTagsEmpty(t *testing.T) {
|
||||
if got := encodeTags(nil); got != "[]" {
|
||||
t.Fatalf("encodeTags(nil) = %q want []", got)
|
||||
}
|
||||
if got := encodeTags([]string{}); got != "[]" {
|
||||
t.Fatalf("encodeTags(empty) = %q want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentInvalidTagsInDB(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "bad-tags")
|
||||
|
||||
if _, err := d.Exec(`UPDATE agents SET tags = ? WHERE id = ?`, "{invalid", "bad-tags"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent("bad-tags")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Tags) != 0 {
|
||||
t.Fatalf("invalid tags should decode to empty slice, got %v", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentMetaClearsTags(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "meta-clear")
|
||||
|
||||
if err := d.UpdateAgentMeta("meta-clear", "note", []string{"x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateAgentMeta("meta-clear", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent("meta-clear")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Notes != "" {
|
||||
t.Fatalf("notes not cleared: %q", got.Notes)
|
||||
}
|
||||
if len(got.Tags) != 0 {
|
||||
t.Fatalf("tags not cleared: %v", got.Tags)
|
||||
}
|
||||
}
|
||||
191
server/internal/db/builds_test.go
Normal file
191
server/internal/db/builds_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func insertBuild(t *testing.T, d *Database, b *models.BuildRecord) {
|
||||
t.Helper()
|
||||
if err := d.InsertBuild(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCRUDAndList(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
|
||||
created := time.Now().UTC().Truncate(time.Second)
|
||||
build := &models.BuildRecord{
|
||||
ID: "build-001",
|
||||
WorkerName: "rig1",
|
||||
ServerURL: "http://localhost:8080",
|
||||
Wallet: "48xyz",
|
||||
Threads: 4,
|
||||
FileSize: 1024,
|
||||
BundleSize: 2048,
|
||||
FilePath: "/data/builds/build-001.zip",
|
||||
FileName: "agent.zip",
|
||||
DownloadURL: "/api/v1/builds/build-001/download",
|
||||
Platform: "windows",
|
||||
CreatedAt: created,
|
||||
PoolHost: "pool.example.com",
|
||||
PoolPort: 443,
|
||||
PoolTLS: true,
|
||||
PoolPass: "x",
|
||||
}
|
||||
insertBuild(t, d, build)
|
||||
|
||||
got, err := d.GetBuild("build-001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.WorkerName != "rig1" || got.Platform != "windows" || got.BundleSize != 2048 {
|
||||
t.Fatalf("build mismatch: %+v", got)
|
||||
}
|
||||
if got.Pinned {
|
||||
t.Fatal("new build should not be pinned")
|
||||
}
|
||||
|
||||
list, err := d.ListBuilds(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != "build-001" {
|
||||
t.Fatalf("list builds: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBuildNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetBuild("missing")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPinnedBuild(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
now := time.Now()
|
||||
insertBuild(t, d, &models.BuildRecord{ID: "b1", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now})
|
||||
insertBuild(t, d, &models.BuildRecord{ID: "b2", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now.Add(time.Second)})
|
||||
|
||||
if err := d.SetPinnedBuild("b1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b1, _ := d.GetBuild("b1")
|
||||
b2, _ := d.GetBuild("b2")
|
||||
if !b1.Pinned || b2.Pinned {
|
||||
t.Fatalf("pin state wrong: b1=%v b2=%v", b1.Pinned, b2.Pinned)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b1, _ = d.GetBuild("b1")
|
||||
if b1.Pinned {
|
||||
t.Fatal("empty id should unpin all builds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBuildForPlatform(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "old-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "linux", CreatedAt: base,
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "linux", CreatedAt: base.Add(time.Minute),
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-windows", WorkerName: "w", ServerURL: "u", Wallet: "w",
|
||||
Platform: "windows", CreatedAt: base.Add(2 * time.Minute),
|
||||
})
|
||||
|
||||
latestLinux, err := d.GetLatestBuildForPlatform("linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latestLinux.ID != "new-linux" {
|
||||
t.Fatalf("latest linux: got %q", latestLinux.ID)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild("old-linux"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinnedLinux, err := d.GetLatestBuildForPlatform("linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinnedLinux.ID != "old-linux" {
|
||||
t.Fatalf("pinned linux: got %q", pinnedLinux.ID)
|
||||
}
|
||||
|
||||
anyLatest, err := d.GetLatestBuildForPlatform("any")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyLatest.ID != "old-linux" {
|
||||
t.Fatalf("any with pinned: got %q want old-linux", anyLatest.ID)
|
||||
}
|
||||
|
||||
if err := d.SetPinnedBuild(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPlatform, err := d.GetLatestBuildForPlatform("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if emptyPlatform.ID != "new-windows" {
|
||||
t.Fatalf("empty platform latest: got %q", emptyPlatform.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBuildForPlatformEmpty(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetLatestBuildForPlatform("linux")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows on empty db, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuildsOlderThanAndDeleteBuild(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
cutoff := time.Now().UTC().Truncate(time.Second)
|
||||
oldTime := cutoff.Add(-time.Hour)
|
||||
newTime := cutoff.Add(time.Hour)
|
||||
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "old-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: oldTime,
|
||||
})
|
||||
insertBuild(t, d, &models.BuildRecord{
|
||||
ID: "new-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: newTime,
|
||||
})
|
||||
|
||||
older, err := d.ListBuildsOlderThan(cutoff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(older) != 1 || older[0].ID != "old-build" {
|
||||
t.Fatalf("ListBuildsOlderThan: %+v", older)
|
||||
}
|
||||
|
||||
if err := d.DeleteBuild("old-build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = d.GetBuild("old-build")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected deleted build missing, got %v", err)
|
||||
}
|
||||
|
||||
if err := d.DeleteBuild("never-existed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,15 @@ func (d *Database) PurgeHashrateSamplesBefore(cutoff time.Time) (int64, error) {
|
||||
|
||||
// ListBuildsOlderThan returns build records created before cutoff.
|
||||
func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE created_at < ?`, cutoff)
|
||||
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
b, err := scanBuild(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
||||
255
server/internal/db/sqlite_test.go
Normal file
255
server/internal/db/sqlite_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *Database {
|
||||
t.Helper()
|
||||
d, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
|
||||
t.Helper()
|
||||
a := &models.Agent{
|
||||
ID: id,
|
||||
Name: "worker-" + id,
|
||||
Wallet: "wallet",
|
||||
IP: "10.0.0.1",
|
||||
Version: "2.0",
|
||||
Status: "offline",
|
||||
CPUCores: 4,
|
||||
MemoryGB: 8,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := d.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestNewCreatesDatabase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
var n int
|
||||
if err := d.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected agents table, got count %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetAgent("missing-agent")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "persist-created")
|
||||
|
||||
first, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
a.Name = "renamed"
|
||||
a.Status = "online"
|
||||
if err := d.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.CreatedAt.Equal(first.CreatedAt) {
|
||||
t.Fatalf("created_at changed: %v -> %v", first.CreatedAt, second.CreatedAt)
|
||||
}
|
||||
if second.Name != "renamed" {
|
||||
t.Fatalf("name not updated: %q", second.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentStats(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "stats-agent")
|
||||
|
||||
if err := d.UpdateAgentStats(a.ID, 100, 200, 300, 10, 8, 2, 55.5, 66.6, 3600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "online" {
|
||||
t.Fatalf("status: got %q want online", got.Status)
|
||||
}
|
||||
if got.Hashrate15s != 100 || got.Hashrate1m != 200 || got.Hashrate15m != 300 {
|
||||
t.Fatalf("hashrate mismatch: %+v", got)
|
||||
}
|
||||
if got.SharesTotal != 10 || got.SharesGood != 8 || got.SharesBad != 2 {
|
||||
t.Fatalf("shares mismatch: %+v", got)
|
||||
}
|
||||
if got.CPUUsagePct != 55.5 || got.MemoryUsagePct != 66.6 || got.UptimeSeconds != 3600 {
|
||||
t.Fatalf("usage/uptime mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentOffline(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
a := seedAgent(t, d, "offline-agent")
|
||||
if err := d.UpdateAgentStats(a.ID, 1, 1, 1, 0, 0, 0, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := d.SetAgentOffline(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.GetAgent(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "offline" {
|
||||
t.Fatalf("status: got %q want offline", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareInsertUpdateAndRecent(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "share-agent")
|
||||
|
||||
ts := time.Now().UTC().Truncate(time.Second)
|
||||
share := &models.Share{
|
||||
AgentID: "share-agent",
|
||||
JobID: "job-1",
|
||||
Difficulty: 1000,
|
||||
Accepted: false,
|
||||
Hash: "abc123",
|
||||
Nonce: "deadbeef",
|
||||
Error: "pending",
|
||||
Timestamp: ts,
|
||||
}
|
||||
id, err := d.InsertShare(share)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("expected positive insert id, got %d", id)
|
||||
}
|
||||
|
||||
if err := d.UpdateShareResult(id, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recent, err := d.GetRecentShares(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(recent))
|
||||
}
|
||||
if !recent[0].Accepted {
|
||||
t.Fatal("share should be accepted after update")
|
||||
}
|
||||
if recent[0].Error != "" {
|
||||
t.Fatalf("error should be cleared, got %q", recent[0].Error)
|
||||
}
|
||||
|
||||
if err := d.UpdateShareResult(id, false, "pool rejected"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recent, err = d.GetRecentShares(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recent[0].Accepted || recent[0].Error != "pool rejected" {
|
||||
t.Fatalf("unexpected share after reject update: %+v", recent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashrateSampleHistory(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "hr-agent")
|
||||
|
||||
if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := d.GetHashrateHistory("hr-agent", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2 samples, got %d", len(history))
|
||||
}
|
||||
if history[0].Hashrate != 200.0 {
|
||||
t.Fatalf("expected newest first, got %f", history[0].Hashrate)
|
||||
}
|
||||
|
||||
empty, err := d.GetHashrateHistory("unknown", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("expected empty history, got %d", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFleetStatsWithAgents(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedAgent(t, d, "fleet-a")
|
||||
seedAgent(t, d, "fleet-b")
|
||||
|
||||
if err := d.UpdateAgentStats("fleet-a", 0, 0, 100, 20, 18, 2, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateAgentStats("fleet-b", 0, 0, 50, 10, 5, 5, 0, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := d.GetFleetStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.TotalAgents != 2 {
|
||||
t.Fatalf("total agents: got %d want 2", stats.TotalAgents)
|
||||
}
|
||||
if stats.OnlineAgents != 2 {
|
||||
t.Fatalf("online agents: got %d want 2", stats.OnlineAgents)
|
||||
}
|
||||
if stats.TotalHashrate != 150 {
|
||||
t.Fatalf("total hashrate: got %f want 150", stats.TotalHashrate)
|
||||
}
|
||||
if stats.TotalShares != 30 || stats.AcceptedShares != 23 || stats.RejectedShares != 7 {
|
||||
t.Fatalf("share totals mismatch: %+v", stats)
|
||||
}
|
||||
wantRate := float64(23) / float64(30) * 100
|
||||
if stats.AcceptRate != wantRate {
|
||||
t.Fatalf("accept rate: got %f want %f", stats.AcceptRate, wantRate)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,20 @@ type Agent struct {
|
||||
|
||||
// Crucible — SSH status probed by the agent every ~60s
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
|
||||
// Defense posture + patch exposure — ATT&CK T1685/T1686.003
|
||||
PostureScore int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCapabilities reports forge-time features available for remote command.
|
||||
|
||||
239
server/web/package-lock.json
generated
239
server/web/package-lock.json
generated
@@ -21,6 +21,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
@@ -30,6 +33,13 @@
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1396,12 +1406,110 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1784,6 +1892,16 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2047,6 +2165,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2217,6 +2342,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
@@ -2232,6 +2367,14 @@
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -2471,6 +2614,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -2610,6 +2763,17 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
@@ -2645,6 +2809,16 @@
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2859,6 +3033,44 @@
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
@@ -3092,6 +3304,20 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -3284,6 +3510,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/suspend-react": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
|
||||
@@ -115,6 +115,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
|
||||
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
|
||||
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
|
||||
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
|
||||
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
|
||||
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
|
||||
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
|
||||
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
|
||||
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
|
||||
40
server/web/src/help/endpointHelpers.test.ts
Normal file
40
server/web/src/help/endpointHelpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatLanEndpoint, lanEndpointCandidates } from './endpointHelpers';
|
||||
import type { ServerInfo } from '../types';
|
||||
|
||||
describe('formatLanEndpoint', () => {
|
||||
it('strips scheme and path from host', () => {
|
||||
expect(formatLanEndpoint('https://192.168.1.5:8989/extra', 8989)).toBe('http://192.168.1.5:8989');
|
||||
});
|
||||
|
||||
it('builds http URL from bare IP', () => {
|
||||
expect(formatLanEndpoint('10.0.0.2', 7777)).toBe('http://10.0.0.2:7777');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lanEndpointCandidates', () => {
|
||||
it('dedupes suggested URL and local IPs', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['192.168.1.10', '192.168.1.10'],
|
||||
suggested_url: 'http://192.168.1.10:8989',
|
||||
dashboard_url: 'http://192.168.1.10:8989',
|
||||
websocket_url: 'ws://192.168.1.10:8989/ws/agent',
|
||||
};
|
||||
const urls = lanEndpointCandidates(info);
|
||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||
});
|
||||
|
||||
it('honours port override', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['10.0.0.1'],
|
||||
suggested_url: '',
|
||||
dashboard_url: '',
|
||||
websocket_url: '',
|
||||
};
|
||||
expect(lanEndpointCandidates(info, 9000)).toEqual(['http://10.0.0.1:9000']);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computeFleetHealth,
|
||||
contributionBars,
|
||||
findUnderperformers,
|
||||
fleetMedianHashrate,
|
||||
groupBySubnet,
|
||||
osArchBreakdown,
|
||||
staleAgentIds,
|
||||
timeToPayout,
|
||||
} from './fleetAnalytics';
|
||||
import type { Agent, PoolStatus } from '../types';
|
||||
|
||||
const agent = (overrides: Partial<Agent> = {}): Agent => ({
|
||||
id: 'a1',
|
||||
name: 'node-1',
|
||||
status: 'online',
|
||||
hashrate_15m: 1000,
|
||||
shares_total: 100,
|
||||
shares_good: 98,
|
||||
ip: '192.168.1.10',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
last_seen: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Agent);
|
||||
|
||||
describe('computeFleetHealth', () => {
|
||||
it('returns NOMINAL for healthy fleet', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', name: 'node-2', hashrate_15m: 900 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'green' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).toBe('NOMINAL');
|
||||
expect(health.score).toBeGreaterThanOrEqual(80);
|
||||
expect(health.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags offline nodes and pool degradation', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', status: 'offline', hashrate_15m: 0 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'red' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).not.toBe('NOMINAL');
|
||||
expect(health.issues.some((i) => i.includes('offline'))).toBe(true);
|
||||
expect(health.issues.some((i) => i.includes('pool'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contributionBars', () => {
|
||||
it('sorts online agents by hashrate share', () => {
|
||||
const bars = contributionBars([
|
||||
agent({ id: 'a1', hashrate_15m: 300 }),
|
||||
agent({ id: 'a2', hashrate_15m: 700 }),
|
||||
agent({ id: 'a3', status: 'offline', hashrate_15m: 9999 }),
|
||||
]);
|
||||
expect(bars).toHaveLength(2);
|
||||
expect(bars[0].id).toBe('a2');
|
||||
expect(bars[0].pct).toBeCloseTo(70, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnderperformers', () => {
|
||||
it('returns agents below 70% of median', () => {
|
||||
const agents = [
|
||||
agent({ id: 'fast', hashrate_15m: 1000 }),
|
||||
agent({ id: 'slow', hashrate_15m: 500 }),
|
||||
agent({ id: 'mid', hashrate_15m: 900 }),
|
||||
];
|
||||
const under = findUnderperformers(agents);
|
||||
expect(under.map((a) => a.id)).toEqual(['slow']);
|
||||
});
|
||||
|
||||
it('returns empty when fewer than 2 online miners', () => {
|
||||
expect(findUnderperformers([agent()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleetMedianHashrate', () => {
|
||||
it('computes median of online non-zero agents', () => {
|
||||
const med = fleetMedianHashrate([
|
||||
agent({ hashrate_15m: 100 }),
|
||||
agent({ id: 'a2', hashrate_15m: 300 }),
|
||||
agent({ id: 'a3', hashrate_15m: 200 }),
|
||||
]);
|
||||
expect(med).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBySubnet', () => {
|
||||
it('groups by /24 prefix', () => {
|
||||
const groups = groupBySubnet([
|
||||
agent({ ip: '10.0.0.1' }),
|
||||
agent({ id: 'a2', ip: '10.0.0.2' }),
|
||||
agent({ id: 'a3', ip: '192.168.5.9' }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].subnet).toBe('10.0.0.x');
|
||||
expect(groups[0].agents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('osArchBreakdown', () => {
|
||||
it('labels platforms for display', () => {
|
||||
const rows = osArchBreakdown([
|
||||
agent({ platform: 'linux', arch: 'amd64' }),
|
||||
agent({ id: 'a2', platform: 'darwin', arch: 'arm64' }),
|
||||
]);
|
||||
expect(rows.some((r) => r.label.includes('Linux'))).toBe(true);
|
||||
expect(rows.some((r) => r.label.includes('macOS'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staleAgentIds', () => {
|
||||
it('flags online agents not seen in 5+ minutes', () => {
|
||||
const staleTime = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
const ids = staleAgentIds([
|
||||
agent({ id: 'fresh', last_seen: new Date().toISOString() }),
|
||||
agent({ id: 'stale', last_seen: staleTime }),
|
||||
]);
|
||||
expect(ids.has('stale')).toBe(true);
|
||||
expect(ids.has('fresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToPayout', () => {
|
||||
it('returns days until payout', () => {
|
||||
expect(timeToPayout(0.5, 0.25)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when data missing', () => {
|
||||
expect(timeToPayout(undefined, 1)).toBeNull();
|
||||
expect(timeToPayout(1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -86,9 +86,9 @@ export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetH
|
||||
// ─── Contribution Map ─────────────────────────────────────────────────────────
|
||||
|
||||
export function contributionBars(agents: Agent[]): ContributionBar[] {
|
||||
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return agents
|
||||
.filter((a) => a.status === 'online')
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const total = online.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return online
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
|
||||
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runForgeCompatibilityChecks } from './forgeCompatibility';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** Valid mainnet-style Monero address (95 chars, starts with 4). */
|
||||
const VALID_WALLET = '4' + 'A'.repeat(94);
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: VALID_WALLET,
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
function hasCheck(form: BuildRequest, fusionPrep: boolean, id: string, level?: string) {
|
||||
const checks = runForgeCompatibilityChecks(form, fusionPrep);
|
||||
const match = checks.find((c) => c.id === id);
|
||||
if (!match) return false;
|
||||
return level === undefined || match.level === level;
|
||||
}
|
||||
|
||||
describe('runForgeCompatibilityChecks', () => {
|
||||
it('returns no errors for a fully valid form', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const errors = checks.filter((c) => c.level === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(checks.some((c) => c.id === 'forge_ready' && c.level === 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
describe('stealth and display', () => {
|
||||
it('errors when stealth mode uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ stealth_mode: true, display_mode: 'visible' }), false, 'stealth_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when stealth mode enables file logging', () => {
|
||||
expect(hasCheck(baseForm({ stealth_mode: true, file_logging: true }), false, 'stealth_logs', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when fusion uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ fusion_enabled: true, display_mode: 'visible' }), false, 'fusion_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle mining mode', () => {
|
||||
it('errors when idle threshold is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 0 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 101 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when idle duration is below 1 minute', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_duration_minutes: 0 }), false, 'idle_duration', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check idle fields when mining mode is not idle', () => {
|
||||
expect(hasCheck(baseForm({ mining_mode: 'always', idle_threshold_pct: 0 }), false, 'idle_threshold')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled mining mode', () => {
|
||||
it('errors when schedule times are missing', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '', schedule_end: '' }),
|
||||
false,
|
||||
'schedule',
|
||||
'error'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when both schedule times are set', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '22:00', schedule_end: '06:00' }),
|
||||
false,
|
||||
'schedule'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threads and CPU limits', () => {
|
||||
it('warns when adapt_to_hardware is on with fixed thread mode', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }), false, 'adapt_fixed', 'warn')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when thread percent is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 0 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 101 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max CPU usage is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 0 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 101 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max memory is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 9 }), false, 'max_mem', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 96 }), false, 'max_mem', 'error')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pool configuration', () => {
|
||||
it('errors when pool port is out of range', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 0 }), false, 'pool_port', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ pool_port: 70000 }), false, 'pool_port', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on port 443 without TLS', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 443, pool_tls: false }), false, 'pool_tls_443', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on TLS with port 3333', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 3333, pool_tls: true }), false, 'pool_tls_3333', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when pool password is empty', () => {
|
||||
expect(hasCheck(baseForm({ pool_pass: ' ' }), false, 'pool_pass', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run mode and AI', () => {
|
||||
it('warns when run_as is service', () => {
|
||||
expect(hasCheck(baseForm({ run_as: 'service' }), false, 'run_as_service', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI uses 127.0.0.1 endpoint', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ ai_enabled: true, ai_ollama_endpoint: 'http://127.0.0.1:11434' }),
|
||||
false,
|
||||
'ai_localhost',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI is enabled without self-healing', () => {
|
||||
expect(hasCheck(baseForm({ ai_enabled: true, self_healing: false }), false, 'ai_no_heal', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity fields', () => {
|
||||
it('errors when process name is empty', () => {
|
||||
expect(hasCheck(baseForm({ process_name: ' ' }), false, 'process_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when process name has unusual characters', () => {
|
||||
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when worker name is empty', () => {
|
||||
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses localhost', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses 127.0.0.1', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when wallet does not match Monero format', () => {
|
||||
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts minimum-length wallet (90 chars)', () => {
|
||||
const wallet = '4' + 'A'.repeat(89);
|
||||
expect(wallet.length).toBe(90);
|
||||
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts subaddress starting with 8', () => {
|
||||
const subaddress = '8' + 'B'.repeat(94);
|
||||
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits forge_ready when core config is coherent', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const ready = checks.find((c) => c.id === 'forge_ready');
|
||||
expect(ready?.level).toBe('ok');
|
||||
expect(ready?.message).toContain('Core miner config looks coherent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fusion and deliverables', () => {
|
||||
it('emits fusion_ready when prep is attached', () => {
|
||||
expect(hasCheck(baseForm({ fusion_enabled: true }), true, 'fusion_ready', 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit targets non-universal OS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, target_os: 'windows' }), false, 'spread_kit_os', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit and fusion are both enabled', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, fusion_enabled: true }), false, 'spread_fusion', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when universal has no spread kit or fusion', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ target_os: 'universal', spread_kit: false, fusion_enabled: false }),
|
||||
false,
|
||||
'universal_deliverable',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform-specific constraints', () => {
|
||||
it('errors when process hollowing is set on Linux', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'linux', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when process hollowing is set on macOS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'darwin', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on Linux', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'linux', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on macOS', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'darwin', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
checks.push({
|
||||
id: 'wallet_invalid',
|
||||
level: 'warn',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
427
server/web/src/help/forgeRules.test.ts
Normal file
427
server/web/src/help/forgeRules.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FORGE_SECTIONS,
|
||||
applyForgeFieldUpdate,
|
||||
forgeBadgeLabel,
|
||||
getForgeFieldMeta,
|
||||
getForgeLiveNotices,
|
||||
type ForgeFieldBadge,
|
||||
} from './forgeRules';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('FORGE_SECTIONS', () => {
|
||||
it('defines six sections in order', () => {
|
||||
expect(FORGE_SECTIONS).toHaveLength(6);
|
||||
expect(FORGE_SECTIONS.map((s) => s.id)).toEqual([
|
||||
'identity',
|
||||
'pool',
|
||||
'performance',
|
||||
'install',
|
||||
'fusion',
|
||||
'ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each section has id, title, description, and baked badge', () => {
|
||||
for (const section of FORGE_SECTIONS) {
|
||||
expect(section.id.length).toBeGreaterThan(0);
|
||||
expect(section.title.length).toBeGreaterThan(0);
|
||||
expect(section.description.length).toBeGreaterThan(0);
|
||||
expect(section.badge).toBe('baked');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses expected section titles', () => {
|
||||
const titles = Object.fromEntries(FORGE_SECTIONS.map((s) => [s.id, s.title]));
|
||||
expect(titles.identity).toBe('Identity');
|
||||
expect(titles.pool).toBe('Pool Configuration');
|
||||
expect(titles.performance).toBe('Performance & Resources');
|
||||
expect(titles.install).toBe('Install & Process');
|
||||
expect(titles.fusion).toBe('Fusion');
|
||||
expect(titles.ai).toBe('AI Autonomy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgeBadgeLabel', () => {
|
||||
const cases: [ForgeFieldBadge, string][] = [
|
||||
['baked', 'Baked into installer'],
|
||||
['server-only', 'Server folder only — not in .exe'],
|
||||
['requires', 'Required when parent option is on'],
|
||||
];
|
||||
|
||||
it.each(cases)('maps %s badge to label', (badge, label) => {
|
||||
expect(forgeBadgeLabel(badge)).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyForgeFieldUpdate', () => {
|
||||
it('stealth_mode disables logging and forces silent/background', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ display_mode: 'visible', file_logging: true, silent_mode: false }),
|
||||
'stealth_mode',
|
||||
true
|
||||
);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('display_mode visible clears stealth and silent', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ stealth_mode: true, silent_mode: true }),
|
||||
'display_mode',
|
||||
'visible'
|
||||
);
|
||||
expect(out.display_mode).toBe('visible');
|
||||
expect(out.stealth_mode).toBe(false);
|
||||
expect(out.silent_mode).toBe(false);
|
||||
});
|
||||
|
||||
it('display_mode silent/background enables silent_mode', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'silent').silent_mode).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'background').silent_mode
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('persistence and auto_start stay linked', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ auto_start: false }), 'persistence', true).auto_start).toBe(true);
|
||||
expect(applyForgeFieldUpdate(baseForm({ persistence: false }), 'auto_start', true).persistence).toBe(true);
|
||||
});
|
||||
|
||||
it('linux target clears Windows-only flags and fixes install base', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({
|
||||
target_os: 'windows',
|
||||
process_hollowing: true,
|
||||
sign_build: true,
|
||||
obfuscate: true,
|
||||
install_base: 'localappdata',
|
||||
}),
|
||||
'target_os',
|
||||
'linux'
|
||||
);
|
||||
expect(out.target_os).toBe('linux');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.obfuscate).toBe(false);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
});
|
||||
|
||||
it('darwin target defaults to arm64', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'darwin');
|
||||
expect(out.target_os).toBe('darwin');
|
||||
expect(out.target_arch).toBe('arm64');
|
||||
});
|
||||
|
||||
it('windows target restores localappdata from xdg path', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ install_base: 'xdg_data_home' }), 'target_os', 'windows');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
});
|
||||
|
||||
it('universal target on single deliverable normalizes back to windows', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('universal target stays when spread kit is active', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ spread_kit: true }), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('fusion_enabled forces background, clears spread kit, and universal target', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ spread_kit: true, display_mode: 'visible', target_os: 'windows' }),
|
||||
'fusion_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.fusion_enabled).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('spread_kit applies full preset and clears fusion', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.run_as).toBe('scheduled');
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.auto_start).toBe(true);
|
||||
expect(out.self_healing).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.firewall_exclusion).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
});
|
||||
|
||||
it('thread_mode fixed sets minimum threads', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ threads: 0 }), 'thread_mode', 'fixed');
|
||||
expect(out.thread_mode).toBe('fixed');
|
||||
expect(out.threads).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('thread_mode percent clamps invalid thread_percent', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ thread_percent: 0 }), 'thread_mode', 'percent');
|
||||
expect(out.thread_percent).toBe(75);
|
||||
});
|
||||
|
||||
it('install_base non-custom clears custom path', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ install_custom_base: 'C:\\custom', install_base: 'custom' }),
|
||||
'install_base',
|
||||
'localappdata'
|
||||
);
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
expect(out.install_custom_base).toBe('');
|
||||
});
|
||||
|
||||
it('run_as scheduled/service forces persistence flags', () => {
|
||||
const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled');
|
||||
expect(scheduled.persistence).toBe(true);
|
||||
expect(scheduled.auto_start).toBe(true);
|
||||
|
||||
const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service');
|
||||
expect(service.persistence).toBe(true);
|
||||
expect(service.auto_start).toBe(true);
|
||||
});
|
||||
|
||||
it('ai_enabled fills default endpoint and model when empty', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ ai_ollama_endpoint: '', ai_model: '' }),
|
||||
'ai_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.ai_enabled).toBe(true);
|
||||
expect(out.ai_ollama_endpoint).toBe('http://localhost:11434');
|
||||
expect(out.ai_model).toBe('llama3.2');
|
||||
});
|
||||
|
||||
it('pool_port 443 auto-enables TLS', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ pool_tls: false }), 'pool_port', 443);
|
||||
expect(out.pool_port).toBe(443);
|
||||
expect(out.pool_tls).toBe(true);
|
||||
});
|
||||
|
||||
it('worker_name update does not auto-derive process_name', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ process_name: 'RuntimeBrokerHelper' }), 'worker_name', 'new-worker');
|
||||
expect(out.worker_name).toBe('new-worker');
|
||||
expect(out.process_name).toBe('RuntimeBrokerHelper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeFieldMeta', () => {
|
||||
it('returns meta for all known forge fields', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
const expectedKeys = [
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'wallet',
|
||||
'output_dir',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'threads',
|
||||
'fusion_enabled',
|
||||
'spread_kit',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
expect(meta[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables thread_percent when thread mode is fixed', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'fixed' }));
|
||||
expect(meta.thread_percent.disabled).toBe(true);
|
||||
expect(meta.threads.disabled).toBe(false);
|
||||
expect(meta.adapt_to_hardware.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables threads when thread mode is percent', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'percent' }));
|
||||
expect(meta.threads.disabled).toBe(true);
|
||||
expect(meta.thread_percent.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables idle fields unless mining mode is idle', () => {
|
||||
const idle = getForgeFieldMeta(baseForm({ mining_mode: 'idle' }));
|
||||
expect(idle.idle_threshold_pct.disabled).toBe(false);
|
||||
expect(idle.idle_duration_minutes.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.idle_threshold_pct.disabled).toBe(true);
|
||||
expect(always.idle_duration_minutes.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables schedule fields unless mining mode is scheduled', () => {
|
||||
const scheduled = getForgeFieldMeta(baseForm({ mining_mode: 'scheduled' }));
|
||||
expect(scheduled.schedule_start.disabled).toBe(false);
|
||||
expect(scheduled.schedule_end.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.schedule_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks file_logging under stealth mode', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ stealth_mode: true }));
|
||||
expect(meta.file_logging.disabled).toBe(true);
|
||||
expect(meta.file_logging.lockedReason).toContain('Stealth mode');
|
||||
});
|
||||
|
||||
it('locks persistence under scheduled/service run_as', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' }));
|
||||
expect(meta.persistence.disabled).toBe(true);
|
||||
expect(meta.auto_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks fusion when spread kit is on and vice versa', () => {
|
||||
const spread = getForgeFieldMeta(baseForm({ spread_kit: true }));
|
||||
expect(spread.fusion_enabled.disabled).toBe(true);
|
||||
expect(spread.target_os.disabled).toBe(true);
|
||||
|
||||
const fusion = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(fusion.spread_kit.disabled).toBe(true);
|
||||
expect(fusion.target_os.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('requires fusion fields only when fusion is enabled', () => {
|
||||
const off = getForgeFieldMeta(baseForm({ fusion_enabled: false }));
|
||||
expect(off.fusion_prep.disabled).toBe(true);
|
||||
expect(off.fusion_run_order.badge).toBe('requires');
|
||||
|
||||
const on = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(on.fusion_prep.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables unix-incompatible fields on linux/darwin', () => {
|
||||
const linux = getForgeFieldMeta(baseForm({ target_os: 'linux' }));
|
||||
expect(linux.process_hollowing.disabled).toBe(true);
|
||||
expect(linux.obfuscate.disabled).toBe(true);
|
||||
expect(linux.target_arch.disabled).toBe(false);
|
||||
|
||||
const windows = getForgeFieldMeta(baseForm({ target_os: 'windows' }));
|
||||
expect(windows.target_arch.disabled).toBe(true);
|
||||
expect(windows.sign_build.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('marks output_dir and obfuscate as server-only', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
expect(meta.output_dir.badge).toBe('server-only');
|
||||
expect(meta.obfuscate.badge).toBe('server-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeLiveNotices', () => {
|
||||
it('returns empty array for a plain windows form', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false);
|
||||
expect(notices).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns about service run mode', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ run_as: 'service' }), false);
|
||||
expect(notices.some((n) => n.includes('scheduled task'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when scheduled/service lacks persistence', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ run_as: 'scheduled', persistence: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Persistence is forced on'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fusion enabled without prep upload', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ fusion_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('upload prep.exe'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes AI Ollama notice when AI is enabled', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ ai_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('Ollama'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fixed threads ignores adapt_to_hardware', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Adapt to hardware'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on pool TLS/port mismatches', () => {
|
||||
const p443 = getForgeLiveNotices(baseForm({ pool_port: 443, pool_tls: false }), false);
|
||||
expect(p443.some((n) => n.includes('Port 443'))).toBe(true);
|
||||
|
||||
const p3333 = getForgeLiveNotices(baseForm({ pool_port: 3333, pool_tls: true }), false);
|
||||
expect(p3333.some((n) => n.includes('3333'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on low max CPU with high thread percent', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ max_cpu_usage_pct: 20, thread_percent: 80, thread_mode: 'percent' }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('throttling'))).toBe(true);
|
||||
});
|
||||
|
||||
it('describes universal, spread kit, and fusion deliverables', () => {
|
||||
const universal = getForgeLiveNotices(baseForm({ target_os: 'universal' }), false);
|
||||
expect(universal.some((n) => n.includes('Windows, Linux, and macOS'))).toBe(true);
|
||||
|
||||
const spread = getForgeLiveNotices(baseForm({ spread_kit: true, target_os: 'universal' }), false);
|
||||
expect(spread.some((n) => n.includes('Spread Kit'))).toBe(true);
|
||||
|
||||
const fusion = getForgeLiveNotices(baseForm({ fusion_enabled: true, target_os: 'universal' }), true);
|
||||
expect(fusion.some((n) => n.includes('Fusion builds a universal ZIP'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes single-platform unix install paths', () => {
|
||||
const linux = getForgeLiveNotices(baseForm({ target_os: 'linux' }), false);
|
||||
expect(linux.some((n) => n.includes('linux worker'))).toBe(true);
|
||||
|
||||
const darwin = getForgeLiveNotices(baseForm({ target_os: 'darwin' }), false);
|
||||
expect(darwin.some((n) => n.includes('darwin worker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on universal without deliverable type', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ target_os: 'universal', fusion_enabled: false, spread_kit: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/settingHelp.test.ts
Normal file
133
server/web/src/help/settingHelp.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIELD_HELP, SETUP_CHEATSHEET } from './settingHelp';
|
||||
|
||||
describe('SETUP_CHEATSHEET', () => {
|
||||
it('defines four setup steps in order', () => {
|
||||
expect(SETUP_CHEATSHEET).toHaveLength(4);
|
||||
expect(SETUP_CHEATSHEET.map((s) => s.title)).toEqual([
|
||||
'1. Calibrate once',
|
||||
'2. Forge (Simple mode)',
|
||||
'3. Deploy',
|
||||
'4. Watch the fleet',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each step has non-empty title and body', () => {
|
||||
for (const step of SETUP_CHEATSHEET) {
|
||||
expect(step.title.trim().length).toBeGreaterThan(0);
|
||||
expect(step.body.trim().length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('mentions key workflow concepts in bodies', () => {
|
||||
const bodies = SETUP_CHEATSHEET.map((s) => s.body).join(' ');
|
||||
expect(bodies).toContain('Calibrate');
|
||||
expect(bodies).toContain('Forge');
|
||||
expect(bodies).toContain('Command Deck');
|
||||
expect(bodies).toContain('Fleet Roster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_HELP', () => {
|
||||
const expectedKeys = [
|
||||
'calibrate_wallet',
|
||||
'calibrate_quick_setup',
|
||||
'forge_simple_mode',
|
||||
'forge_recommended_defaults',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
'obfuscate_default',
|
||||
'sign_enabled',
|
||||
'sign_cert_thumbprint',
|
||||
'sign_tool_path',
|
||||
'sign_timestamp_url',
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'output_dir',
|
||||
'wallet',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'pool_tls',
|
||||
'pool_pass',
|
||||
'threads',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'max_cpu_usage_pct',
|
||||
'max_memory_percent',
|
||||
'min_free_ram_mb',
|
||||
'cpu_priority',
|
||||
'mining_mode',
|
||||
'idle_threshold_pct',
|
||||
'idle_duration_minutes',
|
||||
'schedule_start',
|
||||
'schedule_end',
|
||||
'display_mode',
|
||||
'process_name',
|
||||
'persistence',
|
||||
'run_as',
|
||||
'silent_mode',
|
||||
'auto_start',
|
||||
'fusion_enabled',
|
||||
'fusion_run_order',
|
||||
'fusion_prep',
|
||||
'fusion_media_mode',
|
||||
'fusion_output_name',
|
||||
'fusion_batch',
|
||||
'install_base',
|
||||
'install_custom_base',
|
||||
'install_relative_path',
|
||||
'public_url',
|
||||
'websocket_ping_seconds',
|
||||
'log_pool_traffic',
|
||||
'adapt_to_hardware',
|
||||
'self_healing',
|
||||
'firewall_exclusion',
|
||||
'open_firewall_on_start',
|
||||
'file_logging',
|
||||
'stealth_mode',
|
||||
'ai_enabled',
|
||||
'ai_ollama_endpoint',
|
||||
'ai_model',
|
||||
'process_hollowing',
|
||||
'mesh_p2p',
|
||||
'auto_spread',
|
||||
'usb_spread',
|
||||
'share_spread',
|
||||
'hole_punch',
|
||||
'remote_aggressive',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'spread_kit',
|
||||
'forge_deliverable',
|
||||
] as const;
|
||||
|
||||
it('defines help text for every documented field key', () => {
|
||||
expect(Object.keys(FIELD_HELP).sort()).toEqual([...expectedKeys].sort());
|
||||
});
|
||||
|
||||
it('each help entry is a non-empty string', () => {
|
||||
for (const key of expectedKeys) {
|
||||
const text = FIELD_HELP[key];
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.trim().length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('wallet and server_url entries warn against localhost', () => {
|
||||
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
|
||||
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
|
||||
expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
|
||||
});
|
||||
|
||||
it('fusion entries describe decoy bundling', () => {
|
||||
expect(FIELD_HELP.fusion_enabled).toContain('Fuse the miner');
|
||||
expect(FIELD_HELP.fusion_prep).toContain('decoy');
|
||||
expect(FIELD_HELP.fusion_run_order).toContain('Parallel');
|
||||
});
|
||||
|
||||
it('advanced spread entries describe lateral/USB/share behavior', () => {
|
||||
expect(FIELD_HELP.auto_spread).toContain('SMB');
|
||||
expect(FIELD_HELP.usb_spread).toContain('USB');
|
||||
expect(FIELD_HELP.share_spread).toContain('share');
|
||||
});
|
||||
});
|
||||
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: false,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentsPage() {
|
||||
return render(<AgentsPage />);
|
||||
}
|
||||
|
||||
describe('AgentsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useWebSocketMock.mockReturnValue(wsValue());
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
vi.spyOn(api, 'getAgentStats').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' });
|
||||
vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
|
||||
success: true,
|
||||
agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }),
|
||||
});
|
||||
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
|
||||
success: true,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
action: 'restart',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders page heading and quick deploy labels', async () => {
|
||||
renderAgentsPage();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
|
||||
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Windows')).toHaveLength(2);
|
||||
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
|
||||
expect(screen.getByText('macOS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds quick deploy URLs from server info', async () => {
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
|
||||
});
|
||||
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading then empty multi-OS state', async () => {
|
||||
renderAgentsPage();
|
||||
expect(screen.getByText('Scanning network...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No agents registered')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces listAgents load errors', async () => {
|
||||
vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable'));
|
||||
renderAgentsPage();
|
||||
expect(await screen.findByText('API unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists agents and opens detail panel with section headings', async () => {
|
||||
const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rack B Miner')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.setup().click(screen.getByText('Rack B Miner'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Notes & Tags')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hashrate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shares')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote Control')).toBeInTheDocument();
|
||||
expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60);
|
||||
});
|
||||
|
||||
it('preserves notes draft while typing until agent switch', async () => {
|
||||
const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' });
|
||||
const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Node One'));
|
||||
const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' });
|
||||
const panel = detail.closest('.agent-detail') as HTMLElement;
|
||||
const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(notes.value).toBe('note one'));
|
||||
await user.clear(notes);
|
||||
await user.type(notes, 'typing in progress');
|
||||
expect(notes.value).toBe('typing in progress');
|
||||
await user.click(screen.getByText('Node Two'));
|
||||
await waitFor(() => expect(notes.value).toBe('note two'));
|
||||
});
|
||||
|
||||
it('saves notes and tags via API', async () => {
|
||||
const agent = mockAgent({ id: 'save-me', name: 'Save Target' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
|
||||
success: true,
|
||||
agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] },
|
||||
});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Save Target'));
|
||||
const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' });
|
||||
const panel = detail.closest('.agent-detail') as HTMLElement;
|
||||
const notes = within(panel).getByPlaceholderText('Notes about this machine…');
|
||||
await user.clear(notes);
|
||||
await user.type(notes, 'Living room PC');
|
||||
const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
|
||||
await user.clear(tags);
|
||||
await user.type(tags, 'living-room, rack-b');
|
||||
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
|
||||
});
|
||||
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alerts when bulk action has no online agents', async () => {
|
||||
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([offline]);
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument());
|
||||
const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement;
|
||||
await userEvent.setup().click(within(list).getByRole('checkbox'));
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('alerts when bulk command API fails', async () => {
|
||||
const agent = mockAgent({ name: 'Online One' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument());
|
||||
const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement;
|
||||
await userEvent.setup().click(within(list).getByRole('checkbox'));
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('bulk failed');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('syncs agents from websocket when connected', async () => {
|
||||
const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 });
|
||||
vi.spyOn(api, 'listAgents').mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50))
|
||||
);
|
||||
const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 });
|
||||
useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] }));
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Live Name')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('REST Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter empty hint when no agents match', async () => {
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument());
|
||||
const search = screen.getByPlaceholderText('Search name, IP, notes, tags…');
|
||||
await userEvent.setup().type(search, 'nomatchxyz');
|
||||
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
@@ -93,13 +93,25 @@ export default function AgentsPage() {
|
||||
const [tagsDraft, setTagsDraft] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
isConnectedRef.current = isConnected;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.listAgents()
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
.then((data) => {
|
||||
if (!cancelled && !isConnectedRef.current) setAgents(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +226,7 @@ export default function AgentsPage() {
|
||||
await api.sendBulkCommand(onlineIds, action);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : 'Bulk command failed');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
|
||||
@@ -150,10 +150,82 @@
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cn-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
|
||||
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-posture {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-posture.posture-good { color: var(--neon-green); background: rgba(57,255,20,0.1); }
|
||||
.cn-posture.posture-warn { color: var(--neon-amber); background: rgba(255,176,32,0.12); }
|
||||
.cn-posture.posture-bad { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-posture.posture-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-patch {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); }
|
||||
.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
|
||||
.cn-elevated {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--neon-magenta);
|
||||
background: rgba(255,45,166,0.12);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Pending-updates badge ─────────────────────────────────────────────────── */
|
||||
.cn-upd {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
|
||||
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
|
||||
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
|
||||
|
||||
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
|
||||
.cn-reboot {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-reboot.rb-pending {
|
||||
color: #ff2222;
|
||||
background: rgba(255,34,34,0.15);
|
||||
font-weight: 700;
|
||||
animation: rb-blink 1.4s step-end infinite;
|
||||
}
|
||||
@keyframes rb-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
|
||||
|
||||
.crucible-row {
|
||||
|
||||
@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
|
||||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||||
}
|
||||
|
||||
function postureBadge(score?: number) {
|
||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
|
||||
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
|
||||
return { label: `P:${score}`, cls: 'posture-bad' };
|
||||
}
|
||||
|
||||
function patchLabel(days?: number) {
|
||||
if (days === undefined) return null;
|
||||
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||||
}
|
||||
|
||||
function postureTooltip(agent: Agent): string {
|
||||
const lines: string[] = [];
|
||||
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
|
||||
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
|
||||
|
||||
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
|
||||
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
|
||||
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
|
||||
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
|
||||
lines.push('──────────────────────');
|
||||
|
||||
// Patch exposure
|
||||
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
|
||||
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
|
||||
if (agent.pending_updates !== undefined) {
|
||||
const u = agent.pending_updates;
|
||||
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u} ⚠`}`);
|
||||
}
|
||||
if (agent.reboot_pending !== undefined) {
|
||||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const u = agent.pending_updates;
|
||||
if (u === undefined) return null;
|
||||
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
|
||||
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
|
||||
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
|
||||
return { label: `${u} UPD`, cls: 'upd-bad' };
|
||||
}
|
||||
|
||||
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
if (agent.reboot_pending === undefined) return null;
|
||||
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
|
||||
return null; // no badge when not pending — cleaner UI
|
||||
}
|
||||
|
||||
function platformIcon(platform?: string): string {
|
||||
if (!platform) return '⬡';
|
||||
const p = platform.toLowerCase();
|
||||
@@ -99,8 +150,9 @@ export default function CruciblePage() {
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||
const [histIdx, setHistIdx] = useState(-1);
|
||||
|
||||
// SSH status overrides (from probe results)
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
|
||||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||||
const selectedAgents = useMemo(
|
||||
@@ -128,20 +180,35 @@ export default function CruciblePage() {
|
||||
for (const r of newEntries) {
|
||||
const aid = r.agent_id;
|
||||
if (!aid) continue;
|
||||
// Only show results from agents that are selected (or all if nothing selected)
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Parse SSH probe results to update ssh status
|
||||
const msg = r.message ?? '';
|
||||
// Always update badges from probe / heartbeat command responses
|
||||
if (msg.includes('SSH_PROBE:ONLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
if (r.action === 'posture' || msg.includes('{')) {
|
||||
try {
|
||||
const start = msg.indexOf('{');
|
||||
if (start >= 0) {
|
||||
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean };
|
||||
if (typeof p.posture_score === 'number') {
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
|
||||
}));
|
||||
}
|
||||
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Split multi-line output
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
@@ -258,6 +325,28 @@ export default function CruciblePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const probePosture = (targets?: Agent[]) => {
|
||||
const tgts = targets ?? selectedAgents.filter(online);
|
||||
Promise.all(
|
||||
tgts.map((a) =>
|
||||
api.sendAgentCommand(a.id, 'posture').catch((err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: a.id,
|
||||
agentName: a.name,
|
||||
isCmd: false,
|
||||
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(),
|
||||
success: false,
|
||||
},
|
||||
]);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { sendCmd(); return; }
|
||||
if (e.key === 'ArrowUp') {
|
||||
@@ -282,6 +371,13 @@ export default function CruciblePage() {
|
||||
return sshBadge({ ...a });
|
||||
};
|
||||
|
||||
const postureStatus = (a: Agent) => {
|
||||
const o = postureOverride[a.id];
|
||||
const score = o?.score ?? a.posture_score;
|
||||
const patchDays = o?.patchDays ?? a.last_patch_days;
|
||||
return { ...postureBadge(score), patch: patchLabel(patchDays) };
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -319,6 +415,7 @@ export default function CruciblePage() {
|
||||
const sel = selectedIds.has(a.id);
|
||||
const isOn = online(a);
|
||||
const ssh = sshStatus(a);
|
||||
const posture = postureStatus(a);
|
||||
const color = agentColor(a.id, allIds);
|
||||
return (
|
||||
<div
|
||||
@@ -345,7 +442,36 @@ export default function CruciblePage() {
|
||||
<span>{a.cpu_cores}c</span>
|
||||
<span>{formatHashrate(a.hashrate_15m)}</span>
|
||||
</div>
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div className="cn-badges">
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
className={`cn-posture ${posture.cls}`}
|
||||
title={postureTooltip(a)}
|
||||
>
|
||||
{posture.label}
|
||||
</div>
|
||||
{posture.patch && (
|
||||
<div
|
||||
className={`cn-patch ${posture.patch.cls}`}
|
||||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
|
||||
>
|
||||
{posture.patch.label}
|
||||
</div>
|
||||
)}
|
||||
{(() => { const pb = pendingBadge(a); return pb && (
|
||||
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||||
{pb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{(() => { const rb = rebootBadge(a); return rb && (
|
||||
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
|
||||
{rb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{a.agent_elevated && (
|
||||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -393,6 +519,26 @@ export default function CruciblePage() {
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
</div>
|
||||
<div className="crucible-ops">
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Posture</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => probePosture()}
|
||||
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
|
||||
>
|
||||
Probe Selected
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-wake"
|
||||
disabled={agents.filter(online).length === 0}
|
||||
onClick={() => probePosture(agents.filter(online))}
|
||||
title="Probe ALL online nodes at once"
|
||||
>
|
||||
⚡ Probe All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">SSH</span>
|
||||
<button
|
||||
|
||||
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DashboardPage, { formatShareTime } from './DashboardPage';
|
||||
import { mockAgent, mockShare } from '../test/fixtures';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/3D/FleetTopologyMap', () => ({
|
||||
default: () => <div data-testid="fleet-topology-map" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/MatrixStreamOverlay', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('formatShareTime', () => {
|
||||
it('formats ISO timestamps as locale time strings', () => {
|
||||
const iso = '2026-05-30T12:00:00.000Z';
|
||||
expect(formatShareTime(iso)).toBe(new Date(iso).toLocaleTimeString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
useWebSocketMock.mockReturnValue(wsValue());
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue({
|
||||
port: 8080,
|
||||
data_dir: '',
|
||||
pool: {} as never,
|
||||
wallet: {} as never,
|
||||
server: { dashboard_subtitle: 'custom subtitle from server' },
|
||||
alerts: {} as never,
|
||||
});
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getPoolStatus').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getAIActivity').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getXmrPrice').mockResolvedValue({ usd: 165.5, updated_at: '' });
|
||||
vi.spyOn(api, 'getEarningsEstimate').mockResolvedValue({
|
||||
xmr_per_day: 0.01,
|
||||
usd_per_day: 1.65,
|
||||
network_hashrate: 1e9,
|
||||
});
|
||||
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
|
||||
success: true,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
action: 'restart',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders hero heading and key section titles', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Command Deck' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Pipeline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share Activity Pulse')).toBeInTheDocument();
|
||||
expect(screen.getByText('Machine Roster')).toBeInTheDocument();
|
||||
expect(screen.getByText('PERSONAL NETWORK · LIVE TELEMETRY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reconnecting label when websocket is down', () => {
|
||||
useWebSocketMock.mockReturnValue(wsValue({ isConnected: false }));
|
||||
renderDashboard();
|
||||
expect(screen.getByText('RECONNECTING')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows signal locked when websocket is connected', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByText('SIGNAL LOCKED')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 nodes registered')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads dashboard subtitle from config API', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('custom subtitle from server')).toBeInTheDocument();
|
||||
expect(api.getConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows empty roster state when no agents', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat labels and top agent card', async () => {
|
||||
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
|
||||
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
|
||||
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
|
||||
expect(within(roster).getByText('online')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles advanced mode and reveals share log section', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([mockShare()]);
|
||||
renderDashboard();
|
||||
expect(screen.queryByText('Share Log')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '[ADVANCED]' }));
|
||||
expect(screen.getByText('Share Log')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('aether-dash-advanced')).toBe('1');
|
||||
});
|
||||
|
||||
it('shows share log rows in advanced mode with stable keys', async () => {
|
||||
const share = mockShare({ id: undefined as unknown as number, hash: 'deadbeef' });
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([share]);
|
||||
localStorage.setItem('aether-dash-advanced', '1');
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [mockAgent()] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Share Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accepted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alerts when bulk command API fails', async () => {
|
||||
const agent = mockAgent();
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('network down'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderDashboard();
|
||||
const roster = await screen.findByText('Machine Roster');
|
||||
const section = roster.closest('section') as HTMLElement;
|
||||
await waitFor(() => expect(within(section).getByText(agent.name)).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
const grid = section.querySelector('.agent-grid') as HTMLElement;
|
||||
await user.click(within(grid).getByRole('checkbox'));
|
||||
const bulkBar = section.querySelector('.fleet-bulk-bar') as HTMLElement;
|
||||
await user.click(within(bulkBar).getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('network down');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers live websocket alerts over REST fallback', async () => {
|
||||
useWebSocketMock.mockReturnValue(
|
||||
wsValue({
|
||||
fleetAlerts: [{ id: '1', level: 'warn', type: 'pool_down', message: 'live alert', timestamp: '' }],
|
||||
})
|
||||
);
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([
|
||||
{ id: '2', level: 'warn', type: 'stale', message: 'rest alert', timestamp: '' },
|
||||
]);
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('live alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('rest alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -514,8 +514,8 @@ export default function DashboardPage() {
|
||||
<tr><td colSpan={4} className="empty-table">No shares yet — awaiting proof of work...</td></tr>
|
||||
)}
|
||||
{shares.map((share) => (
|
||||
<tr key={share.id}>
|
||||
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
|
||||
<tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}>
|
||||
<td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td>
|
||||
<td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td>
|
||||
<td>
|
||||
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
||||
@@ -538,6 +538,6 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
export function formatShareTime(t: string): string {
|
||||
return new Date(t).toLocaleTimeString();
|
||||
}
|
||||
|
||||
@@ -587,13 +587,12 @@ export default function SettingsPage() {
|
||||
<NeonCard accent="magenta" className="settings-section">
|
||||
<h2 className="font-display">Access Control</h2>
|
||||
<p className="section-desc">
|
||||
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users.
|
||||
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — username</label>
|
||||
<input type="text" className="input" placeholder="drjones" value={sessionUser}
|
||||
<input type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
onChange={(e) => setSessionUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
51
server/web/src/test/fixtures.ts
Normal file
51
server/web/src/test/fixtures.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Agent, Share, ServerInfo } from '../types';
|
||||
|
||||
export function mockAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: 'agent-001-uuid',
|
||||
name: 'Test Miner',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
ip: '192.168.1.10',
|
||||
version: '1.0.0',
|
||||
status: 'online',
|
||||
cpu_cores: 8,
|
||||
memory_gb: 16,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
hashrate_15s: 500,
|
||||
hashrate_1m: 480,
|
||||
hashrate_15m: 450,
|
||||
shares_total: 100,
|
||||
shares_good: 95,
|
||||
shares_bad: 5,
|
||||
cpu_usage_pct: 42,
|
||||
memory_usage_pct: 55,
|
||||
uptime_seconds: 3600,
|
||||
platform: 'linux',
|
||||
arch: 'amd64',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockShare(overrides: Partial<Share> = {}): Share {
|
||||
return {
|
||||
id: 1,
|
||||
agent_id: 'agent-001-uuid',
|
||||
job_id: 'job-1',
|
||||
difficulty: 1000,
|
||||
accepted: true,
|
||||
hash: 'abc123deadbeef',
|
||||
nonce: '0001',
|
||||
timestamp: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockServerInfo: ServerInfo = {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
local_ips: ['192.168.1.5'],
|
||||
suggested_url: 'http://192.168.1.5:8080',
|
||||
dashboard_url: 'http://192.168.1.5:8080/dashboard',
|
||||
websocket_url: 'ws://192.168.1.5:8080/ws/dashboard',
|
||||
};
|
||||
1
server/web/src/test/setup.ts
Normal file
1
server/web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
413
server/web/src/types/index.test.ts
Normal file
413
server/web/src/types/index.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
Agent,
|
||||
AgentCapabilities,
|
||||
AgentDefaults,
|
||||
AlertsConfig,
|
||||
BackupPool,
|
||||
BlueprintInfo,
|
||||
Build,
|
||||
BuildRecord,
|
||||
BuildRequest,
|
||||
BuildResponse,
|
||||
EarningsEstimate,
|
||||
FleetAlert,
|
||||
FleetStats,
|
||||
FusionEstimate,
|
||||
HashrateSample,
|
||||
PoolConfig,
|
||||
PoolStatus,
|
||||
ServerConfig,
|
||||
ServerInfo,
|
||||
ServerSettings,
|
||||
Share,
|
||||
WalletConfig,
|
||||
WSMessage,
|
||||
XmrPrice,
|
||||
} from './index';
|
||||
|
||||
/** Runtime shape check — types/index.ts exports interfaces only (no type guards). */
|
||||
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('types/index — Agent', () => {
|
||||
const sample: Agent = {
|
||||
id: 'a1',
|
||||
name: 'office-pc',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
ip: '192.168.1.20',
|
||||
version: '1.0.0',
|
||||
status: 'online',
|
||||
cpu_cores: 8,
|
||||
memory_gb: 16,
|
||||
last_seen: '2026-05-30T12:00:00Z',
|
||||
created_at: '2026-05-01T08:00:00Z',
|
||||
hashrate_15s: 1200,
|
||||
hashrate_1m: 1180,
|
||||
hashrate_15m: 1150,
|
||||
shares_total: 100,
|
||||
shares_good: 98,
|
||||
shares_bad: 2,
|
||||
cpu_usage_pct: 45,
|
||||
memory_usage_pct: 60,
|
||||
uptime_seconds: 86400,
|
||||
};
|
||||
|
||||
it('accepts online/offline/error status values', () => {
|
||||
const statuses: Agent['status'][] = ['online', 'offline', 'error'];
|
||||
for (const status of statuses) {
|
||||
expect(statuses).toContain(status);
|
||||
}
|
||||
expect(sample.status).toBe('online');
|
||||
});
|
||||
|
||||
it('supports optional fleet metadata fields', () => {
|
||||
const extended: Agent = {
|
||||
...sample,
|
||||
notes: 'lab machine',
|
||||
tags: ['office', 'gpu'],
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
os_version: '11',
|
||||
capabilities: {
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
process_hollowing: false,
|
||||
ai_enabled: true,
|
||||
} satisfies AgentCapabilities,
|
||||
ssh_available: false,
|
||||
posture_score: 85,
|
||||
last_patch_days: 14,
|
||||
};
|
||||
expect(extended.tags).toHaveLength(2);
|
||||
expect(extended.capabilities?.ai_enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('types/index — BuildRequest', () => {
|
||||
const minimal: BuildRequest = {
|
||||
worker_name: 'worker-1',
|
||||
server_url: 'http://192.168.1.5:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'scheduled',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
max_cpu_usage_pct: 80,
|
||||
max_memory_percent: 70,
|
||||
min_free_ram_mb: 1024,
|
||||
idle_threshold_pct: 20,
|
||||
idle_duration_minutes: 5,
|
||||
schedule_start: '21:00',
|
||||
schedule_end: '06:00',
|
||||
install_base: 'localappdata',
|
||||
install_custom_base: '',
|
||||
install_relative_path: 'CryptoMiner/{worker}',
|
||||
adapt_to_hardware: true,
|
||||
self_healing: true,
|
||||
file_logging: false,
|
||||
stealth_mode: true,
|
||||
firewall_exclusion: true,
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 443,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
ai_enabled: false,
|
||||
ai_ollama_endpoint: 'http://localhost:11434',
|
||||
ai_model: 'llama3.2',
|
||||
};
|
||||
|
||||
it('includes required core forge fields', () => {
|
||||
expectKeys(minimal as unknown as Record<string, unknown>, [
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'wallet',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'fusion_enabled',
|
||||
'ai_enabled',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts optional target_os union values', () => {
|
||||
const platforms: NonNullable<BuildRequest['target_os']>[] = ['windows', 'linux', 'darwin', 'universal'];
|
||||
for (const target_os of platforms) {
|
||||
const req: BuildRequest = { ...minimal, target_os };
|
||||
expect(req.target_os).toBe(target_os);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts backup pool and server URL fallbacks', () => {
|
||||
const backupPools: BackupPool[] = [{ host: 'backup.pool', port: 443, tls: true, pass: 'x' }];
|
||||
const req: BuildRequest = {
|
||||
...minimal,
|
||||
backup_pools: backupPools,
|
||||
backup_server_urls: ['http://192.168.1.6:8989'],
|
||||
cancel_token: 'cancel-abc',
|
||||
};
|
||||
expect(req.backup_pools).toHaveLength(1);
|
||||
expect(req.backup_server_urls?.[0]).toContain('192.168');
|
||||
});
|
||||
});
|
||||
|
||||
describe('types/index — BuildRecord / Build alias', () => {
|
||||
const record: BuildRecord = {
|
||||
id: 'build-uuid',
|
||||
worker_name: 'worker-1',
|
||||
server_url: 'http://192.168.1.5:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
file_size: 1024000,
|
||||
file_path: '/data/builds/worker-1.exe',
|
||||
created_at: '2026-05-30T10:00:00Z',
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 443,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
};
|
||||
|
||||
it('Build alias is assignable from BuildRecord', () => {
|
||||
const build: Build = record;
|
||||
expect(build.id).toBe(record.id);
|
||||
expect(build.worker_name).toBe('worker-1');
|
||||
});
|
||||
|
||||
it('supports optional download and bundle metadata', () => {
|
||||
const extended: BuildRecord = {
|
||||
...record,
|
||||
file_name: 'worker-1.exe',
|
||||
platform: 'windows',
|
||||
bundle_size: 2048000,
|
||||
download_url: '/api/v1/builds/build-uuid/download',
|
||||
pinned: true,
|
||||
};
|
||||
expect(extended.pinned).toBe(true);
|
||||
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('types/index — server and fleet payloads', () => {
|
||||
it('ServerInfo carries LAN and websocket URLs', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: '0.0.0.0',
|
||||
local_ips: ['192.168.1.5'],
|
||||
suggested_url: 'http://192.168.1.5:8989',
|
||||
dashboard_url: 'http://192.168.1.5:8989/',
|
||||
websocket_url: 'ws://192.168.1.5:8989/ws/dashboard',
|
||||
};
|
||||
expect(info.local_ips).toContain('192.168.1.5');
|
||||
});
|
||||
|
||||
it('ServerConfig nests pool, wallet, server, and alerts', () => {
|
||||
const pool: PoolConfig = { host: 'pool.example.com', port: 3333, use_tls: false, password: 'x' };
|
||||
const wallet: WalletConfig = { address: '4' + 'A'.repeat(94), payment_id: '' };
|
||||
const server: ServerSettings = {
|
||||
public_url: 'http://192.168.1.5:8989',
|
||||
stats_retention_hours: 72,
|
||||
build_retention_days: 30,
|
||||
pool_reconnect_seconds: 30,
|
||||
websocket_ping_seconds: 30,
|
||||
max_agents: 100,
|
||||
max_build_size_mb: 512,
|
||||
log_agent_connections: true,
|
||||
log_share_submissions: false,
|
||||
log_pool_traffic: false,
|
||||
strict_wallet_validation: true,
|
||||
dashboard_subtitle: 'Fleet',
|
||||
open_firewall_on_start: true,
|
||||
};
|
||||
const alerts: AlertsConfig = {
|
||||
offline_threshold_minutes: 15,
|
||||
hashrate_drop_threshold_pct: 50,
|
||||
rejection_rate_threshold_pct: 10,
|
||||
};
|
||||
const config: ServerConfig = {
|
||||
port: 8989,
|
||||
data_dir: './data',
|
||||
pool,
|
||||
wallet,
|
||||
server,
|
||||
alerts,
|
||||
};
|
||||
expect(config.pool.host).toBe(pool.host);
|
||||
expect(config.wallet.address.startsWith('4')).toBe(true);
|
||||
});
|
||||
|
||||
it('FleetStats tracks acceptance rate', () => {
|
||||
const stats: FleetStats = {
|
||||
total_agents: 10,
|
||||
online_agents: 8,
|
||||
total_hashrate: 50000,
|
||||
total_shares: 1000,
|
||||
accepted_shares: 980,
|
||||
rejected_shares: 20,
|
||||
accept_rate: 0.98,
|
||||
};
|
||||
expect(stats.accept_rate).toBeCloseTo(stats.accepted_shares / stats.total_shares);
|
||||
});
|
||||
|
||||
it('Share and HashrateSample tie metrics to agents', () => {
|
||||
const share: Share = {
|
||||
id: 1,
|
||||
agent_id: 'a1',
|
||||
job_id: 'job-1',
|
||||
difficulty: 100000,
|
||||
accepted: true,
|
||||
hash: 'abc',
|
||||
nonce: '0001',
|
||||
timestamp: '2026-05-30T12:00:00Z',
|
||||
};
|
||||
const sample: HashrateSample = {
|
||||
id: 1,
|
||||
agent_id: 'a1',
|
||||
hashrate: 1200,
|
||||
timestamp: '2026-05-30T12:00:00Z',
|
||||
};
|
||||
expect(share.agent_id).toBe(sample.agent_id);
|
||||
});
|
||||
|
||||
it('PoolStatus uses green/yellow/red traffic light status', () => {
|
||||
const statuses: PoolStatus['status'][] = ['green', 'yellow', 'red'];
|
||||
const pool: PoolStatus = {
|
||||
key: 'primary',
|
||||
host: 'pool.example.com',
|
||||
port: 443,
|
||||
use_tls: true,
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
connected: true,
|
||||
status: 'green',
|
||||
};
|
||||
expect(statuses).toContain(pool.status);
|
||||
});
|
||||
|
||||
it('FleetAlert supports warn and error levels', () => {
|
||||
const alert: FleetAlert = {
|
||||
id: 'alert-1',
|
||||
level: 'warn',
|
||||
type: 'offline',
|
||||
agent_id: 'a1',
|
||||
agent_name: 'office-pc',
|
||||
message: 'Agent offline',
|
||||
timestamp: '2026-05-30T12:00:00Z',
|
||||
};
|
||||
expect(['warn', 'error']).toContain(alert.level);
|
||||
});
|
||||
|
||||
it('EarningsEstimate and XmrPrice carry pricing metadata', () => {
|
||||
const earnings: EarningsEstimate = {
|
||||
hashrate: 5000,
|
||||
xmr_per_day: 0.001,
|
||||
note: 'estimate',
|
||||
};
|
||||
const price: XmrPrice = {
|
||||
usd: 180,
|
||||
fetched_at: '2026-05-30T12:00:00Z',
|
||||
source: 'coingecko',
|
||||
};
|
||||
expect(earnings.xmr_per_day).toBeGreaterThan(0);
|
||||
expect(price.usd).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('types/index — build pipeline responses', () => {
|
||||
it('BuildResponse covers success and error paths', () => {
|
||||
const ok: BuildResponse = {
|
||||
success: true,
|
||||
build_id: 'uuid',
|
||||
file_name: 'worker.exe',
|
||||
download_url: '/api/v1/builds/uuid/download',
|
||||
signed: true,
|
||||
obfuscated: false,
|
||||
};
|
||||
const err: BuildResponse = { success: false, error: 'compile failed' };
|
||||
expect(ok.success).toBe(true);
|
||||
expect(err.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FusionEstimate lists byte breakdown fields', () => {
|
||||
const est: FusionEstimate = {
|
||||
prep_bytes: 1000,
|
||||
prep_name: 'video.mp4',
|
||||
estimated_worker_bytes: 5000000,
|
||||
estimated_fusion_stub_bytes: 200000,
|
||||
estimated_resource_patch_bytes: 50000,
|
||||
estimated_total_bytes: 5251000,
|
||||
output_file_name: 'runner.exe',
|
||||
project_root_path: '/proj',
|
||||
archive_path_hint: '/proj/out.zip',
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
notes: ['ok'],
|
||||
};
|
||||
expect(est.estimated_total_bytes).toBeGreaterThan(est.prep_bytes);
|
||||
});
|
||||
|
||||
it('BlueprintInfo stores optional parsed data', () => {
|
||||
const bp: BlueprintInfo = {
|
||||
name: 'default.json',
|
||||
size: 4096,
|
||||
created_at: '2026-05-30T10:00:00Z',
|
||||
data: { worker_name: 'worker-1' },
|
||||
};
|
||||
expect(bp.data?.worker_name).toBe('worker-1');
|
||||
});
|
||||
|
||||
it('AgentDefaults captures deprecated calibrate defaults shape', () => {
|
||||
const defaults: AgentDefaults = {
|
||||
threads: 4,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
max_cpu_usage_pct: 80,
|
||||
max_memory_percent: 70,
|
||||
min_free_ram_mb: 1024,
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
idle_threshold_pct: 20,
|
||||
idle_duration_minutes: 5,
|
||||
schedule_start: '21:00',
|
||||
schedule_end: '06:00',
|
||||
install_base: 'localappdata',
|
||||
install_custom_base: '',
|
||||
install_relative_path: 'CryptoMiner/{worker}',
|
||||
adapt_to_hardware: true,
|
||||
self_healing: true,
|
||||
file_logging: false,
|
||||
stealth_mode: true,
|
||||
};
|
||||
expect(defaults.thread_mode).toBe('percent');
|
||||
});
|
||||
|
||||
it('WSMessage references typed ws payload import', () => {
|
||||
const msg: WSMessage = {
|
||||
type: 'stats_update',
|
||||
payload: { agents: [] } as WSMessage['payload'],
|
||||
};
|
||||
expect(msg.type).toBe('stats_update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('types/index — no runtime exports', () => {
|
||||
it('module provides interfaces only (no type guards or constants at runtime)', () => {
|
||||
// Documented expectation: consumers validate API JSON against these shapes manually.
|
||||
expect(typeof Agent).toBe('undefined');
|
||||
expect(typeof BuildRequest).toBe('undefined');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,18 @@ export interface Agent {
|
||||
os_version?: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
defender_enabled?: boolean;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
last_patch?: string; // ISO date YYYY-MM-DD
|
||||
pending_updates?: number; // -1 = unknown
|
||||
reboot_pending?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface WSStatsUpdate {
|
||||
shares_submitted?: number;
|
||||
shares_accepted?: number;
|
||||
ssh_available?: boolean;
|
||||
posture_score?: number;
|
||||
last_patch_days?: number;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
last_patch?: string;
|
||||
pending_updates?: number;
|
||||
reboot_pending?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
}
|
||||
|
||||
export interface WSCommandResult {
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
environmentMatchGlobs: [
|
||||
['src/api/**', 'happy-dom'],
|
||||
['src/pages/**', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user