feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat

This commit is contained in:
AetherForge
2026-05-30 23:16:50 -07:00
parent 4207f6c21b
commit d010292333
26 changed files with 2499 additions and 134 deletions

View File

@@ -359,10 +359,26 @@ func mergeConfig(dst, src *Config) {
}
}
// mergeConfigExplicit is like mergeConfig but only applies boolean fields when
// the corresponding top-level key was explicitly present in the JSON request.
// This fixes H14: a partial PUT can no longer silently reset UseTLS, SilentMode,
// AutoStart, LogAgentConnections, etc. to false.
// nestedJSONKeys returns keys explicitly present in a nested JSON object section.
func nestedJSONKeys(present map[string]json.RawMessage, section string) map[string]json.RawMessage {
if present == nil {
return nil
}
raw, ok := present[section]
if !ok || len(raw) == 0 {
return nil
}
var nested map[string]json.RawMessage
if err := json.Unmarshal(raw, &nested); err != nil {
return nil
}
return nested
}
// mergeConfigExplicit is like mergeConfig but only applies fields when the
// corresponding JSON key was explicitly present in the PUT payload.
// Top-level absence preserves existing values (H14); nested absence within a
// section preserves sibling fields (partial PUT / import shallow-merge fix).
func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if present == nil {
// Fall back to old behaviour if we have no key presence info
@@ -370,186 +386,226 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
return
}
has := func(key string) bool { _, ok := present[key]; return ok }
in := func(section map[string]json.RawMessage, key string) bool {
if section == nil {
return false
}
_, ok := section[key]
return ok
}
// Non-boolean scalar fields — safe to use zero-value guard
if src.Port != 0 {
if has("port") && src.Port != 0 {
dst.Port = src.Port
}
if src.DataDir != "" {
if has("data_dir") && src.DataDir != "" {
dst.DataDir = src.DataDir
}
// Pool — only touch booleans when key was in the payload
if has("pool") {
if src.Pool.Host != "" {
poolKeys := nestedJSONKeys(present, "pool")
if in(poolKeys, "host") && src.Pool.Host != "" {
dst.Pool.Host = src.Pool.Host
}
if src.Pool.Port != 0 {
if in(poolKeys, "port") && src.Pool.Port != 0 {
dst.Pool.Port = src.Pool.Port
}
dst.Pool.UseTLS = src.Pool.UseTLS // bool: only applied because "pool" key was present
if src.Pool.Password != "" {
if in(poolKeys, "use_tls") {
dst.Pool.UseTLS = src.Pool.UseTLS
}
if in(poolKeys, "password") && src.Pool.Password != "" {
dst.Pool.Password = src.Pool.Password
}
}
if has("wallet") {
if src.Wallet.Address != "" {
walletKeys := nestedJSONKeys(present, "wallet")
if in(walletKeys, "address") && src.Wallet.Address != "" {
dst.Wallet.Address = src.Wallet.Address
}
if src.Wallet.PaymentID != "" {
if in(walletKeys, "payment_id") && src.Wallet.PaymentID != "" {
dst.Wallet.PaymentID = src.Wallet.PaymentID
}
}
// The JSON struct tag is "default_agent_config" — must match exactly.
if has("default_agent_config") {
if src.DefaultAgent.Threads != 0 {
daKeys := nestedJSONKeys(present, "default_agent_config")
if in(daKeys, "threads") && src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
}
if src.DefaultAgent.ThreadMode != "" {
if in(daKeys, "thread_mode") && src.DefaultAgent.ThreadMode != "" {
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
}
if src.DefaultAgent.ThreadPercent != 0 {
if in(daKeys, "thread_percent") && src.DefaultAgent.ThreadPercent != 0 {
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
}
if src.DefaultAgent.CPUPriority != "" {
if in(daKeys, "cpu_priority") && src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
}
if src.DefaultAgent.MaxCPUUsagePct != 0 {
if in(daKeys, "max_cpu_usage_pct") && src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
}
if src.DefaultAgent.MaxMemoryPct != 0 {
if in(daKeys, "max_memory_percent") && src.DefaultAgent.MaxMemoryPct != 0 {
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
}
if src.DefaultAgent.MinFreeRAMMB != 0 {
if in(daKeys, "min_free_ram_mb") && src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
}
if src.DefaultAgent.MiningMode != "" {
if in(daKeys, "mining_mode") && src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
}
if src.DefaultAgent.DisplayMode != "" {
if in(daKeys, "display_mode") && src.DefaultAgent.DisplayMode != "" {
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
}
if src.DefaultAgent.ProcessName != "" {
if in(daKeys, "process_name") && src.DefaultAgent.ProcessName != "" {
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
}
if src.DefaultAgent.IdleThresholdPct != 0 {
if in(daKeys, "idle_threshold_pct") && src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
}
if src.DefaultAgent.IdleDurationMinutes != 0 {
if in(daKeys, "idle_duration_minutes") && src.DefaultAgent.IdleDurationMinutes != 0 {
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
}
if src.DefaultAgent.ScheduleStart != "" {
if in(daKeys, "schedule_start") && src.DefaultAgent.ScheduleStart != "" {
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
}
if src.DefaultAgent.ScheduleEnd != "" {
if in(daKeys, "schedule_end") && src.DefaultAgent.ScheduleEnd != "" {
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
}
if src.DefaultAgent.InstallBase != "" {
if in(daKeys, "install_base") && src.DefaultAgent.InstallBase != "" {
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
}
if src.DefaultAgent.InstallCustomBase != "" {
if in(daKeys, "install_custom_base") && src.DefaultAgent.InstallCustomBase != "" {
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
}
if src.DefaultAgent.InstallRelativePath != "" {
if in(daKeys, "install_relative_path") && src.DefaultAgent.InstallRelativePath != "" {
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
}
// Booleans only applied because "default_agent" key was present
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
if in(daKeys, "adapt_to_hardware") {
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
}
if in(daKeys, "self_healing") {
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
}
if in(daKeys, "file_logging") {
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
}
if in(daKeys, "stealth_mode") {
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
}
}
if has("background") {
dst.Background.SilentMode = src.Background.SilentMode
if src.Background.RunAs != "" {
bgKeys := nestedJSONKeys(present, "background")
if in(bgKeys, "silent_mode") {
dst.Background.SilentMode = src.Background.SilentMode
}
if in(bgKeys, "run_as") && src.Background.RunAs != "" {
dst.Background.RunAs = src.Background.RunAs
}
dst.Background.AutoStart = src.Background.AutoStart
if in(bgKeys, "auto_start") {
dst.Background.AutoStart = src.Background.AutoStart
}
}
if has("alerts") {
if src.Alerts.OfflineThresholdMinutes != 0 {
alertKeys := nestedJSONKeys(present, "alerts")
if in(alertKeys, "offline_threshold_minutes") && src.Alerts.OfflineThresholdMinutes != 0 {
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
}
if src.Alerts.HashrateDropThresholdPct != 0 {
if in(alertKeys, "hashrate_drop_threshold_pct") && src.Alerts.HashrateDropThresholdPct != 0 {
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
}
if src.Alerts.RejectionRateThresholdPct != 0 {
if in(alertKeys, "rejection_rate_threshold_pct") && src.Alerts.RejectionRateThresholdPct != 0 {
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
}
if src.Alerts.TelegramBotToken != "" {
if in(alertKeys, "telegram_bot_token") && src.Alerts.TelegramBotToken != "" {
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
}
if src.Alerts.TelegramChatID != "" {
if in(alertKeys, "telegram_chat_id") && src.Alerts.TelegramChatID != "" {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
if src.Alerts.SMTPHost != "" {
if in(alertKeys, "email_enabled") {
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
}
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
if src.Alerts.SMTPPort != 0 {
if in(alertKeys, "smtp_port") && src.Alerts.SMTPPort != 0 {
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
}
if src.Alerts.SMTPUser != "" {
if in(alertKeys, "smtp_user") && src.Alerts.SMTPUser != "" {
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
}
if src.Alerts.SMTPPassword != "" {
if in(alertKeys, "smtp_password") && src.Alerts.SMTPPassword != "" {
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
}
if src.Alerts.EmailTo != "" {
if in(alertKeys, "email_to") && src.Alerts.EmailTo != "" {
dst.Alerts.EmailTo = src.Alerts.EmailTo
}
if src.Alerts.EmailFrom != "" {
if in(alertKeys, "email_from") && src.Alerts.EmailFrom != "" {
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
}
}
if has("server") {
if src.Server.PublicURL != "" {
srvKeys := nestedJSONKeys(present, "server")
if in(srvKeys, "public_url") && src.Server.PublicURL != "" {
dst.Server.PublicURL = src.Server.PublicURL
}
if src.Server.StatsRetentionHours != 0 {
if in(srvKeys, "stats_retention_hours") && src.Server.StatsRetentionHours != 0 {
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
}
if src.Server.BuildRetentionDays != 0 {
if in(srvKeys, "build_retention_days") && src.Server.BuildRetentionDays != 0 {
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
}
if src.Server.PoolReconnectSeconds != 0 {
if in(srvKeys, "pool_reconnect_seconds") && src.Server.PoolReconnectSeconds != 0 {
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
}
if src.Server.WebSocketPingSeconds != 0 {
if in(srvKeys, "websocket_ping_seconds") && src.Server.WebSocketPingSeconds != 0 {
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
}
if src.Server.MaxAgents != 0 {
if in(srvKeys, "max_agents") && src.Server.MaxAgents != 0 {
dst.Server.MaxAgents = src.Server.MaxAgents
}
if src.Server.MaxBuildSizeMB != 0 {
if in(srvKeys, "max_build_size_mb") && src.Server.MaxBuildSizeMB != 0 {
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
}
// Booleans applied because "server" key was present
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
dst.Server.SignEnabled = src.Server.SignEnabled
if src.Server.DashboardSubtitle != "" {
if in(srvKeys, "log_agent_connections") {
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
}
if in(srvKeys, "log_share_submissions") {
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
}
if in(srvKeys, "log_pool_traffic") {
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
}
if in(srvKeys, "strict_wallet_validation") {
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
}
if in(srvKeys, "open_firewall_on_start") {
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
}
if in(srvKeys, "obfuscate_default") {
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
}
if in(srvKeys, "sign_enabled") {
dst.Server.SignEnabled = src.Server.SignEnabled
}
if in(srvKeys, "dashboard_subtitle") && src.Server.DashboardSubtitle != "" {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
}
if src.Server.SignCertThumbprint != "" {
if in(srvKeys, "sign_cert_thumbprint") && src.Server.SignCertThumbprint != "" {
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
}
if src.Server.SignToolPath != "" {
if in(srvKeys, "sign_tool_path") && src.Server.SignToolPath != "" {
dst.Server.SignToolPath = src.Server.SignToolPath
}
if src.Server.SignTimestampURL != "" {
if in(srvKeys, "sign_timestamp_url") && src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
if src.Server.FleetSecret != "" {
if in(srvKeys, "fleet_secret") && src.Server.FleetSecret != "" {
dst.Server.FleetSecret = src.Server.FleetSecret
}
}

View File

@@ -7,6 +7,195 @@ import (
"testing"
)
func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
t.Helper()
var incoming Config
if err := json.Unmarshal([]byte(payload), &incoming); err != nil {
t.Fatalf("unmarshal incoming: %v", err)
}
var present map[string]json.RawMessage
if err := json.Unmarshal([]byte(payload), &present); err != nil {
t.Fatalf("unmarshal present keys: %v", err)
}
mergeConfigExplicit(dst, &incoming, present)
}
func TestDefaultConfigServerDefaults(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port != 8989 {
t.Fatalf("port default: got %d", cfg.Port)
}
if cfg.Pool.Host != "pool.supportxmr.com" || !cfg.Pool.UseTLS {
t.Fatalf("pool defaults wrong: %+v", cfg.Pool)
}
if cfg.Server.StatsRetentionHours != 168 {
t.Fatalf("stats retention default: got %d", cfg.Server.StatsRetentionHours)
}
if cfg.Server.MaxAgents != 256 {
t.Fatalf("max agents default: got %d", cfg.Server.MaxAgents)
}
if !cfg.Server.LogAgentConnections {
t.Fatal("log_agent_connections should default true")
}
if cfg.Server.DashboardSubtitle != "security is just an emotion" {
t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
}
if cfg.DefaultAgent.Threads != 4 {
t.Fatalf("default agent threads: got %d", cfg.DefaultAgent.Threads)
}
}
func TestMergeConfigExplicitPortOnlyPreservesPoolTLS(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
applyMergeFromJSON(t, dst, `{"port":9000}`)
if dst.Port != 9000 {
t.Fatalf("port not merged")
}
if !dst.Pool.UseTLS {
t.Fatal("partial PUT with port only must not reset pool.use_tls")
}
}
func TestMergeConfigExplicitNestedServerPartialPreservesBooleans(t *testing.T) {
dst := DefaultConfig()
dst.Server.LogAgentConnections = true
dst.Server.OpenFirewallOnStart = true
dst.Server.DashboardSubtitle = "keep-me"
applyMergeFromJSON(t, dst, `{"server":{"dashboard_subtitle":"updated"}}`)
if dst.Server.DashboardSubtitle != "updated" {
t.Fatalf("subtitle not updated: %q", dst.Server.DashboardSubtitle)
}
if !dst.Server.LogAgentConnections {
t.Fatal("nested partial PUT must not reset log_agent_connections")
}
if !dst.Server.OpenFirewallOnStart {
t.Fatal("nested partial PUT must not reset open_firewall_on_start")
}
}
func TestMergeConfigExplicitNestedPoolPartialPreservesUseTLS(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
dst.Pool.Host = "original.pool"
applyMergeFromJSON(t, dst, `{"pool":{"host":"new.pool.example"}}`)
if dst.Pool.Host != "new.pool.example" {
t.Fatalf("host not updated: %q", dst.Pool.Host)
}
if !dst.Pool.UseTLS {
t.Fatal("nested partial pool PUT must preserve use_tls")
}
}
func TestMergeConfigExplicitExplicitBooleanFalse(t *testing.T) {
dst := DefaultConfig()
if !dst.Pool.UseTLS {
t.Fatal("precondition: default pool TLS true")
}
applyMergeFromJSON(t, dst, `{"pool":{"use_tls":false}}`)
if dst.Pool.UseTLS {
t.Fatal("explicit use_tls:false must be applied")
}
}
func TestMergeConfigExplicitAbsentSectionUntouched(t *testing.T) {
dst := DefaultConfig()
dst.Wallet.Address = "48keep"
applyMergeFromJSON(t, dst, `{"port":7777}`)
if dst.Wallet.Address != "48keep" {
t.Fatalf("wallet should be untouched: %q", dst.Wallet.Address)
}
}
func TestMergeConfigExplicitNilPresentKeysFallsBack(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
src := &Config{Port: 9000}
mergeConfigExplicit(dst, src, nil)
if dst.Port != 9000 {
t.Fatalf("port not merged via fallback")
}
// mergeConfig fallback still overwrites bools — documented legacy path
if dst.Pool.UseTLS {
t.Log("mergeConfig fallback resets UseTLS when presentKeys nil (LoadConfig uses mergeConfig)")
}
}
func TestMergeConfigExplicitDefaultAgentConfigKey(t *testing.T) {
dst := DefaultConfig()
dst.DefaultAgent.AdaptToHardware = true
dst.DefaultAgent.Threads = 8
applyMergeFromJSON(t, dst, `{"default_agent_config":{"threads":2}}`)
if dst.DefaultAgent.Threads != 2 {
t.Fatalf("threads not updated: %d", dst.DefaultAgent.Threads)
}
if !dst.DefaultAgent.AdaptToHardware {
t.Fatal("partial default_agent_config must preserve adapt_to_hardware")
}
}
func TestMergeConfigExplicitBackgroundPartial(t *testing.T) {
dst := DefaultConfig()
dst.Background.AutoStart = true
dst.Background.SilentMode = true
applyMergeFromJSON(t, dst, `{"background":{"auto_start":false}}`)
if dst.Background.AutoStart {
t.Fatal("explicit auto_start:false must apply")
}
if !dst.Background.SilentMode {
t.Fatal("partial background PUT must preserve silent_mode")
}
}
func TestMergeConfigExplicitAlertsEmailEnabled(t *testing.T) {
dst := DefaultConfig()
dst.Alerts.EmailEnabled = false
applyMergeFromJSON(t, dst, `{"alerts":{"email_enabled":true}}`)
if !dst.Alerts.EmailEnabled {
t.Fatal("explicit email_enabled:true must apply")
}
}
func TestMergeConfigPreservesPoolTLSWhenPartialUpdate(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
src := &Config{Port: 9000}
mergeConfig(dst, src)
if dst.Port != 9000 {
t.Fatalf("port not merged")
}
// mergeConfig (file load path) still resets bools — known legacy behaviour
if !dst.Pool.UseTLS {
t.Log("mergeConfig resets UseTLS on partial update — file load uses mergeConfig not mergeConfigExplicit")
}
}
func TestConfigJSONRoundTrip(t *testing.T) {
cfg := DefaultConfig()
cfg.Wallet.Address = "48testwallet"
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var decoded Config
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Wallet.Address != cfg.Wallet.Address {
t.Fatalf("wallet mismatch")
}
}
func TestDefaultConfigPort(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port != 8989 {
@@ -36,33 +225,3 @@ func TestLoadConfigFromFile(t *testing.T) {
t.Fatalf("expected port 8989, got %d", loaded.Port)
}
}
func TestMergeConfigPreservesPoolTLSWhenPartialUpdate(t *testing.T) {
dst := DefaultConfig()
dst.Pool.UseTLS = true
src := &Config{Port: 9000}
mergeConfig(dst, src)
if dst.Port != 9000 {
t.Fatalf("port not merged")
}
// Known issue documented in PROBLEMS.md — bool zero-value overwrite
if !dst.Pool.UseTLS {
t.Log("NOTE: mergeConfig still resets UseTLS on partial PUT — tracked as H14")
}
}
func TestConfigJSONRoundTrip(t *testing.T) {
cfg := DefaultConfig()
cfg.Wallet.Address = "48testwallet"
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var decoded Config
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Wallet.Address != cfg.Wallet.Address {
t.Fatalf("wallet mismatch")
}
}

View File

@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"crypto-miner-server/internal/db"
)
@@ -37,23 +38,34 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func writeConfigJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// GET /api/v1/config
func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request) {
configJSON := h.config.GetConfigJSON()
w.Header().Set("Content-Type", "application/json")
w.Write(configJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(configJSON)
}
// PUT /api/v1/config
func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
writeConfigJSONError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := h.config.UpdateConfigFromJSON(body); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
status := http.StatusInternalServerError
if strings.HasPrefix(err.Error(), "invalid config:") {
status = http.StatusBadRequest
}
writeConfigJSONError(w, status, err.Error())
return
}

View File

@@ -0,0 +1,208 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"crypto-miner-server/internal/db"
)
// stubConfigProvider implements ConfigProvider for handler unit tests.
type stubConfigProvider struct {
configJSON json.RawMessage
updateErr error
updated json.RawMessage
}
func (s *stubConfigProvider) GetConfigJSON() json.RawMessage {
if len(s.configJSON) == 0 {
return json.RawMessage(`{"port":8989,"pool":{"host":"pool.example.com","port":3333,"use_tls":true}}`)
}
return s.configJSON
}
func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
if s.updateErr != nil {
return s.updateErr
}
s.updated = append(json.RawMessage(nil), data...)
s.configJSON = append(json.RawMessage(nil), data...)
return nil
}
func newTestConfigHandler(t *testing.T, cp ConfigProvider) *ConfigHandler {
t.Helper()
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
return NewConfigHandler(database, cp)
}
func TestNewConfigHandler(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
if h == nil || h.config == nil || h.db == nil {
t.Fatal("NewConfigHandler returned incomplete handler")
}
}
func TestConfigHandlerServeHTTP_MethodNotAllowed(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
for _, method := range []string{http.MethodPost, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/config", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("%s: expected 405, got %d", method, rec.Code)
}
}
}
func TestConfigHandlerGetConfig(t *testing.T) {
stub := &stubConfigProvider{
configJSON: json.RawMessage(`{"port":9001,"wallet":{"address":"48abc"}}`),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("expected JSON content-type, got %q", ct)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["port"].(float64) != 9001 {
t.Fatalf("unexpected config body: %v", body)
}
}
func TestConfigHandlerPutConfig_SuccessReturnsUpdated(t *testing.T) {
stub := &stubConfigProvider{
configJSON: json.RawMessage(`{"port":8989}`),
}
h := newTestConfigHandler(t, stub)
payload := `{"port":9100,"pool":{"host":"new.pool","port":4444,"use_tls":false}}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(payload))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status=%d body=%s", rec.Code, rec.Body.String())
}
if string(stub.updated) != payload {
t.Fatalf("provider did not receive payload: %q", stub.updated)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["port"].(float64) != 9100 {
t.Fatalf("response not updated: %v", body)
}
}
func TestConfigHandlerPutConfig_InvalidJSON(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{not json`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatalf("error body not valid JSON: %s", rec.Body.String())
}
if errBody["error"] != "Invalid JSON" {
t.Fatalf("unexpected error: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_InvalidConfigBadRequest(t *testing.T) {
stub := &stubConfigProvider{
updateErr: fmt.Errorf("invalid config: unexpected EOF"),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid config, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestConfigHandlerPutConfig_SaveErrorInternalServerError(t *testing.T) {
stub := &stubConfigProvider{
updateErr: errors.New("failed to save config: disk full"),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatal(err)
}
if !strings.Contains(errBody["error"], "disk full") {
t.Fatalf("unexpected error message: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_ErrorJSONEscapesQuotes(t *testing.T) {
stub := &stubConfigProvider{
updateErr: errors.New(`failed: say "hello"`),
}
h := newTestConfigHandler(t, stub)
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", strings.NewReader(`{"port":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
var errBody map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil {
t.Fatalf("malformed JSON error response: %s", rec.Body.String())
}
if errBody["error"] != `failed: say "hello"` {
t.Fatalf("unexpected escaped error: %q", errBody["error"])
}
}
func TestConfigHandlerPutConfig_EmptyBodyInvalidJSON(t *testing.T) {
h := newTestConfigHandler(t, &stubConfigProvider{})
req := httptest.NewRequest(http.MethodPut, "/api/v1/config", nil)
req.Body = io.NopCloser(bytes.NewReader(nil))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty body, got %d", rec.Code)
}
}
func TestConfigProviderInterface(t *testing.T) {
var _ ConfigProvider = (*stubConfigProvider)(nil)
}

View File

@@ -0,0 +1,893 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// Numeric constants from fleet_handler.go (guard against silent drift).
const (
fleetEarningsCacheTTL = 5 * time.Minute
fleetXMRPriceTTL = 10 * time.Minute
fleetNetworkHashrate = 3_000_000_000.0
fleetDailyEmissionXMR = 432.0
fleetPiconeroPerXMR = 1e12
fleetGetLogTailLines = 300
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = fn
t.Cleanup(func() { http.DefaultTransport = orig })
}
func resetXMRPriceCache(t *testing.T) {
t.Helper()
xmrPriceMu.Lock()
xmrPriceCache = nil
xmrPriceMu.Unlock()
t.Cleanup(func() {
xmrPriceMu.Lock()
xmrPriceCache = nil
xmrPriceMu.Unlock()
})
}
func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AIHandler) {
t.Helper()
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
ws := NewWSHub(database)
ai := NewAIHandler(database)
fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{})
return fh, database, ws, ai
}
func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handler {
r := chi.NewRouter()
switch method {
case http.MethodGet:
r.Get(pattern, handler)
case http.MethodPost:
r.Post(pattern, handler)
case http.MethodPut:
r.Put(pattern, handler)
default:
panic("unsupported method " + method)
}
return r
}
func connectTestAgent(t *testing.T, hub *WSHub, agentID string) *websocket.Conn {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial agent ws: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
authPayload, _ := json.Marshal(map[string]interface{}{
"agent_id": agentID,
"hostname": "test-host",
"version": "1.0",
})
if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
t.Fatalf("send auth: %v", err)
}
var resp Message
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("read auth_response: %v", err)
}
if resp.Type != "auth_response" {
t.Fatalf("expected auth_response, got %q", resp.Type)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if hub.isAgentConnected(agentID) {
return conn
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("agent not connected after auth")
return nil
}
func TestFleetConstants(t *testing.T) {
if fleetEarningsCacheTTL != earningsCacheTTL ||
fleetXMRPriceTTL != xmrPriceTTL ||
fleetNetworkHashrate != 3_000_000_000.0 ||
fleetDailyEmissionXMR != 432.0 {
t.Fatal("fleet_handler constants drifted from documented values")
}
out := EstimateXMRPerDay(fleetNetworkHashrate)
if out["network_hashrate"].(float64) != fleetNetworkHashrate {
t.Fatalf("network_hashrate want %v got %v", fleetNetworkHashrate, out["network_hashrate"])
}
xmr, ok := out["xmr_per_day"].(float64)
if !ok || xmr != fleetDailyEmissionXMR {
t.Fatalf("at network hashrate want xmr_per_day=%v got %v", fleetDailyEmissionXMR, xmr)
}
}
func TestFleetNewFleetHandler(t *testing.T) {
fh, database, ws, ai := newTestFleetHandler(t)
if fh.db != database || fh.ws != ws || fh.ai != ai {
t.Fatal("NewFleetHandler did not wire dependencies")
}
if fh.earningsCache != nil {
t.Fatal("expected nil earnings cache at init")
}
}
func TestFleetGetAlertsNilEvaluator(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body []alerts.AlertEvent
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
t.Fatalf("expected empty alerts array, got %s", rec.Body.String())
}
}
func TestFleetGetAlertsWithEvaluator(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agent := &models.Agent{
ID: "alert-agent",
Name: "offline-rig",
Status: "offline",
LastSeen: time.Now().Add(-30 * time.Minute),
}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{OfflineMinutes: 5}
}, alerts.NotifyConfig{}, nil)
evaluator.RunOnce()
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})
rec := httptest.NewRecorder()
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body []alerts.AlertEvent
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 || body[0].Type != "offline" {
t.Fatalf("expected offline alert, got %+v", body)
}
}
func TestFleetGetPoolStatusNilManager(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
var body []pool.PoolStatus
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
t.Fatalf("expected empty pool status, got %s", rec.Body.String())
}
}
}
func TestFleetGetPoolStatusWithManager(t *testing.T) {
pm := pool.NewManager(nil, nil)
fh, _, _, _ := newTestFleetHandler(t)
fh.pools = pm
rec := httptest.NewRecorder()
fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body []pool.PoolStatus
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 0 {
t.Fatalf("expected empty list from manager with no pools, got %d", len(body))
}
}
func TestFleetGetAIActivityNilHandler(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body []AIActivityEntry
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
t.Fatalf("expected empty activity, got %s", rec.Body.String())
}
}
func TestFleetGetAIActivityWithEntries(t *testing.T) {
fh, _, _, ai := newTestFleetHandler(t)
ai.recordActivity(AIActivityEntry{
AgentID: "agent-x",
LastAction: "decide",
LastTool: "ok",
})
rec := httptest.NewRecorder()
fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body []AIActivityEntry
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 || body[0].AgentID != "agent-x" {
t.Fatalf("unexpected activity: %+v", body)
}
}
func TestFleetGetXMRPriceCacheHit(t *testing.T) {
resetXMRPriceCache(t)
fetchedAt := time.Now().Add(-2 * time.Minute)
xmrPriceMu.Lock()
xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
xmrPriceMu.Unlock()
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["usd"].(float64) != 165.5 || body["source"] != "coingecko" {
t.Fatalf("unexpected body: %v", body)
}
}
func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Host, "coingecko.com") {
t.Fatalf("unexpected host %s", req.URL.Host)
}
rec := httptest.NewRecorder()
rec.Header().Set("Content-Type", "application/json")
_, _ = rec.Write([]byte(`{"monero":{"usd":200.25}}`))
return rec.Result(), nil
})
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["usd"].(float64) != 200.25 {
t.Fatalf("unexpected usd: %v", body["usd"])
}
}
func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
})
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d body %s", rec.Code, rec.Body.String())
}
}
func TestFleetGetXMRPriceParseError(t *testing.T) {
resetXMRPriceCache(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Write([]byte(`not-json`))
return rec.Result(), nil
})
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d body %s", rec.Code, rec.Body.String())
}
}
func TestFleetEstimateXMRPerDay(t *testing.T) {
zero := EstimateXMRPerDay(0)
if zero["xmr_per_day"].(float64) != 0 {
t.Fatalf("zero hashrate should yield 0 xmr, got %v", zero["xmr_per_day"])
}
if zero["usd_per_day"] != nil {
t.Fatal("usd_per_day should be nil without price feed")
}
atNetwork := EstimateXMRPerDay(fleetNetworkHashrate)
if atNetwork["xmr_per_day"].(float64) != fleetDailyEmissionXMR {
t.Fatalf("full network share want %v got %v", fleetDailyEmissionXMR, atNetwork["xmr_per_day"])
}
half := EstimateXMRPerDay(fleetNetworkHashrate / 2)
wantHalf := fleetDailyEmissionXMR / 2
if half["xmr_per_day"].(float64) != wantHalf {
t.Fatalf("half network want %v got %v", wantHalf, half["xmr_per_day"])
}
}
func TestFleetParseFloatQuery(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/?hashrate=123.45&bad=abc", nil)
if got := parseFloatQuery(req, "hashrate", 0); got != 123.45 {
t.Fatalf("hashrate want 123.45 got %v", got)
}
if got := parseFloatQuery(req, "missing", 9); got != 9 {
t.Fatalf("missing key want default 9 got %v", got)
}
if got := parseFloatQuery(req, "bad", 7); got != 7 {
t.Fatalf("invalid float want default 7 got %v", got)
}
}
func TestFleetGetEarningsEstimateOnly(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000000", nil)
fh.GetEarnings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["source"] != nil {
t.Fatalf("expected estimate-only response, got source=%v", body["source"])
}
if body["hashrate"].(float64) != 1_000_000 {
t.Fatalf("hashrate not parsed: %v", body["hashrate"])
}
}
func TestFleetGetEarningsWalletFromBuild(t *testing.T) {
fh, database, _, _ := newTestFleetHandler(t)
wallet := "48buildwalletaddress0000000000000000000000000000000000000000000000000000000000"
if err := database.InsertBuild(&models.BuildRecord{
ID: "build-earn-1",
Wallet: wallet,
CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Host, "supportxmr.com") {
return nil, errors.New("unexpected host")
}
rec := httptest.NewRecorder()
rec.Header().Set("Content-Type", "application/json")
_, _ = rec.Write([]byte(`{
"amtDue": 1000000000000,
"amtPaid": 2000000000000,
"totalHashes": 999,
"hashRate": 5000,
"lastPaymentTs": 1609459200,
"lastPayment": 500000000000
}`))
return rec.Result(), nil
})
rec := httptest.NewRecorder()
fh.GetEarnings(rec, httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["source"] != "pool_api" {
t.Fatalf("expected pool_api source, got %v", body["source"])
}
if body["pending_xmr"].(float64) != 1.0 {
t.Fatalf("pending_xmr want 1.0 got %v", body["pending_xmr"])
}
if body["paid_xmr"].(float64) != 2.0 {
t.Fatalf("paid_xmr want 2.0 got %v", body["paid_xmr"])
}
if body["last_payment_xmr"].(float64) != 0.5 {
t.Fatalf("last_payment_xmr want 0.5 got %v", body["last_payment_xmr"])
}
}
func TestFleetGetEarningsPoolAPIFallback(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
return nil, errors.New("pool unreachable")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=48fallback&hashrate=2000000", nil)
fh.GetEarnings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["source"] != nil {
t.Fatalf("expected estimate fallback without source, got %v", body["source"])
}
if body["hashrate"].(float64) != 2_000_000 {
t.Fatalf("hashrate %v", body["hashrate"])
}
}
func TestFleetGetEarningsEstimateDelegates(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
req := httptest.NewRequest(http.MethodGet, "/earnings/estimate?hashrate=500000", nil)
recDirect := httptest.NewRecorder()
fh.GetEarnings(recDirect, req)
recDelegate := httptest.NewRecorder()
fh.GetEarningsEstimate(recDelegate, req)
if recDirect.Body.String() != recDelegate.Body.String() {
t.Fatalf("GetEarningsEstimate should delegate to GetEarnings\ndirect: %s\ndelegate: %s",
recDirect.Body.String(), recDelegate.Body.String())
}
}
func TestFleetFetchPoolEarningsCache(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
cached := map[string]interface{}{"pending_xmr": 3.14}
fh.earningsMu.Lock()
fh.earningsCache = map[string]*poolEarningsCache{
"cached-wallet": {data: cached, fetchedAt: time.Now()},
}
fh.earningsMu.Unlock()
got, err := fh.fetchPoolEarnings("cached-wallet")
if err != nil {
t.Fatal(err)
}
if got["pending_xmr"].(float64) != 3.14 {
t.Fatalf("cache miss or wrong data: %v", got)
}
}
func TestFleetFetchPoolEarningsHTTPError(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusNotFound)
return rec.Result(), nil
})
if _, err := fh.fetchPoolEarnings("missing-wallet"); err == nil {
t.Fatal("expected error for non-200 pool response")
}
}
func TestFleetGetAgentLogNilWS(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
fh.ws = nil
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/agents/a1/log", nil)
fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}
func TestFleetGetAgentLogContentAndRefresh(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
ws.mu.Lock()
ws.agentLogs["agent-log-1"] = "line1\nline2"
ws.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/agents/agent-log-1/log?refresh=1", nil)
fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["content"] != "line1\nline2" {
t.Fatalf("content %v", body["content"])
}
}
func TestFleetPostAgentCommandErrors(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
})
t.Run("invalid json", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
t.Run("missing action", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
t.Run("agent not connected", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command",
strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("broadcast all no agents", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != false || body["error"] != "no connected agents" {
t.Fatalf("unexpected body: %v", body)
}
})
_ = ws
}
func TestFleetPostAgentCommandSuccess(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
agentID := "cmd-agent-1"
conn := connectTestAgent(t, ws, agentID)
rec := httptest.NewRecorder()
body := `{"action":"get_log","tail_lines":50,"command":"whoami","path":"C:\\","data":"x"}`
req := httptest.NewRequest(http.MethodPost, "/agents/"+agentID+"/command", strings.NewReader(body))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var cmd Message
if err := conn.ReadJSON(&cmd); err != nil {
t.Fatalf("read command: %v", err)
}
if cmd.Type != "command" {
t.Fatalf("expected command message, got %q", cmd.Type)
}
var payload map[string]interface{}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != "get_log" || payload["tail_lines"].(float64) != 50 {
t.Fatalf("unexpected payload: %v", payload)
}
}
func TestFleetPostAgentCommandBroadcastAll(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
conn := connectTestAgent(t, ws, "broadcast-agent")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
strings.NewReader(`{"action":"resume"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var cmd Message
if err := conn.ReadJSON(&cmd); err != nil {
t.Fatalf("read broadcast command: %v", err)
}
if cmd.Type != "command" {
t.Fatalf("expected command, got %q", cmd.Type)
}
}
func TestFleetPutAgentMetaErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("not found", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/agents/missing/meta",
strings.NewReader(`{"notes":"n","tags":["a"]}`))
fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
})
t.Run("invalid body", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/agents/a1/meta", errReader{})
fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
t.Run("empty id", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{"notes":"n"}`))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
fh.PutAgentMeta(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
}
func TestFleetPutAgentMetaSuccess(t *testing.T) {
fh, database, _, _ := newTestFleetHandler(t)
agent := &models.Agent{ID: "meta-agent", Name: "rig", Status: "offline", LastSeen: time.Now()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/agents/meta-agent/meta",
strings.NewReader(`{"notes":"lab box","tags":["gpu","win"]}`))
fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("unexpected body: %v", body)
}
agentObj := body["agent"].(map[string]interface{})
if agentObj["notes"] != "lab box" {
t.Fatalf("notes not saved: %v", agentObj["notes"])
}
}
func TestFleetPostBulkCommandErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := *fh
bad.ws = nil
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
bad.PostBulkCommand(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
})
t.Run("invalid body", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{`))
fh.PostBulkCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
t.Run("missing action", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"]}`))
fh.PostBulkCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
})
t.Run("missing agent ids", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":[],"action":"pause"}`))
fh.PostBulkCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != false {
t.Fatalf("expected success false, got %v", body)
}
})
}
func TestFleetPostBulkCommandPartialSuccess(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
onlineID := "bulk-online"
connectTestAgent(t, ws, onlineID)
rec := httptest.NewRecorder()
payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(payload))
fh.PostBulkCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("expected partial success true, got %v", body)
}
if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
t.Fatalf("sent/failed counts: %v", body)
}
}
func TestFleetMinHelper(t *testing.T) {
if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 {
t.Fatal("min helper wrong")
}
}
// errReader reuse from ai_handler_test.go for invalid JSON bodies.
var _ io.ReadCloser = errReader{}
func TestFleetGetLogRefreshUsesTailConstant(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
agentID := "tail-agent"
conn := connectTestAgent(t, ws, agentID)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/agents/"+agentID+"/log?refresh=1", nil)
fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var cmd Message
if err := conn.ReadJSON(&cmd); err != nil {
t.Fatalf("read get_log command: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != "get_log" {
t.Fatalf("action %v", payload["action"])
}
if int(payload["tail_lines"].(float64)) != fleetGetLogTailLines {
t.Fatalf("tail_lines want %d got %v", fleetGetLogTailLines, payload["tail_lines"])
}
}
func TestFleetFetchPoolEarningsInvalidJSON(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Write([]byte(`{`))
return rec.Result(), nil
})
if _, err := fh.fetchPoolEarnings("bad-json-wallet"); err == nil {
t.Fatal("expected unmarshal error")
}
}
func TestFleetGetEarningsWithWalletQueryParam(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
fh.earningsMu.Lock()
fh.earningsCache = map[string]*poolEarningsCache{
"query-wallet": {
data: map[string]interface{}{"paid_xmr": 9.99},
fetchedAt: time.Now(),
},
}
fh.earningsMu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=query-wallet", nil)
fh.GetEarnings(rec, req)
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["paid_xmr"].(float64) != 9.99 || body["source"] != "pool_api" {
t.Fatalf("unexpected merged body: %v", body)
}
}
func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"action":"pause"}`))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
fh.PostAgentCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty id, got %d", rec.Code)
}
}

View File

@@ -532,6 +532,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
Services []struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"`
StartType string `json:"start_type"`
} `json:"services,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
@@ -598,6 +604,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.AgentElevated != nil {
broadcast["agent_elevated"] = *stats.AgentElevated
}
if len(stats.Services) > 0 {
broadcast["services"] = stats.Services
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":

View File

@@ -9,17 +9,23 @@ import (
"crypto-miner-server/internal/db"
)
// retentionTickInterval is the delay between scheduled retention passes (overridable in tests).
var retentionTickInterval = 6 * time.Hour
// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
var runRetentionFn = runRetention
// StartRetentionJobs purges old stats and build artifacts on an interval.
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
if statsHours <= 0 && buildDays <= 0 {
return
}
go func() {
runRetention(database, dataDir, statsHours, buildDays)
ticker := time.NewTicker(6 * time.Hour)
runRetentionFn(database, dataDir, statsHours, buildDays)
ticker := time.NewTicker(retentionTickInterval)
defer ticker.Stop()
for range ticker.C {
runRetention(database, dataDir, statsHours, buildDays)
runRetentionFn(database, dataDir, statsHours, buildDays)
}
}()
}

View File

@@ -0,0 +1,307 @@
package maintenance
import (
"bytes"
"database/sql"
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
func openTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
return d
}
func insertBuild(t *testing.T, d *db.Database, b *models.BuildRecord) {
t.Helper()
if err := d.InsertBuild(b); err != nil {
t.Fatal(err)
}
}
func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Time, hashrate float64) {
t.Helper()
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)",
agentID, hashrate, ts)
if err != nil {
t.Fatal(err)
}
}
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
d := openTestDB(t)
StartRetentionJobs(d, t.TempDir(), 0, 0)
// Disabled config must not start a goroutine that mutates data.
time.Sleep(20 * time.Millisecond)
}
func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
StartRetentionJobs(d, t.TempDir(), 24, 0)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
if err != nil {
t.Fatal(err)
}
if n == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("expected immediate retention pass to purge old hashrate samples")
}
func TestStartRetentionJobs_TickerInterval(t *testing.T) {
prev := retentionTickInterval
retentionTickInterval = 40 * time.Millisecond
t.Cleanup(func() { retentionTickInterval = prev })
d := openTestDB(t)
var passes int32
noopRetention := func(database *db.Database, dataDir string, statsHours, buildDays int) {
atomic.AddInt32(&passes, 1)
}
runRetentionFn = noopRetention
t.Cleanup(func() {
retentionTickInterval = prev
// StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
})
StartRetentionJobs(d, t.TempDir(), 1, 0)
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&passes) >= 2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
}
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
seedHashrateSample(t, d, "a1", time.Now(), 200)
runRetention(d, t.TempDir(), 24, 0)
n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("expected old sample already purged, PurgeHashrateSamplesBefore returned %d", n)
}
var remaining int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
t.Fatal(err)
}
if remaining != 1 {
t.Fatalf("expected 1 recent sample left, got %d", remaining)
}
}
func TestRunRetention_SkipsStatsWhenZero(t *testing.T) {
d := openTestDB(t)
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
runRetention(d, t.TempDir(), 0, 0)
var remaining int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
t.Fatal(err)
}
if remaining != 1 {
t.Fatalf("expected sample retained when statsHours=0, got %d", remaining)
}
}
func TestRunRetention_PurgesBuildWithFilePath(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
artifactDir := filepath.Join(dataDir, "artifacts", "old-build")
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
t.Fatal(err)
}
artifactFile := filepath.Join(artifactDir, "agent.exe")
if err := os.WriteFile(artifactFile, []byte("binary"), 0o644); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "old-build",
WorkerName: "worker-1",
ServerURL: "http://localhost",
Wallet: "wallet",
FilePath: artifactFile,
CreatedAt: time.Now().Add(-48 * time.Hour),
})
insertBuild(t, d, &models.BuildRecord{
ID: "new-build",
WorkerName: "worker-2",
ServerURL: "http://localhost",
Wallet: "wallet",
FilePath: filepath.Join(dataDir, "artifacts", "new-build", "agent.exe"),
CreatedAt: time.Now(),
})
runRetention(d, dataDir, 0, 1)
if _, err := os.Stat(artifactDir); !os.IsNotExist(err) {
t.Fatalf("expected artifact dir removed, stat err=%v", err)
}
_, err := d.GetBuild("old-build")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected old build deleted from db, got %v", err)
}
if _, err := d.GetBuild("new-build"); err != nil {
t.Fatalf("expected new build retained: %v", err)
}
}
func TestRunRetention_PurgesBuildWithoutFilePath(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
buildDir := filepath.Join(dataDir, "builds", "legacy-build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(buildDir, "bundle.zip"), []byte("zip"), 0o644); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "legacy-build",
WorkerName: "worker-legacy",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-72 * time.Hour),
})
runRetention(d, dataDir, 0, 1)
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
t.Fatalf("expected fallback build dir removed, stat err=%v", err)
}
_, err := d.GetBuild("legacy-build")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected legacy build deleted, got %v", err)
}
}
func TestRunRetention_SkipsBuildsWhenZero(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
buildDir := filepath.Join(dataDir, "builds", "keep-me")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "keep-me",
WorkerName: "worker",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-72 * time.Hour),
})
runRetention(d, dataDir, 0, 0)
if _, err := os.Stat(buildDir); err != nil {
t.Fatalf("expected build dir kept when buildDays=0: %v", err)
}
if _, err := d.GetBuild("keep-me"); err != nil {
t.Fatalf("expected build record kept: %v", err)
}
}
func TestRunRetention_HashratePurgeErrorLogged(t *testing.T) {
d := openTestDB(t)
_ = d.Close()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
runRetention(d, t.TempDir(), 24, 0)
if !strings.Contains(buf.String(), "[Retention] hashrate purge failed:") {
t.Fatalf("expected hashrate purge error log, got: %q", buf.String())
}
}
func TestRunRetention_BuildListErrorLogged(t *testing.T) {
d := openTestDB(t)
_ = d.Close()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
runRetention(d, t.TempDir(), 0, 7)
if !strings.Contains(buf.String(), "[Retention] build list failed:") {
t.Fatalf("expected build list error log, got: %q", buf.String())
}
}
func TestRunRetention_StatsAndBuildsTogether(t *testing.T) {
d := openTestDB(t)
dataDir := t.TempDir()
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
buildDir := filepath.Join(dataDir, "builds", "combo-old")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
insertBuild(t, d, &models.BuildRecord{
ID: "combo-old",
WorkerName: "worker",
ServerURL: "http://localhost",
Wallet: "wallet",
CreatedAt: time.Now().Add(-48 * time.Hour),
})
runRetention(d, dataDir, 24, 1)
var samples int
if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&samples); err != nil {
t.Fatal(err)
}
if samples != 0 {
t.Fatalf("expected stats purged, got %d samples", samples)
}
if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
t.Fatalf("expected build dir removed, stat err=%v", err)
}
_, err := d.GetBuild("combo-old")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected build removed, got %v", err)
}
}
func TestRetentionTickIntervalDefault(t *testing.T) {
if retentionTickInterval != 6*time.Hour {
t.Fatalf("expected default tick interval 6h, got %v", retentionTickInterval)
}
}

View File

@@ -46,10 +46,21 @@ type Agent struct {
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
LastPatch *string `json:"last_patch,omitempty"`
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
// T1007 System Service Discovery — fixed allowlist only
Services []AgentService `json:"services,omitempty"`
}
// AgentService mirrors the ServiceStatus reported by the agent.
type AgentService struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"`
StartType string `json:"start_type"`
}
// AgentCapabilities reports forge-time features available for remote command.

21
server/maintenance Normal file
View File

@@ -0,0 +1,21 @@
mode: set
crypto-miner-server/internal/maintenance/retention.go:19.91,20.39 1 1
crypto-miner-server/internal/maintenance/retention.go:20.39,22.3 1 1
crypto-miner-server/internal/maintenance/retention.go:23.2,23.12 1 1
crypto-miner-server/internal/maintenance/retention.go:23.12,27.22 4 1
crypto-miner-server/internal/maintenance/retention.go:27.22,29.4 1 1
crypto-miner-server/internal/maintenance/retention.go:33.85,34.20 1 1
crypto-miner-server/internal/maintenance/retention.go:34.20,37.17 3 1
crypto-miner-server/internal/maintenance/retention.go:37.17,39.4 1 1
crypto-miner-server/internal/maintenance/retention.go:39.9,39.19 1 1
crypto-miner-server/internal/maintenance/retention.go:39.19,41.4 1 1
crypto-miner-server/internal/maintenance/retention.go:43.2,43.19 1 1
crypto-miner-server/internal/maintenance/retention.go:43.19,46.17 3 1
crypto-miner-server/internal/maintenance/retention.go:46.17,49.4 2 1
crypto-miner-server/internal/maintenance/retention.go:50.3,50.28 1 1
crypto-miner-server/internal/maintenance/retention.go:50.28,51.24 1 1
crypto-miner-server/internal/maintenance/retention.go:51.24,54.5 2 1
crypto-miner-server/internal/maintenance/retention.go:54.10,56.5 1 1
crypto-miner-server/internal/maintenance/retention.go:57.4,57.53 1 1
crypto-miner-server/internal/maintenance/retention.go:57.53,59.5 1 0
crypto-miner-server/internal/maintenance/retention.go:59.10,61.5 1 1

View File

@@ -126,6 +126,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(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 } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
}
: a
)

View File

@@ -0,0 +1,157 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
import { ForgeProvider } from '../context/ForgeContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
function renderBuilder(initialEntries = ['/forge']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<ForgeProvider>
<BuilderPage />
</ForgeProvider>
</MemoryRouter>
);
}
describe('formatBytes', () => {
it('formats sub-kilobyte values as bytes', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('formats kilobytes and megabytes with two decimals', () => {
expect(formatBytes(2048)).toBe('2.00 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB');
});
it('formats gigabytes and terabytes', () => {
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.00 GB');
expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024)).toBe('2.00 TB');
});
});
describe('BuilderPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
file_name: 'worker-1.exe',
file_size: 4 * 1024 * 1024,
download_url: '/api/v1/builds/test/download',
fusion_enabled: false,
obfuscated: false,
signed: false,
});
vi.spyOn(api, 'listBlueprints').mockResolvedValue([]);
vi.spyOn(api, 'saveBlueprint').mockResolvedValue({ name: 'my-blueprint', size: 1024, created_at: '' });
vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
});
afterEach(() => {
cleanup();
});
it('shows loading hero before defaults arrive', () => {
renderBuilder();
expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument();
expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument();
});
it('renders forge form with identity field labels after load', async () => {
renderBuilder();
expect(await screen.findByRole('heading', { level: 2, name: 'Quick Forge' })).toBeInTheDocument();
expect(screen.getByText('INSTALLER FORGE')).toBeInTheDocument();
expect(screen.getByText('Worker Name')).toBeInTheDocument();
expect(screen.getByText('Control Endpoint')).toBeInTheDocument();
expect(screen.getByText('XMR Wallet Address')).toBeInTheDocument();
expect(api.getConfig).toHaveBeenCalled();
expect(api.getServerInfo).toHaveBeenCalled();
expect(api.listBuilds).toHaveBeenCalled();
});
it('surfaces load failure when server info cannot be fetched', async () => {
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
renderBuilder();
expect(
await screen.findByText('Failed to load server info — is the control server running?')
).toBeInTheDocument();
});
it('shows LAN endpoint chips from server info', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(screen.getByRole('button', { name: mockServerInfo.suggested_url! })).toBeInTheDocument();
});
it('switches to advanced mode and reveals pool configuration', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
expect(screen.getByText('Pool Host')).toBeInTheDocument();
});
it('submits forge when preflight passes', async () => {
const buildSpy = vi.spyOn(api, 'buildAgent');
renderBuilder();
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
await waitFor(() => {
expect(buildSpy).toHaveBeenCalled();
});
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument();
});
it('blocks forge when preflight has errors (empty wallet)', async () => {
renderBuilder();
const wallet = await screen.findByDisplayValue(/^4A+/);
fireEvent.change(wallet, { target: { value: '' } });
const forgeBtn = screen.getByRole('button', { name: /FIX .* ERROR/i });
expect(forgeBtn).toBeDisabled();
expect(screen.getByText('PREFLIGHT CROSS-CHECK')).toBeInTheDocument();
});
it('persists simple vs advanced mode in localStorage', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('advanced');
await userEvent.setup().click(screen.getByRole('button', { name: 'Simple' }));
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('simple');
});
it('loads blueprint list when Load Blueprint is clicked', async () => {
vi.spyOn(api, 'listBlueprints').mockResolvedValue([
{ name: 'office-preset', size: 2048, created_at: '2026-05-30T12:00:00Z' },
]);
renderBuilder();
await screen.findByRole('button', { name: /Load Blueprint/i });
await userEvent.setup().click(screen.getByRole('button', { name: /Load Blueprint/i }));
expect(await screen.findByText('Saved Blueprints')).toBeInTheDocument();
expect(screen.getByText('office-preset')).toBeInTheDocument();
expect(api.listBlueprints).toHaveBeenCalled();
});
it('shows fusion deliverable hint when Fusion card is selected', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(
screen.getByRole('button', { name: /Hide miner in any file/i })
);
expect(
screen.getByText(/Fusion selected — drop your files below and forge/i)
).toBeInTheDocument();
});
});

