Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
@@ -183,12 +183,16 @@ func LoadConfig() *Config {
|
||||
cfg.Port = *port
|
||||
cfg.DataDir = *dataDir
|
||||
|
||||
// Try to load from config file
|
||||
// Try to load from config file
|
||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
var fileCfg Config
|
||||
if err := json.Unmarshal(data, &fileCfg); err == nil {
|
||||
mergeConfig(cfg, &fileCfg)
|
||||
// Use mergeConfigExplicit so that boolean fields absent from the file
|
||||
// keep their DefaultConfig values instead of being zeroed (H14).
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
mergeConfigExplicit(cfg, &fileCfg, presentKeys)
|
||||
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
|
||||
cfg.Server.OpenFirewallOnStart = true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetConfigFlags(args []string) {
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
os.Args = args
|
||||
}
|
||||
|
||||
func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
|
||||
t.Helper()
|
||||
var incoming Config
|
||||
@@ -225,3 +232,294 @@ func TestLoadConfigFromFile(t *testing.T) {
|
||||
t.Fatalf("expected port 8989, got %d", loaded.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSaveAndReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.DataDir = dir
|
||||
cfg.Port = 7777
|
||||
cfg.Wallet.Address = "48savedwallet"
|
||||
cfg.Server.FleetSecret = "test-secret"
|
||||
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var loaded Config
|
||||
if err := json.Unmarshal(raw, &loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Port != 7777 {
|
||||
t.Fatalf("saved port: got %d", loaded.Port)
|
||||
}
|
||||
if loaded.Wallet.Address != "48savedwallet" {
|
||||
t.Fatalf("saved wallet: got %q", loaded.Wallet.Address)
|
||||
}
|
||||
if loaded.Server.FleetSecret != "test-secret" {
|
||||
t.Fatalf("saved fleet secret: got %q", loaded.Server.FleetSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigCLIFlags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
resetConfigFlags([]string{"test", "-port", "9999", "-data", dir})
|
||||
|
||||
cfg := LoadConfig()
|
||||
if cfg.Port != 9999 {
|
||||
t.Fatalf("CLI port: got %d", cfg.Port)
|
||||
}
|
||||
if cfg.DataDir != dir {
|
||||
t.Fatalf("CLI data dir: got %q", cfg.DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMergesFileOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fileCfg := DefaultConfig()
|
||||
fileCfg.Port = 9001
|
||||
fileCfg.Pool.Host = "custom.pool.example"
|
||||
fileCfg.Wallet.Address = "48fromfile"
|
||||
data, err := json.Marshal(fileCfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConfigFlags([]string{"test", "-port", "8989", "-data", dir})
|
||||
cfg := LoadConfig()
|
||||
|
||||
if cfg.Port != 9001 {
|
||||
t.Fatalf("file port override: got %d", cfg.Port)
|
||||
}
|
||||
if cfg.Pool.Host != "custom.pool.example" {
|
||||
t.Fatalf("file pool host: got %q", cfg.Pool.Host)
|
||||
}
|
||||
if cfg.Wallet.Address != "48fromfile" {
|
||||
t.Fatalf("file wallet: got %q", cfg.Wallet.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigLegacyOpenFirewallDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Legacy config without open_firewall_on_start key — LoadConfig forces true.
|
||||
payload := `{"port":9100,"server":{"dashboard_subtitle":"legacy"}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(payload), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConfigFlags([]string{"test", "-data", dir})
|
||||
cfg := LoadConfig()
|
||||
|
||||
if !cfg.Server.OpenFirewallOnStart {
|
||||
t.Fatal("legacy config without open_firewall_on_start must default true")
|
||||
}
|
||||
if cfg.Server.DashboardSubtitle != "legacy" {
|
||||
t.Fatalf("subtitle from file: got %q", cfg.Server.DashboardSubtitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigExplicitOpenFirewallFalse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
payload := `{"server":{"open_firewall_on_start":false}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(payload), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConfigFlags([]string{"test", "-data", dir})
|
||||
cfg := LoadConfig()
|
||||
|
||||
if cfg.Server.OpenFirewallOnStart {
|
||||
t.Fatal("explicit open_firewall_on_start:false must be honored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolURLTLS(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
got := cfg.PoolURL()
|
||||
want := "stratum+ssl://pool.supportxmr.com:3333"
|
||||
if got != want {
|
||||
t.Fatalf("PoolURL TLS: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolURLPlainTCP(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Pool.UseTLS = false
|
||||
got := cfg.PoolURL()
|
||||
want := "stratum+tcp://pool.supportxmr.com:3333"
|
||||
if got != want {
|
||||
t.Fatalf("PoolURL plain: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedJSONKeys(t *testing.T) {
|
||||
present := map[string]json.RawMessage{
|
||||
"pool": json.RawMessage(`{"host":"x","port":4444}`),
|
||||
"server": json.RawMessage(`invalid`),
|
||||
}
|
||||
poolKeys := nestedJSONKeys(present, "pool")
|
||||
if poolKeys == nil {
|
||||
t.Fatal("expected pool keys")
|
||||
}
|
||||
if _, ok := poolKeys["host"]; !ok {
|
||||
t.Fatal("missing host key")
|
||||
}
|
||||
if nestedJSONKeys(present, "missing") != nil {
|
||||
t.Fatal("missing section should return nil")
|
||||
}
|
||||
if nestedJSONKeys(present, "server") != nil {
|
||||
t.Fatal("invalid nested JSON should return nil")
|
||||
}
|
||||
if nestedJSONKeys(nil, "pool") != nil {
|
||||
t.Fatal("nil present map should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigLegacyScalarsAndBooleans(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Wallet.Address = "48original"
|
||||
dst.Pool.UseTLS = true
|
||||
dst.Background.SilentMode = true
|
||||
dst.Server.LogAgentConnections = true
|
||||
|
||||
src := &Config{
|
||||
Port: 8080,
|
||||
Pool: PoolConfig{Host: "legacy.pool", Port: 4444, Password: "pw"},
|
||||
Wallet: WalletConfig{Address: "48new"},
|
||||
Background: BackgroundConfig{
|
||||
SilentMode: false,
|
||||
RunAs: "user",
|
||||
AutoStart: false,
|
||||
},
|
||||
Alerts: AlertsConfig{EmailEnabled: true},
|
||||
Server: ServerSettings{
|
||||
LogAgentConnections: false,
|
||||
LogShareSubmissions: true,
|
||||
OpenFirewallOnStart: false,
|
||||
DashboardSubtitle: "merged",
|
||||
StatsRetentionHours: 72,
|
||||
},
|
||||
}
|
||||
mergeConfig(dst, src)
|
||||
|
||||
if dst.Port != 8080 {
|
||||
t.Fatalf("port: got %d", dst.Port)
|
||||
}
|
||||
if dst.Pool.Host != "legacy.pool" || dst.Pool.Port != 4444 {
|
||||
t.Fatalf("pool: %+v", dst.Pool)
|
||||
}
|
||||
if dst.Wallet.Address != "48new" {
|
||||
t.Fatalf("wallet: %q", dst.Wallet.Address)
|
||||
}
|
||||
if dst.Background.SilentMode {
|
||||
t.Fatal("mergeConfig must apply background.silent_mode false")
|
||||
}
|
||||
if dst.Background.RunAs != "user" {
|
||||
t.Fatalf("run_as: %q", dst.Background.RunAs)
|
||||
}
|
||||
if dst.Background.AutoStart {
|
||||
t.Fatal("auto_start false must apply")
|
||||
}
|
||||
if !dst.Alerts.EmailEnabled {
|
||||
t.Fatal("email_enabled true must apply")
|
||||
}
|
||||
if dst.Server.LogAgentConnections {
|
||||
t.Fatal("log_agent_connections false must apply")
|
||||
}
|
||||
if !dst.Server.LogShareSubmissions {
|
||||
t.Fatal("log_share_submissions true must apply")
|
||||
}
|
||||
if dst.Server.OpenFirewallOnStart {
|
||||
t.Fatal("open_firewall_on_start false must apply")
|
||||
}
|
||||
if dst.Server.DashboardSubtitle != "merged" {
|
||||
t.Fatalf("subtitle: %q", dst.Server.DashboardSubtitle)
|
||||
}
|
||||
if dst.Server.StatsRetentionHours != 72 {
|
||||
t.Fatalf("stats retention: %d", dst.Server.StatsRetentionHours)
|
||||
}
|
||||
// Known legacy: UseTLS copied from src even when false on zero-value partial src
|
||||
if dst.Pool.UseTLS {
|
||||
t.Log("mergeConfig sets UseTLS from src zero value on partial update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigLegacyDefaultAgentBools(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.DefaultAgent.AdaptToHardware = true
|
||||
dst.DefaultAgent.FileLogging = true
|
||||
dst.DefaultAgent.StealthMode = false
|
||||
|
||||
src := &Config{
|
||||
DefaultAgent: AgentDefaults{
|
||||
StealthMode: true,
|
||||
},
|
||||
}
|
||||
mergeConfig(dst, src)
|
||||
|
||||
if !dst.DefaultAgent.StealthMode {
|
||||
t.Fatal("stealth_mode true must apply via bool branch")
|
||||
}
|
||||
if dst.DefaultAgent.AdaptToHardware {
|
||||
t.Fatal("adapt_to_hardware should follow src false on stealth_mode branch")
|
||||
}
|
||||
if dst.DefaultAgent.FileLogging {
|
||||
t.Fatal("file_logging should follow src false on stealth_mode branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigLegacyPoolUseTLSFalse(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Pool.UseTLS = true
|
||||
src := &Config{Pool: PoolConfig{Host: "only-host"}}
|
||||
mergeConfig(dst, src)
|
||||
if dst.Pool.UseTLS {
|
||||
t.Fatal("mergeConfig always copies UseTLS from src; zero src clears TLS")
|
||||
}
|
||||
if dst.Pool.Host != "only-host" {
|
||||
t.Fatalf("host: %q", dst.Pool.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigLegacyZeroValuesPreserveScalars(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Port = 8989
|
||||
dst.Pool.Port = 3333
|
||||
src := &Config{}
|
||||
mergeConfig(dst, src)
|
||||
if dst.Port != 8989 {
|
||||
t.Fatalf("zero src port must not overwrite: got %d", dst.Port)
|
||||
}
|
||||
if dst.Pool.Port != 3333 {
|
||||
t.Fatalf("zero src pool port must not overwrite: got %d", dst.Pool.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigIgnoresInvalidJSONFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{broken`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resetConfigFlags([]string{"test", "-port", "8888", "-data", dir})
|
||||
cfg := LoadConfig()
|
||||
if cfg.Port != 8888 {
|
||||
t.Fatalf("invalid file should fall back to CLI port: got %d", cfg.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
|
||||
withKey := `{"server":{"open_firewall_on_start":false}}`
|
||||
if strings.Contains(withKey, `"open_firewall_on_start"`) != true {
|
||||
t.Fatal("test precondition")
|
||||
}
|
||||
withoutKey := `{"server":{"dashboard_subtitle":"x"}}`
|
||||
if strings.Contains(withoutKey, `"open_firewall_on_start"`) {
|
||||
t.Fatal("test precondition")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,14 @@ func (h *AIHandler) HandleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ─── Decide ───────────────────────────────────
|
||||
|
||||
// decideRequest carries the agent state for an AI decision cycle.
|
||||
// OllamaEndpoint and Model are intentionally ignored on the server side —
|
||||
// the engine endpoint is set server-side when the agent authenticates via WS
|
||||
// to prevent SSRF via caller-supplied URLs.
|
||||
type decideRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
// OllamaEndpoint is accepted in the payload for forward-compat but NEVER used;
|
||||
// the server uses only the endpoint set during WS authentication.
|
||||
OllamaEndpoint string `json:"ollama_endpoint,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ollama.AgentState
|
||||
@@ -159,16 +165,14 @@ func (h *AIHandler) handleDecide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create engine for this agent
|
||||
// Only use the engine that was registered when the agent authenticated via
|
||||
// WebSocket — do NOT create a new engine from caller-supplied OllamaEndpoint,
|
||||
// which would allow SSRF by pointing the server at an internal URL.
|
||||
engine := h.GetEngine(req.AgentID)
|
||||
if engine == nil {
|
||||
// Create engine on first request
|
||||
h.SetEngineForAgent(req.AgentID, req.OllamaEndpoint, req.Model)
|
||||
engine = h.GetEngine(req.AgentID)
|
||||
}
|
||||
|
||||
if engine == nil {
|
||||
http.Error(w, "failed to create AI engine", http.StatusInternalServerError)
|
||||
// Agent has not yet authenticated via WebSocket; reject to prevent
|
||||
// unauthenticated callers from triggering outbound Ollama requests.
|
||||
http.Error(w, "agent not registered — authenticate via WebSocket first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -180,11 +180,12 @@ func TestAIHandleDecideSuccess(t *testing.T) {
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
// Engine must be pre-registered via WS auth (caller-supplied endpoint is ignored to prevent SSRF).
|
||||
h.SetEngineForAgent("agent-decide-ok", srv.URL, "test-model")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-ok",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "test-model",
|
||||
"hostname": "host1",
|
||||
"agent_id": "agent-decide-ok",
|
||||
"hostname": "host1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -220,9 +221,11 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
|
||||
// Engine must be pre-registered; caller-supplied endpoint is ignored (SSRF prevention).
|
||||
h.SetEngineForAgent("agent-decide-fail", srv.URL, "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-fail",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"agent_id": "agent-decide-fail",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -251,25 +254,27 @@ func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
|
||||
// TestAIHandleDecideRejectsUnregisteredAgent verifies that decide returns 403
|
||||
// when the agent has not first authenticated via WebSocket. This prevents SSRF
|
||||
// by ensuring the server never initiates an outbound Ollama request to a
|
||||
// caller-supplied URL.
|
||||
func TestAIHandleDecideRejectsUnregisteredAgent(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,
|
||||
"agent_id": "agent-not-in-ws",
|
||||
"ollama_endpoint": "http://internal-server/v1/chat",
|
||||
"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 w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unregistered agent should get 403, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if h.GetEngine("agent-new") == nil {
|
||||
t.Fatal("engine should exist after first decide")
|
||||
// Engine must NOT be created from the caller-supplied URL.
|
||||
if h.GetEngine("agent-not-in-ws") != nil {
|
||||
t.Fatal("engine should NOT be created from caller-supplied endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// ConfigHandler handles GET/PUT for server configuration settings
|
||||
type ConfigHandler struct {
|
||||
db *db.Database
|
||||
config ConfigProvider
|
||||
}
|
||||
|
||||
@@ -20,11 +17,8 @@ type ConfigProvider interface {
|
||||
UpdateConfigFromJSON(data json.RawMessage) error
|
||||
}
|
||||
|
||||
func NewConfigHandler(database *db.Database, cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
db: database,
|
||||
config: cp,
|
||||
}
|
||||
func NewConfigHandler(cp ConfigProvider) *ConfigHandler {
|
||||
return &ConfigHandler{config: cp}
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// stubConfigProvider implements ConfigProvider for handler unit tests.
|
||||
@@ -39,17 +37,12 @@ func (s *stubConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
|
||||
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)
|
||||
return NewConfigHandler(cp)
|
||||
}
|
||||
|
||||
func TestNewConfigHandler(t *testing.T) {
|
||||
h := newTestConfigHandler(t, &stubConfigProvider{})
|
||||
if h == nil || h.config == nil || h.db == nil {
|
||||
if h == nil || h.config == nil {
|
||||
t.Fatal("NewConfigHandler returned incomplete handler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ type xmrPriceEntry struct {
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
xmrPriceMu sync.Mutex
|
||||
xmrPriceCache *xmrPriceEntry
|
||||
xmrPriceTTL = 10 * time.Minute
|
||||
)
|
||||
const xmrPriceTTL = 10 * time.Minute
|
||||
|
||||
type FleetHandler struct {
|
||||
db *db.Database
|
||||
@@ -37,6 +33,10 @@ type FleetHandler struct {
|
||||
alerts *alerts.Evaluator
|
||||
defaultPool pool.Config
|
||||
|
||||
// XMR price cache — per-handler so multiple routers in one process stay isolated.
|
||||
xmrPriceMu sync.Mutex
|
||||
xmrPriceCache *xmrPriceEntry
|
||||
|
||||
// Real-earnings cache (avoids hammering the pool API)
|
||||
earningsMu sync.Mutex
|
||||
earningsCache map[string]*poolEarningsCache
|
||||
@@ -87,11 +87,11 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
|
||||
// GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes.
|
||||
// Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully.
|
||||
func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
xmrPriceMu.Lock()
|
||||
if xmrPriceCache != nil && time.Since(xmrPriceCache.fetchedAt) < xmrPriceTTL {
|
||||
usd := xmrPriceCache.USD
|
||||
at := xmrPriceCache.fetchedAt
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Lock()
|
||||
if f.xmrPriceCache != nil && time.Since(f.xmrPriceCache.fetchedAt) < xmrPriceTTL {
|
||||
usd := f.xmrPriceCache.USD
|
||||
at := f.xmrPriceCache.fetchedAt
|
||||
f.xmrPriceMu.Unlock()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
"fetched_at": at.UTC().Format(time.RFC3339),
|
||||
@@ -99,7 +99,7 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Unlock()
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
|
||||
@@ -118,9 +118,9 @@ func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
usd := raw["monero"]["usd"]
|
||||
|
||||
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = entry
|
||||
xmrPriceMu.Unlock()
|
||||
f.xmrPriceMu.Lock()
|
||||
f.xmrPriceCache = entry
|
||||
f.xmrPriceMu.Unlock()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
|
||||
@@ -43,15 +43,15 @@ func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
}
|
||||
|
||||
func resetXMRPriceCache(t *testing.T) {
|
||||
func resetXMRPriceCache(t *testing.T, fh *FleetHandler) {
|
||||
t.Helper()
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = nil
|
||||
xmrPriceMu.Unlock()
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = nil
|
||||
fh.xmrPriceMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = nil
|
||||
xmrPriceMu.Unlock()
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = nil
|
||||
fh.xmrPriceMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -270,13 +270,12 @@ func TestFleetGetAIActivityWithEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
resetXMRPriceCache(t, fh)
|
||||
fetchedAt := time.Now().Add(-2 * time.Minute)
|
||||
fh.xmrPriceMu.Lock()
|
||||
fh.xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
|
||||
fh.xmrPriceMu.Unlock()
|
||||
rec := httptest.NewRecorder()
|
||||
fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -292,7 +291,6 @@ func TestFleetGetXMRPriceCacheHit(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -319,7 +317,6 @@ func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
})
|
||||
@@ -333,7 +330,6 @@ func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFleetGetXMRPriceParseError(t *testing.T) {
|
||||
resetXMRPriceCache(t)
|
||||
setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
|
||||
rec := httptest.NewRecorder()
|
||||
rec.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type mockConfigProvider struct {
|
||||
@@ -31,6 +39,7 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
const testFleetSecret = "test-fleet-secret-integration"
|
||||
|
||||
func seedTestUsers(t *testing.T, dataDir string) {
|
||||
t.Helper()
|
||||
@@ -44,7 +53,7 @@ func seedTestUsers(t *testing.T, dataDir string) {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
@@ -57,7 +66,7 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
@@ -68,11 +77,95 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes).
|
||||
func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.Header.Set("X-Fleet-Secret", secret)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func insertTestBuild(t *testing.T, database *db.Database, dataDir, buildID, platform, fileName string) {
|
||||
t.Helper()
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binPath := filepath.Join(buildDir, fileName)
|
||||
if err := os.WriteFile(binPath, []byte("fake-binary-"+platform), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "worker", ServerURL: "http://localhost:8989", Wallet: "48x",
|
||||
FilePath: binPath, FileName: fileName, Platform: platform, CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func startRouterServer(t *testing.T, router http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func connectAgentViaRouter(t *testing.T, router http.Handler, agentID string) (*websocket.Conn, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := startRouterServer(t, router)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
|
||||
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": "integration-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)
|
||||
}
|
||||
return conn, srv
|
||||
}
|
||||
|
||||
func TestHealthIsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -89,7 +182,7 @@ func TestHealthIsPublic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -99,7 +192,7 @@ func TestConfigRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -110,7 +203,7 @@ func TestConfigWithValidAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -120,7 +213,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -138,13 +231,14 @@ func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestArtifactDownloadRejectsTraversal(t *testing.T) {
|
||||
router, dataDir := newTestRouter(t)
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
buildID := "test-build-id"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
@@ -153,7 +247,7 @@ func TestArtifactDownloadRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSPAServesIndex(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -179,7 +273,7 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -189,3 +283,437 @@ func TestStatsLimitCapped(t *testing.T) {
|
||||
t.Fatalf("limit cap caused server error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationServerInfo(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/server/info", 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 _, ok := body["port"]; !ok {
|
||||
t.Fatalf("expected port in server info: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDashboardStats(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/dashboard/stats", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationGetAgentNotFound(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/missing-agent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentLog(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
agentID := "log-agent"
|
||||
connectTestAgent(t, wsHub, agentID)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/"+agentID+"/log", 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["agent_id"] != agentID {
|
||||
t.Fatalf("unexpected agent_id: %v", body["agent_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentCommandOffline(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
|
||||
[]byte(`{"action":"pause"}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for offline agent, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentMeta(t *testing.T) {
|
||||
router, _, database, _ := newTestRouter(t)
|
||||
agent := &models.Agent{ID: "meta-rig", Name: "rig", Status: "offline", LastSeen: time.Now()}
|
||||
if err := database.UpsertAgent(agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/agents/meta-rig/meta",
|
||||
[]byte(`{"notes":"integration test","tags":["lab"]}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBulkCommandPartialFailure(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
onlineID := "bulk-online"
|
||||
connectTestAgent(t, wsHub, onlineID)
|
||||
|
||||
payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/bulk-command", []byte(payload))
|
||||
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 TestIntegrationFleetReadEndpoints(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
paths := []string{
|
||||
"/api/v1/alerts",
|
||||
"/api/v1/pools/status",
|
||||
"/api/v1/ai/activity",
|
||||
"/api/v1/earnings/estimate",
|
||||
"/api/v1/shares",
|
||||
"/api/v1/builds",
|
||||
}
|
||||
for _, path := range paths {
|
||||
rec := serveAuthed(t, router, http.MethodGet, path, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationMarketXMR(t *testing.T) {
|
||||
setMockHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.String(), "coingecko") {
|
||||
return nil, errors.New("unexpected url")
|
||||
}
|
||||
body := `{"monero":{"usd":165.5}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}))
|
||||
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/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 {
|
||||
t.Fatalf("unexpected price: %v", body["usd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuildsLifecycle(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
buildID := "lifecycle-build"
|
||||
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodPut, "/api/v1/builds/"+buildID+"/pin", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("pin status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/pin", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unpin status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/"+buildID, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPutConfig(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/config", []byte(`{"port":9090}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "9090") {
|
||||
t.Fatalf("expected updated config: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuilderRoutes(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/builder/build", []byte(`{}`))
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatal("builder/build route not registered")
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/estimate", []byte("not-multipart"))
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatal("builder/estimate route not registered")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("estimate without multipart expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builder/cancel/no-such-token", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBlueprintsCRUD(t *testing.T) {
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
|
||||
saveBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "integration-preset",
|
||||
"data": map[string]interface{}{"threads": 2},
|
||||
})
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", saveBody)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dataDir, "blueprints", "integration-preset.json")
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
t.Fatalf("blueprint file missing: %v", err)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/integration-preset", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints?name=integration-preset", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBlueprintErrors(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", []byte(`{"name":"","data":{}}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty name expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/missing-preset", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing blueprint expected 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("delete without name expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAIRoutes(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
// Agent routes require fleet secret (not Basic Auth).
|
||||
SetAgentPathSecret(testFleetSecret)
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
// decide: missing agent_id → 400.
|
||||
rec := serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/decide", testFleetSecret, []byte(`{"worker_name":"x"}`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("decide missing agent_id expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// report and heartbeat require fleet secret; they don't need a pre-registered engine.
|
||||
body, _ := json.Marshal(map[string]string{"agent_id": "report-agent", "tool": "sleep", "output": "ok"})
|
||||
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/report", testFleetSecret, body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("report status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(map[string]string{"agent_id": "hb-agent", "status": "alive"})
|
||||
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/heartbeat", testFleetSecret, hb)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("heartbeat status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAgentRoutesFleetSecret(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
SetAgentPathSecret("integration-fleet-secret")
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat",
|
||||
bytes.NewReader([]byte(`{"agent_id":"a","status":"alive"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing fleet secret expected 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Fleet-Secret", "integration-fleet-secret")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid fleet secret expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDropperVariants(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("no builds expected 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
insertTestBuild(t, database, dataDir, "win-drop", "windows", "worker.exe")
|
||||
req = httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("windows build expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/get?os=linux", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("linux with only windows build falls back to latest, expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0)")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("UA-detected windows expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/install.sh", "/install.ps1"} {
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "forge.local:8989"
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationBuildUninstallNotFound(t *testing.T) {
|
||||
router, _, database, dataDir := newTestRouter(t)
|
||||
buildID := "uninstall-build"
|
||||
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds/"+buildID+"/uninstall", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing uninstall script expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketDashboard(t *testing.T) {
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
srv := startRouterServer(t, router)
|
||||
|
||||
badURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard"
|
||||
_, resp, err := websocket.DefaultDialer.Dial(badURL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected dial failure without token")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got err=%v status=%v", err, resp)
|
||||
}
|
||||
|
||||
goodURL := badURL + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(goodURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial with token: %v status=%v", err, resp)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("read init: %v", err)
|
||||
}
|
||||
if msg.Type != "init" {
|
||||
t.Fatalf("expected init, got %q", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketAgentBadSecret(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
wsHub.SetFleetSecret("ws-fleet-secret")
|
||||
t.Cleanup(func() { wsHub.SetFleetSecret("") })
|
||||
|
||||
srv := startRouterServer(t, router)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "bad-secret-agent", "fleet_secret": "wrong", "hostname": "host",
|
||||
})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != false {
|
||||
t.Fatalf("expected auth failure with bad fleet secret, got %+v", body)
|
||||
}
|
||||
if wsHub.isAgentConnected("bad-secret-agent") {
|
||||
t.Fatal("agent should not register with bad fleet secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) {
|
||||
router, wsHub, _, _ := newTestRouter(t)
|
||||
agentID := "router-cmd-agent"
|
||||
connectAgentViaRouter(t, router, agentID)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if wsHub.isAgentConnected(agentID) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !wsHub.isAgentConnected(agentID) {
|
||||
t.Fatal("agent not connected via router ws")
|
||||
}
|
||||
|
||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command",
|
||||
[]byte(`{"action":"pause"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,10 +216,11 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
path := r.URL.Path
|
||||
|
||||
// Health check and download endpoints are always open.
|
||||
// Health check and one-liner installer endpoints are always open.
|
||||
// NOTE: build download/artifact routes are intentionally NOT in this list —
|
||||
// they require fleet-secret or Basic Auth (see isDownload block below).
|
||||
if path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -227,22 +228,44 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
// Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret
|
||||
// in the X-Fleet-Secret header instead of Basic auth. This ensures only
|
||||
// legitimately forged agents can call these endpoints.
|
||||
// A missing or empty fleet secret is always rejected — the server auto-
|
||||
// generates one at startup so this state should never occur in production.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
secret := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
if secret != "" {
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if secret == "" {
|
||||
http.Error(w, "server not ready: fleet secret not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Secret is empty (first run before config save) or matched — allow through.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Build download/artifact/uninstall routes: accept fleet secret OR Basic Auth.
|
||||
// This lets forged agents self-upgrade (they have the fleet secret baked in)
|
||||
// while still requiring credentials for unauthenticated callers.
|
||||
isDownload := strings.HasPrefix(path, "/api/v1/builds/") &&
|
||||
(strings.HasSuffix(path, "/download") ||
|
||||
strings.Contains(path, "/artifact/") ||
|
||||
strings.HasSuffix(path, "/uninstall"))
|
||||
if isDownload {
|
||||
fleetSecretForAgentPathsMu.RLock()
|
||||
secret := fleetSecretForAgentPaths
|
||||
fleetSecretForAgentPathsMu.RUnlock()
|
||||
provided := r.Header.Get("X-Fleet-Secret")
|
||||
if secret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Fall through to Basic Auth below.
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||
|
||||
@@ -86,7 +86,9 @@ func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
|
||||
// TestBasicAuthMiddlewareBuildDownloadRequiresAuth verifies that build download
|
||||
// routes are no longer publicly accessible — they require fleet secret or Basic Auth.
|
||||
func TestBasicAuthMiddlewareBuildDownloadRequiresAuth(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -95,12 +97,25 @@ func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
|
||||
"/api/v1/builds/abc/download",
|
||||
"/api/v1/builds/abc/artifact/worker.exe",
|
||||
}
|
||||
for _, path := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s without auth should be 401, got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Fleet secret should grant access.
|
||||
SetAgentPathSecret("test-secret-abc")
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
req.Header.Set("X-Fleet-Secret", "test-secret-abc")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s should be public, got %d", path, rec.Code)
|
||||
t.Fatalf("%s with fleet secret should be 200, got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +227,7 @@ func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterPostUsersValidation(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
@@ -224,7 +239,7 @@ func TestRouterPostUsersValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterPostUsersSuccess(t *testing.T) {
|
||||
router, dataDir := newTestRouter(t)
|
||||
router, _, _, dataDir := newTestRouter(t)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
|
||||
@@ -250,7 +265,7 @@ func TestRouterPostUsersSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -261,7 +276,7 @@ func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretSuccess(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
SetRotateSecretFn(func() (string, error) {
|
||||
return "new-secret-token-xyz", nil
|
||||
})
|
||||
@@ -284,7 +299,7 @@ func TestRouterRotateSecretSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterBuilderCancelNotFound(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -294,7 +309,10 @@ func TestRouterBuilderCancelNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterBuildDownloadNoAuth(t *testing.T) {
|
||||
// TestRouterBuildDownloadAuth verifies that build download routes require either
|
||||
// the fleet secret (X-Fleet-Secret header) or Basic Auth — they are no longer
|
||||
// publicly accessible without credentials.
|
||||
func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
database, err := db.New(dataDir)
|
||||
@@ -319,25 +337,50 @@ func TestRouterBuildDownloadNoAuth(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const testSecret = "test-fleet-secret-12345"
|
||||
SetAgentPathSecret(testSecret)
|
||||
t.Cleanup(func() { SetAgentPathSecret("") })
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/download", nil)
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
// 1. Unauthenticated → 401.
|
||||
req := httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated download should be 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. Valid fleet secret → 200.
|
||||
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
req.Header.Set("X-Fleet-Secret", testSecret)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download should be public, got %d body=%s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("fleet-secret download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Basic Auth → 200.
|
||||
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("basic-auth download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
for _, path := range []string{"/install.sh", "/install.ps1"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "forge.local:8989"
|
||||
@@ -353,7 +396,7 @@ func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
router, _, _, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -376,7 +419,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, cfg)
|
||||
configHandler := NewConfigHandler(cfg)
|
||||
aiHandler := NewAIHandler(database)
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
|
||||
194
server/internal/builder/build_universal_test.go
Normal file
194
server/internal/builder/build_universal_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFinishSpreadKitUniversal(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
buildDir := t.TempDir()
|
||||
buildID := "spread-universal-1"
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
worker := filepath.Join(buildDir, "windows-amd64", "install-pc.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(worker, []byte("fake-worker"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "My Worker",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
PoolHost: "pool.supportxmr.com",
|
||||
PoolPort: 3333,
|
||||
}
|
||||
workers := map[string]string{win.Label(): worker}
|
||||
resp, code, primary := h.finishSpreadKit(buildID, buildDir, req, workers, []BuildPlatform{win})
|
||||
if code != http.StatusOK || !resp.Success {
|
||||
t.Fatalf("finishSpreadKit failed: code=%d resp=%+v", code, resp)
|
||||
}
|
||||
if primary != worker {
|
||||
t.Fatalf("primary path: got %q want %q", primary, worker)
|
||||
}
|
||||
if !strings.Contains(resp.RelativePath, "universal") {
|
||||
t.Fatalf("expected universal kit path, got %q", resp.RelativePath)
|
||||
}
|
||||
outDir := filepath.Join(h.projectRoot, "spread-kits", sanitizeFileName(req.WorkerName)+"-universal")
|
||||
if _, err := os.Stat(filepath.Join(outDir, "README.txt")); err != nil {
|
||||
t.Fatalf("spread kit output missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishSpreadKitSpreadKitFlag(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
buildDir := t.TempDir()
|
||||
buildID := "spread-kit-2"
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
worker := filepath.Join(buildDir, "windows-amd64", "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{WorkerName: "kit", ServerURL: "http://x", Wallet: "48a", SpreadKit: true}
|
||||
resp, code, _ := h.finishSpreadKit(buildID, buildDir, req, map[string]string{win.Label(): worker}, []BuildPlatform{win})
|
||||
if code != http.StatusOK || !resp.Success {
|
||||
t.Fatalf("unexpected: code=%d %+v", code, resp)
|
||||
}
|
||||
sub := sanitizeFileName(req.WorkerName) + "-spread-kit"
|
||||
if !strings.Contains(resp.RelativePath, sub) {
|
||||
t.Fatalf("relative path %q should contain %q", resp.RelativePath, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishSpreadKitCopyWorkerFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
buildDir := t.TempDir()
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48a"}
|
||||
workers := map[string]string{win.Label(): filepath.Join(buildDir, "missing.exe")}
|
||||
resp, code, _ := h.finishSpreadKit("id", buildDir, req, workers, []BuildPlatform{win})
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("expected copy failure, code=%d success=%v err=%q", code, resp.Success, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishSpreadKitPrimaryPrefersWindows(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
buildDir := t.TempDir()
|
||||
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
linuxWorker := filepath.Join(buildDir, "linux-amd64", "worker")
|
||||
winWorker := filepath.Join(buildDir, "windows-amd64", "worker.exe")
|
||||
for _, p := range []string{filepath.Dir(linuxWorker), filepath.Dir(winWorker)} {
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(linuxWorker, []byte("l"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(winWorker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{WorkerName: "multi", ServerURL: "http://x", Wallet: "48a"}
|
||||
workers := map[string]string{linux.Label(): linuxWorker, win.Label(): winWorker}
|
||||
_, _, primary := h.finishSpreadKit("id", buildDir, req, workers, []BuildPlatform{linux, win})
|
||||
if primary != winWorker {
|
||||
t.Fatalf("primary should prefer windows-amd64, got %q", primary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUniversalAgentCopySourceFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
h.agentSrcDir = filepath.Join(t.TempDir(), "no-agent")
|
||||
|
||||
req := &BuildRequest{
|
||||
TargetOS: "universal",
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
}
|
||||
resp, code, _ := h.buildUniversalAgent(context.Background(), req, "")
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("expected copy failure: code=%d %+v", code, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "agent source") {
|
||||
t.Fatalf("error: %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUniversalAgentCompileFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
req := &BuildRequest{
|
||||
TargetOS: "universal",
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
SpreadKit: true,
|
||||
}
|
||||
resp, code, _ := h.buildUniversalAgent(context.Background(), req, "")
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("expected compile failure: code=%d %+v", code, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishUniversalFusionBuildFusionFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
buildID := "fusion-fail-1"
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
worker := filepath.Join(buildDir, "windows-amd64", "worker-pc.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(worker), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(worker, []byte("worker"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
FusionEnabled: true,
|
||||
FusionPayloadKind: "file",
|
||||
FusionMediaMode: "paired",
|
||||
FusionMediaBaseName: "report.pdf",
|
||||
}
|
||||
resp, code, _ := h.finishUniversalFusion(
|
||||
context.Background(), buildID, buildDir, req, prep,
|
||||
map[string]string{win.Label(): worker}, []BuildPlatform{win},
|
||||
)
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("expected fusion compile failure: code=%d %+v", code, resp)
|
||||
}
|
||||
}
|
||||
43
server/internal/builder/compile_test.go
Normal file
43
server/internal/builder/compile_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildTagsFor(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{ProcessHollowing: true, MeshP2P: true}
|
||||
tags := h.buildTagsFor(req)
|
||||
if len(tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %v", tags)
|
||||
}
|
||||
if tags[0] != "hollow" || tags[1] != "p2p" {
|
||||
t.Fatalf("unexpected tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagsForEmpty(t *testing.T) {
|
||||
h := &Handler{}
|
||||
if tags := h.buildTagsFor(&BuildRequest{}); len(tags) != 0 {
|
||||
t.Fatalf("expected no tags, got %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldObfuscateRequestFlag(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{DefaultObfuscate: false}}
|
||||
if !h.shouldObfuscate(&BuildRequest{Obfuscate: true}) {
|
||||
t.Fatal("request obfuscate flag should win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldObfuscatePolicyDefault(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{DefaultObfuscate: true}}
|
||||
if !h.shouldObfuscate(&BuildRequest{}) {
|
||||
t.Fatal("policy default should enable obfuscation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldObfuscateOff(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{DefaultObfuscate: false}}
|
||||
if h.shouldObfuscate(&BuildRequest{}) {
|
||||
t.Fatal("expected obfuscation off")
|
||||
}
|
||||
}
|
||||
129
server/internal/builder/disguise_test.go
Normal file
129
server/internal/builder/disguise_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileDisguiseForExtKnown(t *testing.T) {
|
||||
info := fileDisguiseForExt(".pdf")
|
||||
if info.OriginalFilename != "AcroRd32.exe" {
|
||||
t.Fatalf("pdf disguise: %+v", info)
|
||||
}
|
||||
if info.CompanyName != "Adobe Inc." {
|
||||
t.Fatalf("expected Adobe, got %q", info.CompanyName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDisguiseForExtFallback(t *testing.T) {
|
||||
info := fileDisguiseForExt(".unknownext")
|
||||
if info.ProductName != "Windows" {
|
||||
t.Fatalf("fallback disguise: %+v", info)
|
||||
}
|
||||
if info.OriginalFilename != "Explorer.exe" {
|
||||
t.Fatalf("fallback filename: %q", info.OriginalFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDisguiseForExtCaseInsensitive(t *testing.T) {
|
||||
a := fileDisguiseForExt(".PDF")
|
||||
b := fileDisguiseForExt(".pdf")
|
||||
if a.OriginalFilename != b.OriginalFilename {
|
||||
t.Fatal("case should not matter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisguisedRunnerNameDoubleExtension(t *testing.T) {
|
||||
got := disguisedRunnerName("quarterly-report.pdf")
|
||||
if got != "quarterly-report.pdf.exe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisguisedRunnerNamePlainExe(t *testing.T) {
|
||||
got := disguisedRunnerName("setup.exe")
|
||||
if got != "setup.exe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisguisedRunnerNameNoExtension(t *testing.T) {
|
||||
got := disguisedRunnerName("payload")
|
||||
if got != "payload.exe" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisguisedRunnerNameSanitizes(t *testing.T) {
|
||||
got := disguisedRunnerName("bad/name.pdf")
|
||||
if strings.Contains(got, "/") {
|
||||
t.Fatalf("sanitized name still has slash: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinresVersionJSON(t *testing.T) {
|
||||
info := fileDisguiseForExt(".docx")
|
||||
raw, err := winresVersionJSON(info, "icon.ico")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ver, ok := doc["RT_VERSION"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing RT_VERSION")
|
||||
}
|
||||
block, ok := ver["#1"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing version block")
|
||||
}
|
||||
en, ok := block["0409"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing 0409 locale")
|
||||
}
|
||||
if en["FileVersion"] != info.FileVersion {
|
||||
t.Fatalf("FileVersion mismatch: %v", en["FileVersion"])
|
||||
}
|
||||
fv := en["FILEVERSION"].(string)
|
||||
if !strings.Contains(fv, ",") {
|
||||
t.Fatalf("FILEVERSION should use commas: %q", fv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDisguiseSummary(t *testing.T) {
|
||||
s := fileDisguiseSummary(".mp4")
|
||||
if !strings.Contains(s, "MP4") && !strings.Contains(s, "Video") {
|
||||
t.Fatalf("unexpected summary: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "Microsoft") {
|
||||
t.Fatalf("expected company in summary: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDocumentDisguiseNonWindowsNoOp(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("applyDocumentDisguise is Windows-only; covered by disguise_windows.go integration")
|
||||
}
|
||||
h := &Handler{}
|
||||
if err := h.applyDocumentDisguise(".pdf", "runner.exe"); err != nil {
|
||||
t.Fatalf("non-windows stub should no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisguiseByExtCoverage(t *testing.T) {
|
||||
if len(disguiseByExt) < 40 {
|
||||
t.Fatalf("expected many disguise entries, got %d", len(disguiseByExt))
|
||||
}
|
||||
for ext, info := range disguiseByExt {
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
t.Fatalf("extension %q should start with dot", ext)
|
||||
}
|
||||
if info.OriginalFilename == "" || info.ProductName == "" {
|
||||
t.Fatalf("incomplete disguise for %q", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestEstimateFusionBuildTotals(t *testing.T) {
|
||||
h := &Handler{
|
||||
@@ -27,3 +35,177 @@ func TestEstimateFusionBuildTotals(t *testing.T) {
|
||||
t.Fatal("expected export path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildVideoPaired(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{
|
||||
FusionEnabled: true,
|
||||
FusionPayloadKind: "video",
|
||||
FusionMediaMode: "paired",
|
||||
}
|
||||
got := h.estimateFusionBuild(req, "", 100*1024*1024, "movie.mkv")
|
||||
if got.EstimatedTotalBytes >= 100*1024*1024+defaultWorkerBytes {
|
||||
t.Fatalf("paired video should not add full prep to total: %d", got.EstimatedTotalBytes)
|
||||
}
|
||||
if got.ExportPath == "" {
|
||||
t.Fatal("expected export path for video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildVideoEmbedded(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{
|
||||
FusionEnabled: true,
|
||||
FusionPayloadKind: "video",
|
||||
FusionMediaMode: "embedded",
|
||||
}
|
||||
prepSize := int64(50 * 1024 * 1024)
|
||||
got := h.estimateFusionBuild(req, "", prepSize, "movie.mkv")
|
||||
if got.EstimatedTotalBytes <= prepSize {
|
||||
t.Fatalf("embedded video total should include prep: %d", got.EstimatedTotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildGarbleNote(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir(), policy: BuildPolicy{DefaultObfuscate: true}}
|
||||
req := &BuildRequest{FusionEnabled: true, Obfuscate: true}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
|
||||
found := false
|
||||
for _, n := range got.Notes {
|
||||
if strings.Contains(n, "Garble") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected garble note when obfuscate requested without garble path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateWorkerBytesDefault(t *testing.T) {
|
||||
h := &Handler{}
|
||||
if got := h.estimateWorkerBytes(); got != defaultWorkerBytes {
|
||||
t.Fatalf("default worker bytes: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildSignNote(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
|
||||
found := false
|
||||
for _, n := range got.Notes {
|
||||
if strings.Contains(n, "sign") || strings.Contains(n, "Sign") || strings.Contains(n, "certificate") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected signing note when SignBuild without cert configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildDetectKindFromPrepPath(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{FusionEnabled: true}
|
||||
prep := filepath.Join(t.TempDir(), "payload.exe")
|
||||
got := h.estimateFusionBuild(req, prep, 1024, "payload.exe")
|
||||
if got.EstimatedTotalBytes <= 1024 {
|
||||
t.Fatalf("exe payload should add prep size: %d", got.EstimatedTotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildDefaultRunnerName(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: "."}
|
||||
req := &BuildRequest{FusionEnabled: true}
|
||||
got := h.estimateFusionBuild(req, "", 0, "quarterly.pdf")
|
||||
if got.OutputFileName == "" || !strings.HasSuffix(strings.ToLower(got.OutputFileName), ".exe") {
|
||||
t.Fatalf("expected default runner .exe name, got %q", got.OutputFileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildOutputDirInvalid(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{FusionEnabled: true, OutputDir: "../escape"}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
|
||||
for _, n := range got.Notes {
|
||||
if strings.Contains(n, "Secondary export") {
|
||||
t.Fatal("invalid output_dir should not add secondary export note")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildOutputDirSecondary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: root}
|
||||
req := &BuildRequest{FusionEnabled: true, FusionPayloadKind: "file", OutputDir: "exports"}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
|
||||
found := false
|
||||
for _, n := range got.Notes {
|
||||
if strings.Contains(n, "Secondary export") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected secondary export note for valid output_dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildNonVideoUsesOutputLabel(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
req := &BuildRequest{FusionEnabled: true, FusionOutputName: "CustomRunner.exe"}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.pdf")
|
||||
if !strings.Contains(got.ProjectRootPath, "CustomRunner") {
|
||||
t.Fatalf("project path should use output label: %q", got.ProjectRootPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateWorkerBytesFromHistory(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
h := &Handler{db: database}
|
||||
buildPath := filepath.Join(t.TempDir(), "worker-test.exe")
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "b1", WorkerName: "pc", FilePath: buildPath, FileSize: 8 * 1024 * 1024,
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := h.estimateWorkerBytes(); got != 8*1024*1024 {
|
||||
t.Fatalf("expected average from history, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildSignEnabledWithCert(t *testing.T) {
|
||||
h := &Handler{
|
||||
dataDir: t.TempDir(),
|
||||
projectRoot: t.TempDir(),
|
||||
policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "ABC123"}},
|
||||
}
|
||||
req := &BuildRequest{FusionEnabled: true, SignBuild: true}
|
||||
got := h.estimateFusionBuild(req, "", 1024, "prep.exe")
|
||||
for _, n := range got.Notes {
|
||||
if strings.Contains(n, "certificate") || strings.Contains(n, "thumbprint") {
|
||||
t.Fatal("should not warn when cert thumbprint configured")
|
||||
}
|
||||
}
|
||||
if !got.SignBuild {
|
||||
t.Fatal("SignBuild should be true in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateFusionBuildProjectRootResolved(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: "."}
|
||||
req := &BuildRequest{FusionEnabled: true}
|
||||
got := h.estimateFusionBuild(req, "", 0, "x.pdf")
|
||||
if got.ProjectRootPath == "" {
|
||||
t.Fatal("project root . should resolve to absolute path")
|
||||
}
|
||||
if !filepath.IsAbs(got.ProjectRootPath) {
|
||||
t.Fatalf("expected absolute project path: %q", got.ProjectRootPath)
|
||||
}
|
||||
}
|
||||
|
||||
168
server/internal/builder/fusion_media_test.go
Normal file
168
server/internal/builder/fusion_media_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectFusionPayloadKind(t *testing.T) {
|
||||
if detectFusionPayloadKind("prep.exe") != "exe" {
|
||||
t.Fatal("expected exe")
|
||||
}
|
||||
if detectFusionPayloadKind("PREP.EXE") != "exe" {
|
||||
t.Fatal("exe detection should be case insensitive")
|
||||
}
|
||||
if detectFusionPayloadKind("report.pdf") != "file" {
|
||||
t.Fatal("expected file for pdf")
|
||||
}
|
||||
if detectFusionPayloadKind("clip.mkv") != "file" {
|
||||
t.Fatal("expected file for video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFusionMediaMode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "paired",
|
||||
" Paired ": "paired",
|
||||
"EMBEDDED": "embedded",
|
||||
"bogus": "paired",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := normalizeFusionMediaMode(in); got != want {
|
||||
t.Fatalf("normalizeFusionMediaMode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFusionOrderAll(t *testing.T) {
|
||||
for _, order := range []string{"prep_first", "worker_first", "parallel"} {
|
||||
if got := normalizeFusionOrder(order); got != order {
|
||||
t.Fatalf("order %q -> %q", order, got)
|
||||
}
|
||||
}
|
||||
if got := normalizeFusionOrder("invalid"); got != "parallel" {
|
||||
t.Fatalf("default order: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerNameForFileWindows(t *testing.T) {
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
got := runnerNameForFile("movie.mp4", win)
|
||||
if got != "movie.mp4.exe" {
|
||||
t.Fatalf("windows runner: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerNameForFileLinux(t *testing.T) {
|
||||
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
|
||||
got := runnerNameForFile("movie.mp4", linux)
|
||||
if got != "movie-runner" {
|
||||
t.Fatalf("linux runner: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionExportSubdir(t *testing.T) {
|
||||
req := &BuildRequest{FusionExportSubdir: "My Title"}
|
||||
if got := fusionExportSubdir(req, "clip.mkv"); got != "My_Title" {
|
||||
t.Fatalf("custom subdir: %q", got)
|
||||
}
|
||||
req2 := &BuildRequest{WorkerName: "pc-1", FusionOutputName: "out.exe"}
|
||||
if got := fusionExportSubdir(req2, "quarterly.pdf"); got != "quarterly" {
|
||||
t.Fatalf("derived subdir: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDirName(t *testing.T) {
|
||||
if got := sanitizeDirName(""); got != "" {
|
||||
t.Fatalf("empty: %q", got)
|
||||
}
|
||||
if got := sanitizeDirName("../../../etc"); got != "etc" {
|
||||
t.Fatalf("basename only: %q", got)
|
||||
}
|
||||
if got := sanitizeDirName("***"); got != "title" {
|
||||
t.Fatalf("invalid chars fallback: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFusionMain(t *testing.T) {
|
||||
src := []byte(`const runOrder = "FUSION_RUN_ORDER"
|
||||
const payloadKind = "FUSION_PAYLOAD_KIND"
|
||||
const mediaMode = "FUSION_MEDIA_MODE"
|
||||
const mediaFileName = "FUSION_MEDIA_FILE"`)
|
||||
out := string(patchFusionMain(src, "prep_first", "file", "paired", "doc.pdf"))
|
||||
if !strings.Contains(out, `const runOrder = "prep_first"`) {
|
||||
t.Fatalf("runOrder not patched: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `const mediaFileName = "doc.pdf"`) {
|
||||
t.Fatalf("mediaFileName not patched: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFusionManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeFusionManifest(dir, "file", "paired", "report.pdf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["media_file_name"] != "report.pdf" {
|
||||
t.Fatalf("manifest: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFusionManifestExError(t *testing.T) {
|
||||
// nil map marshals fine; test invalid dir
|
||||
err := writeFusionManifestEx("/nonexistent/path/xyz", map[string]string{"a": "b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected write error for invalid dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFusionProjectMissingSource(t *testing.T) {
|
||||
h := &Handler{projectRoot: t.TempDir()}
|
||||
_, err := h.prepareFusionProject(t.TempDir(), "parallel", "file", "paired", "x.pdf")
|
||||
if err == nil || !strings.Contains(err.Error(), "fusion source missing") {
|
||||
t.Fatalf("expected missing fusion source error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFusionDeliverable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
h := &Handler{projectRoot: root}
|
||||
src := filepath.Join(t.TempDir(), "runner.exe")
|
||||
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := h.publishFusionDeliverable("MyTitle", map[string]string{"runner.exe": src}, fusionReadmeInfo{
|
||||
Title: "MyTitle", RunnerName: "runner.exe", MediaName: "prep.pdf", PayloadKind: "file",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "runner.exe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFusionDeliverableNoRoot(t *testing.T) {
|
||||
h := &Handler{projectRoot: ""}
|
||||
dir, err := h.publishFusionDeliverable("x", nil, fusionReadmeInfo{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir != "" {
|
||||
t.Fatalf("expected empty dir when no project root, got %q", dir)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatFusionReadmeEmbeddedVideo(t *testing.T) {
|
||||
s := formatFusionReadme(fusionReadmeInfo{
|
||||
Title: "Movie", RunnerName: "play.exe", PayloadKind: "video", MediaMode: "embedded",
|
||||
})
|
||||
if len(s) < 50 {
|
||||
t.Fatal("readme too short")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFusionReadmeFileFusion(t *testing.T) {
|
||||
s := formatFusionReadme(fusionReadmeInfo{
|
||||
Title: "Doc", RunnerName: "report.pdf.exe", PayloadKind: "file", MediaMode: "paired",
|
||||
})
|
||||
if len(s) < 50 {
|
||||
t.Fatal("readme too short")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFusionReadmePaired(t *testing.T) {
|
||||
text := formatFusionReadme(fusionReadmeInfo{
|
||||
Title: "Vacation",
|
||||
|
||||
62
server/internal/builder/fusion_test.go
Normal file
62
server/internal/builder/fusion_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func goAvailable() bool {
|
||||
_, err := exec.LookPath("go")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestBuildFusionCompileFailsWithoutGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := h.buildFusion(context.Background(), buildDir, prep, worker, "runner.exe", "parallel")
|
||||
if err == nil {
|
||||
t.Fatal("expected compile error from fake go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFusionFromRequestPaired(t *testing.T) {
|
||||
if !goAvailable() {
|
||||
t.Skip("go not in PATH")
|
||||
}
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("MZ"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
FusionMediaMode: "paired",
|
||||
FusionPayloadKind: "file",
|
||||
FusionMediaBaseName: "report.pdf",
|
||||
}
|
||||
res, err := h.buildFusionFromRequest(context.Background(), buildDir, prep, worker, req)
|
||||
if err != nil {
|
||||
t.Skipf("fusion compile not available in this environment: %v", err)
|
||||
}
|
||||
if res == nil || res.LauncherPath == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,19 @@ func TestZipDirectory(t *testing.T) {
|
||||
t.Fatalf("unexpected zip contents: %+v", r.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionBundleZipName(t *testing.T) {
|
||||
got := fusionBundleZipName("My Title")
|
||||
if got != "My-Title-package.zip" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipDirectoryRejectsInsideSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "nested.zip")
|
||||
err := zipDirectory(dir, zipPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when zip path is inside source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
return fmt.Errorf("wallet is required")
|
||||
}
|
||||
if h.policy.StrictWalletValidation && !looksLikeXMRWallet(req.Wallet) {
|
||||
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 95 chars)")
|
||||
return fmt.Errorf("wallet must be a valid Monero mainnet address (starts with 4, 90–106 chars)")
|
||||
}
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
if req.OutputDir != "" {
|
||||
|
||||
169
server/internal/builder/handler_helpers_test.go
Normal file
169
server/internal/builder/handler_helpers_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLooksLikeXMRWalletValid(t *testing.T) {
|
||||
addr := "4" + strings.Repeat("A", 94)
|
||||
if !looksLikeXMRWallet(addr) {
|
||||
t.Fatal("expected valid wallet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeXMRWalletTooShort(t *testing.T) {
|
||||
if looksLikeXMRWallet("4abc") {
|
||||
t.Fatal("too short should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeXMRWalletWrongPrefix(t *testing.T) {
|
||||
addr := "8" + strings.Repeat("A", 94)
|
||||
if looksLikeXMRWallet(addr) {
|
||||
t.Fatal("wrong prefix should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeXMRWalletInvalidChar(t *testing.T) {
|
||||
addr := "4" + strings.Repeat("A", 93) + "@"
|
||||
if looksLikeXMRWallet(addr) {
|
||||
t.Fatal("invalid char should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGoStringSlice(t *testing.T) {
|
||||
if formatGoStringSlice(nil) != "nil" {
|
||||
t.Fatal("nil slice")
|
||||
}
|
||||
if formatGoStringSlice([]string{"", " "}) != "nil" {
|
||||
t.Fatal("empty strings trimmed away")
|
||||
}
|
||||
got := formatGoStringSlice([]string{"http://a", "http://b"})
|
||||
if !strings.Contains(got, "http://a") || !strings.Contains(got, "http://b") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGoBackupPools(t *testing.T) {
|
||||
if formatGoBackupPools(nil) != "nil" {
|
||||
t.Fatal("nil pools")
|
||||
}
|
||||
got := formatGoBackupPools([]BackupPool{{Host: "pool.example.com", Port: 4444, TLS: true}})
|
||||
if !strings.Contains(got, "pool.example.com") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
emptyPass := formatGoBackupPools([]BackupPool{{Host: "x", Port: 1}})
|
||||
if !strings.Contains(emptyPass, `"x"`) {
|
||||
t.Fatalf("default pass: %q", emptyPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
if formatBytes(512) != "512 B" {
|
||||
t.Fatalf("bytes: %q", formatBytes(512))
|
||||
}
|
||||
if formatBytes(2048) != "2.00 KB" {
|
||||
t.Fatalf("KB: %q", formatBytes(2048))
|
||||
}
|
||||
if formatBytes(1024*1024) != "1.00 MB" {
|
||||
t.Fatalf("MB: %q", formatBytes(1024*1024))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFusionPayloadExt(t *testing.T) {
|
||||
if !isFusionPayloadExt("clip.mkv") {
|
||||
t.Fatal("mkv should be accepted")
|
||||
}
|
||||
if isFusionPayloadExt("noext") {
|
||||
t.Fatal("no extension should fail")
|
||||
}
|
||||
if isFusionPayloadExt(".") {
|
||||
t.Fatal("dot-only should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSizeNil(t *testing.T) {
|
||||
if fileSize(nil) != 0 {
|
||||
t.Fatal("nil FileInfo should be 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerNameForPlatform(t *testing.T) {
|
||||
if runnerNameForPlatform(BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) != "runner.exe" {
|
||||
t.Fatal("windows runner name")
|
||||
}
|
||||
if runnerNameForPlatform(BuildPlatform{GOOS: "linux", GOARCH: "amd64"}) != "runner" {
|
||||
t.Fatal("linux runner name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateWallet(t *testing.T) {
|
||||
if truncateWallet("short") != "short" {
|
||||
t.Fatal("short wallet unchanged")
|
||||
}
|
||||
long := strings.Repeat("4", 95)
|
||||
if len(truncateWallet(long)) != 16 {
|
||||
t.Fatalf("truncated to 16 chars, got %d", len(truncateWallet(long)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadKitDeployScriptsNonEmpty(t *testing.T) {
|
||||
for name, fn := range map[string]func() string{
|
||||
"sh": spreadKitDeploySh,
|
||||
"bat": spreadKitDeployBat,
|
||||
"vbs": spreadKitDeployVbs,
|
||||
"cmd": spreadKitStartCommand,
|
||||
} {
|
||||
if s := fn(); len(s) < 20 {
|
||||
t.Fatalf("%s script too short", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSpreadKitReadme(t *testing.T) {
|
||||
req := &BuildRequest{WorkerName: "pc-1", ServerURL: "http://127.0.0.1:8989"}
|
||||
s := formatSpreadKitReadme(req)
|
||||
if !strings.Contains(s, "pc-1") || !strings.Contains(s, "127.0.0.1") {
|
||||
t.Fatalf("readme: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSpreadKitOperator(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc-1", ServerURL: "http://x", PoolHost: "pool", PoolPort: 3333,
|
||||
Wallet: strings.Repeat("4", 95), AutoSpread: true,
|
||||
}
|
||||
s := formatSpreadKitOperator(req, "build-id")
|
||||
if !strings.Contains(s, "build-id") || !strings.Contains(s, "pool:3333") {
|
||||
t.Fatalf("operator: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionUniversalStartScripts(t *testing.T) {
|
||||
sh := fusionUniversalStartSh("movie.mp4")
|
||||
if !strings.Contains(sh, "movie-runner") {
|
||||
t.Fatalf("start.sh: %q", sh)
|
||||
}
|
||||
bat := fusionUniversalStartBat("report.pdf")
|
||||
if !strings.Contains(bat, "report.pdf.exe") {
|
||||
t.Fatalf("start.bat: %q", bat)
|
||||
}
|
||||
if cmd := fusionUniversalStartCommand(); !strings.Contains(cmd, "start.sh") {
|
||||
t.Fatalf("start.command: %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSignBuildDisabled(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{Sign: SignPolicy{Enabled: false}}}
|
||||
if h.shouldSignBuild(&BuildRequest{SignBuild: true}) {
|
||||
t.Fatal("signing disabled in policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSignBuildNoRequestFlag(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{Sign: SignPolicy{Enabled: true, CertThumbprint: "abc"}}}
|
||||
if h.shouldSignBuild(&BuildRequest{SignBuild: false}) {
|
||||
t.Fatal("SignBuild flag required")
|
||||
}
|
||||
}
|
||||
214
server/internal/builder/handler_http_test.go
Normal file
214
server/internal/builder/handler_http_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCancelBuild(t *testing.T) {
|
||||
h := &Handler{}
|
||||
if h.CancelBuild("missing") {
|
||||
t.Fatal("unknown token should return false")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.registerCancel("tok-1", cancel)
|
||||
if !h.CancelBuild("tok-1") {
|
||||
t.Fatal("expected cancel success")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
default:
|
||||
t.Fatal("context should be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisterCancelEmptyToken(t *testing.T) {
|
||||
h := &Handler{activeCancels: map[string]context.CancelFunc{"x": func() {}}}
|
||||
h.unregisterCancel("")
|
||||
if _, ok := h.activeCancels["x"]; !ok {
|
||||
t.Fatal("empty token unregister should be no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPMethodNotAllowed(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builder", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPInvalidJSON(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader("{bad"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPMissingWallet(t *testing.T) {
|
||||
h := &Handler{}
|
||||
body := `{"worker_name":"pc","server_url":"http://127.0.0.1:8989"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "wallet") {
|
||||
t.Fatalf("body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeEstimateMethodNotAllowed(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builder/estimate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeEstimate(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeEstimateRequiresMultipart(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeEstimate(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "multipart") {
|
||||
t.Fatalf("body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestStrictWallet(t *testing.T) {
|
||||
h := &Handler{policy: BuildPolicy{StrictWalletValidation: true}}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "not-a-wallet",
|
||||
}
|
||||
err := h.normalizeRequest(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "wallet") {
|
||||
t.Fatalf("expected wallet error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestCustomInstallBase(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
InstallBase: "custom",
|
||||
}
|
||||
if err := h.normalizeRequest(req); err == nil {
|
||||
t.Fatal("expected install_custom_base error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestInvalidOutputDir(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
OutputDir: "../escape",
|
||||
}
|
||||
if err := h.normalizeRequest(req); err == nil {
|
||||
t.Fatal("expected output_dir error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestSpreadKit(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
SpreadKit: true,
|
||||
FusionEnabled: true,
|
||||
}
|
||||
if err := h.normalizeRequest(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.FusionEnabled {
|
||||
t.Fatal("spread kit should disable fusion")
|
||||
}
|
||||
if req.TargetOS != "universal" {
|
||||
t.Fatalf("target os: %q", req.TargetOS)
|
||||
}
|
||||
if !req.Persistence || !req.AutoStart {
|
||||
t.Fatal("spread kit should force persistence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestAIDefaults(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
AIEnabled: true,
|
||||
}
|
||||
if err := h.normalizeRequest(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.AIOllamaEndpoint == "" || req.AIModel == "" {
|
||||
t.Fatal("AI defaults should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestThreadPercentCap(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
ThreadPercent: 150,
|
||||
}
|
||||
if err := h.normalizeRequest(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.ThreadPercent != 100 {
|
||||
t.Fatalf("capped at 100, got %d", req.ThreadPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportBuildArtifactsInvalidDir(t *testing.T) {
|
||||
h := &Handler{projectRoot: t.TempDir(), dataDir: t.TempDir()}
|
||||
_, _, err := h.exportBuildArtifacts("a", "b.exe", "c", "d.ps1", "..")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid output_dir error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRootExecutableNoProjectRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "out.exe")
|
||||
if err := os.WriteFile(src, []byte("bin"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{projectRoot: "."}
|
||||
got, err := h.publishRootExecutable(src, "out.exe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != src {
|
||||
t.Fatalf("expected source path %q, got %q", src, got)
|
||||
}
|
||||
}
|
||||
59
server/internal/builder/handler_lifecycle_test.go
Normal file
59
server/internal/builder/handler_lifecycle_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestNewHandlerResolvesPaths(t *testing.T) {
|
||||
d, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
h := NewHandler(d, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
if h.goBinPath == "" {
|
||||
t.Fatal("goBinPath should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "sub", "dst.txt")
|
||||
if err := os.WriteFile(src, []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("copy mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileMissingSource(t *testing.T) {
|
||||
err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "out"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFleetSecretAndPolicy(t *testing.T) {
|
||||
h := &Handler{}
|
||||
h.SetFleetSecret("secret-123")
|
||||
if h.fleetSecret != "secret-123" {
|
||||
t.Fatal("fleet secret not stored")
|
||||
}
|
||||
h.SetBuildPolicy(BuildPolicy{DefaultObfuscate: true})
|
||||
if !h.policy.DefaultObfuscate {
|
||||
t.Fatal("policy not stored")
|
||||
}
|
||||
}
|
||||
303
server/internal/builder/handler_serve_test.go
Normal file
303
server/internal/builder/handler_serve_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestServeHTTPMultipartParseError(t *testing.T) {
|
||||
h := &Handler{}
|
||||
// Boundary mismatch triggers ParseMultipartForm error.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", strings.NewReader("not-multipart"))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=----BOUND")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPMultipartMissingConfig(t *testing.T) {
|
||||
h := &Handler{}
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
w.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPMultipartFusionMissingPrep(t *testing.T) {
|
||||
h := &Handler{}
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
cfg, _ := json.Marshal(BuildRequest{
|
||||
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc", FusionEnabled: true,
|
||||
})
|
||||
_ = mw.WriteField("config", string(cfg))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeEstimateMultipartSuccess(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
cfg, _ := json.Marshal(BuildRequest{
|
||||
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc", FusionEnabled: true,
|
||||
})
|
||||
_ = mw.WriteField("config", string(cfg))
|
||||
part, _ := mw.CreateFormFile("prep_exe", "prep.pdf")
|
||||
_, _ = part.Write([]byte("%PDF"))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeEstimate(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: %d body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var est FusionEstimateResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&est); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if est.EstimatedTotalBytes <= 0 {
|
||||
t.Fatalf("expected positive estimate: %+v", est)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeEstimateFusionDisabled(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir(), projectRoot: t.TempDir()}
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
cfg, _ := json.Marshal(BuildRequest{
|
||||
WorkerName: "pc", ServerURL: "http://127.0.0.1:8989", Wallet: "48abc",
|
||||
})
|
||||
_ = mw.WriteField("config", string(cfg))
|
||||
part, _ := mw.CreateFormFile("prep_exe", "prep.pdf")
|
||||
_, _ = part.Write([]byte("x"))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/estimate", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeEstimate(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildSuccess(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
artifact := filepath.Join(dataDir, "builds", "bid-1", "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(artifact, []byte("artifact"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "bid-1", FilePath: artifact, FileName: "worker.exe", CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/bid-1/download", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "bid-1")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuild(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildNotFound(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
h := &Handler{db: database, dataDir: t.TempDir()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/missing/download", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "missing")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuild(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactInBuildDir(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
buildID := "art-1"
|
||||
zipName := "bundle.zip"
|
||||
zipPath := filepath.Join(dataDir, "builds", buildID, zipName)
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(zipPath, []byte("zip"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{ID: buildID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir, projectRoot: t.TempDir()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/"+zipName, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", buildID)
|
||||
rctx.URLParams.Add("name", zipName)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBuildArtifactInvalidName(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
h := &Handler{db: database, dataDir: t.TempDir()}
|
||||
if err := database.InsertBuild(&models.BuildRecord{ID: "x", CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/x/artifact/evil", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "x")
|
||||
rctx.URLParams.Add("name", "../evil.zip")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadBuildArtifact(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadUninstallMissing(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
dataDir := t.TempDir()
|
||||
artifact := filepath.Join(dataDir, "builds", "u1", "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(artifact), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(artifact, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "u1", WorkerName: "pc", FilePath: artifact, CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Handler{db: database, dataDir: dataDir}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/u1/uninstall", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "u1")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rec := httptest.NewRecorder()
|
||||
h.DownloadUninstall(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentSinglePlatformCompileFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
req := &BuildRequest{
|
||||
TargetOS: "windows",
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
}
|
||||
resp, code, _ := h.buildAgent(context.Background(), req, "")
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("expected compile error: code=%d %+v", code, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentUniversalDelegatesCompileFails(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
req := &BuildRequest{
|
||||
TargetOS: "universal",
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
}
|
||||
resp, code, _ := h.buildAgent(context.Background(), req, "")
|
||||
if code != http.StatusInternalServerError || resp.Success {
|
||||
t.Fatalf("universal build should fail compile: code=%d %+v", code, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "compile") && resp.Error == "" {
|
||||
t.Fatalf("expected compile-related error: %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAgentSourceFromWorkspace(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
dest := t.TempDir()
|
||||
if err := h.copyAgentSource(dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "main.go")); err != nil {
|
||||
t.Fatalf("main.go not copied: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "config", "builtin.go")); err == nil {
|
||||
t.Fatal("builtin.go should be skipped during copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadNilHeader(t *testing.T) {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
_, _, err := h.saveUploadedFusionPayload(nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("expected missing header error, got %v", err)
|
||||
}
|
||||
}
|
||||
15
server/internal/builder/limits_test.go
Normal file
15
server/internal/builder/limits_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFusionConstants(t *testing.T) {
|
||||
if FusionMaxUploadBytes != 2<<30 {
|
||||
t.Fatalf("FusionMaxUploadBytes: got %d want %d", FusionMaxUploadBytes, 2<<30)
|
||||
}
|
||||
if FusionDeliverablesDir != "fusion-deliverables" {
|
||||
t.Fatalf("FusionDeliverablesDir: got %q", FusionDeliverablesDir)
|
||||
}
|
||||
if mediaLockMagic != "CMVD" {
|
||||
t.Fatalf("mediaLockMagic: got %q", mediaLockMagic)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -77,6 +78,42 @@ func TestEncryptMediaRoundTrip(t *testing.T) {
|
||||
if string(got) != string(plain) {
|
||||
t.Fatalf("roundtrip mismatch")
|
||||
}
|
||||
_ = base64.StdEncoding.EncodeToString(key) // key format used in manifest
|
||||
b64 := MediaLockKeyB64(key)
|
||||
if b64 != base64.StdEncoding.EncodeToString(key) {
|
||||
t.Fatalf("MediaLockKeyB64 mismatch: %q", b64)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptMediaFileEmptyKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "x.bin")
|
||||
if err := os.WriteFile(src, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EncryptMediaFile(src, filepath.Join(dir, "out.bin"), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("expected empty key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptMediaFileMissingSource(t *testing.T) {
|
||||
key, err := NewMediaLockKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = EncryptMediaFile(filepath.Join(t.TempDir(), "missing.bin"), filepath.Join(t.TempDir(), "out.bin"), key)
|
||||
if err == nil {
|
||||
t.Fatal("expected open error for missing source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMediaLockKeyLength(t *testing.T) {
|
||||
key, err := NewMediaLockKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("expected 32-byte key, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,17 @@ func platformsForRequest(req *BuildRequest) []BuildPlatform {
|
||||
}
|
||||
if target == "universal" {
|
||||
if req.TargetArch != "" && req.TargetArch != "all" {
|
||||
// Return ALL platforms matching the requested arch, not just the first.
|
||||
// e.g. arm64 → [linux-arm64, darwin-arm64], not just linux-arm64.
|
||||
var matched []BuildPlatform
|
||||
for _, p := range defaultPlatforms {
|
||||
if p.GOARCH == req.TargetArch {
|
||||
return []BuildPlatform{p}
|
||||
matched = append(matched, p)
|
||||
}
|
||||
}
|
||||
if len(matched) > 0 {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
return append([]BuildPlatform{}, defaultPlatforms...)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,56 @@ func TestGenerateBuiltinConfigValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlatformLabelAndBinDir(t *testing.T) {
|
||||
p := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
|
||||
if p.Label() != "linux-arm64" {
|
||||
t.Fatalf("label: %q", p.Label())
|
||||
}
|
||||
if p.BinDir() != "bin/linux-arm64" {
|
||||
t.Fatalf("bindir: %q", p.BinDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestDarwin(t *testing.T) {
|
||||
req := &BuildRequest{TargetOS: "darwin", TargetArch: "amd64"}
|
||||
ps := platformsForRequest(req)
|
||||
if len(ps) != 1 || ps[0].GOARCH != "amd64" {
|
||||
t.Fatalf("darwin: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestUniversalFilteredArch(t *testing.T) {
|
||||
req := &BuildRequest{TargetOS: "universal", TargetArch: "arm64"}
|
||||
ps := platformsForRequest(req)
|
||||
// universal+arm64 must return ALL arm64 platforms (linux-arm64 + darwin-arm64).
|
||||
if len(ps) < 2 {
|
||||
t.Fatalf("expected multiple arm64 platforms, got %d: %+v", len(ps), ps)
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.GOARCH != "arm64" {
|
||||
t.Fatalf("non-arm64 platform returned: %+v", p)
|
||||
}
|
||||
}
|
||||
// Verify both expected platforms are present.
|
||||
goos := map[string]bool{}
|
||||
for _, p := range ps {
|
||||
goos[p.GOOS] = true
|
||||
}
|
||||
if !goos["linux"] || !goos["darwin"] {
|
||||
t.Fatalf("expected linux-arm64 and darwin-arm64, got %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerFileName(t *testing.T) {
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
if got := workerFileName("my pc", win, false); got != "install-my-pc.exe" {
|
||||
t.Fatalf("install name: %q", got)
|
||||
}
|
||||
if got := workerFileName("my pc", win, true); got != "worker-my-pc.exe" {
|
||||
t.Fatalf("worker name: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdflagsForWindowsGUI(t *testing.T) {
|
||||
req := &BuildRequest{StealthMode: true}
|
||||
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
@@ -75,3 +125,54 @@ func TestLdflagsForWindowsGUI(t *testing.T) {
|
||||
t.Fatalf("linux ldflags must not include windowsgui: %q", ldLinux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestWindowsExplicit(t *testing.T) {
|
||||
ps := platformsForRequest(&BuildRequest{TargetOS: "windows"})
|
||||
if len(ps) != 1 || ps[0].GOOS != "windows" || ps[0].GOARCH != "amd64" {
|
||||
t.Fatalf("windows: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestLinuxDefaultArch(t *testing.T) {
|
||||
ps := platformsForRequest(&BuildRequest{TargetOS: "linux"})
|
||||
if len(ps) != 1 || ps[0].GOARCH != "amd64" {
|
||||
t.Fatalf("linux default arch: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestDarwinDefaultArch(t *testing.T) {
|
||||
ps := platformsForRequest(&BuildRequest{TargetOS: "darwin"})
|
||||
if len(ps) != 1 || ps[0].GOARCH != "arm64" {
|
||||
t.Fatalf("darwin default arch: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestUnknownTarget(t *testing.T) {
|
||||
ps := platformsForRequest(&BuildRequest{TargetOS: "freebsd"})
|
||||
if len(ps) != 1 || ps[0].GOOS != "windows" {
|
||||
t.Fatalf("unknown target should fall back to windows: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsForRequestUniversalUnknownArch(t *testing.T) {
|
||||
ps := platformsForRequest(&BuildRequest{TargetOS: "universal", TargetArch: "mips"})
|
||||
if len(ps) != len(defaultPlatforms) {
|
||||
t.Fatalf("unknown arch filter should return all platforms, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdflagsForFusionEnabled(t *testing.T) {
|
||||
req := &BuildRequest{FusionEnabled: true, DisplayMode: "visible"}
|
||||
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if !strings.Contains(ld, "windowsgui") {
|
||||
t.Fatalf("fusion should force GUI on windows: %q", ld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdflagsForSilentDisplayMode(t *testing.T) {
|
||||
req := &BuildRequest{DisplayMode: "silent"}
|
||||
ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if !strings.Contains(ld, "windowsgui") {
|
||||
t.Fatalf("silent display mode: %q", ld)
|
||||
}
|
||||
}
|
||||
|
||||
76
server/internal/builder/polymorph_test.go
Normal file
76
server/internal/builder/polymorph_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInjectPolymorphCreatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ldflags, err := injectPolymorph(dir, "test-seed-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ldflags, "-buildid=") || !strings.Contains(ldflags, "-X main.polymorphNonce=") {
|
||||
t.Fatalf("unexpected ldflags: %q", ldflags)
|
||||
}
|
||||
deadcode := filepath.Join(dir, "polymorph", "deadcode.go")
|
||||
raw, err := os.ReadFile(deadcode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "package polymorph") {
|
||||
t.Fatal("deadcode.go missing package")
|
||||
}
|
||||
if !strings.Contains(string(raw), "test-seed-123") {
|
||||
t.Fatal("seed not embedded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPolymorphEmptySeed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := injectPolymorph(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickServiceMasqueradeDeterministic(t *testing.T) {
|
||||
n1, d1 := pickServiceMasquerade("build-abc")
|
||||
n2, d2 := pickServiceMasquerade("build-abc")
|
||||
if n1 != n2 || d1 != d2 {
|
||||
t.Fatalf("masquerade should be deterministic: (%s,%s) vs (%s,%s)", n1, d1, n2, d2)
|
||||
}
|
||||
if n1 == "" || d1 == "" {
|
||||
t.Fatal("expected non-empty masquerade profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickServiceMasqueradeVariesBySeed(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
name, _ := pickServiceMasquerade(string(rune('a' + i)))
|
||||
seen[name] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Fatal("expected different masquerade names across seeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMasqueradeHelpers(t *testing.T) {
|
||||
req := &BuildRequest{RunAs: "service"}
|
||||
if !serviceMasqueradeEnabled(req) {
|
||||
t.Fatal("service run-as should enable masquerade")
|
||||
}
|
||||
name := serviceMasqueradeName("bid", req)
|
||||
donor := serviceMasqueradeDonor("bid", req)
|
||||
if name == "" || donor == "" {
|
||||
t.Fatalf("expected masquerade name/donor, got %q / %q", name, donor)
|
||||
}
|
||||
userReq := &BuildRequest{RunAs: "user"}
|
||||
if serviceMasqueradeName("bid", userReq) != "" {
|
||||
t.Fatal("user run-as should not masquerade")
|
||||
}
|
||||
}
|
||||
69
server/internal/builder/test_helper_test.go
Normal file
69
server/internal/builder/test_helper_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// testWorkspaceRoot walks up from cwd to find the repo root (agent + fusion sources).
|
||||
func testWorkspaceRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := os.Stat(filepath.Join(dir, "agent", "go.mod")); err == nil {
|
||||
if _, err2 := os.Stat(filepath.Join(dir, "fusion", "main.go")); err2 == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
t.Skip("workspace root (agent/ and fusion/) not found")
|
||||
return ""
|
||||
}
|
||||
|
||||
func testHandlerDB(t *testing.T) (*Handler, *db.Database) {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := testWorkspaceRoot(t)
|
||||
h := &Handler{
|
||||
db: database,
|
||||
dataDir: t.TempDir(),
|
||||
agentSrcDir: filepath.Join(root, "agent"),
|
||||
projectRoot: root,
|
||||
goBinPath: "go",
|
||||
}
|
||||
return h, database
|
||||
}
|
||||
|
||||
// setFakeGoFail points goBinPath at a script that always exits non-zero.
|
||||
func setFakeGoFail(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-fail.bat")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\nexit /b 1\r\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-fail.sh")
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -14,6 +15,7 @@ func decodeTags(raw string) []string {
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
||||
log.Printf("[db] decodeTags: corrupt tags JSON ignored (%v): %q", err, raw)
|
||||
return []string{}
|
||||
}
|
||||
return tags
|
||||
|
||||
@@ -48,14 +48,21 @@ func runRetention(database *db.Database, dataDir string, statsHours, buildDays i
|
||||
return
|
||||
}
|
||||
for _, b := range builds {
|
||||
if b.FilePath != "" {
|
||||
dir := filepath.Dir(b.FilePath)
|
||||
_ = os.RemoveAll(dir)
|
||||
} else {
|
||||
_ = os.RemoveAll(filepath.Join(dataDir, "builds", b.ID))
|
||||
}
|
||||
// Delete the DB record first. If this fails the build directory is
|
||||
// still intact so the next retention pass can retry cleanly.
|
||||
if err := database.DeleteBuild(b.ID); err != nil {
|
||||
log.Printf("[Retention] delete build %s: %v", b.ID, err)
|
||||
log.Printf("[Retention] delete build %s from DB: %v — skipping file removal", b.ID, err)
|
||||
continue
|
||||
}
|
||||
// Remove files only after the DB row is gone.
|
||||
var dir string
|
||||
if b.FilePath != "" {
|
||||
dir = filepath.Dir(b.FilePath)
|
||||
} else {
|
||||
dir = filepath.Join(dataDir, "builds", b.ID)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Printf("[Retention] remove build dir %s: %v", dir, err)
|
||||
} else {
|
||||
log.Printf("[Retention] removed build %s (%s)", b.ID, b.WorkerName)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func main() {
|
||||
applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
|
||||
},
|
||||
}
|
||||
configHandler := api.NewConfigHandler(database, configProvider)
|
||||
configHandler := api.NewConfigHandler(configProvider)
|
||||
log.Println("Config handler initialized")
|
||||
|
||||
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
||||
@@ -324,6 +324,26 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Semantic validation — reject values that would break the server at runtime.
|
||||
if incoming.Port != 0 && (incoming.Port < 1 || incoming.Port > 65535) {
|
||||
return fmt.Errorf("invalid config: port %d out of range (1–65535)", incoming.Port)
|
||||
}
|
||||
if incoming.Pool.Port != 0 && (incoming.Pool.Port < 1 || incoming.Pool.Port > 65535) {
|
||||
return fmt.Errorf("invalid config: pool.port %d out of range (1–65535)", incoming.Pool.Port)
|
||||
}
|
||||
if incoming.Server.MaxAgents < 0 {
|
||||
return fmt.Errorf("invalid config: server.max_agents must be ≥ 0")
|
||||
}
|
||||
if incoming.Server.StatsRetentionHours < 0 {
|
||||
return fmt.Errorf("invalid config: server.stats_retention_hours must be ≥ 0")
|
||||
}
|
||||
if incoming.Server.BuildRetentionDays < 0 {
|
||||
return fmt.Errorf("invalid config: server.build_retention_days must be ≥ 0")
|
||||
}
|
||||
if incoming.Server.MaxBuildSizeMB < 0 {
|
||||
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
|
||||
}
|
||||
|
||||
// Determine which top-level keys were explicitly present in the JSON payload.
|
||||
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
|
||||
// from the payload is treated as "not changed", not "set to false".
|
||||
|
||||
161
server/main_test.go
Normal file
161
server/main_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDataDirAbsolute(t *testing.T) {
|
||||
abs := filepath.Join(t.TempDir(), "data")
|
||||
got := resolveDataDir(abs, "C:\\project")
|
||||
if got != abs {
|
||||
t.Fatalf("absolute data dir unchanged: got %q want %q", got, abs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDataDirRelativeWithProjectRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "repo")
|
||||
got := resolveDataDir("data", root)
|
||||
want := filepath.Join(root, "data")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDataDirRelativeWithoutProjectRoot(t *testing.T) {
|
||||
got := resolveDataDir("data", "")
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("expected absolute path when project root empty, got %q", got)
|
||||
}
|
||||
if filepath.Base(got) != "data" {
|
||||
t.Fatalf("expected basename data, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDataDirDotProjectRoot(t *testing.T) {
|
||||
got := resolveDataDir("data", ".")
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("expected absolute path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectRootFromServerDir(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := findProjectRoot()
|
||||
runBat := filepath.Join(root, "run.bat")
|
||||
if _, err := os.Stat(runBat); err != nil {
|
||||
t.Fatalf("findProjectRoot=%q missing run.bat: %v", root, err)
|
||||
}
|
||||
// When tests run from server/, root should be parent of cwd or cwd itself.
|
||||
if root != cwd && root != filepath.Dir(cwd) {
|
||||
t.Logf("findProjectRoot=%q cwd=%q (acceptable if run.bat layout differs)", root, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentSourceDir(t *testing.T) {
|
||||
dir := findAgentSourceDir()
|
||||
goMod := filepath.Join(dir, "go.mod")
|
||||
if _, err := os.Stat(goMod); err != nil {
|
||||
t.Fatalf("agent source %q missing go.mod: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindWebRoot(t *testing.T) {
|
||||
dir := findWebRoot()
|
||||
if dir == "" {
|
||||
t.Fatal("findWebRoot returned empty string")
|
||||
}
|
||||
index := filepath.Join(dir, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
t.Fatalf("web root %q missing index.html: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigProviderPublicURL(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server.PublicURL = "https://forge.example"
|
||||
p := &serverConfigProvider{config: cfg}
|
||||
if got := p.PublicURL(); got != "https://forge.example" {
|
||||
t.Fatalf("PublicURL: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigProviderGetConfigJSON(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Port = 7777
|
||||
p := &serverConfigProvider{config: cfg}
|
||||
raw := p.GetConfigJSON()
|
||||
var decoded Config
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Port != 7777 {
|
||||
t.Fatalf("port in JSON: got %d", decoded.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigProviderUpdateConfigFromJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.DataDir = dir
|
||||
cfg.Wallet.Address = "48keep"
|
||||
p := &serverConfigProvider{config: cfg}
|
||||
|
||||
if err := p.UpdateConfigFromJSON([]byte(`{"port":8888}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Port != 8888 {
|
||||
t.Fatalf("port not updated: %d", cfg.Port)
|
||||
}
|
||||
if cfg.Wallet.Address != "48keep" {
|
||||
t.Fatalf("partial update must preserve wallet: %q", cfg.Wallet.Address)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved Config
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Port != 8888 {
|
||||
t.Fatalf("saved port: got %d", saved.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigProviderUpdateConfigInvalidJSON(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
p := &serverConfigProvider{config: cfg}
|
||||
err := p.UpdateConfigFromJSON([]byte(`{not json`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigProviderOnSavedCallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.DataDir = dir
|
||||
called := false
|
||||
p := &serverConfigProvider{
|
||||
config: cfg,
|
||||
onSaved: func(c *Config) {
|
||||
called = true
|
||||
if c.Port != 6666 {
|
||||
t.Fatalf("callback port: got %d", c.Port)
|
||||
}
|
||||
},
|
||||
}
|
||||
if err := p.UpdateConfigFromJSON([]byte(`{"port":6666}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("onSaved callback not invoked")
|
||||
}
|
||||
}
|
||||
17
server/web/e2e/fixtures.ts
Normal file
17
server/web/e2e/fixtures.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
|
||||
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
|
||||
export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
|
||||
|
||||
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
|
||||
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
|
||||
|
||||
export async function loginToDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByLabel('Username').fill(E2E_USER);
|
||||
await page.getByLabel('Password').fill(E2E_PASS);
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
33
server/web/e2e/pages.spec.ts
Normal file
33
server/web/e2e/pages.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
test.describe('Page smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('Dashboard renders Command Deck', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible();
|
||||
await expect(page.getByText('Fleet Pipeline')).toBeVisible();
|
||||
await expect(page.getByText('Machine Roster')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Agents renders Fleet Roster', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/NODES/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Settings renders Calibrate', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Calibrate/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Builder renders The Forge', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Forge/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Simple' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
72
server/web/e2e/remote-actions.spec.ts
Normal file
72
server/web/e2e/remote-actions.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
const OFFLINE_AGENT = {
|
||||
id: 'e2e-offline-agent',
|
||||
name: 'Offline Node',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
ip: '192.168.1.99',
|
||||
version: '1.0.0',
|
||||
status: 'offline',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
last_seen: new Date(Date.now() - 3600_000).toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
hashrate_15s: 0,
|
||||
hashrate_1m: 0,
|
||||
hashrate_15m: 0,
|
||||
shares_total: 0,
|
||||
shares_good: 0,
|
||||
shares_bad: 0,
|
||||
cpu_usage_pct: 0,
|
||||
memory_usage_pct: 0,
|
||||
uptime_seconds: 0,
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
};
|
||||
|
||||
test.describe('Remote actions UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/agents', async (route) => {
|
||||
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await page.route('**/api/v1/agents/*/stats*', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route('**/api/v1/builds', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route('**/api/v1/server/info', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
version: 'test',
|
||||
suggested_url: 'http://127.0.0.1:8989',
|
||||
agent_count: 1,
|
||||
online_count: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('detail panel disables remote actions for offline agent', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const detail = page.locator('.agent-detail');
|
||||
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
|
||||
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('compact row remote actions disabled when offline', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const compact = page.locator('.agent-list-item.expanded');
|
||||
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
test.describe('AetherForge smoke', () => {
|
||||
test('health endpoint responds', async ({ request }) => {
|
||||
@@ -9,20 +10,11 @@ test.describe('AetherForge smoke', () => {
|
||||
});
|
||||
|
||||
test('login gate renders and accepts credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByLabel('Username').fill('drjones');
|
||||
await page.getByLabel('Password').fill('czapiewski');
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('forge page loads after login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Username').fill('drjones');
|
||||
await page.getByLabel('Password').fill('czapiewski');
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /forge/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
71
server/web/src/App.test.tsx
Normal file
71
server/web/src/App.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import App, { PageFallback } from './App';
|
||||
|
||||
vi.mock('./context/WebSocketProvider', () => ({
|
||||
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./context/ForgeContext', () => ({
|
||||
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/SessionGate', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/Layout/Layout', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div data-testid="layout">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
|
||||
vi.mock('./pages/AgentsPage', () => ({ default: () => <div>Agents Page</div> }));
|
||||
vi.mock('./pages/BuilderPage', () => ({ default: () => <div>Forge Page</div> }));
|
||||
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
|
||||
vi.mock('./pages/GuidePage', () => ({ default: () => <div>Guide Page</div> }));
|
||||
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
|
||||
|
||||
describe('PageFallback', () => {
|
||||
it('shows loading copy', () => {
|
||||
render(<PageFallback />);
|
||||
expect(screen.getByText('Loading…')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('App route config', () => {
|
||||
it('redirects / to dashboard and /builder to forge', () => {
|
||||
function RedirectProbe({ path }: { path: string }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/forge" element={<div>Forge Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
const { unmount: u1 } = render(<RedirectProbe path="/" />);
|
||||
expect(screen.getByText('Dashboard Page')).toBeTruthy();
|
||||
u1();
|
||||
render(<RedirectProbe path="/builder" />);
|
||||
expect(screen.getByText('Forge Page')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders crucible route via App shell', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crucible']}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText('Crucible Page')).toBeTruthy();
|
||||
expect(screen.getByTestId('layout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
||||
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
||||
|
||||
function PageFallback() {
|
||||
export function PageFallback() {
|
||||
return (
|
||||
<div className="session-gate" style={{ minHeight: '40vh' }}>
|
||||
<p className="font-tech">Loading…</p>
|
||||
|
||||
@@ -153,6 +153,12 @@ describe('api client', () => {
|
||||
expectAuthHeaders(init);
|
||||
});
|
||||
|
||||
it('estimateFusion rejects without prep file', async () => {
|
||||
const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
|
||||
await expect(api.estimateFusion(req, null)).rejects.toThrow('Fusion requires prep.exe upload');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))
|
||||
|
||||
@@ -70,7 +70,10 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
estimateFusion: (req: BuildRequest, prepFile: File) => {
|
||||
estimateFusion: (req: BuildRequest, prepFile?: File | null) => {
|
||||
if (!prepFile) {
|
||||
return Promise.reject(new Error('Fusion requires prep.exe upload'));
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('config', JSON.stringify(req));
|
||||
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
|
||||
|
||||
@@ -17,7 +17,8 @@ export default function GaugeRing({
|
||||
color = 'var(--neon-cyan)',
|
||||
size = 100,
|
||||
}: GaugeRingProps) {
|
||||
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
const clampedValue = Math.min(max, Math.max(0, value));
|
||||
const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
|
||||
const circumference = 2 * Math.PI * 42;
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
|
||||
@@ -50,7 +51,7 @@ export default function GaugeRing({
|
||||
/>
|
||||
</svg>
|
||||
<div className="gauge-ring-center">
|
||||
<span className="gauge-ring-value font-tech">{formatValue(value, max)}</span>
|
||||
<span className="gauge-ring-value font-tech">{formatValue(clampedValue, max)}</span>
|
||||
<span className="gauge-ring-label">{label}</span>
|
||||
{sublabel && <span className="gauge-ring-sub">{sublabel}</span>}
|
||||
</div>
|
||||
|
||||
@@ -298,11 +298,11 @@ describe('GaugeRing', () => {
|
||||
|
||||
it('shows raw value in center while ring arc clamps to 0–100%', () => {
|
||||
const { rerender, container } = render(<GaugeRing value={-10} max={100} label="X" />);
|
||||
expect(screen.getByText('-10%')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
const fill = container.querySelector('.gauge-ring-fill') as SVGCircleElement;
|
||||
expect(fill.getAttribute('stroke-dashoffset')).toBe(String(2 * Math.PI * 42));
|
||||
rerender(<GaugeRing value={200} max={100} label="X" />);
|
||||
expect(screen.getByText('200%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
expect((container.querySelector('.gauge-ring-fill') as SVGCircleElement).getAttribute('stroke-dashoffset')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,8 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
routeLabel: 'Fleet Roster',
|
||||
tips: [
|
||||
'Status dot: green = online now, grey = last seen X ago',
|
||||
'Remote action buttons are disabled when the agent is offline — by design',
|
||||
'Agent logs: use Fetch Log (get_log) in Remote Control, or AI upload_log tool reports — no separate log-ingest API',
|
||||
'If agent never appears: check C2 URL is reachable from the target machine',
|
||||
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
|
||||
'Worker name you set in Forge shows as the agent name in the roster',
|
||||
@@ -404,7 +406,7 @@ export const ROADMAP_FEATURES = [
|
||||
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' },
|
||||
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' },
|
||||
{ priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' },
|
||||
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
|
||||
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail (get_log) from dashboard when agent is online.' },
|
||||
{ priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' },
|
||||
{ priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' },
|
||||
{ priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' },
|
||||
|
||||
@@ -115,6 +115,9 @@ describe('FIELD_HELP', () => {
|
||||
|
||||
it('wallet and server_url entries warn against localhost', () => {
|
||||
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
|
||||
expect(FIELD_HELP.wallet).toMatch(/90.*106/);
|
||||
expect(FIELD_HELP.calibrate_wallet).toMatch(/90.*106/);
|
||||
expect(FIELD_HELP.calibrate_wallet).not.toMatch(/95 char/);
|
||||
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
|
||||
expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
|
||||
});
|
||||
|
||||
@@ -13,13 +13,13 @@ export const SETUP_CHEATSHEET = [
|
||||
},
|
||||
{
|
||||
title: '4. Watch the fleet',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
|
||||
},
|
||||
];
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
calibrate_wallet:
|
||||
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.',
|
||||
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 or 8 and be 90–106 characters.',
|
||||
calibrate_quick_setup:
|
||||
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
|
||||
forge_simple_mode:
|
||||
@@ -45,7 +45,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
|
||||
output_dir:
|
||||
'Optional extra copy into a subfolder (e.g. exports). The forged .exe is always written to the project root as a single file with the same name as your Fusion output setting.',
|
||||
wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8.',
|
||||
wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8 (90–106 characters).',
|
||||
pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.',
|
||||
pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.',
|
||||
pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.',
|
||||
|
||||
@@ -216,4 +216,18 @@ describe('AgentsPage', () => {
|
||||
await userEvent.setup().type(search, 'nomatchxyz');
|
||||
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('select all filtered selects every visible agent', async () => {
|
||||
const a1 = mockAgent({ id: 'a1', name: 'Alpha', tags: ['prod'] });
|
||||
const a2 = mockAgent({ id: 'a2', name: 'Beta', tags: ['prod'] });
|
||||
const a3 = mockAgent({ id: 'a3', name: 'Gamma', tags: ['dev'] });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2, a3]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(screen.getByTitle('Filter by tag'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Select all filtered (2)' }));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function AgentsPage() {
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
|
||||
93
server/web/src/pages/BuildManagerPage.test.tsx
Normal file
93
server/web/src/pages/BuildManagerPage.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuildManagerPage, {
|
||||
fmtSize,
|
||||
platformColor,
|
||||
platformLabel,
|
||||
truncateUrl,
|
||||
truncateWallet,
|
||||
} from './BuildManagerPage';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../components/Fleet/LanDownloadQR', () => ({
|
||||
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
|
||||
}));
|
||||
|
||||
describe('BuildManagerPage helpers', () => {
|
||||
it('truncateWallet shortens long addresses', () => {
|
||||
const w = '4' + 'A'.repeat(94);
|
||||
expect(truncateWallet(w)).toMatch(/^4AAAAA…AAAAAA$/);
|
||||
expect(truncateWallet('short')).toBe('short');
|
||||
expect(truncateWallet('')).toBe('—');
|
||||
});
|
||||
|
||||
it('truncateUrl returns host for valid URLs', () => {
|
||||
expect(truncateUrl('https://pool.example.com:3333/path')).toBe('pool.example.com:3333');
|
||||
expect(truncateUrl('not-a-url-but-long-enough-to-truncate-xyz')).toMatch(/…$/);
|
||||
});
|
||||
|
||||
it('fmtSize formats bytes to KB and MB', () => {
|
||||
expect(fmtSize(0)).toBe('—');
|
||||
expect(fmtSize(512)).toBe('1 KB');
|
||||
expect(fmtSize(2 * 1024 * 1024)).toBe('2.0 MB');
|
||||
});
|
||||
|
||||
it('platformLabel and platformColor map known platforms', () => {
|
||||
expect(platformLabel('linux')).toBe('Linux');
|
||||
expect(platformLabel()).toBe('Win');
|
||||
expect(platformColor('darwin')).toBe('#f0abfc');
|
||||
expect(platformColor('unknown-os')).toBe('#aaa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuildManagerPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([
|
||||
{
|
||||
id: 'build-1',
|
||||
worker_name: 'office-worker',
|
||||
server_url: 'https://c2.example.com',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
file_size: 1024 * 1024,
|
||||
file_path: 'builds/agent.exe',
|
||||
file_name: 'agent.exe',
|
||||
created_at: '2026-05-30T12:00:00.000Z',
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
platform: 'windows',
|
||||
download_url: '/api/v1/builds/build-1/download',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue({
|
||||
port: 8989,
|
||||
host: '0.0.0.0',
|
||||
local_ips: [],
|
||||
suggested_url: 'http://localhost:8989',
|
||||
dashboard_url: 'http://localhost:8989/dashboard',
|
||||
websocket_url: 'ws://localhost:8989/ws/dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders build list after load', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('office-worker')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,12 @@ import './BuildManagerPage.css';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncateWallet(w: string): string {
|
||||
export function truncateWallet(w: string): string {
|
||||
if (!w || w.length < 12) return w || '—';
|
||||
return `${w.slice(0, 6)}…${w.slice(-6)}`;
|
||||
}
|
||||
|
||||
function truncateUrl(u: string): string {
|
||||
export function truncateUrl(u: string): string {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return parsed.host;
|
||||
@@ -24,7 +24,7 @@ function truncateUrl(u: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
export function fmtSize(bytes: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
@@ -40,7 +40,7 @@ function fmtDate(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function platformLabel(p?: string): string {
|
||||
export function platformLabel(p?: string): string {
|
||||
if (!p) return 'Win';
|
||||
const m: Record<string, string> = {
|
||||
windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal',
|
||||
@@ -48,7 +48,7 @@ function platformLabel(p?: string): string {
|
||||
return m[p.toLowerCase()] ?? p;
|
||||
}
|
||||
|
||||
function platformColor(p?: string): string {
|
||||
export function platformColor(p?: string): string {
|
||||
if (!p) return 'var(--neon-cyan)';
|
||||
const m: Record<string, string> = {
|
||||
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
|
||||
|
||||
@@ -635,6 +635,20 @@ export default function BuilderPage() {
|
||||
fusionPrepFile,
|
||||
]);
|
||||
|
||||
// Cancel any in-progress server-side build when the component unmounts
|
||||
// (e.g. user navigates away mid-forge). This closes the M14 UI desync where
|
||||
// the server kept compiling after the Forge page was left.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const tok = cancelTokenRef.current;
|
||||
if (tok) {
|
||||
api.cancelBuild(tok).catch(() => {});
|
||||
cancelTokenRef.current = '';
|
||||
}
|
||||
batchCancelRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loadingDefaults) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
|
||||
72
server/web/src/pages/CruciblePage.test.tsx
Normal file
72
server/web/src/pages/CruciblePage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import {
|
||||
agentColor,
|
||||
patchLabel,
|
||||
pendingBadge,
|
||||
portsBadge,
|
||||
postureBadge,
|
||||
postureTooltip,
|
||||
sshBadge,
|
||||
thermalBadge,
|
||||
} from './CruciblePage';
|
||||
|
||||
describe('CruciblePage helpers', () => {
|
||||
it('agentColor cycles palette by agent order', () => {
|
||||
const ids = ['a', 'b', 'c'];
|
||||
expect(agentColor('a', ids)).toBe('#00f5ff');
|
||||
expect(agentColor('b', ids)).toBe('#39ff14');
|
||||
expect(agentColor('missing', ids)).toBe('#00f5ff');
|
||||
});
|
||||
|
||||
it('sshBadge reflects ssh_available tri-state', () => {
|
||||
expect(sshBadge(mockAgent({ ssh_available: true })).label).toBe('SSH ON');
|
||||
expect(sshBadge(mockAgent({ ssh_available: false })).cls).toBe('ssh-off');
|
||||
expect(sshBadge(mockAgent({ ssh_available: undefined })).label).toBe('SSH ?');
|
||||
});
|
||||
|
||||
it('postureBadge buckets score thresholds', () => {
|
||||
expect(postureBadge(undefined).cls).toBe('posture-unk');
|
||||
expect(postureBadge(90).cls).toBe('posture-good');
|
||||
expect(postureBadge(50).cls).toBe('posture-warn');
|
||||
expect(postureBadge(10).cls).toBe('posture-bad');
|
||||
});
|
||||
|
||||
it('patchLabel marks stale patches beyond 30 days', () => {
|
||||
expect(patchLabel(10)?.cls).toBe('patch-ok');
|
||||
expect(patchLabel(45)?.cls).toBe('patch-stale');
|
||||
expect(patchLabel(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('portsBadge flags high listener counts', () => {
|
||||
expect(portsBadge(5)?.cls).toBe('ports-ok');
|
||||
expect(portsBadge(25)?.cls).toBe('ports-many');
|
||||
});
|
||||
|
||||
it('pendingBadge encodes update counts', () => {
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 0 }))?.label).toBe('UP TO DATE');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 3 }))?.cls).toBe('upd-warn');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 12 }))?.cls).toBe('upd-bad');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: -1 }))?.label).toBe('UPD ?');
|
||||
});
|
||||
|
||||
it('thermalBadge shows hot and warm thresholds', () => {
|
||||
expect(thermalBadge(mockAgent({ cpu_temp_c: 60 }))).toBeNull();
|
||||
expect(thermalBadge(mockAgent({ cpu_temp_c: 70 }))?.cls).toBe('therm-warm');
|
||||
expect(thermalBadge(mockAgent({ gpu_temp_c: 85 }))?.cls).toBe('therm-hot');
|
||||
});
|
||||
|
||||
it('postureTooltip includes defender, DNS drift, and services', () => {
|
||||
const agent = mockAgent({
|
||||
defender_enabled: true,
|
||||
defender_rtp: false,
|
||||
dns_servers: ['8.8.8.8'],
|
||||
dns_drifted: true,
|
||||
services: [{ name: 'sshd', display_name: 'OpenSSH', status: 'running', start_type: 'auto' }],
|
||||
});
|
||||
const tip = postureTooltip(agent);
|
||||
expect(tip).toContain('Defender');
|
||||
expect(tip).toContain('DNS changed since last heartbeat');
|
||||
expect(tip).toContain('OpenSSH');
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,53 @@ interface TermLine {
|
||||
text: string;
|
||||
ts: Date;
|
||||
success?: boolean;
|
||||
// Structured data for rich terminal renderers
|
||||
richData?: RichTermData;
|
||||
}
|
||||
|
||||
// ── Rich terminal data types ────────────────────────────────────────────────
|
||||
|
||||
interface RichListenPort {
|
||||
port: number;
|
||||
addr: string;
|
||||
proto: string;
|
||||
process?: string;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
interface RichListenPorts {
|
||||
type: 'listen_ports';
|
||||
ports: RichListenPort[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RichPatchStatus {
|
||||
type: 'patch_status';
|
||||
pending_updates?: number;
|
||||
last_patch?: string;
|
||||
last_patch_days?: number;
|
||||
reboot_pending?: boolean;
|
||||
}
|
||||
|
||||
interface RichPostureSummary {
|
||||
type: 'posture';
|
||||
posture_score?: number;
|
||||
defender_enabled?: boolean;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
ssh_listening?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
last_patch_days?: number;
|
||||
pending_updates?: number;
|
||||
reboot_pending?: boolean;
|
||||
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
|
||||
|
||||
interface NodeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -34,35 +79,35 @@ const AGENT_COLORS = [
|
||||
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
|
||||
];
|
||||
|
||||
function agentColor(agentId: string, allIds: string[]): string {
|
||||
export function agentColor(agentId: string, allIds: string[]): string {
|
||||
const idx = allIds.indexOf(agentId);
|
||||
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
|
||||
}
|
||||
|
||||
function sshBadge(agent: Agent) {
|
||||
export function sshBadge(agent: Agent) {
|
||||
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
|
||||
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
|
||||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||||
}
|
||||
|
||||
function postureBadge(score?: number) {
|
||||
export function postureBadge(score?: number) {
|
||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
|
||||
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
|
||||
return { label: `POSTURE ${score}`, cls: 'posture-bad' };
|
||||
}
|
||||
|
||||
function patchLabel(days?: number) {
|
||||
export function patchLabel(days?: number) {
|
||||
if (days === undefined) return null;
|
||||
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||||
}
|
||||
|
||||
function portsBadge(count?: number): { label: string; cls: string } | null {
|
||||
export function portsBadge(count?: number): { label: string; cls: string } | null {
|
||||
if (count === undefined) return null;
|
||||
return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' };
|
||||
}
|
||||
|
||||
function postureTooltip(agent: Agent): string {
|
||||
export 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) : '?';
|
||||
@@ -118,7 +163,7 @@ function postureTooltip(agent: Agent): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
export 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' };
|
||||
@@ -135,7 +180,7 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
|
||||
// ── Resource pressure badges ───────────────────────────────────────────────
|
||||
|
||||
function thermalBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
export function thermalBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const t = agent.gpu_temp_c ?? agent.cpu_temp_c;
|
||||
if (t === undefined) return null;
|
||||
if (t > 80) return { label: `${t}°`, cls: 'therm-hot' };
|
||||
@@ -289,44 +334,68 @@ export default function CruciblePage() {
|
||||
if (!aid) continue;
|
||||
|
||||
const msg = r.message ?? '';
|
||||
// Always update badges from probe / heartbeat command responses
|
||||
|
||||
// ── SSH badge updates ───────────────────────────────────────────────
|
||||
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('{')) {
|
||||
|
||||
// ── Parse structured JSON for known actions ────────────────────────
|
||||
let richData: RichTermData | undefined;
|
||||
const jsonStart = msg.indexOf('{');
|
||||
|
||||
if (jsonStart >= 0) {
|
||||
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') {
|
||||
const parsed = JSON.parse(msg.slice(jsonStart));
|
||||
|
||||
if (r.action === 'listen_ports' && Array.isArray(parsed.ports)) {
|
||||
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
|
||||
} else if (r.action === 'patch_status') {
|
||||
richData = { type: 'patch_status', ...parsed };
|
||||
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
|
||||
richData = { type: 'posture', ...parsed };
|
||||
// Update badge state
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||||
}));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
} else {
|
||||
// Generic JSON with posture fields (legacy path)
|
||||
if (typeof parsed.posture_score === 'number') {
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
|
||||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||||
}));
|
||||
}
|
||||
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
} catch { /* malformed JSON — fall through to plain text */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
if (richData) {
|
||||
// Single rich-rendered line (table/block replaces raw JSON)
|
||||
lines.push({
|
||||
id: mkId(),
|
||||
agentId: aid,
|
||||
agentName: name,
|
||||
isCmd: false,
|
||||
text: line,
|
||||
ts: new Date(),
|
||||
success: r.success,
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: '', ts: new Date(),
|
||||
success: r.success, richData,
|
||||
});
|
||||
} else {
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: line, ts: new Date(), success: r.success,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
|
||||
23
server/web/src/pages/GuidePage.test.tsx
Normal file
23
server/web/src/pages/GuidePage.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GuidePage from './GuidePage';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -297,21 +297,21 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Listen Port</label>
|
||||
<input type="number" className="input" min={1024} max={65535} value={config.port}
|
||||
<label htmlFor="cfg-port" className="label">Listen Port</label>
|
||||
<input id="cfg-port" type="number" className="input" min={1024} max={65535} value={config.port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
|
||||
<span className="form-hint">Restart server after changing port.</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Data Directory</label>
|
||||
<input type="text" className="input mono" value={config.data_dir}
|
||||
<label htmlFor="cfg-data-dir" className="label">Data Directory</label>
|
||||
<input id="cfg-data-dir" type="text" className="input mono" value={config.data_dir}
|
||||
onChange={(e) => updateField('data_dir', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
|
||||
<label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||||
<input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||||
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
|
||||
value={s.public_url}
|
||||
onChange={(e) => updateField('server.public_url', e.target.value)} />
|
||||
@@ -325,8 +325,8 @@ export default function SettingsPage() {
|
||||
<FieldHint field="public_url" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Dashboard Subtitle</label>
|
||||
<input type="text" className="input" value={s.dashboard_subtitle}
|
||||
<label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
|
||||
<input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}
|
||||
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -343,14 +343,14 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Upstream Pool</h2>
|
||||
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host <HelpTip field="pool_host" /></label>
|
||||
<input type="text" className="input" value={config.pool.host}
|
||||
<label htmlFor="cfg-pool-host" className="label">Pool Host <HelpTip field="pool_host" /></label>
|
||||
<input id="cfg-pool-host" type="text" className="input" value={config.pool.host}
|
||||
onChange={(e) => updateField('pool.host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Port</label>
|
||||
<input type="number" className="input" value={config.pool.port}
|
||||
<label htmlFor="cfg-pool-port" className="label">Port</label>
|
||||
<input id="cfg-pool-port" type="number" className="input" value={config.pool.port}
|
||||
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
|
||||
@@ -362,13 +362,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Password</label>
|
||||
<input type="text" className="input" value={config.pool.password}
|
||||
<label htmlFor="cfg-pool-pass" className="label">Pool Password</label>
|
||||
<input id="cfg-pool-pass" type="text" className="input" value={config.pool.password}
|
||||
onChange={(e) => updateField('pool.password', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Reconnect Interval (sec)</label>
|
||||
<input type="number" className="input" min={5} value={s.pool_reconnect_seconds}
|
||||
<label htmlFor="cfg-pool-reconnect" className="label">Pool Reconnect Interval (sec)</label>
|
||||
<input id="cfg-pool-reconnect" type="number" className="input" min={5} value={s.pool_reconnect_seconds}
|
||||
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
@@ -377,15 +377,15 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Fleet Payout Wallet</h2>
|
||||
<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… or 8… (90–106 chars)"
|
||||
<label htmlFor="cfg-wallet-addr" className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
|
||||
<input id="cfg-wallet-addr" type="text" className="input mono" placeholder="4… or 8… (90–106 chars)"
|
||||
value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||||
<FieldHint field="calibrate_wallet" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Payment ID (optional)</label>
|
||||
<input type="text" className="input mono" value={config.wallet.payment_id}
|
||||
<label htmlFor="cfg-payment-id" className="label">Payment ID (optional)</label>
|
||||
<input id="cfg-payment-id" type="text" className="input mono" value={config.wallet.payment_id}
|
||||
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -401,19 +401,19 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Offline After (minutes)</label>
|
||||
<input type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
|
||||
<label htmlFor="cfg-alert-offline" className="label">Offline After (minutes)</label>
|
||||
<input id="cfg-alert-offline" type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
|
||||
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Hashrate Drop (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
|
||||
<label htmlFor="cfg-alert-hashrate" className="label">Hashrate Drop (%)</label>
|
||||
<input id="cfg-alert-hashrate" type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Rejection Rate (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
|
||||
<label htmlFor="cfg-alert-reject" className="label">Rejection Rate (%)</label>
|
||||
<input id="cfg-alert-reject" type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,13 +424,13 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Bot Token</label>
|
||||
<input type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
|
||||
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
|
||||
<input id="cfg-tg-token" type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Chat ID</label>
|
||||
<input type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
|
||||
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
|
||||
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,37 +445,37 @@ export default function SettingsPage() {
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Host</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_host || ''}
|
||||
<label htmlFor="cfg-smtp-host" className="label">SMTP Host</label>
|
||||
<input id="cfg-smtp-host" type="text" className="input" value={config.alerts.smtp_host || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Port</label>
|
||||
<input type="number" className="input" value={config.alerts.smtp_port || 587}
|
||||
<label htmlFor="cfg-smtp-port" className="label">SMTP Port</label>
|
||||
<input id="cfg-smtp-port" type="number" className="input" value={config.alerts.smtp_port || 587}
|
||||
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP User</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_user || ''}
|
||||
<label htmlFor="cfg-smtp-user" className="label">SMTP User</label>
|
||||
<input id="cfg-smtp-user" type="text" className="input" value={config.alerts.smtp_user || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Password</label>
|
||||
<input type="password" className="input" value={config.alerts.smtp_password || ''}
|
||||
<label htmlFor="cfg-smtp-pass" className="label">SMTP Password</label>
|
||||
<input id="cfg-smtp-pass" type="password" className="input" value={config.alerts.smtp_password || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Email To</label>
|
||||
<input type="email" className="input" value={config.alerts.email_to || ''}
|
||||
<label htmlFor="cfg-email-to" className="label">Email To</label>
|
||||
<input id="cfg-email-to" type="email" className="input" value={config.alerts.email_to || ''}
|
||||
onChange={(e) => updateField('alerts.email_to', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Email From</label>
|
||||
<input type="email" className="input" value={config.alerts.email_from || ''}
|
||||
<label htmlFor="cfg-email-from" className="label">Email From</label>
|
||||
<input id="cfg-email-from" type="email" className="input" value={config.alerts.email_from || ''}
|
||||
onChange={(e) => updateField('alerts.email_from', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,21 +503,21 @@ export default function SettingsPage() {
|
||||
<FieldHint field="sign_enabled" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
|
||||
<input type="text" className="input mono" placeholder="AB CD EF ..."
|
||||
<label htmlFor="cfg-sign-cert" className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
|
||||
<input id="cfg-sign-cert" type="text" className="input mono" placeholder="AB CD EF ..."
|
||||
value={s.sign_cert_thumbprint || ''}
|
||||
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
|
||||
<FieldHint field="sign_cert_thumbprint" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
|
||||
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
|
||||
<label htmlFor="cfg-sign-tool" className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
|
||||
<input id="cfg-sign-tool" type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
|
||||
value={s.sign_tool_path || ''}
|
||||
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
|
||||
<input type="text" className="input mono"
|
||||
<label htmlFor="cfg-sign-ts" className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
|
||||
<input id="cfg-sign-ts" type="text" className="input mono"
|
||||
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
|
||||
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
|
||||
<FieldHint field="sign_timestamp_url" />
|
||||
@@ -529,31 +529,31 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Stats Retention (hours)</label>
|
||||
<input type="number" className="input" min={24} value={s.stats_retention_hours}
|
||||
<label htmlFor="cfg-stats-ret" className="label">Stats Retention (hours)</label>
|
||||
<input id="cfg-stats-ret" type="number" className="input" min={24} value={s.stats_retention_hours}
|
||||
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Keep Builds (days)</label>
|
||||
<input type="number" className="input" min={1} value={s.build_retention_days}
|
||||
<label htmlFor="cfg-build-ret" className="label">Keep Builds (days)</label>
|
||||
<input id="cfg-build-ret" type="number" className="input" min={1} value={s.build_retention_days}
|
||||
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Max Agents</label>
|
||||
<input type="number" className="input" min={1} value={s.max_agents}
|
||||
<label htmlFor="cfg-max-agents" className="label">Max Agents</label>
|
||||
<input id="cfg-max-agents" type="number" className="input" min={1} value={s.max_agents}
|
||||
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Build Size (MB)</label>
|
||||
<input type="number" className="input" min={10} value={s.max_build_size_mb}
|
||||
<label htmlFor="cfg-max-build-mb" className="label">Max Build Size (MB)</label>
|
||||
<input id="cfg-max-build-mb" type="number" className="input" min={10} value={s.max_build_size_mb}
|
||||
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">WebSocket Ping (sec)</label>
|
||||
<input type="number" className="input" min={10} value={s.websocket_ping_seconds}
|
||||
<label htmlFor="cfg-ws-ping" className="label">WebSocket Ping (sec)</label>
|
||||
<input id="cfg-ws-ping" type="number" className="input" min={10} value={s.websocket_ping_seconds}
|
||||
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
@@ -591,13 +591,13 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — username</label>
|
||||
<input type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
<label htmlFor="cfg-session-user" className="label">Browser session — username</label>
|
||||
<input id="cfg-session-user" type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
onChange={(e) => setSessionUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — password</label>
|
||||
<input type="password" className="input" value={sessionPass}
|
||||
<label htmlFor="cfg-session-pass" className="label">Browser session — password</label>
|
||||
<input id="cfg-session-pass" type="password" className="input" value={sessionPass}
|
||||
onChange={(e) => setSessionPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,13 +612,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">New Username</label>
|
||||
<input type="text" className="input" placeholder="admin" value={newUser}
|
||||
<label htmlFor="cfg-new-user" className="label">New Username</label>
|
||||
<input id="cfg-new-user" type="text" className="input" placeholder="admin" value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">New Password</label>
|
||||
<input type="password" className="input" placeholder="••••••••" value={newPass}
|
||||
<label htmlFor="cfg-new-pass" className="label">New Password</label>
|
||||
<input id="cfg-new-pass" type="password" className="input" placeholder="••••••••" value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Dashboard TypeScript models — compile-time contracts only.
|
||||
*
|
||||
* These interfaces mirror server JSON (`server/internal/models`, `ws_types.go`).
|
||||
* There are no runtime validators or schema guards here; API responses are trusted
|
||||
* after auth and validated ad hoc where it matters (forms, forge preflight, etc.).
|
||||
* Drift is caught by unit tests, shared WS type tests, and integration tests — not
|
||||
* by automatic parsing of every endpoint payload.
|
||||
*/
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
71
server/web/src/types/ws.test.ts
Normal file
71
server/web/src/types/ws.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
WSAgentLog,
|
||||
WSAgentOffline,
|
||||
WSCommandResult,
|
||||
WSDashboardInit,
|
||||
WSMessageTyped,
|
||||
WSServerLog,
|
||||
WSStatsUpdate,
|
||||
} from './ws';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('types/ws payloads', () => {
|
||||
it('WSDashboardInit carries agents array', () => {
|
||||
const init: WSDashboardInit = { agents: [mockAgent()] };
|
||||
expectKeys(init as unknown as Record<string, unknown>, ['agents']);
|
||||
expect(init.agents[0].id).toBe('agent-001-uuid');
|
||||
});
|
||||
|
||||
it('WSStatsUpdate includes hashrate fields', () => {
|
||||
const stats: WSStatsUpdate = {
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
};
|
||||
expectKeys(stats as unknown as Record<string, unknown>, [
|
||||
'agent_id',
|
||||
'hashrate_15s',
|
||||
'hashrate_1m',
|
||||
'hashrate_15m',
|
||||
'cpu_usage_pct',
|
||||
]);
|
||||
});
|
||||
|
||||
it('WSAgentOffline and WSCommandResult shape', () => {
|
||||
const offline: WSAgentOffline = { agent_id: 'gone' };
|
||||
const cmd: WSCommandResult = { agent_id: 'a1', action: 'pause', success: true, message: 'ok' };
|
||||
expect(offline.agent_id).toBe('gone');
|
||||
expect(cmd.success).toBe(true);
|
||||
});
|
||||
|
||||
it('log payloads carry content lines', () => {
|
||||
const agentLog: WSAgentLog = { agent_id: 'a1', content: 'line1\nline2' };
|
||||
const serverLog: WSServerLog = { line: 'server boot' };
|
||||
expect(agentLog.content).toContain('line1');
|
||||
expect(serverLog.line).toBe('server boot');
|
||||
});
|
||||
|
||||
it('WSMessageTyped pairs type with payload', () => {
|
||||
const msg: WSMessageTyped<'stats_update'> = {
|
||||
type: 'stats_update',
|
||||
payload: {
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 1,
|
||||
hashrate_1m: 1,
|
||||
hashrate_15m: 1,
|
||||
cpu_usage_pct: 0,
|
||||
},
|
||||
};
|
||||
expect(msg.type).toBe('stats_update');
|
||||
expect((msg.payload as WSStatsUpdate).agent_id).toBe('a1');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
['src/hooks/**', 'happy-dom'],
|
||||
['src/pages/**', 'happy-dom'],
|
||||
['src/components/**', 'happy-dom'],
|
||||
['src/App.test.tsx', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user