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() 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 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"]) } }) } }