CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation. - Introduced a new Auth session endpoint to retrieve current tenant and role information. - Updated the authentication middleware to support API key-based authentication and track last used timestamps. - Enhanced documentation to reflect new API key functionalities and usage guidelines. - Improved logging for demo authentication scenarios.
432 lines
12 KiB
Go
432 lines
12 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"
|
|
"evobgp/internal/version"
|
|
)
|
|
|
|
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()
|
|
mustSetTestAPIKeys(t, srv, "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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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"])
|
|
}
|
|
}
|
|
|
|
func TestVersionEndpoints(t *testing.T) {
|
|
srv, err := New(Options{
|
|
InsecureDev: true,
|
|
SeedDemo: true,
|
|
BundleSeedHex: testBundleSeed,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer srv.Close()
|
|
|
|
oldVer := version.Version
|
|
oldSHA := version.GitSHA
|
|
oldTime := version.BuildTime
|
|
version.Version = "1.2.3"
|
|
version.GitSHA = "abc123def456"
|
|
version.BuildTime = "2026-05-20T12:00:00Z"
|
|
t.Cleanup(func() {
|
|
version.Version = oldVer
|
|
version.GitSHA = oldSHA
|
|
version.BuildTime = oldTime
|
|
})
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
for _, path := range []string{"/version", "/v1/version"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
resp, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
|
}
|
|
var body map[string]string
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["version"] != "1.2.3" {
|
|
t.Fatalf("version=%q want 1.2.3", body["version"])
|
|
}
|
|
if body["api_version"] != "1.2.3" {
|
|
t.Fatalf("api_version=%q want 1.2.3", body["api_version"])
|
|
}
|
|
if body["git_sha"] != "abc123def456" {
|
|
t.Fatalf("git_sha=%q", body["git_sha"])
|
|
}
|
|
if body["build_time"] != "2026-05-20T12:00:00Z" {
|
|
t.Fatalf("build_time=%q", body["build_time"])
|
|
}
|
|
})
|
|
}
|
|
}
|