feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
@@ -3,22 +3,278 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/ollama"
|
||||
)
|
||||
|
||||
func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
// Numeric constants from ai_handler.go (guard against silent drift).
|
||||
const (
|
||||
aiReportBufferCap = 1000
|
||||
aiHeartbeatIntervalSeconds = 60
|
||||
aiReasoningTruncateLen = 120
|
||||
aiReportOutputTruncateLen = 200
|
||||
aiEngineIdleTTL = time.Hour
|
||||
aiActivityIdleTTL = 24 * time.Hour
|
||||
aiCleanupTickerInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
func newTestAIHandler(t *testing.T) *AIHandler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return NewAIHandler(database)
|
||||
}
|
||||
|
||||
h := NewAIHandler(database)
|
||||
func mockOllamaChatServer(t *testing.T, content string, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if status != http.StatusOK {
|
||||
_, _ = w.Write([]byte("ollama down"))
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]string{"content": content},
|
||||
"done": true,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func ollamaDecideContent(reasoning string, tools []ollama.ToolCall) string {
|
||||
resp := ollama.DecideResponse{Reasoning: reasoning, ToolCalls: tools}
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestAIConstants(t *testing.T) {
|
||||
if aiReportBufferCap != 1000 || aiHeartbeatIntervalSeconds != 60 ||
|
||||
aiReasoningTruncateLen != 120 || aiReportOutputTruncateLen != 200 ||
|
||||
aiEngineIdleTTL != time.Hour || aiActivityIdleTTL != 24*time.Hour ||
|
||||
aiCleanupTickerInterval != 10*time.Minute {
|
||||
t.Fatal("ai_handler constants drifted from documented values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAINewAIHandler(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
if h.db == nil || h.engines == nil || h.activity == nil {
|
||||
t.Fatal("NewAIHandler did not initialize fields")
|
||||
}
|
||||
if cap(h.reports) != aiReportBufferCap {
|
||||
t.Fatalf("reports slice cap want %d got %d", aiReportBufferCap, cap(h.reports))
|
||||
}
|
||||
if len(h.ActivitySnapshot()) != 0 {
|
||||
t.Fatal("expected empty activity snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITruncateStr(t *testing.T) {
|
||||
if truncateStr("short", aiReasoningTruncateLen) != "short" {
|
||||
t.Fatal("short string should be unchanged")
|
||||
}
|
||||
long := strings.Repeat("x", aiReasoningTruncateLen+10)
|
||||
out := truncateStr(long, aiReasoningTruncateLen)
|
||||
if len(out) != aiReasoningTruncateLen+3 || !strings.HasSuffix(out, "...") {
|
||||
t.Fatalf("truncate want len %d with ellipsis, got %q", aiReasoningTruncateLen+3, out)
|
||||
}
|
||||
if truncateStr("", 5) != "" {
|
||||
t.Fatal("empty string")
|
||||
}
|
||||
if truncateStr("exact", 5) != "exact" {
|
||||
t.Fatal("exact length boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentAndGetRemove(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, ollamaDecideContent("ok", nil), http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-a", srv.URL, "test-model")
|
||||
if h.GetEngine("agent-a") == nil {
|
||||
t.Fatal("GetEngine returned nil after SetEngineForAgent")
|
||||
}
|
||||
h.RemoveEngine("agent-a")
|
||||
if h.GetEngine("agent-a") != nil {
|
||||
t.Fatal("GetEngine should be nil after RemoveEngine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISetEngineForAgentDefaults(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var gotModel string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
gotModel = req.Model
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": map[string]string{"content": ollamaDecideContent("ok", nil)},
|
||||
"done": true,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h.SetEngineForAgent("agent-defaults", srv.URL, "")
|
||||
eng := h.GetEngine("agent-defaults")
|
||||
if eng == nil {
|
||||
t.Fatal("engine missing")
|
||||
}
|
||||
_, err := eng.Decide(&ollama.AgentState{AgentID: "agent-defaults"})
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if gotModel != "llama3.2" {
|
||||
t.Fatalf("default model want llama3.2 got %q", gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", strings.NewReader(`{"worker_name":"x"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "agent_id") {
|
||||
t.Fatalf("missing agent_id status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideSuccess(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("all good", []ollama.ToolCall{{
|
||||
Tool: "check_miner", Args: map[string]string{"process_name": "xmrig"}, Reason: "verify",
|
||||
}})
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-ok",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "test-model",
|
||||
"hostname": "host1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp ollama.DecideResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Tool != "check_miner" {
|
||||
t.Fatalf("unexpected tool calls: %+v", resp.ToolCalls)
|
||||
}
|
||||
|
||||
snap := h.ActivitySnapshot()
|
||||
var found bool
|
||||
for _, e := range snap {
|
||||
if e.AgentID == "agent-decide-ok" && e.LastAction == "check_miner" && e.ToolCallCount == 1 {
|
||||
found = true
|
||||
if len(e.LastReasoning) > aiReasoningTruncateLen+3 {
|
||||
t.Fatalf("reasoning should be truncated to %d", aiReasoningTruncateLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("activity not recorded: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideOllamaFailureFallback(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
srv := mockOllamaChatServer(t, "", http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-decide-fail",
|
||||
"ollama_endpoint": srv.URL,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fallback should return 200, got %d", w.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["error"] == nil {
|
||||
t.Fatal("expected error field")
|
||||
}
|
||||
tools, ok := out["tool_calls"].([]interface{})
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("expected sleep fallback tool_calls, got %v", out["tool_calls"])
|
||||
}
|
||||
first, _ := tools[0].(map[string]interface{})
|
||||
if first["tool"] != "sleep" {
|
||||
t.Fatalf("expected sleep tool, got %v", first["tool"])
|
||||
}
|
||||
args, _ := first["args"].(map[string]interface{})
|
||||
if args["seconds"] != "120" {
|
||||
t.Fatalf("fallback sleep seconds want 120 got %v", args["seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleDecideCreatesEngineOnFirstRequest(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
content := ollamaDecideContent("idle", nil)
|
||||
srv := mockOllamaChatServer(t, content, http.StatusOK)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"agent_id": "agent-new",
|
||||
"ollama_endpoint": srv.URL,
|
||||
"model": "m1",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDecide(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if h.GetEngine("agent-new") == nil {
|
||||
t.Fatal("engine should exist after first decide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportSingleAndArray(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
single := ollama.Report{
|
||||
AgentID: "agent-1",
|
||||
@@ -54,6 +310,166 @@ func TestHandleReportSingleAndArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportErrors(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", errReader{})
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("read error status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", strings.NewReader("not-json"))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid json status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportBufferCap(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
for i := 0; i < aiReportBufferCap+5; i++ {
|
||||
r := ollama.Report{AgentID: "cap-agent", Tool: "sleep", Success: true}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("report %d status %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
n := len(h.reports)
|
||||
h.mu.RUnlock()
|
||||
if n != aiReportBufferCap {
|
||||
t.Fatalf("reports len want %d got %d", aiReportBufferCap, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleReportEventBroadcaster(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
var events []AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
events = append(events, e)
|
||||
})
|
||||
|
||||
r := ollama.Report{AgentID: "evt-agent", Tool: "upload_log", Success: true, Output: "done"}
|
||||
b, _ := json.Marshal(r)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(b))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleReport(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if len(events) != 1 || events[0].AgentID != "evt-agent" || events[0].LastTool != "upload_log" {
|
||||
t.Fatalf("broadcaster events: %+v", events)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&resp)
|
||||
if resp["received"].(float64) != 1 || resp["success"] != true {
|
||||
t.Fatalf("response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIHandleHeartbeat(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader("{"))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status %d", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", strings.NewReader(`{"status":"alive"}`))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing agent_id status %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(heartbeatRequest{AgentID: "hb-agent", Status: "alive", Message: "ok"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
h.HandleHeartbeat(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["success"] != true {
|
||||
t.Fatalf("success: %v", out["success"])
|
||||
}
|
||||
interval, ok := out["interval"].(float64)
|
||||
if !ok || int(interval) != aiHeartbeatIntervalSeconds {
|
||||
t.Fatalf("interval want %d got %v", aiHeartbeatIntervalSeconds, out["interval"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIRecordActivityMergeAndBroadcast(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
h.activity["merge-agent"] = AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastReportAt: now,
|
||||
LastSuccess: true,
|
||||
ToolCallCount: 3,
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var broadcasted AIActivityEntry
|
||||
h.SetEventBroadcaster(func(e AIActivityEntry) {
|
||||
broadcasted = e
|
||||
})
|
||||
|
||||
h.recordActivity(AIActivityEntry{
|
||||
AgentID: "merge-agent",
|
||||
LastDecideAt: now.Add(time.Minute),
|
||||
LastAction: "sleep",
|
||||
LastTool: "sleep",
|
||||
LastReasoning: "rest",
|
||||
ToolCallCount: 0, // zero must not wipe previous count
|
||||
})
|
||||
|
||||
h.mu.RLock()
|
||||
prev := h.activity["merge-agent"]
|
||||
h.mu.RUnlock()
|
||||
if prev.ToolCallCount != 3 {
|
||||
t.Fatalf("zero ToolCallCount should not clear prior count, got %d", prev.ToolCallCount)
|
||||
}
|
||||
if prev.LastAction != "sleep" || prev.LastReasoning != "rest" {
|
||||
t.Fatalf("merged activity: %+v", prev)
|
||||
}
|
||||
if broadcasted.AgentID != "merge-agent" {
|
||||
t.Fatalf("broadcast: %+v", broadcasted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIActivitySnapshot(t *testing.T) {
|
||||
h := newTestAIHandler(t)
|
||||
h.mu.Lock()
|
||||
h.activity["a"] = AIActivityEntry{AgentID: "a"}
|
||||
h.activity["b"] = AIActivityEntry{AgentID: "b"}
|
||||
h.mu.Unlock()
|
||||
snap := h.ActivitySnapshot()
|
||||
if len(snap) != 2 {
|
||||
t.Fatalf("want 2 entries got %d", len(snap))
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read([]byte) (int, error) { return 0, errors.New("read fail") }
|
||||
func (errReader) Close() error { return nil }
|
||||
|
||||
// errReader must satisfy io.ReadCloser for request body
|
||||
var _ io.ReadCloser = errReader{}
|
||||
|
||||
func TestEstimateXMRPerDay(t *testing.T) {
|
||||
out := EstimateXMRPerDay(3_000_000_000)
|
||||
xmr, ok := out["xmr_per_day"].(float64)
|
||||
|
||||
72
server/internal/api/auth_test.go
Normal file
72
server/internal/api/auth_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckPasswordBcryptAndLegacy(t *testing.T) {
|
||||
hashed, err := hashPassword("secret123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !checkPassword(hashed, "secret123") {
|
||||
t.Fatal("bcrypt hash should match correct password")
|
||||
}
|
||||
if checkPassword(hashed, "wrong") {
|
||||
t.Fatal("bcrypt hash should reject wrong password")
|
||||
}
|
||||
if !checkPassword("plainlegacy", "plainlegacy") {
|
||||
t.Fatal("legacy plain-text should match")
|
||||
}
|
||||
if checkPassword("plainlegacy", "other") {
|
||||
t.Fatal("legacy plain-text should reject mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBcryptHash(t *testing.T) {
|
||||
h, _ := hashPassword("x")
|
||||
if !isBcryptHash(h) {
|
||||
t.Fatal("expected bcrypt prefix")
|
||||
}
|
||||
if isBcryptHash("plaintext") {
|
||||
t.Fatal("plaintext should not look like bcrypt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCacheHitAndMiss(t *testing.T) {
|
||||
user, pass := "cacheuser", "cachepass"
|
||||
if authCacheHit(user, pass) {
|
||||
t.Fatal("cache should miss before set")
|
||||
}
|
||||
authCacheSet(user, pass)
|
||||
if !authCacheHit(user, pass) {
|
||||
t.Fatal("cache should hit after set")
|
||||
}
|
||||
if authCacheHit(user, "wrong") {
|
||||
t.Fatal("different password should miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomPasswordLength(t *testing.T) {
|
||||
pw := generateRandomPassword()
|
||||
if len(pw) != 20 {
|
||||
t.Fatalf("expected 20-char hex password, got len %d (%q)", len(pw), pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentForgeConfigDefaults(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if cfg.poolHostOrDefault("fallback.host") != "fallback.host" {
|
||||
t.Fatal("empty pool host should use fallback")
|
||||
}
|
||||
if cfg.poolPortOrDefault(3333) != 3333 {
|
||||
t.Fatal("zero pool port should use fallback")
|
||||
}
|
||||
cfg = AgentForgeConfig{PoolHost: "pool.example.com", PoolPort: 443}
|
||||
if cfg.poolHostOrDefault("fallback") != "pool.example.com" {
|
||||
t.Fatal("explicit pool host should win")
|
||||
}
|
||||
if cfg.poolPortOrDefault(3333) != 443 {
|
||||
t.Fatal("explicit pool port should win")
|
||||
}
|
||||
}
|
||||
91
server/internal/api/handlers_test.go
Normal file
91
server/internal/api/handlers_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return NewHandler(database)
|
||||
}
|
||||
|
||||
func TestHealthCheckReturnsOK(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HealthCheck(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("unexpected body: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgentsEmptyArray(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAgents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
|
||||
var agents []json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agents) != 0 {
|
||||
t.Fatalf("expected empty list, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatsLimitCap(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Get("/agents/{id}/stats", h.GetAgentStats)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusInternalServerError {
|
||||
t.Fatalf("limit cap caused 500: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentSharesLimitCap(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/shares?limit=999999", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRecentShares(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentNotFound(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Get("/agents/{id}", h.GetAgent)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,26 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
|
||||
func seedTestUsers(t *testing.T, dataDir string) {
|
||||
t.Helper()
|
||||
usersPath := filepath.Join(dataDir, "users.json")
|
||||
data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal users: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(usersPath, data, 0600); err != nil {
|
||||
t.Fatalf("write users.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
@@ -84,7 +101,7 @@ func TestConfigRequiresAuth(t *testing.T) {
|
||||
func TestConfigWithValidAuth(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -105,7 +122,7 @@ func TestAgentsListRequiresAuth(t *testing.T) {
|
||||
func TestAgentsListAuthedEmpty(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -164,7 +181,7 @@ func indexOf(s, sub string) int {
|
||||
func TestStatsLimitCapped(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
|
||||
req.SetBasicAuth("drjones", "czapiewski")
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit
|
||||
|
||||
86
server/internal/api/server_info_test.go
Normal file
86
server/internal/api/server_info_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePort(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"8989", 8989},
|
||||
{"80", 80},
|
||||
{"abc", 8989},
|
||||
{"", 8989},
|
||||
{"0", 8989},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := parsePort(tc.in); got != tc.want {
|
||||
t.Fatalf("parsePort(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLoopbackHost(t *testing.T) {
|
||||
if !isLoopbackHost("localhost") {
|
||||
t.Fatal("localhost should be loopback")
|
||||
}
|
||||
if !isLoopbackHost("127.0.0.1") {
|
||||
t.Fatal("127.0.0.1 should be loopback")
|
||||
}
|
||||
if isLoopbackHost("192.168.1.1") {
|
||||
t.Fatal("192.168.1.1 should not be loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
if itoa(8989) != "8989" {
|
||||
t.Fatalf("itoa(8989) = %q", itoa(8989))
|
||||
}
|
||||
if itoa(0) != "0" {
|
||||
t.Fatalf("itoa(0) = %q", itoa(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerInfoJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Port != 8989 {
|
||||
t.Fatalf("port = %d, want 8989", info.Port)
|
||||
}
|
||||
if info.WebSocketURL == "" || info.SuggestedURL == "" {
|
||||
t.Fatalf("missing URLs: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerInfoPublicURLOverride(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)
|
||||
req.Host = "localhost:8989"
|
||||
rec := httptest.NewRecorder()
|
||||
GetServerInfo(rec, req, "https://forge.example.com:443")
|
||||
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.SuggestedURL != "https://forge.example.com:443" {
|
||||
t.Fatalf("override not applied: %q", info.SuggestedURL)
|
||||
}
|
||||
if info.WebSocketURL != "wss://forge.example.com:443/ws/agent" {
|
||||
t.Fatalf("ws url = %q", info.WebSocketURL)
|
||||
}
|
||||
}
|
||||
@@ -519,8 +519,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
}
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -550,6 +562,42 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.SSHAvailable != nil {
|
||||
broadcast["ssh_available"] = *stats.SSHAvailable
|
||||
}
|
||||
if stats.PostureScore != nil {
|
||||
broadcast["posture_score"] = *stats.PostureScore
|
||||
}
|
||||
if stats.DefenderEnabled != nil {
|
||||
broadcast["defender_enabled"] = *stats.DefenderEnabled
|
||||
}
|
||||
if stats.DefenderRTP != nil {
|
||||
broadcast["defender_rtp"] = *stats.DefenderRTP
|
||||
}
|
||||
if len(stats.AVProducts) > 0 {
|
||||
broadcast["av_products"] = stats.AVProducts
|
||||
}
|
||||
if stats.FirewallDomain != nil {
|
||||
broadcast["firewall_domain"] = *stats.FirewallDomain
|
||||
}
|
||||
if stats.FirewallPrivate != nil {
|
||||
broadcast["firewall_private"] = *stats.FirewallPrivate
|
||||
}
|
||||
if stats.FirewallPublic != nil {
|
||||
broadcast["firewall_public"] = *stats.FirewallPublic
|
||||
}
|
||||
if stats.LastPatchDays != nil {
|
||||
broadcast["last_patch_days"] = *stats.LastPatchDays
|
||||
}
|
||||
if stats.LastPatch != nil {
|
||||
broadcast["last_patch"] = *stats.LastPatch
|
||||
}
|
||||
if stats.PendingUpdates != nil {
|
||||
broadcast["pending_updates"] = *stats.PendingUpdates
|
||||
}
|
||||
if stats.RebootPending != nil {
|
||||
broadcast["reboot_pending"] = *stats.RebootPending
|
||||
}
|
||||
if stats.AgentElevated != nil {
|
||||
broadcast["agent_elevated"] = *stats.AgentElevated
|
||||
}
|
||||
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
|
||||
|
||||
case "submit_share":
|
||||
|
||||
Reference in New Issue
Block a user