//go:build integration package api_test import ( "bufio" "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "time" "forge-mesh/internal/api/handlers" "forge-mesh/internal/erasure" "forge-mesh/internal/fleet" "forge-mesh/internal/testutil" "github.com/gorilla/websocket" ) type routeResult struct { name string pass bool detail string } func TestIntegrationAllRoutes(t *testing.T) { ts := testutil.NewTestServer(t) defer ts.Close() seedData(t, ts) var results []routeResult record := func(name string, pass bool, detail string) { results = append(results, routeResult{name: name, pass: pass, detail: detail}) if !pass { t.Errorf("%s: %s", name, detail) } } check := func(name string, fn func() (bool, string)) { pass, detail := fn() record(name, pass, detail) } // --- Public routes --- check("GET /api/v1/health", func() (bool, string) { return testHealth(t, ts) }) check("GET /install.sh", func() (bool, string) { return testInstallSh(t, ts) }) check("GET /get", func() (bool, string) { return testGetRedirect(t, ts) }) check("GET /spread/", func() (bool, string) { return testSpread(t, ts) }) check("GET /api/v1/public/builds", func() (bool, string) { return testPublicBuilds(t, ts) }) check("GET /api/v1/public/builds/latest", func() (bool, string) { return testPublicBuildsLatest(t, ts) }) check("GET /api/v1/public/download/{id}", func() (bool, string) { return testPublicDownload(t, ts) }) check("GET /api/v1/public/erasure/{bundle_id}", func() (bool, string) { return testErasureBundle(t, ts) }) check("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", func() (bool, string) { return testErasureShard(t, ts) }) check("GET /api/v1/public/policy-snapshot/{token}", func() (bool, string) { return testPolicySnapshot(t, ts) }) // --- Agent routes (Bearer) --- check("POST /api/v1/fleet/register", func() (bool, string) { return testFleetRegister(t, ts) }) check("POST /api/v1/fleet/beacon", func() (bool, string) { return testFleetBeacon(t, ts) }) // --- Protected routes (Basic) --- check("GET /api/v1/fleet", func() (bool, string) { return testFleetList(t, ts) }) check("GET /api/v1/fleet/hosts", func() (bool, string) { return testFleetHosts(t, ts) }) check("POST /api/v1/fleet/{id}/mining-profile", func() (bool, string) { return testMiningProfile(t, ts) }) check("POST /api/v1/fleet/{id}/action pause", func() (bool, string) { return testFleetAction(t, ts, "pause") }) check("POST /api/v1/fleet/{id}/action reboot", func() (bool, string) { return testFleetAction(t, ts, "reboot") }) check("POST /api/v1/fleet/{id}/action screenshot", func() (bool, string) { return testFleetAction(t, ts, "screenshot") }) check("GET /api/v1/fleet/{id}/lotl/timeline", func() (bool, string) { return testLOTLTimeline(t, ts) }) check("GET /api/v1/forge/builds", func() (bool, string) { return testForgeBuilds(t, ts) }) check("GET /api/v1/calibrate/profiles", func() (bool, string) { return testCalibrateProfiles(t, ts) }) check("POST /api/v1/crucible/batch", func() (bool, string) { return testCrucibleBatch(t, ts) }) check("GET /api/v1/crucible/batch/{id}", func() (bool, string) { return testCrucibleBatchGet(t, ts) }) check("POST /api/v1/crucible/exec", func() (bool, string) { return testCrucibleExec(t, ts) }) check("GET /api/v1/seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/api/v1/seer") }) check("GET /seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/seer") }) check("GET /api/v1/war-room/campaigns", func() (bool, string) { return testWarRoom(t, ts) }) check("GET /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardList(t, ts) }) check("POST /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardCreate(t, ts) }) // --- WebSocket --- check("POST /api/v1/ws/ticket", func() (bool, string) { return testWSTicket(t, ts) }) check("GET /api/v1/ws/fleet deck connect", func() (bool, string) { return testWSFleetDeck(t, ts) }) check("GET /api/v1/ws/fleet agent heartbeat", func() (bool, string) { return testWSFleetAgentHeartbeat(t, ts) }) check("GET /api/v1/ws/fleet command roundtrip", func() (bool, string) { return testWSCommandRoundtrip(t, ts) }) t.Log("--- Route checklist ---") passed, failed := 0, 0 for _, r := range results { status := "PASS" if !r.pass { status = "FAIL" failed++ } else { passed++ } t.Logf("[%s] %s %s", status, r.name, r.detail) } t.Logf("Total: %d passed, %d failed", passed, failed) } var ( testHostID string testBundleID string testBatchID string ) func seedData(t *testing.T, ts *testutil.TestServer) { t.Helper() ctx := context.Background() // Demo host from SeedDemoHost hosts, err := fleet.NewStore(ts.SQL).ListHosts() if err != nil || len(hosts) == 0 { t.Fatal("expected seeded demo host") } testHostID = hosts[0].ID _ = fleet.NewStore(ts.SQL).LogLOTL(ctx, testHostID, 2, "deploy", "success", "", `{}`) svc := erasure.NewService(ts.SQL) bundle, err := svc.Encode(ctx, "integration-test", []byte("aetherforge erasure integration payload")) if err != nil { t.Fatalf("seed erasure: %v", err) } testBundleID = bundle.ID policyJSON := `{"wallet_address":"test-wallet","tiers":[{"type":"xmrig"}]}` if err := handlers.SeedPolicySnapshot(ctx, ts.SQL, "test-policy-token", policyJSON); err != nil { t.Fatalf("seed policy: %v", err) } if err := handlers.SeedCampaign(ctx, ts.SQL, "ember", "Emberwake Test", 42); err != nil { t.Fatalf("seed campaign: %v", err) } artifactPath := filepath.Join(t.TempDir(), "agent-linux-amd64") if err := os.WriteFile(artifactPath, []byte("#!/bin/sh\necho agent"), 0o755); err != nil { t.Fatalf("write artifact: %v", err) } buildID := "integration-build-amd64" _, err = ts.SQL.ExecContext(ctx, ` INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at) VALUES (?, 'linux', 'amd64', 'integration-test', 'abc123', 'sig', 1, ?, datetime('now'))`, buildID, artifactPath) if err != nil { t.Fatalf("seed build: %v", err) } } func testHealth(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/health") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return false, err.Error() } if body["status"] != "ok" { return false, fmt.Sprintf("body %v", body) } return true, "200 ok" } func testInstallSh(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/install.sh") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return false, fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } return true, "200 shell script" } func testGetRedirect(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/get?c=test") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { return false, fmt.Sprintf("status %d", resp.StatusCode) } loc := resp.Header.Get("Location") if !strings.Contains(loc, "/install.sh") { return false, "missing install.sh redirect" } return true, "302 -> install.sh" } func testSpread(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/spread/?c=ember") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "Emberwake") { return false, "missing lander content" } return true, "200 HTML lander" } func testPublicBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/builds") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 builds list" } func testPublicBuildsLatest(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/builds/latest?os=linux&arch=amd64") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 latest build" } func testPublicDownload(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/download/integration-build-amd64") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 artifact stream" } func testErasureBundle(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 bundle metadata" } func testErasureShard(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID + "/shard/0") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 shard hex" } func testPolicySnapshot(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := http.Get(ts.URL + "/api/v1/public/policy-snapshot/test-policy-token") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 policy JSON" } func testFleetRegister(t *testing.T, ts *testutil.TestServer) (bool, string) { body := bytes.NewBufferString(`{"hostname":"agent-integration","arch":"amd64"}`) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/register", body) req.Header.Set("Authorization", "Bearer "+ts.FleetSecret) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 enrolled" } func testFleetBeacon(t *testing.T, ts *testutil.TestServer) (bool, string) { payload := fmt.Sprintf(`{"host_id":%q,"hostname":"beacon-host","hashrate_hps":1000}`, testHostID) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/beacon", strings.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+ts.FleetSecret) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 beacon ack" } func testFleetList(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/fleet") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 fleet summary" } func testFleetHosts(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/fleet/hosts") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var body map[string]any _ = json.NewDecoder(resp.Body).Decode(&body) if _, ok := body["hosts"]; !ok { return false, "missing hosts key" } return true, "200 hosts list" } func testMiningProfile(t *testing.T, ts *testutil.TestServer) (bool, string) { body := bytes.NewBufferString(`{"wallet_address":"4integrationtestwallet","name":"Integration"}`) resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/mining-profile", body) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 profile pushed" } func testFleetAction(t *testing.T, ts *testutil.TestServer, action string) (bool, string) { body := bytes.NewBufferString(fmt.Sprintf(`{"action":%q}`, action)) resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 " + action + " dispatched" } func testLOTLTimeline(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/fleet/"+testHostID+"/lotl/timeline") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return false, err.Error() } if _, ok := body["timeline"]; !ok { return false, "missing timeline" } return true, "200 LOTL timeline" } func testForgeBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/forge/builds") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 forge builds" } func testCalibrateProfiles(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/calibrate/profiles") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 calibrate profiles" } func testCrucibleBatch(t *testing.T, ts *testutil.TestServer) (bool, string) { body := bytes.NewBufferString(fmt.Sprintf(`{"command":"status","host_ids":[%q]}`, testHostID)) resp, err := basicPost(ts, "/api/v1/crucible/batch", body) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var job map[string]any if err := json.NewDecoder(resp.Body).Decode(&job); err != nil { return false, err.Error() } id, _ := job["id"].(string) if id == "" { return false, "missing job id" } testBatchID = id return true, "200 batch created" } func testCrucibleBatchGet(t *testing.T, ts *testutil.TestServer) (bool, string) { if testBatchID == "" { return false, "no batch id from prior test" } resp, err := basicGet(ts, "/api/v1/crucible/batch/"+testBatchID) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 batch status" } func testCrucibleExec(t *testing.T, ts *testutil.TestServer) (bool, string) { body := bytes.NewBufferString(fmt.Sprintf(`{"host_id":%q,"command":"shell echo hi"}`, testHostID)) resp, err := basicPost(ts, "/api/v1/crucible/exec", body) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 exec dispatched" } func testSeerSSE(t *testing.T, ts *testutil.TestServer, path string) (bool, string) { req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) req.SetBasicAuth(ts.BasicUser, ts.BasicPass) req.Header.Set("Accept", "text/event-stream") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() req = req.WithContext(ctx) resp, err := http.DefaultClient.Do(req) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "text/event-stream") { return false, "not SSE: " + ct } reader := bufio.NewReader(resp.Body) line, err := reader.ReadString('\n') if err != nil && err != io.EOF { return false, err.Error() } if !strings.HasPrefix(line, "data:") { return false, "no SSE data line" } return true, "200 SSE stream" } func testWarRoom(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/war-room/campaigns") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var body map[string]any _ = json.NewDecoder(resp.Body).Decode(&body) campaigns, _ := body["campaigns"].([]any) if len(campaigns) == 0 { return false, "empty campaigns" } return true, "200 war room campaigns" } func testWireGuardList(t *testing.T, ts *testutil.TestServer) (bool, string) { resp, err := basicGet(ts, "/api/v1/wireguard/peers") if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "200 peers list" } func testWireGuardCreate(t *testing.T, ts *testutil.TestServer) (bool, string) { body := bytes.NewBufferString(`{"host_id":"` + testHostID + `","public_key":"wg-test-pubkey","endpoint":"10.0.0.1:51820"}`) resp, err := basicPost(ts, "/api/v1/wireguard/peers", body) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return false, fmt.Sprintf("status %d", resp.StatusCode) } return true, "201 peer created" } func testWSTicket(t *testing.T, ts *testutil.TestServer) (bool, string) { req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil) req.SetBasicAuth(ts.BasicUser, ts.BasicPass) resp, err := http.DefaultClient.Do(req) if err != nil { return false, err.Error() } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("status %d", resp.StatusCode) } var out struct { Ticket string `json:"ticket"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { return false, "empty ticket" } return true, "200 ticket issued" } func testWSFleetDeck(t *testing.T, ts *testutil.TestServer) (bool, string) { ticket := issueTicket(t, ts) wsURL := wsURL(ts.URL, "/api/v1/ws/fleet?ticket="+url.QueryEscape(ticket)) conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { return false, err.Error() } defer conn.Close() if resp.StatusCode != http.StatusSwitchingProtocols { return false, fmt.Sprintf("status %d", resp.StatusCode) } _ = conn.WriteMessage(websocket.PingMessage, nil) return true, "101 deck connected" } func testWSFleetAgentHeartbeat(t *testing.T, ts *testutil.TestServer) (bool, string) { conn, resp, err := dialAgentWS(ts) if err != nil { return false, err.Error() } defer conn.Close() if resp.StatusCode != http.StatusSwitchingProtocols { return false, fmt.Sprintf("status %d", resp.StatusCode) } hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"ws-agent","hashrate_hps":5000}`, testHostID) if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil { return false, err.Error() } conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, _, err = conn.ReadMessage() if err != nil && !strings.Contains(err.Error(), "timeout") { // heartbeat may not produce a direct reply; connection staying open is success } return true, "101 agent heartbeat sent" } func testWSCommandRoundtrip(t *testing.T, ts *testutil.TestServer) (bool, string) { conn, _, err := dialAgentWS(ts) if err != nil { return false, err.Error() } defer conn.Close() cmdCh := make(chan []byte, 1) go func() { for { _, msg, err := conn.ReadMessage() if err != nil { return } var frame map[string]any if json.Unmarshal(msg, &frame) == nil && frame["type"] == "command" { cmdCh <- msg return } } }() hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"cmd-agent","hashrate_hps":9000}`, testHostID) if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil { return false, err.Error() } time.Sleep(300 * time.Millisecond) body := bytes.NewBufferString(`{"action":"pause"}`) resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body) if err != nil { return false, err.Error() } resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, fmt.Sprintf("action status %d", resp.StatusCode) } select { case msg := <-cmdCh: var frame map[string]any _ = json.Unmarshal(msg, &frame) return true, fmt.Sprintf("command action=%v", frame["command"]) case <-time.After(5 * time.Second): return false, "no command frame received" } } func dialAgentWS(ts *testutil.TestServer) (*websocket.Conn, *http.Response, error) { wsURL := wsURL(ts.URL, "/api/v1/ws/fleet") header := http.Header{} header.Set("Authorization", "Bearer "+ts.FleetSecret) return websocket.DefaultDialer.Dial(wsURL, header) } func issueTicket(t *testing.T, ts *testutil.TestServer) string { t.Helper() req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil) req.SetBasicAuth(ts.BasicUser, ts.BasicPass) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { Ticket string `json:"ticket"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { t.Fatal("ticket issue failed") } return out.Ticket } func wsURL(httpURL, path string) string { u, _ := url.Parse(httpURL) u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1) u.Path = "" u.RawPath = "" u.RawQuery = "" if strings.Contains(path, "?") { parts := strings.SplitN(path, "?", 2) u.Path = parts[0] u.RawQuery = parts[1] } else { u.Path = path } return u.String() } func basicGet(ts *testutil.TestServer, path string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) if err != nil { return nil, err } req.SetBasicAuth(ts.BasicUser, ts.BasicPass) return http.DefaultClient.Do(req) } func basicPost(ts *testutil.TestServer, path string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(http.MethodPost, ts.URL+path, body) if err != nil { return nil, err } req.SetBasicAuth(ts.BasicUser, ts.BasicPass) req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req) } // Ensure unused import guard for sql in case of build tags var _ = sql.ErrNoRows