feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge
This commit is contained in:
159
server/internal/api/blueprint_handler_test.go
Normal file
159
server/internal/api/blueprint_handler_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newTestBlueprintHandler(t *testing.T) (*BlueprintHandler, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
return NewBlueprintHandler(dataDir), dataDir
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
if sanitizeFilename(" my preset ") != "my preset" {
|
||||
t.Fatalf("trim failed: %q", sanitizeFilename(" my preset "))
|
||||
}
|
||||
if sanitizeFilename("../../../etc/passwd") == "" || strings.Contains(sanitizeFilename("../../../etc/passwd"), "..") {
|
||||
t.Fatalf("traversal not sanitized: %q", sanitizeFilename("../../../etc/passwd"))
|
||||
}
|
||||
if sanitizeFilename("bad/name") != "badname" {
|
||||
t.Fatalf("slashes removed: %q", sanitizeFilename("bad/name"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintListEmpty(t *testing.T) {
|
||||
h, _ := newTestBlueprintHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/blueprints", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list []BlueprintInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected empty list, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintSaveGetDelete(t *testing.T) {
|
||||
h, dataDir := newTestBlueprintHandler(t)
|
||||
|
||||
saveBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "fleet-default",
|
||||
"data": map[string]interface{}{"pool_host": "pool.example.com", "threads": 4},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader(saveBody))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dataDir, "blueprints", "fleet-default.json")
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
t.Fatalf("blueprint file missing: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/blueprints", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var listed []BlueprintInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].Name != "fleet-default" {
|
||||
t.Fatalf("list after save: %+v", listed)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/blueprints/{name}", h.GetBlueprint)
|
||||
req = httptest.NewRequest(http.MethodGet, "/blueprints/fleet-default", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "pool.example.com") {
|
||||
t.Fatalf("unexpected blueprint body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/blueprints?name=fleet-default", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Fatal("blueprint file should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintSaveValidationErrors(t *testing.T) {
|
||||
h, _ := newTestBlueprintHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`{"name":"","data":{}}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty name should be 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`not-json`)))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid json should be 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/blueprints", bytes.NewReader([]byte(`{"name":"ok","data":}`)))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed data json should be 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintGetNotFound(t *testing.T) {
|
||||
h, _ := newTestBlueprintHandler(t)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/blueprints/{name}", h.GetBlueprint)
|
||||
req := httptest.NewRequest(http.MethodGet, "/blueprints/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintDeleteMissingName(t *testing.T) {
|
||||
h, _ := newTestBlueprintHandler(t)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/blueprints", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintMethodNotAllowed(t *testing.T) {
|
||||
h, _ := newTestBlueprintHandler(t)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/blueprints", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
150
server/internal/api/dropper_handler_test.go
Normal file
150
server/internal/api/dropper_handler_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func newTestDropperHandler(t *testing.T) (*DropperHandler, *db.Database, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return NewDropperHandler(database, func() string { return "https://public.example.com" }), database, dataDir
|
||||
}
|
||||
|
||||
func TestDetectPlatformQueryParam(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"windows": "windows", "win": "windows",
|
||||
"linux": "linux",
|
||||
"darwin": "darwin", "mac": "darwin", "macos": "darwin",
|
||||
"universal": "universal", "any": "universal",
|
||||
"unknown": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/get?os="+in, nil)
|
||||
if got := detectPlatform(req); got != want {
|
||||
t.Fatalf("detectPlatform(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPlatformUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{"Mozilla/5.0 (Windows NT 10.0)", "windows"},
|
||||
{"Mozilla/5.0 (Macintosh; Intel Mac OS X)", "darwin"},
|
||||
{"Mozilla/5.0 (X11; Linux x86_64)", "linux"},
|
||||
{"curl/8.0", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
req.Header.Set("User-Agent", tc.ua)
|
||||
if got := detectPlatform(req); got != tc.want {
|
||||
t.Fatalf("UA %q => %q, want %q", tc.ua, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServeGetNoBuilds(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/get", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeGet(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "no agent build available") {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServeGetWindowsBuild(t *testing.T) {
|
||||
h, database, dataDir := newTestDropperHandler(t)
|
||||
buildID := "win-build"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binPath := filepath.Join(buildDir, "worker.exe")
|
||||
content := []byte("windows-agent-binary")
|
||||
if err := os.WriteFile(binPath, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: binPath, FileName: "worker.exe", Platform: "windows",
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeGet(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "worker.exe") {
|
||||
t.Fatalf("missing disposition: %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperResolveBasePublicURL(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
|
||||
req.Host = "ignored.local:8989"
|
||||
if base := h.resolveBase(req); base != "https://public.example.com" {
|
||||
t.Fatalf("publicURL override = %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperResolveBaseFromRequest(t *testing.T) {
|
||||
h := NewDropperHandler(nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
|
||||
req.Host = "deck.local:8989"
|
||||
req.Header.Set("X-Forwarded-Host", "proxy.example.com")
|
||||
if base := h.resolveBase(req); base != "http://proxy.example.com" {
|
||||
t.Fatalf("resolveBase = %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServeShContent(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeSh(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "#!/bin/sh") || !strings.Contains(body, "https://public.example.com/get") {
|
||||
t.Fatalf("unexpected install.sh body prefix: %.120s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropperServePs1Content(t *testing.T) {
|
||||
h, _, _ := newTestDropperHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServePs1(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "DownloadFile") {
|
||||
t.Fatal("expected PowerShell download snippet")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -89,3 +90,120 @@ func TestGetAgentNotFound(t *testing.T) {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
handlerAgentStatsDefaultLimit = 100
|
||||
handlerAgentStatsMaxLimit = 1000
|
||||
handlerSharesDefaultLimit = 50
|
||||
handlerSharesMaxLimit = 1000
|
||||
handlerListBuildsLimit = 50
|
||||
)
|
||||
|
||||
func TestHandlerConstants(t *testing.T) {
|
||||
if handlerAgentStatsDefaultLimit != 100 || handlerAgentStatsMaxLimit != 1000 {
|
||||
t.Fatal("agent stats limit constants drifted")
|
||||
}
|
||||
if handlerSharesDefaultLimit != 50 || handlerSharesMaxLimit != 1000 {
|
||||
t.Fatal("shares limit constants drifted")
|
||||
}
|
||||
if handlerListBuildsLimit != 50 {
|
||||
t.Fatal("list builds limit constant drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardStatsEmptyFleet(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetDashboardStats(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuildsEmptyArray(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListBuilds(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var builds []json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &builds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(builds) != 0 {
|
||||
t.Fatalf("expected empty builds, got %d", len(builds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinBuildAndUnpinAll(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
database := h.db
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "pin-me", WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: "/tmp/x", FileName: "x.exe", Platform: "windows",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Put("/builds/{id}/pin", h.PinBuild)
|
||||
r.Delete("/builds/pin", h.UnpinAll)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/builds/pin-me/pin", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("pin status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/builds/pin", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unpin status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBuildSuccess(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: "del-me", WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: "/tmp/x", FileName: "x.exe", Platform: "windows",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/builds/{id}", h.DeleteBuild)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/builds/del-me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentSharesDefaultLimit(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/shares", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRecentShares(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatsInvalidLimitUsesDefault(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/agents/x/stats?limit=abc", 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("invalid limit should not 500: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
396
server/internal/api/router_test.go
Normal file
396
server/internal/api/router_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
const routerAuthCacheTTL = 5 * time.Minute
|
||||
|
||||
func resetAuthState(t *testing.T) {
|
||||
t.Helper()
|
||||
authSessionCacheMu.Lock()
|
||||
authSessionCache = map[string]time.Time{}
|
||||
authSessionCacheMu.Unlock()
|
||||
usersMu.Lock()
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath = ""
|
||||
usersMu.Unlock()
|
||||
SetAgentPathSecret("")
|
||||
SetRotateSecretFn(nil)
|
||||
t.Cleanup(resetAuthGlobals)
|
||||
}
|
||||
|
||||
func resetAuthGlobals() {
|
||||
authSessionCacheMu.Lock()
|
||||
authSessionCache = map[string]time.Time{}
|
||||
authSessionCacheMu.Unlock()
|
||||
SetAgentPathSecret("")
|
||||
SetRotateSecretFn(nil)
|
||||
}
|
||||
|
||||
func TestRouterConstants(t *testing.T) {
|
||||
if authCacheTTL != routerAuthCacheTTL {
|
||||
t.Fatalf("authCacheTTL = %v, want %v", authCacheTTL, routerAuthCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCacheKeyDeterministic(t *testing.T) {
|
||||
k1 := authCacheKey("user", "pass")
|
||||
k2 := authCacheKey("user", "pass")
|
||||
if k1 != k2 || k1 == "" {
|
||||
t.Fatalf("cache key not stable: %q %q", k1, k2)
|
||||
}
|
||||
if authCacheKey("user", "other") == k1 {
|
||||
t.Fatal("different passwords should produce different cache keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareOptionsPassthrough(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
called := false
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/v1/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !called || rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("OPTIONS should bypass auth: called=%v status=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("health should be public, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareBuildDownloadPublic(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
paths := []string{
|
||||
"/api/v1/builds/abc/download",
|
||||
"/api/v1/builds/abc/artifact/worker.exe",
|
||||
}
|
||||
for _, path := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s should be public, got %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareDropperPublic(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
for _, path := range []string{"/get", "/install.sh", "/install.ps1"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s should be public, got %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareMissingCredentials(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
usersMu.Lock()
|
||||
authUsers["admin"] = "secret"
|
||||
usersMu.Unlock()
|
||||
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not run without auth")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
|
||||
t.Fatal("expected WWW-Authenticate header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
hashed, err := hashPassword("correct")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
authUsers["admin"] = hashed
|
||||
usersMu.Unlock()
|
||||
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not run with bad password")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("admin", "wrong")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
hashed, err := hashPassword("correct")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
authUsers["admin"] = hashed
|
||||
usersMu.Unlock()
|
||||
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
req.SetBasicAuth("admin", "correct")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if !authCacheHit("admin", "correct") {
|
||||
t.Fatal("successful auth should populate cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
SetAgentPathSecret("fleet-secret-123")
|
||||
|
||||
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing fleet secret should be 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
|
||||
req.Header.Set("X-Fleet-Secret", "fleet-secret-123")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid fleet secret should pass, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPostUsersValidation(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty payload should be 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPostUsersSuccess(t *testing.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))
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
usersPath := filepath.Join(dataDir, "users.json")
|
||||
data, err := os.ReadFile(usersPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var users map[string]string
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !checkPassword(users["newop"], "newpass") {
|
||||
t.Fatal("new user password should be bcrypt stored and verifiable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRotateSecretSuccess(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
SetRotateSecretFn(func() (string, error) {
|
||||
return "new-secret-token-xyz", nil
|
||||
})
|
||||
t.Cleanup(func() { SetRotateSecretFn(nil) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %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["ok"] != true {
|
||||
t.Fatalf("unexpected body: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterBuilderCancelNotFound(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
|
||||
req.SetBasicAuth(testAuthUser, testAuthPass)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterBuildDownloadNoAuth(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
buildID := "dl-build"
|
||||
buildDir := filepath.Join(dataDir, "builds", buildID)
|
||||
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binPath := filepath.Join(buildDir, "agent.exe")
|
||||
if err := os.WriteFile(binPath, []byte("fake-binary"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
||||
FilePath: binPath, FileName: "agent.exe", Platform: "windows",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, 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)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "AetherForge") {
|
||||
t.Fatalf("%s missing branding", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
|
||||
router, _ := newTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected SPA fallback 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "AetherForge") {
|
||||
t.Fatal("expected index.html fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
seedTestUsers(t, dataDir)
|
||||
database, err := db.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
wsHub := NewWSHub(database)
|
||||
cfg := &mockConfigProvider{}
|
||||
configHandler := NewConfigHandler(database, 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, nil, "", dataDir, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "No frontend configured") {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,9 @@ func checkDashboardWSToken(r *http.Request) bool {
|
||||
}
|
||||
user, pass := parts[0], parts[1]
|
||||
usersMu.RLock()
|
||||
expectedPass, exists := authUsers[user]
|
||||
stored, exists := authUsers[user]
|
||||
usersMu.RUnlock()
|
||||
return exists && secureStringEqual(pass, expectedPass)
|
||||
return exists && checkPassword(stored, pass)
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -106,6 +106,8 @@ type WSHub struct {
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentCapabilities map[string]models.AgentCapabilities
|
||||
agentLogs map[string]string
|
||||
// T1016 DNS drift detection — stores last seen resolver list per agent
|
||||
agentDNS map[string][]string
|
||||
serverPolicy ServerPolicy
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
@@ -120,6 +122,7 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentCapabilities: make(map[string]models.AgentCapabilities),
|
||||
agentLogs: make(map[string]string),
|
||||
agentDNS: make(map[string][]string),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
}
|
||||
@@ -519,6 +522,9 @@ 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"`
|
||||
// DNS config (T1016)
|
||||
DNSServers []string `json:"dns_servers,omitempty"`
|
||||
DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
|
||||
// Resource pressure
|
||||
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
|
||||
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
|
||||
@@ -587,6 +593,23 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC }
|
||||
if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct }
|
||||
|
||||
// T1016 DNS drift detection
|
||||
if len(stats.DNSServers) > 0 {
|
||||
broadcast["dns_servers"] = stats.DNSServers
|
||||
if stats.DNSSearchDomains != nil {
|
||||
broadcast["dns_search_domains"] = stats.DNSSearchDomains
|
||||
}
|
||||
h.mu.Lock()
|
||||
prev, hasPrev := h.agentDNS[agentID]
|
||||
drifted := hasPrev && !dnsEqual(prev, stats.DNSServers)
|
||||
h.agentDNS[agentID] = stats.DNSServers
|
||||
h.mu.Unlock()
|
||||
if drifted {
|
||||
broadcast["dns_drifted"] = true
|
||||
log.Printf("[T1016] DNS drift detected on agent %s: %v → %v", agentID, prev, stats.DNSServers)
|
||||
}
|
||||
}
|
||||
|
||||
if stats.SSHAvailable != nil {
|
||||
broadcast["ssh_available"] = *stats.SSHAvailable
|
||||
}
|
||||
@@ -850,6 +873,25 @@ func mustMarshal(v interface{}) json.RawMessage {
|
||||
return data
|
||||
}
|
||||
|
||||
// dnsEqual returns true when two DNS server lists contain the same addresses
|
||||
// regardless of order. Used for T1016 drift detection.
|
||||
func dnsEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
m := make(map[string]int, len(a))
|
||||
for _, v := range a {
|
||||
m[v]++
|
||||
}
|
||||
for _, v := range b {
|
||||
m[v]--
|
||||
if m[v] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BroadcastToAgents sends a message to all connected agents
|
||||
func (h *WSHub) BroadcastToAgents(msg Message) {
|
||||
h.mu.RLock()
|
||||
|
||||
314
server/internal/api/websocket_test.go
Normal file
314
server/internal/api/websocket_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const wsDefaultPingIntervalSec = 30
|
||||
|
||||
func resetWSAuthUsers(t *testing.T, user, pass string) {
|
||||
t.Helper()
|
||||
hashed, err := hashPassword(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
authUsers = map[string]string{user: hashed}
|
||||
usersMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
usersMu.Lock()
|
||||
authUsers = map[string]string{}
|
||||
usersMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func wsDashboardToken(user, pass string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
}
|
||||
|
||||
func dialAgentWS(t *testing.T, hub *WSHub) (*websocket.Conn, string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
|
||||
t.Cleanup(srv.Close)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn, wsURL
|
||||
}
|
||||
|
||||
func authAgentConn(t *testing.T, conn *websocket.Conn, payload map[string]interface{}) Message {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(payload)
|
||||
if err := conn.WriteJSON(Message{Type: "auth", Payload: data}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp Message
|
||||
if err := conn.ReadJSON(&resp); err != nil {
|
||||
t.Fatalf("read auth_response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestWSHubPingIntervalConstants(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
if hub.pingIntervalSec != wsDefaultPingIntervalSec {
|
||||
t.Fatalf("default ping interval = %d", hub.pingIntervalSec)
|
||||
}
|
||||
hub.SetPingInterval(5)
|
||||
if hub.pingIntervalSec != wsDefaultPingIntervalSec {
|
||||
t.Fatalf("below-minimum ping should clamp to %d, got %d", wsDefaultPingIntervalSec, hub.pingIntervalSec)
|
||||
}
|
||||
hub.SetPingInterval(15)
|
||||
if hub.pingIntervalSec != 15 {
|
||||
t.Fatalf("expected 15, got %d", hub.pingIntervalSec)
|
||||
}
|
||||
if hub.pingInterval().Seconds() != 15 {
|
||||
t.Fatalf("pingInterval duration = %v", hub.pingInterval())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDashboardWSTokenBcryptUser(t *testing.T) {
|
||||
resetWSAuthUsers(t, "dash", "secret-pass")
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws/dashboard?token="+wsDashboardToken("dash", "secret-pass"), nil)
|
||||
if !checkDashboardWSToken(req) {
|
||||
t.Fatal("valid bcrypt user token should pass")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/ws/dashboard?token="+wsDashboardToken("dash", "wrong"), nil)
|
||||
if checkDashboardWSToken(req) {
|
||||
t.Fatal("wrong password should fail")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/ws/dashboard", nil)
|
||||
if checkDashboardWSToken(req) {
|
||||
t.Fatal("missing token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDashboardWSUnauthorized(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
_, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected dial failure without token")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 upgrade rejection, got err=%v status=%v", err, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDashboardWSAuthorizedInit(t *testing.T) {
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %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 message, got %q", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentWSBadFleetSecret(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetFleetSecret("required-secret")
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "agent-bad-secret", "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, got %+v", body)
|
||||
}
|
||||
if hub.isAgentConnected("agent-bad-secret") {
|
||||
t.Fatal("agent should not register with bad fleet secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentWSStatsAndLogTail(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
agentID := "stats-agent"
|
||||
conn := connectTestAgent(t, hub, agentID)
|
||||
|
||||
statsPayload, _ := json.Marshal(map[string]interface{}{
|
||||
"hashrate_15s": 100.0, "hashrate_1m": 90.0, "hashrate_15m": 80.0,
|
||||
"shares_submitted": 5, "shares_accepted": 4,
|
||||
"cpu_usage_pct": 12.5, "memory_usage_pct": 40.0, "uptime_seconds": 60,
|
||||
})
|
||||
if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logPayload, _ := json.Marshal(map[string]interface{}{"content": "line1\nline2", "lines": 2})
|
||||
if err := conn.WriteJSON(Message{Type: "log_tail", Payload: logPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := hub.GetAgentLog(agentID); got != "line1\nline2" {
|
||||
t.Fatalf("log tail = %q", got)
|
||||
}
|
||||
|
||||
cmdPayload, _ := json.Marshal(map[string]interface{}{"action": "exec", "success": true})
|
||||
if err := conn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentWSMaxAgentsPolicy(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{MaxAgents: 1})
|
||||
|
||||
conn1, _ := dialAgentWS(t, hub)
|
||||
authAgentConn(t, conn1, map[string]interface{}{"agent_id": "first", "hostname": "h1"})
|
||||
|
||||
conn2, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn2, map[string]interface{}{"agent_id": "second", "hostname": "h2"})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["success"] != false {
|
||||
t.Fatalf("second agent should be rejected at max=1: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentWSInvalidAuthPayload(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
if err := conn.WriteJSON(Message{Type: "auth", Payload: json.RawMessage(`"not-an-object"`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp Message
|
||||
if err := conn.ReadJSON(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(resp.Payload, &body)
|
||||
if body["success"] != false {
|
||||
t.Fatalf("invalid auth payload should fail: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSHubSendAgentCommandNotConnected(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
if err := hub.SendAgentCommand("missing", "restart", nil); err == nil {
|
||||
t.Fatal("expected error for disconnected agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSHubBroadcastHelpers(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
hub.BroadcastServerLog(" ")
|
||||
hub.BroadcastFleetAlert(map[string]string{"level": "info"})
|
||||
hub.BroadcastPoolStatus(map[string]string{"connected": "true"})
|
||||
hub.BroadcastAIActivity(map[string]string{"agent_id": "a"})
|
||||
}
|
||||
|
||||
func TestWSHubEnrichAgentsCapabilities(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
agentID := "cap-agent"
|
||||
connectTestAgent(t, hub, agentID)
|
||||
|
||||
agents := []*models.Agent{{ID: agentID, Name: "x"}}
|
||||
hub.enrichAgentsCapabilities(agents)
|
||||
if agents[0].Capabilities == nil {
|
||||
t.Fatal("expected capabilities enrichment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSHubAgentPoolConfigDefaults(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
hub.defaultPool = pool.Config{Host: "primary.pool", Port: 3333, Wallet: "48wallet", Password: "pw"}
|
||||
hub.agentConfigs["a1"] = AgentForgeConfig{PoolHost: "custom.pool", PoolPort: 4444}
|
||||
cfg := hub.agentPoolConfig("a1")
|
||||
if cfg.Host != "custom.pool" || cfg.Port != 4444 {
|
||||
t.Fatalf("unexpected pool cfg: %+v", cfg)
|
||||
}
|
||||
cfg = hub.agentPoolConfig("missing")
|
||||
if cfg.Host != "primary.pool" {
|
||||
t.Fatalf("missing agent should use default pool: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSHubConnectedAgentCount(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
if hub.connectedAgentCount() != 0 {
|
||||
t.Fatal("expected zero agents initially")
|
||||
}
|
||||
connectTestAgent(t, hub, "count-agent")
|
||||
if hub.connectedAgentCount() != 1 {
|
||||
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
|
||||
}
|
||||
}
|
||||
30
server/internal/api/ws_types_test.go
Normal file
30
server/internal/api/ws_types_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWSTypesJSONRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
}{
|
||||
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
|
||||
{"offline", WSAgentOffline{AgentID: "a1"}},
|
||||
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},
|
||||
{"log", WSAgentLog{AgentID: "a1", Content: "tail"}},
|
||||
{"server_log", WSServerLog{Line: "started"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tc.in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("empty JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user