View File

@@ -33,7 +33,7 @@ import {
} from '../help/fusionMedia';
import './Pages.css';
function formatBytes(n: number): string {
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB'];
let v = n / 1024;
@@ -635,7 +635,7 @@ export default function BuilderPage() {
fusionPrepFile,
]);
if (loadingDefaults || !form) {
if (loadingDefaults) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
@@ -649,6 +649,22 @@ export default function BuilderPage() {
);
}
if (!form) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
</div>
</header>
<NeonCard accent="brass">
<p>{error || 'Failed to load forge defaults from server.'}</p>
</NeonCard>
</div>
);
}
const installPreview = previewInstallPath({
install_base: form.install_base,
install_custom_base: form.install_custom_base,
@@ -946,7 +962,7 @@ export default function BuilderPage() {
<input
type="text"
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
placeholder="4... or 8... (95106 characters)"
placeholder="4... or 8... (90106 characters)"
value={form.wallet}
onChange={(e) => updateField('wallet', e.target.value)}
required
@@ -954,7 +970,7 @@ export default function BuilderPage() {
/>
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
Wallet address looks short Monero addresses are 95106 characters starting with 4 or 8.
Wallet address looks short Monero addresses are 90106 characters starting with 4 or 8.
</p>
)}
<FieldHint field="wallet" />

View File

@@ -226,6 +226,34 @@
50% { opacity: 0.45; }
}
/* ── T1007 service row ─────────────────────────────────────────────────────── */
.cn-services {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 4px;
}
.cn-svc {
display: flex;
align-items: center;
gap: 3px;
font-size: 0.6rem;
font-family: var(--font-tech);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.05em;
cursor: default;
}
.cn-svc .svc-dot { font-size: 0.55rem; }
.cn-svc.svc-run { color: #00ff88; background: rgba(0,255,136,0.07); }
.cn-svc.svc-stop { color: #888; background: rgba(128,128,128,0.07); text-decoration: line-through; opacity: 0.7; }
.cn-svc.svc-unk { color: #555; background: rgba(80,80,80,0.05); }
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row {

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent } from '../types';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
@@ -78,6 +78,15 @@ function postureTooltip(agent: Agent): string {
if (agent.reboot_pending !== undefined) {
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
}
if (agent.services?.length) {
lines.push('──────────────────────');
lines.push('Services (T1007):');
for (const svc of agent.services) {
const icon = svc.status === 'running' ? '●' : svc.status === 'stopped' ? '○' : '—';
const st = svc.start_type !== 'unknown' ? ` [${svc.start_type}]` : '';
lines.push(` ${icon} ${svc.display_name ?? svc.name}${st}`);
}
}
return lines.join('\n');
}
@@ -96,6 +105,42 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
return null; // no badge when not pending — cleaner UI
}
// ── Service helpers (T1007) ────────────────────────────────────────────────
// Human-readable label for well-known service names
const SVC_LABELS: Record<string, string> = {
sshd: 'SSH',
ssh: 'SSH',
'openssh ssh server': 'SSH',
cloudflared: 'CF Tunnel',
wuauserv: 'WU',
windefend: 'Defender',
ufw: 'UFW',
fail2ban: 'Fail2Ban',
};
function svcLabel(svc: AgentService): string {
return SVC_LABELS[svc.name.toLowerCase()] ?? svc.display_name ?? svc.name;
}
function svcDot(status: string): string {
if (status === 'running') return '●';
if (status === 'stopped') return '○';
return '—';
}
function svcDotClass(status: string): string {
if (status === 'running') return 'svc-run';
if (status === 'stopped') return 'svc-stop';
return 'svc-unk';
}
// Only surface services that are interesting to show (skip self-service clutter)
const IMPORTANT_SVCS = new Set(['sshd', 'ssh', 'openssh ssh server', 'cloudflared', 'wuauserv', 'windefend']);
function importantServices(svcs: AgentService[]): AgentService[] {
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
@@ -472,6 +517,20 @@ export default function CruciblePage() {
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
)}
</div>
{a.services && a.services.length > 0 && (
<div className="cn-services">
{importantServices(a.services).map(svc => (
<span
key={svc.name}
className={`cn-svc ${svcDotClass(svc.status)}`}
title={`${svc.display_name ?? svc.name} status: ${svc.status} start: ${svc.start_type}`}
>
<span className="svc-dot">{svcDot(svc.status)}</span>
{svcLabel(svc)}
</span>
))}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,148 @@
/**
* @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 SettingsPage, { deepMerge } from './SettingsPage';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(<SettingsPage />);
}
describe('deepMerge', () => {
it('preserves base keys not present in override', () => {
const base = { a: 1, nested: { keep: true, old: 'x' } };
const result = deepMerge(base, { nested: { old: 'y' } });
expect(result.a).toBe(1);
expect(result.nested.keep).toBe(true);
expect(result.nested.old).toBe('y');
});
it('replaces scalar values from override', () => {
const base = { port: 8989, pool: { host: 'a', port: 3333 } };
const result = deepMerge(base, { port: 8080, pool: { host: 'b' } });
expect(result.port).toBe(8080);
expect(result.pool.host).toBe('b');
expect(result.pool.port).toBe(3333);
});
});
describe('SettingsPage (Calibrate)', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'updateConfig').mockImplementation(async (cfg) => cfg as ReturnType<typeof mockServerConfig>);
vi.spyOn(api, 'createUser').mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
});
it('shows loading state then Calibrate heading', async () => {
renderSettings();
expect(screen.getByRole('heading', { level: 1, name: 'Calibrate' })).toBeInTheDocument();
expect(screen.getByText('Loading server calibration...')).toBeInTheDocument();
expect(await screen.findByText('CONTROL SERVER · LOCAL HOST')).toBeInTheDocument();
expect(screen.getAllByRole('heading', { level: 1, name: 'Calibrate' })).toHaveLength(1);
});
it('renders key section headings and labels', async () => {
renderSettings();
expect(await screen.findByText('Control Server')).toBeInTheDocument();
expect(screen.getByText('Upstream Pool')).toBeInTheDocument();
expect(screen.getByText('Fleet Payout Wallet')).toBeInTheDocument();
expect(screen.getByText('Access Control')).toBeInTheDocument();
expect(screen.getByText('Fleet Security')).toBeInTheDocument();
expect(screen.getByText('Listen Port')).toBeInTheDocument();
expect(screen.getByText(/Public URL \(LAN\)/i)).toBeInTheDocument();
expect(screen.getByText(/XMR Address/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('8080')).toBeInTheDocument();
});
it('shows detected LAN endpoints banner', async () => {
renderSettings();
expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument();
expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument();
});
it('saves calibration via updateConfig API', async () => {
const updateSpy = vi.spyOn(api, 'updateConfig');
renderSettings();
await screen.findByRole('button', { name: 'Save Calibration' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Save Calibration' }));
await waitFor(() => {
expect(updateSpy).toHaveBeenCalled();
});
expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument();
});
it('applies best defaults to public URL from server info', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
await screen.findByRole('button', { name: 'Use best defaults' });
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe('');
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.')
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
});
it('stores browser session credentials', async () => {
renderSettings();
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
const user = userEvent.setup();
const sessionUser = within(accessSection).getAllByPlaceholderText('admin')[0];
const sessionPass = accessSection.querySelectorAll('input[type="password"]')[0] as HTMLInputElement;
await user.type(sessionUser, 'admin');
await user.type(sessionPass, 'secret-pass');
await user.click(within(accessSection).getByRole('button', { name: 'Save session login' }));
expect(getStoredAuth()).toBeTruthy();
expect(await screen.findByText(/Session login saved/i)).toBeInTheDocument();
});
it('clears browser session on logout', async () => {
setStoredAuth('admin', 'secret-pass');
renderSettings();
await screen.findByText('Session active');
await userEvent.setup().click(screen.getByRole('button', { name: 'Clear session' }));
expect(getStoredAuth()).toBeNull();
expect(await screen.findByText('Session login cleared.')).toBeInTheDocument();
clearStoredAuth();
});
it('adds dashboard user via createUser API', async () => {
const createSpy = vi.spyOn(api, 'createUser');
renderSettings();
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
const user = userEvent.setup();
const newUserInput = within(accessSection).getAllByPlaceholderText('admin')[1];
const newPassInput = within(accessSection).getByPlaceholderText('••••••••');
await user.type(newUserInput, 'operator');
await user.type(newPassInput, 'op-pass-123');
await user.click(within(accessSection).getByRole('button', { name: 'Add User' }));
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith('operator', 'op-pass-123');
});
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
});
it('describes first-run admin credentials in Access Control help', async () => {
renderSettings();
expect(
await screen.findByText(/first server start, credentials are printed once in the server console/i)
).toBeInTheDocument();
expect(screen.getByText(/admin/i)).toBeInTheDocument();
});
});

View File

@@ -7,7 +7,7 @@ import NeonCard from '../components/NeonCard/NeonCard';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
const result = { ...base } as T;
for (const key in override) {
const val = override[key];
@@ -378,7 +378,7 @@ export default function SettingsPage() {
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group">
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input type="text" className="input mono" placeholder="4… (95 chars)"
<input type="text" className="input mono" placeholder="4… or 8… (90106 chars)"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} />
<FieldHint field="calibrate_wallet" />

View File

@@ -1,4 +1,50 @@
import type { Agent, Share, ServerInfo } from '../types';
import type { Agent, Share, ServerConfig, ServerInfo } from '../types';
const VALID_XMR_WALLET = '4' + 'A'.repeat(94);
export function mockServerConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
return {
port: 8080,
data_dir: './data',
pool: {
host: 'pool.supportxmr.com',
port: 3333,
use_tls: false,
password: 'x',
...(overrides.pool ?? {}),
},
wallet: {
address: VALID_XMR_WALLET,
payment_id: '',
...(overrides.wallet ?? {}),
},
server: {
public_url: 'http://192.168.1.5:8080',
stats_retention_hours: 168,
build_retention_days: 30,
pool_reconnect_seconds: 30,
websocket_ping_seconds: 30,
max_agents: 256,
max_build_size_mb: 150,
log_agent_connections: true,
log_share_submissions: false,
log_pool_traffic: false,
strict_wallet_validation: false,
dashboard_subtitle: 'security is just an emotion',
open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,
...(overrides.server ?? {}),
},
alerts: {
offline_threshold_minutes: 5,
hashrate_drop_threshold_pct: 50,
rejection_rate_threshold_pct: 5,
...(overrides.alerts ?? {}),
},
...overrides,
};
}
export function mockAgent(overrides: Partial<Agent> = {}): Agent {
return {

View File

@@ -37,6 +37,14 @@ export interface Agent {
pending_updates?: number; // -1 = unknown
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
}
export interface AgentService {
name: string;
display_name?: string;
status: 'running' | 'stopped' | 'not_found';
start_type: 'auto' | 'manual' | 'disabled' | 'unknown';
}
export interface AgentCapabilities {

View File

@@ -1,4 +1,4 @@
import type { Agent } from '../types';
import type { Agent, AgentService } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
export interface WSDashboardInit {
@@ -31,6 +31,7 @@ export interface WSStatsUpdate {
pending_updates?: number;
reboot_pending?: boolean;
agent_elevated?: boolean;
services?: AgentService[];
}
export interface WSCommandResult {