Files
EvoBGP/internal/httpapi/server_test.go
T
Denozordec 8a50ba44b3
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 39s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 13s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 6m19s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m28s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m22s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m25s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m10s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m23s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m22s
feat: add live BGP protocol state tracking to peer listing
Enhanced the peer listing API to include live BGP session states by integrating a new function to retrieve protocol states from the BIRD socket. Added parsing logic for BGP protocol states and updated the peer JSON response to include session state information. Introduced unit tests for the new parsing functionality to ensure accuracy in state retrieval.
2026-04-09 15:39:01 +07:00

360 lines
10 KiB
Go

package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"evobgp/internal/jobs"
"evobgp/internal/signing"
)
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
func TestAPIRefreshApplyJobsBundle(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
BundleSeedHex: testBundleSeed,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
t.Run("prometheus metrics", func(t *testing.T) {
// HTTPMiddleware increments the counter after the handler returns, so the scrape
// of /metrics does not include that same request; warm with a public route first.
warm, _ := http.NewRequest(http.MethodGet, base+"/v1/health", nil)
warmResp, err := client.Do(warm)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, warmResp.Body)
_ = warmResp.Body.Close()
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
for _, needle := range []string{
"evobgp_materialized_prefixes_max",
"evobgp_bgp_peers_configured_total",
"evobgp_http_requests_total", // incremented by this scrape request
} {
if !strings.Contains(s, needle) {
t.Fatalf("metrics body missing %q", needle)
}
}
})
t.Run("refresh IP_RANGES queues render job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("refresh CDN queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modCDN+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("preview revision", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/preview", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("diagnostic log for revision", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/diagnostic-log", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/plain") {
t.Fatalf("unexpected content-type: %q", got)
}
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
for _, needle := range []string{
"EvoBGP revision diagnostic log",
"revision_id=" + rev,
"## Raw rows",
} {
if !strings.Contains(body, needle) {
t.Fatalf("diagnostic log missing %q", needle)
}
}
})
t.Run("list modules peers speakers", func(t *testing.T) {
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(b, &body); err != nil {
t.Fatalf("%s json: %v", path, err)
}
if len(body.Items) < 1 {
t.Fatalf("%s expected items", path)
}
}
})
t.Run("modules filter by type", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?type=IP_RANGES", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
Items []struct {
Type string `json:"type"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Items) == 0 {
t.Fatalf("expected at least one IP_RANGES module")
}
for _, item := range body.Items {
if item.Type != "IP_RANGES" {
t.Fatalf("unexpected module type %q", item.Type)
}
}
})
t.Run("router lists catalog endpoint", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/router-lists/catalog", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
Modules struct {
Items []map[string]any `json:"items"`
} `json:"modules"`
Domains struct {
Items []map[string]any `json:"items"`
} `json:"domains"`
ASNs struct {
Items []map[string]any `json:"items"`
} `json:"asns"`
IPRanges struct {
Items []map[string]any `json:"items"`
} `json:"ip_ranges"`
Communities struct {
Items []map[string]any `json:"items"`
} `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Modules.Items) == 0 {
t.Fatalf("expected modules in catalog")
}
if body.Communities.Items == nil {
t.Fatalf("expected communities.items field in catalog")
}
})
t.Run("rollback queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("apply all speakers", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/apply", strings.NewReader(`{"revision_id":"`+rev+`"}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
pubB64 := srv.BundlePublicKeyBase64()
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatal(err)
}
t.Run("node bundle roundtrip verify", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/speakers/"+speaker+"/bundle/"+rev, nil)
req.Header.Set("Authorization", "Bearer nodekey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_, err = signing.VerifyGzippedTar(raw, ed25519.PublicKey(pubBytes))
if err != nil {
t.Fatal(err)
}
})
}
func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var body struct {
Status string `json:"status"`
}
_ = json.Unmarshal(b, &body)
if body.Status == jobs.StatusSucceeded || body.Status == jobs.StatusFailed {
if body.Status != jobs.StatusSucceeded {
t.Fatalf("job %s status %s", jobID, body.Status)
}
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("job %s did not complete", jobID)
}
func TestParseBGPProtocolStates(t *testing.T) {
raw := `BIRD 2.16.2 ready.
name proto table state since info
device1 Device --- up 2026-04-09
evobgp_p_abcdef01 BGP master up 2026-04-09 Established
evobgp_p_01234567 BGP master start 2026-04-09 Connect
`
got := parseBGPProtocolStates(raw)
if got["evobgp_p_abcdef01"] != "Established" {
t.Fatalf("expected protocol state Established, got %q", got["evobgp_p_abcdef01"])
}
if got["evobgp_p_01234567"] != "Connect" {
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
}
}