Files
AetherForge/server/internal/api/router_test.go
AetherForge 0002e5fd93
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Calibrate AI Control UI and fleet LLM backend wiring.
Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
2026-06-07 02:14:28 -07:00

599 lines
18 KiB
Go

package api
import (
"bytes"
"encoding/json"
"fmt"
"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()
authLoadMu.Lock()
authLoadedDataDir = ""
authLoadMu.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)
}
}
// TestBasicAuthMiddlewareBuildDownloadRequiresAuth verifies that build download
// routes are no longer publicly accessible — they require fleet secret or Basic Auth.
func TestBasicAuthMiddlewareBuildDownloadRequiresAuth(t *testing.T) {
resetAuthState(t)
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
paths := []string{
"/api/v1/builds/abc/download",
"/api/v1/builds/abc/artifact/worker.exe",
}
for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s without auth should be 401, got %d", p, rec.Code)
}
}
// Fleet secret should grant access.
SetAgentPathSecret("test-secret-abc")
t.Cleanup(func() { SetAgentPathSecret("") })
for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil)
req.Header.Set("X-Fleet-Secret", "test-secret-abc")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s with fleet secret should be 200, got %d", p, 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)
}
if strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
t.Fatal("SPA requests with Authorization must not get WWW-Authenticate")
}
}
func TestIsSPAAuthRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
if isSPAAuthRequest(req) {
t.Fatal("bare request should not be SPA")
}
req.Header.Set("X-AetherForge-Client", "dashboard")
if !isSPAAuthRequest(req) {
t.Fatal("client header should mark SPA request")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
if !isSPAAuthRequest(req) {
t.Fatal("Authorization header should mark SPA request")
}
}
func TestBasicAuthMiddlewareSPANoWWWAuthenticate(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")
}))
for _, setup := range []func(*http.Request){
func(r *http.Request) { r.Header.Set("X-AetherForge-Client", "dashboard") },
func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") },
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
setup(req)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "" {
t.Fatal("SPA-marked 401 must not include WWW-Authenticate")
}
}
}
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 TestRouterPostUsersConflict(t *testing.T) {
router, _, _, _ := newTestRouter(t)
body, _ := json.Marshal(map[string]string{"username": testAuthUser, "password": "otherpass"})
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.StatusConflict {
t.Fatalf("existing username should 409, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestRouterPostWSTicket(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", 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["ticket"] == nil || body["ticket"] == "" {
t.Fatalf("expected ticket in response: %v", body)
}
}
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())
}
}
// TestRouterBuildDownloadAuth verifies that build download routes require either
// the fleet secret (X-Fleet-Secret header) or Basic Auth — they are no longer
// publicly accessible without credentials.
func TestRouterBuildDownloadAuth(t *testing.T) {
dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir)
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)
}
const testSecret = "test-fleet-secret-12345"
SetAgentPathSecret(testSecret)
t.Cleanup(func() { SetAgentPathSecret("") })
wsHub := NewWSHub(database)
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
// 1. Unauthenticated → 401.
req := httptest.NewRequest(http.MethodGet, dlURL, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated download should be 401, got %d", rec.Code)
}
// 2. Valid fleet secret → 200.
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
req.Header.Set("X-Fleet-Secret", testSecret)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("fleet-secret download should be 200, got %d body=%s", rec.Code, rec.Body.String())
}
// 3. Basic Auth → 200.
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
req.SetBasicAuth("testuser", "testpass")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("basic-auth download should be 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestRouterDropperInstallScriptsPublic(t *testing.T) {
router, _, _, _ := newTestRouter(t)
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(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, 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())
}
}
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
resetAuthState(t)
dataDir := t.TempDir()
LoadUsers(dataDir)
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
data, err := os.ReadFile(sidecarPath)
if err != nil {
t.Fatalf("login sidecar: %v", err)
}
var creds map[string]string
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatal(err)
}
pw, ok := creds["admin"]
if !ok || pw == "" {
t.Fatalf("expected admin password in sidecar: %v", creds)
}
if !checkPassword(authUsers["admin"], pw) {
t.Fatal("sidecar password should match users.json hash")
}
comradePW, ok := creds["comrade"]
if !ok || comradePW == "" {
t.Fatalf("expected comrade password in sidecar: %v", creds)
}
if !checkPassword(authUsers["comrade"], comradePW) {
t.Fatal("comrade sidecar password should match users.json hash")
}
if err := saveUser("admin", "new-secret-pass"); err != nil {
t.Fatal(err)
}
reloaded, err := readLoginSidecar(sidecarPath)
if err != nil || reloaded["admin"] != "new-secret-pass" {
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
}
}
func TestLoadUsersAddsComradeToExistingAdminDeck(t *testing.T) {
resetAuthState(t)
dataDir := t.TempDir()
adminHash, err := hashPassword("keep-admin")
if err != nil {
t.Fatal(err)
}
usersPath := filepath.Join(dataDir, "users.json")
if err := os.WriteFile(usersPath, []byte(fmt.Sprintf(`{"admin":%q}`, adminHash)), 0600); err != nil {
t.Fatal(err)
}
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
if err := writeLoginSidecar(sidecarPath, map[string]string{"admin": "keep-admin"}); err != nil {
t.Fatal(err)
}
LoadUsers(dataDir)
usersMu.RLock()
_, hasComrade := authUsers["comrade"]
usersMu.RUnlock()
if !hasComrade {
t.Fatal("expected comrade in users.json after startup")
}
creds, err := readLoginSidecar(sidecarPath)
if err != nil {
t.Fatal(err)
}
if creds["admin"] != "keep-admin" {
t.Fatalf("admin sidecar password changed: %q", creds["admin"])
}
comradePW, ok := creds["comrade"]
if !ok || comradePW == "" {
t.Fatalf("expected comrade in sidecar: %v", creds)
}
if !checkPassword(authUsers["comrade"], comradePW) {
t.Fatal("comrade sidecar password should match users.json hash")
}
}