Add runtime comrade account, null-safe spread funnel API/UI, server-started cloudflared connector with Calibrate token field and builtin fallback, and simplify LAUNCH to delegate tunnel startup to AetherForge.
523 lines
16 KiB
Go
523 lines
16 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)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
// 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, NewDropperHandler(database, nil), nil, nil, "", dataDir, 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, "", 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())
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|