Files
EvoBGP/internal/httpapi/server_test.go
T

374 lines
11 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,edkey|" + tenant + "|editor")
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 forbidden for editor", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer edkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
}
})
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"])
}
}