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:
AetherForge
2026-05-31 01:13:49 -07:00
parent 159747877c
commit ea6f54ad03
89 changed files with 5307 additions and 322 deletions

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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"`)

View File

@@ -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)

View 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)
}
}

View 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")
}
}

View 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)
}
}
}

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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",

View 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")
}
}

View File

@@ -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")
}
}

View File

@@ -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, 90106 chars)")
}
req.OutputDir = strings.TrimSpace(req.OutputDir)
if req.OutputDir != "" {

View 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")
}
}

View 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)
}
}

View 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")
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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))
}
}

View File

@@ -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...)
}

View File

@@ -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)
}
}

View 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")
}
}

View 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
}

View File

@@ -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

View File

@@ -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)
}