Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
This commit is contained in:
718
internal/api/integration_test.go
Normal file
718
internal/api/integration_test.go
Normal file
@@ -0,0 +1,718 @@
|
||||
//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
|
||||
Reference in New Issue
Block a user