feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack

This commit is contained in:
AetherForge
2026-05-30 23:11:32 -07:00
parent 9232f4c448
commit 4207f6c21b
43 changed files with 4359 additions and 180 deletions

View File

@@ -3,22 +3,278 @@ package api
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/ollama"
)
func TestHandleReportSingleAndArray(t *testing.T) {
// Numeric constants from ai_handler.go (guard against silent drift).
const (
aiReportBufferCap = 1000
aiHeartbeatIntervalSeconds = 60
aiReasoningTruncateLen = 120
aiReportOutputTruncateLen = 200
aiEngineIdleTTL = time.Hour
aiActivityIdleTTL = 24 * time.Hour
aiCleanupTickerInterval = 10 * time.Minute
)
func newTestAIHandler(t *testing.T) *AIHandler {
t.Helper()
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer database.Close()
t.Cleanup(func() { database.Close() })
return NewAIHandler(database)
}
h := NewAIHandler(database)
func mockOllamaChatServer(t *testing.T, content string, status int) *httptest.Server {
t.Helper()
if status == 0 {
status = http.StatusOK
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(status)
if status != http.StatusOK {
_, _ = w.Write([]byte("ollama down"))
return
}
payload := map[string]interface{}{
"message": map[string]string{"content": content},
"done": true,
}
_ = json.NewEncoder(w).Encode(payload)
}))
}
func ollamaDecideContent(reasoning string, tools []ollama.ToolCall) string {
resp := ollama.DecideResponse{Reasoning: reasoning, ToolCalls: tools}
b, _ := json.Marshal(resp)
return string(b)
}
func TestAIConstants(t *testing.T) {
if aiReportBufferCap != 1000 || aiHeartbeatIntervalSeconds != 60 ||
aiReasoningTruncateLen != 120 || aiReportOutputTruncateLen != 200 ||
aiEngineIdleTTL != time.Hour || aiActivityIdleTTL != 24*time.Hour ||
aiCleanupTickerInterval != 10*time.Minute {
t.Fatal("ai_handler constants drifted from documented values")
}
}
func TestAINewAIHandler(t *testing.T) {
h := newTestAIHandler(t)
if h.db == nil || h.engines == nil || h.activity == nil {
t.Fatal("NewAIHandler did not initialize fields")
}
if cap(h.reports) != aiReportBufferCap {
t.Fatalf("reports slice cap want %d got %d", aiReportBufferCap, cap(h.reports))
}
if len(h.ActivitySnapshot()) != 0 {
t.Fatal("expected empty activity snapshot")
}
}
func TestAITruncateStr(t *testing.T) {
if truncateStr("short", aiReasoningTruncateLen) != "short" {
t.Fatal("short string should be unchanged")
}
long := strings.Repeat("x", aiReasoningTruncateLen+10)
out := truncateStr(long, aiReasoningTruncateLen)
if len(out) != aiReasoningTruncateLen+3 || !strings.HasSuffix(out, "...") {
t.Fatalf("truncate want len %d with ellipsis, got %q", aiReasoningTruncateLen+3, out)
}
if truncateStr("", 5) != "" {
t.Fatal("empty string")
}
if truncateStr("exact", 5) != "exact" {
t.Fatal("exact length boundary")
}
}
func TestAISetEngineForAgentAndGetRemove(t *testing.T) {
h := newTestAIHandler(t)
srv := mockOllamaChatServer(t, ollamaDecideContent("ok", nil), http.StatusOK)
defer srv.Close()
h.SetEngineForAgent("agent-a", srv.URL, "test-model")
if h.GetEngine("agent-a") == nil {
t.Fatal("GetEngine returned nil after SetEngineForAgent")
}
h.RemoveEngine("agent-a")
if h.GetEngine("agent-a") != nil {
t.Fatal("GetEngine should be nil after RemoveEngine")
}
}
func TestAISetEngineForAgentDefaults(t *testing.T) {
h := newTestAIHandler(t)
var gotModel string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Model string `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
gotModel = req.Model
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"message": map[string]string{"content": ollamaDecideContent("ok", nil)},
"done": true,
})
}))
defer srv.Close()
h.SetEngineForAgent("agent-defaults", srv.URL, "")
eng := h.GetEngine("agent-defaults")
if eng == nil {
t.Fatal("engine missing")
}
_, err := eng.Decide(&ollama.AgentState{AgentID: "agent-defaults"})
if err != nil {
t.Fatalf("Decide: %v", err)
}
if gotModel != "llama3.2" {
t.Fatalf("default model want llama3.2 got %q", gotModel)
}
}
func TestAIHandleDecideErrors(t *testing.T) {
h := newTestAIHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader("{"))
w := httptest.NewRecorder()
h.HandleDecide(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid JSON status %d", w.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader(`{"worker_name":"x"}`))
w = httptest.NewRecorder()
h.HandleDecide(w, req)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "agent_id") {
t.Fatalf("missing agent_id status %d body %s", w.Code, w.Body.String())
}
}
func TestAIHandleDecideSuccess(t *testing.T) {
h := newTestAIHandler(t)
content := ollamaDecideContent("all good", []ollama.ToolCall{{
Tool: "check_miner", Args: map[string]string{"process_name": "xmrig"}, Reason: "verify",
}})
srv := mockOllamaChatServer(t, content, http.StatusOK)
defer srv.Close()
body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-decide-ok",
"ollama_endpoint": srv.URL,
"model": "test-model",
"hostname": "host1",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleDecide(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp ollama.DecideResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "check_miner" {
t.Fatalf("unexpected tool calls: %+v", resp.ToolCalls)
}
snap := h.ActivitySnapshot()
var found bool
for _, e := range snap {
if e.AgentID == "agent-decide-ok" && e.LastAction == "check_miner" && e.ToolCallCount == 1 {
found = true
if len(e.LastReasoning) > aiReasoningTruncateLen+3 {
t.Fatalf("reasoning should be truncated to %d", aiReasoningTruncateLen)
}
}
}
if !found {
t.Fatalf("activity not recorded: %+v", snap)
}
}
func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
h := newTestAIHandler(t)
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
defer srv.Close()
body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-decide-fail",
"ollama_endpoint": srv.URL,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleDecide(w, req)
if w.Code != http.StatusOK {
t.Fatalf("fallback should return 200, got %d", w.Code)
}
var out map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out["error"] == nil {
t.Fatal("expected error field")
}
tools, ok := out["tool_calls"].([]interface{})
if !ok || len(tools) != 1 {
t.Fatalf("expected sleep fallback tool_calls, got %v", out["tool_calls"])
}
first, _ := tools[0].(map[string]interface{})
if first["tool"] != "sleep" {
t.Fatalf("expected sleep tool, got %v", first["tool"])
}
args, _ := first["args"].(map[string]interface{})
if args["seconds"] != "120" {
t.Fatalf("fallback sleep seconds want 120 got %v", args["seconds"])
}
}
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
h := newTestAIHandler(t)
content := ollamaDecideContent("idle", nil)
srv := mockOllamaChatServer(t, content, http.StatusOK)
defer srv.Close()
body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-new",
"ollama_endpoint": srv.URL,
"model": "m1",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
w := httptest.NewRecorder()
h.HandleDecide(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d %s", w.Code, w.Body.String())
}
if h.GetEngine("agent-new") == nil {
t.Fatal("engine should exist after first decide")
}
}
func TestAIHandleReportSingleAndArray(t *testing.T) {
h := newTestAIHandler(t)
single := ollama.Report{
AgentID: "agent-1",
@@ -54,6 +310,166 @@ func TestHandleReportSingleAndArray(t *testing.T) {
}
}
func TestAIHandleReportErrors(t *testing.T) {
h := newTestAIHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", errReader{})
w := httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("read error status %d", w.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", strings.NewReader("not-json"))
w = httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid json status %d", w.Code)
}
}
func TestAIHandleReportBufferCap(t *testing.T) {
h := newTestAIHandler(t)
for i := 0; i < aiReportBufferCap+5; i++ {
r := ollama.Report{AgentID: "cap-agent", Tool: "sleep", Success: true}
b, _ := json.Marshal(r)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
w := httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusOK {
t.Fatalf("report %d status %d", i, w.Code)
}
}
h.mu.RLock()
n := len(h.reports)
h.mu.RUnlock()
if n != aiReportBufferCap {
t.Fatalf("reports len want %d got %d", aiReportBufferCap, n)
}
}
func TestAIHandleReportEventBroadcaster(t *testing.T) {
h := newTestAIHandler(t)
var events []AIActivityEntry
h.SetEventBroadcaster(func(e AIActivityEntry) {
events = append(events, e)
})
r := ollama.Report{AgentID: "evt-agent", Tool: "upload_log", Success: true, Output: "done"}
b, _ := json.Marshal(r)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
w := httptest.NewRecorder()
h.HandleReport(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if len(events) != 1 || events[0].AgentID != "evt-agent" || events[0].LastTool != "upload_log" {
t.Fatalf("broadcaster events: %+v", events)
}
var resp map[string]interface{}
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&resp)
if resp["received"].(float64) != 1 || resp["success"] != true {
t.Fatalf("response: %v", resp)
}
}
func TestAIHandleHeartbeat(t *testing.T) {
h := newTestAIHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader("{"))
w := httptest.NewRecorder()
h.HandleHeartbeat(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid JSON status %d", w.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader(`{"status":"alive"}`))
w = httptest.NewRecorder()
h.HandleHeartbeat(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing agent_id status %d", w.Code)
}
body, _ := json.Marshal(heartbeatRequest{AgentID: "hb-agent", Status: "alive", Message: "ok"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", bytes.NewReader(body))
w = httptest.NewRecorder()
h.HandleHeartbeat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d %s", w.Code, w.Body.String())
}
var out map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out["success"] != true {
t.Fatalf("success: %v", out["success"])
}
interval, ok := out["interval"].(float64)
if !ok || int(interval) != aiHeartbeatIntervalSeconds {
t.Fatalf("interval want %d got %v", aiHeartbeatIntervalSeconds, out["interval"])
}
}
func TestAIRecordActivityMergeAndBroadcast(t *testing.T) {
h := newTestAIHandler(t)
now := time.Now()
h.mu.Lock()
h.activity["merge-agent"] = AIActivityEntry{
AgentID: "merge-agent",
LastReportAt: now,
LastSuccess: true,
ToolCallCount: 3,
}
h.mu.Unlock()
var broadcasted AIActivityEntry
h.SetEventBroadcaster(func(e AIActivityEntry) {
broadcasted = e
})
h.recordActivity(AIActivityEntry{
AgentID: "merge-agent",
LastDecideAt: now.Add(time.Minute),
LastAction: "sleep",
LastTool: "sleep",
LastReasoning: "rest",
ToolCallCount: 0, // zero must not wipe previous count
})
h.mu.RLock()
prev := h.activity["merge-agent"]
h.mu.RUnlock()
if prev.ToolCallCount != 3 {
t.Fatalf("zero ToolCallCount should not clear prior count, got %d", prev.ToolCallCount)
}
if prev.LastAction != "sleep" || prev.LastReasoning != "rest" {
t.Fatalf("merged activity: %+v", prev)
}
if broadcasted.AgentID != "merge-agent" {
t.Fatalf("broadcast: %+v", broadcasted)
}
}
func TestAIActivitySnapshot(t *testing.T) {
h := newTestAIHandler(t)
h.mu.Lock()
h.activity["a"] = AIActivityEntry{AgentID: "a"}
h.activity["b"] = AIActivityEntry{AgentID: "b"}
h.mu.Unlock()
snap := h.ActivitySnapshot()
if len(snap) != 2 {
t.Fatalf("want 2 entries got %d", len(snap))
}
}
type errReader struct{}
func (errReader) Read([]byte) (int, error) { return 0, errors.New("read fail") }
func (errReader) Close() error { return nil }
// errReader must satisfy io.ReadCloser for request body
var _ io.ReadCloser = errReader{}
func TestEstimateXMRPerDay(t *testing.T) {
out := EstimateXMRPerDay(3_000_000_000)
xmr, ok := out["xmr_per_day"].(float64)

View File

@@ -0,0 +1,72 @@
package api
import (
"testing"
)
func TestCheckPasswordBcryptAndLegacy(t *testing.T) {
hashed, err := hashPassword("secret123")
if err != nil {
t.Fatal(err)
}
if !checkPassword(hashed, "secret123") {
t.Fatal("bcrypt hash should match correct password")
}
if checkPassword(hashed, "wrong") {
t.Fatal("bcrypt hash should reject wrong password")
}
if !checkPassword("plainlegacy", "plainlegacy") {
t.Fatal("legacy plain-text should match")
}
if checkPassword("plainlegacy", "other") {
t.Fatal("legacy plain-text should reject mismatch")
}
}
func TestIsBcryptHash(t *testing.T) {
h, _ := hashPassword("x")
if !isBcryptHash(h) {
t.Fatal("expected bcrypt prefix")
}
if isBcryptHash("plaintext") {
t.Fatal("plaintext should not look like bcrypt")
}
}
func TestAuthCacheHitAndMiss(t *testing.T) {
user, pass := "cacheuser", "cachepass"
if authCacheHit(user, pass) {
t.Fatal("cache should miss before set")
}
authCacheSet(user, pass)
if !authCacheHit(user, pass) {
t.Fatal("cache should hit after set")
}
if authCacheHit(user, "wrong") {
t.Fatal("different password should miss")
}
}
func TestGenerateRandomPasswordLength(t *testing.T) {
pw := generateRandomPassword()
if len(pw) != 20 {
t.Fatalf("expected 20-char hex password, got len %d (%q)", len(pw), pw)
}
}
func TestAgentForgeConfigDefaults(t *testing.T) {
cfg := AgentForgeConfig{}
if cfg.poolHostOrDefault("fallback.host") != "fallback.host" {
t.Fatal("empty pool host should use fallback")
}
if cfg.poolPortOrDefault(3333) != 3333 {
t.Fatal("zero pool port should use fallback")
}
cfg = AgentForgeConfig{PoolHost: "pool.example.com", PoolPort: 443}
if cfg.poolHostOrDefault("fallback") != "pool.example.com" {
t.Fatal("explicit pool host should win")
}
if cfg.poolPortOrDefault(3333) != 443 {
t.Fatal("explicit pool port should win")
}
}

View File

@@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"github.com/go-chi/chi/v5"
)
func newTestHandler(t *testing.T) *Handler {
t.Helper()
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
return NewHandler(database)
}
func TestHealthCheckReturnsOK(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec := httptest.NewRecorder()
h.HealthCheck(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "ok" {
t.Fatalf("unexpected body: %v", body)
}
}
func TestListAgentsEmptyArray(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
rec := httptest.NewRecorder()
h.ListAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
var agents []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil {
t.Fatal(err)
}
if len(agents) != 0 {
t.Fatalf("expected empty list, got %d", len(agents))
}
}
}
func TestGetAgentStatsLimitCap(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", nil)
rec := httptest.NewRecorder()
r := chi.NewRouter()
r.Get("/agents/{id}/stats", h.GetAgentStats)
r.ServeHTTP(rec, req)
if rec.Code == http.StatusInternalServerError {
t.Fatalf("limit cap caused 500: %s", rec.Body.String())
}
}
func TestGetRecentSharesLimitCap(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/shares?limit=999999", nil)
rec := httptest.NewRecorder()
h.GetRecentShares(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
}
func TestGetAgentNotFound(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/agents/nope", nil)
rec := httptest.NewRecorder()
r := chi.NewRouter()
r.Get("/agents/{id}", h.GetAgent)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}

View File

@@ -29,9 +29,26 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
return nil
}
const testAuthUser = "testuser"
const testAuthPass = "testpass"
func seedTestUsers(t *testing.T, dataDir string) {
t.Helper()
usersPath := filepath.Join(dataDir, "users.json")
data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass})
if err != nil {
t.Fatalf("marshal users: %v", err)
}
if err := os.WriteFile(usersPath, data, 0600); err != nil {
t.Fatalf("write users.json: %v", err)
}
}
func newTestRouter(t *testing.T) (http.Handler, string) {
t.Helper()
dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir)
if err != nil {
t.Fatalf("db: %v", err)
@@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) {
func TestConfigWithValidAuth(t *testing.T) {
router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
req.SetBasicAuth("drjones", "czapiewski")
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
func TestAgentsListAuthedEmpty(t *testing.T) {
router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.SetBasicAuth("drjones", "czapiewski")
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -164,7 +181,7 @@ func indexOf(s, sub string) int {
func TestStatsLimitCapped(t *testing.T) {
router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
req.SetBasicAuth("drjones", "czapiewski")
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit

View File

@@ -0,0 +1,86 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestParsePort(t *testing.T) {
tests := []struct {
in string
want int
}{
{"8989", 8989},
{"80", 80},
{"abc", 8989},
{"", 8989},
{"0", 8989},
}
for _, tc := range tests {
if got := parsePort(tc.in); got != tc.want {
t.Fatalf("parsePort(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestIsLoopbackHost(t *testing.T) {
if !isLoopbackHost("localhost") {
t.Fatal("localhost should be loopback")
}
if !isLoopbackHost("127.0.0.1") {
t.Fatal("127.0.0.1 should be loopback")
}
if isLoopbackHost("192.168.1.1") {
t.Fatal("192.168.1.1 should not be loopback")
}
}
func TestItoa(t *testing.T) {
if itoa(8989) != "8989" {
t.Fatalf("itoa(8989) = %q", itoa(8989))
}
if itoa(0) != "0" {
t.Fatalf("itoa(0) = %q", itoa(0))
}
}
func TestGetServerInfoJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.Port != 8989 {
t.Fatalf("port = %d, want 8989", info.Port)
}
if info.WebSocketURL == "" || info.SuggestedURL == "" {
t.Fatalf("missing URLs: %+v", info)
}
}
func TestGetServerInfoPublicURLOverride(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
req.Host = "localhost:8989"
rec := httptest.NewRecorder()
GetServerInfo(rec, req, "https://forge.example.com:443")
var info ServerInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if info.SuggestedURL != "https://forge.example.com:443" {
t.Fatalf("override not applied: %q", info.SuggestedURL)
}
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" {
t.Fatalf("ws url = %q", info.WebSocketURL)
}
}

View File

@@ -519,8 +519,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
SSHAvailable *bool `json:"ssh_available,omitempty"`
}
SSHAvailable *bool `json:"ssh_available,omitempty"`
PostureScore *int `json:"posture_score,omitempty"`
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
DefenderRTP *bool `json:"defender_rtp,omitempty"`
AVProducts []string `json:"av_products,omitempty"`
FirewallDomain *bool `json:"firewall_domain,omitempty"`
FirewallPrivate *bool `json:"firewall_private,omitempty"`
FirewallPublic *bool `json:"firewall_public,omitempty"`
LastPatchDays *int `json:"last_patch_days,omitempty"`
LastPatch *string `json:"last_patch,omitempty"`
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
}
@@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.SSHAvailable != nil {
broadcast["ssh_available"] = *stats.SSHAvailable
}
if stats.PostureScore != nil {
broadcast["posture_score"] = *stats.PostureScore
}
if stats.DefenderEnabled != nil {
broadcast["defender_enabled"] = *stats.DefenderEnabled
}
if stats.DefenderRTP != nil {
broadcast["defender_rtp"] = *stats.DefenderRTP
}
if len(stats.AVProducts) > 0 {
broadcast["av_products"] = stats.AVProducts
}
if stats.FirewallDomain != nil {
broadcast["firewall_domain"] = *stats.FirewallDomain
}
if stats.FirewallPrivate != nil {
broadcast["firewall_private"] = *stats.FirewallPrivate
}
if stats.FirewallPublic != nil {
broadcast["firewall_public"] = *stats.FirewallPublic
}
if stats.LastPatchDays != nil {
broadcast["last_patch_days"] = *stats.LastPatchDays
}
if stats.LastPatch != nil {
broadcast["last_patch"] = *stats.LastPatch
}
if stats.PendingUpdates != nil {
broadcast["pending_updates"] = *stats.PendingUpdates
}
if stats.RebootPending != nil {
broadcast["reboot_pending"] = *stats.RebootPending
}
if stats.AgentElevated != nil {
broadcast["agent_elevated"] = *stats.AgentElevated
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share":

View File

@@ -0,0 +1,76 @@
package db
import (
"testing"
)
func TestDecodeTagsEdgeCases(t *testing.T) {
tests := []struct {
name string
raw string
want int
}{
{"empty", "", 0},
{"brackets", "[]", 0},
{"whitespace", " [] ", 0},
{"valid", `["a","b"]`, 2},
{"invalid json", "{not-json", 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := decodeTags(tc.raw)
if len(got) != tc.want {
t.Fatalf("decodeTags(%q) len=%d want %d (%v)", tc.raw, len(got), tc.want, got)
}
})
}
}
func TestEncodeTagsEmpty(t *testing.T) {
if got := encodeTags(nil); got != "[]" {
t.Fatalf("encodeTags(nil) = %q want []", got)
}
if got := encodeTags([]string{}); got != "[]" {
t.Fatalf("encodeTags(empty) = %q want []", got)
}
}
func TestGetAgentInvalidTagsInDB(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "bad-tags")
if _, err := d.Exec(`UPDATE agents SET tags = ? WHERE id = ?`, "{invalid", "bad-tags"); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent("bad-tags")
if err != nil {
t.Fatal(err)
}
if len(got.Tags) != 0 {
t.Fatalf("invalid tags should decode to empty slice, got %v", got.Tags)
}
}
func TestUpdateAgentMetaClearsTags(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "meta-clear")
if err := d.UpdateAgentMeta("meta-clear", "note", []string{"x"}); err != nil {
t.Fatal(err)
}
if err := d.UpdateAgentMeta("meta-clear", "", nil); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent("meta-clear")
if err != nil {
t.Fatal(err)
}
if got.Notes != "" {
t.Fatalf("notes not cleared: %q", got.Notes)
}
if len(got.Tags) != 0 {
t.Fatalf("tags not cleared: %v", got.Tags)
}
}

View File

@@ -0,0 +1,191 @@
package db
import (
"database/sql"
"errors"
"testing"
"time"
"crypto-miner-server/internal/models"
)
func insertBuild(t *testing.T, d *Database, b *models.BuildRecord) {
t.Helper()
if err := d.InsertBuild(b); err != nil {
t.Fatal(err)
}
}
func TestBuildCRUDAndList(t *testing.T) {
d := openTestDB(t)
created := time.Now().UTC().Truncate(time.Second)
build := &models.BuildRecord{
ID: "build-001",
WorkerName: "rig1",
ServerURL: "http://localhost:8080",
Wallet: "48xyz",
Threads: 4,
FileSize: 1024,
BundleSize: 2048,
FilePath: "/data/builds/build-001.zip",
FileName: "agent.zip",
DownloadURL: "/api/v1/builds/build-001/download",
Platform: "windows",
CreatedAt: created,
PoolHost: "pool.example.com",
PoolPort: 443,
PoolTLS: true,
PoolPass: "x",
}
insertBuild(t, d, build)
got, err := d.GetBuild("build-001")
if err != nil {
t.Fatal(err)
}
if got.WorkerName != "rig1" || got.Platform != "windows" || got.BundleSize != 2048 {
t.Fatalf("build mismatch: %+v", got)
}
if got.Pinned {
t.Fatal("new build should not be pinned")
}
list, err := d.ListBuilds(10)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].ID != "build-001" {
t.Fatalf("list builds: %+v", list)
}
}
func TestGetBuildNotFound(t *testing.T) {
d := openTestDB(t)
_, err := d.GetBuild("missing")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected sql.ErrNoRows, got %v", err)
}
}
func TestSetPinnedBuild(t *testing.T) {
d := openTestDB(t)
now := time.Now()
insertBuild(t, d, &models.BuildRecord{ID: "b1", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now})
insertBuild(t, d, &models.BuildRecord{ID: "b2", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: now.Add(time.Second)})
if err := d.SetPinnedBuild("b1"); err != nil {
t.Fatal(err)
}
b1, _ := d.GetBuild("b1")
b2, _ := d.GetBuild("b2")
if !b1.Pinned || b2.Pinned {
t.Fatalf("pin state wrong: b1=%v b2=%v", b1.Pinned, b2.Pinned)
}
if err := d.SetPinnedBuild(""); err != nil {
t.Fatal(err)
}
b1, _ = d.GetBuild("b1")
if b1.Pinned {
t.Fatal("empty id should unpin all builds")
}
}
func TestGetLatestBuildForPlatform(t *testing.T) {
d := openTestDB(t)
base := time.Now().UTC().Truncate(time.Second)
insertBuild(t, d, &models.BuildRecord{
ID: "old-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
Platform: "linux", CreatedAt: base,
})
insertBuild(t, d, &models.BuildRecord{
ID: "new-linux", WorkerName: "w", ServerURL: "u", Wallet: "w",
Platform: "linux", CreatedAt: base.Add(time.Minute),
})
insertBuild(t, d, &models.BuildRecord{
ID: "new-windows", WorkerName: "w", ServerURL: "u", Wallet: "w",
Platform: "windows", CreatedAt: base.Add(2 * time.Minute),
})
latestLinux, err := d.GetLatestBuildForPlatform("linux")
if err != nil {
t.Fatal(err)
}
if latestLinux.ID != "new-linux" {
t.Fatalf("latest linux: got %q", latestLinux.ID)
}
if err := d.SetPinnedBuild("old-linux"); err != nil {
t.Fatal(err)
}
pinnedLinux, err := d.GetLatestBuildForPlatform("linux")
if err != nil {
t.Fatal(err)
}
if pinnedLinux.ID != "old-linux" {
t.Fatalf("pinned linux: got %q", pinnedLinux.ID)
}
anyLatest, err := d.GetLatestBuildForPlatform("any")
if err != nil {
t.Fatal(err)
}
if anyLatest.ID != "old-linux" {
t.Fatalf("any with pinned: got %q want old-linux", anyLatest.ID)
}
if err := d.SetPinnedBuild(""); err != nil {
t.Fatal(err)
}
emptyPlatform, err := d.GetLatestBuildForPlatform("")
if err != nil {
t.Fatal(err)
}
if emptyPlatform.ID != "new-windows" {
t.Fatalf("empty platform latest: got %q", emptyPlatform.ID)
}
}
func TestGetLatestBuildForPlatformEmpty(t *testing.T) {
d := openTestDB(t)
_, err := d.GetLatestBuildForPlatform("linux")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected sql.ErrNoRows on empty db, got %v", err)
}
}
func TestListBuildsOlderThanAndDeleteBuild(t *testing.T) {
d := openTestDB(t)
cutoff := time.Now().UTC().Truncate(time.Second)
oldTime := cutoff.Add(-time.Hour)
newTime := cutoff.Add(time.Hour)
insertBuild(t, d, &models.BuildRecord{
ID: "old-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: oldTime,
})
insertBuild(t, d, &models.BuildRecord{
ID: "new-build", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: newTime,
})
older, err := d.ListBuildsOlderThan(cutoff)
if err != nil {
t.Fatal(err)
}
if len(older) != 1 || older[0].ID != "old-build" {
t.Fatalf("ListBuildsOlderThan: %+v", older)
}
if err := d.DeleteBuild("old-build"); err != nil {
t.Fatal(err)
}
_, err = d.GetBuild("old-build")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected deleted build missing, got %v", err)
}
if err := d.DeleteBuild("never-existed"); err != nil {
t.Fatal(err)
}
}

View File

@@ -17,16 +17,15 @@ func (d *Database) PurgeHashrateSamplesBefore(cutoff time.Time) (int64, error) {
// ListBuildsOlderThan returns build records created before cutoff.
func (d *Database) ListBuildsOlderThan(cutoff time.Time) ([]*models.BuildRecord, error) {
rows, err := d.Query(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE created_at < ?`, cutoff)
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds WHERE created_at < ?`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.BuildRecord
for rows.Next() {
b := &models.BuildRecord{}
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
b, err := scanBuild(rows)
if err != nil {
return nil, err
}
out = append(out, b)

View File

@@ -0,0 +1,255 @@
package db
import (
"database/sql"
"errors"
"testing"
"time"
"crypto-miner-server/internal/models"
)
func openTestDB(t *testing.T) *Database {
t.Helper()
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return d
}
func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
t.Helper()
a := &models.Agent{
ID: id,
Name: "worker-" + id,
Wallet: "wallet",
IP: "10.0.0.1",
Version: "2.0",
Status: "offline",
CPUCores: 4,
MemoryGB: 8,
LastSeen: time.Now(),
}
if err := d.UpsertAgent(a); err != nil {
t.Fatal(err)
}
return a
}
func TestNewCreatesDatabase(t *testing.T) {
dir := t.TempDir()
d, err := New(dir)
if err != nil {
t.Fatal(err)
}
defer d.Close()
var n int
if err := d.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agents'").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("expected agents table, got count %d", n)
}
}
func TestGetAgentNotFound(t *testing.T) {
d := openTestDB(t)
_, err := d.GetAgent("missing-agent")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected sql.ErrNoRows, got %v", err)
}
}
func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
d := openTestDB(t)
a := seedAgent(t, d, "persist-created")
first, err := d.GetAgent(a.ID)
if err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
a.Name = "renamed"
a.Status = "online"
if err := d.UpsertAgent(a); err != nil {
t.Fatal(err)
}
second, err := d.GetAgent(a.ID)
if err != nil {
t.Fatal(err)
}
if !second.CreatedAt.Equal(first.CreatedAt) {
t.Fatalf("created_at changed: %v -> %v", first.CreatedAt, second.CreatedAt)
}
if second.Name != "renamed" {
t.Fatalf("name not updated: %q", second.Name)
}
}
func TestUpdateAgentStats(t *testing.T) {
d := openTestDB(t)
a := seedAgent(t, d, "stats-agent")
if err := d.UpdateAgentStats(a.ID, 100, 200, 300, 10, 8, 2, 55.5, 66.6, 3600); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent(a.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != "online" {
t.Fatalf("status: got %q want online", got.Status)
}
if got.Hashrate15s != 100 || got.Hashrate1m != 200 || got.Hashrate15m != 300 {
t.Fatalf("hashrate mismatch: %+v", got)
}
if got.SharesTotal != 10 || got.SharesGood != 8 || got.SharesBad != 2 {
t.Fatalf("shares mismatch: %+v", got)
}
if got.CPUUsagePct != 55.5 || got.MemoryUsagePct != 66.6 || got.UptimeSeconds != 3600 {
t.Fatalf("usage/uptime mismatch: %+v", got)
}
}
func TestSetAgentOffline(t *testing.T) {
d := openTestDB(t)
a := seedAgent(t, d, "offline-agent")
if err := d.UpdateAgentStats(a.ID, 1, 1, 1, 0, 0, 0, 0, 0, 0); err != nil {
t.Fatal(err)
}
if err := d.SetAgentOffline(a.ID); err != nil {
t.Fatal(err)
}
got, err := d.GetAgent(a.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != "offline" {
t.Fatalf("status: got %q want offline", got.Status)
}
}
func TestShareInsertUpdateAndRecent(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "share-agent")
ts := time.Now().UTC().Truncate(time.Second)
share := &models.Share{
AgentID: "share-agent",
JobID: "job-1",
Difficulty: 1000,
Accepted: false,
Hash: "abc123",
Nonce: "deadbeef",
Error: "pending",
Timestamp: ts,
}
id, err := d.InsertShare(share)
if err != nil {
t.Fatal(err)
}
if id <= 0 {
t.Fatalf("expected positive insert id, got %d", id)
}
if err := d.UpdateShareResult(id, true, ""); err != nil {
t.Fatal(err)
}
recent, err := d.GetRecentShares(10)
if err != nil {
t.Fatal(err)
}
if len(recent) != 1 {
t.Fatalf("expected 1 share, got %d", len(recent))
}
if !recent[0].Accepted {
t.Fatal("share should be accepted after update")
}
if recent[0].Error != "" {
t.Fatalf("error should be cleared, got %q", recent[0].Error)
}
if err := d.UpdateShareResult(id, false, "pool rejected"); err != nil {
t.Fatal(err)
}
recent, err = d.GetRecentShares(1)
if err != nil {
t.Fatal(err)
}
if recent[0].Accepted || recent[0].Error != "pool rejected" {
t.Fatalf("unexpected share after reject update: %+v", recent[0])
}
}
func TestHashrateSampleHistory(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "hr-agent")
if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil {
t.Fatal(err)
}
if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil {
t.Fatal(err)
}
history, err := d.GetHashrateHistory("hr-agent", 5)
if err != nil {
t.Fatal(err)
}
if len(history) != 2 {
t.Fatalf("expected 2 samples, got %d", len(history))
}
if history[0].Hashrate != 200.0 {
t.Fatalf("expected newest first, got %f", history[0].Hashrate)
}
empty, err := d.GetHashrateHistory("unknown", 5)
if err != nil {
t.Fatal(err)
}
if len(empty) != 0 {
t.Fatalf("expected empty history, got %d", len(empty))
}
}
func TestGetFleetStatsWithAgents(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "fleet-a")
seedAgent(t, d, "fleet-b")
if err := d.UpdateAgentStats("fleet-a", 0, 0, 100, 20, 18, 2, 0, 0, 0); err != nil {
t.Fatal(err)
}
if err := d.UpdateAgentStats("fleet-b", 0, 0, 50, 10, 5, 5, 0, 0, 0); err != nil {
t.Fatal(err)
}
stats, err := d.GetFleetStats()
if err != nil {
t.Fatal(err)
}
if stats.TotalAgents != 2 {
t.Fatalf("total agents: got %d want 2", stats.TotalAgents)
}
if stats.OnlineAgents != 2 {
t.Fatalf("online agents: got %d want 2", stats.OnlineAgents)
}
if stats.TotalHashrate != 150 {
t.Fatalf("total hashrate: got %f want 150", stats.TotalHashrate)
}
if stats.TotalShares != 30 || stats.AcceptedShares != 23 || stats.RejectedShares != 7 {
t.Fatalf("share totals mismatch: %+v", stats)
}
wantRate := float64(23) / float64(30) * 100
if stats.AcceptRate != wantRate {
t.Fatalf("accept rate: got %f want %f", stats.AcceptRate, wantRate)
}
}

View File

@@ -36,6 +36,20 @@ type Agent struct {
// Crucible — SSH status probed by the agent every ~60s
SSHAvailable *bool `json:"ssh_available,omitempty"`
// Defense posture + patch exposure — ATT&CK T1685/T1686.003
PostureScore int `json:"posture_score,omitempty"`
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
DefenderRTP *bool `json:"defender_rtp,omitempty"`
AVProducts []string `json:"av_products,omitempty"`
FirewallDomain *bool `json:"firewall_domain,omitempty"`
FirewallPrivate *bool `json:"firewall_private,omitempty"`
FirewallPublic *bool `json:"firewall_public,omitempty"`
LastPatchDays *int `json:"last_patch_days,omitempty"`
LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD
PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
}
// AgentCapabilities reports forge-time features available for remote command.