Files
EvoBGP/internal/httpapi/routes_crud_csv_test.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 17:23:59 +07:00

159 lines
4.9 KiB
Go

package httpapi
import (
"context"
"encoding/csv"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evobgp/internal/importer"
"evobgp/internal/store"
)
func TestCSVImportLimitsBeforeMutations(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
mod, err := m.CreateModule(tenant, &store.Module{Type: "AS_PREFIXES", Name: "csv-limits"})
if err != nil {
t.Fatal(err)
}
module := mod.ID
s := &Server{store: m}
before, err := m.ListASEntries(tenant, module)
if err != nil {
t.Fatal(err)
}
header := "asn,community\n"
validPrefix := header + "64512,\n"
oversized := validPrefix + strings.Repeat("\n", importer.MaxCSVBytes-len(validPrefix)) + "64513,\n"
for _, tc := range []struct {
name, body string
unknownLength bool
status int
}{
{"exact", header + strings.Repeat("\n", importer.MaxCSVBytes-len(header)), false, http.StatusOK},
{"oversize", oversized, false, http.StatusRequestEntityTooLarge},
{"unknown_length", oversized, true, http.StatusRequestEntityTooLarge},
{"rows", header + strings.Repeat("64512,\n", importer.MaxCSVRows+1), true, http.StatusRequestEntityTooLarge},
{"fields", header + "64512," + strings.Repeat(",", importer.MaxCSVFields), false, http.StatusRequestEntityTooLarge},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/modules/"+module+"/entries.csv", strings.NewReader(tc.body))
if tc.unknownLength {
req.ContentLength = -1
req.TransferEncoding = []string{"chunked"}
}
req.SetPathValue("module_id", module)
req = req.WithContext(context.WithValue(req.Context(), authCtxKey, Auth{TenantID: tenant, Role: "operator"}))
w := httptest.NewRecorder()
s.handleImportModuleEntriesCSV(w, req)
if w.Code != tc.status {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
after, e := m.ListASEntries(tenant, module)
if e != nil || len(after) != len(before) {
t.Fatalf("mutations on rejected/header-only request: count=%d err=%v", len(after), e)
}
})
}
}
func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
reqList, _ := http.NewRequest(http.MethodGet, base+"/v1/communities?limit=10", nil)
reqList.Header.Set("Authorization", "Bearer opkey")
respList, err := client.Do(reqList)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respList.Body.Close() }()
if respList.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respList.Body)
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
}
var listBody struct {
Items []struct {
Community string `json:"community"`
} `json:"items"`
}
if err := json.NewDecoder(respList.Body).Decode(&listBody); err != nil {
t.Fatal(err)
}
if len(listBody.Items) == 0 {
t.Fatal("expected seeded community")
}
csvBody := "\"ipRange\",\"community\"\n\"10.254.1.254/32\",\"" + listBody.Items[0].Community + "\"\n\"160.79.104.0/23\",\"" + listBody.Items[0].Community + "\"\n"
reqImport, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/entries.csv", strings.NewReader(csvBody))
reqImport.Header.Set("Authorization", "Bearer opkey")
reqImport.Header.Set("Content-Type", "text/csv")
respImport, err := client.Do(reqImport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respImport.Body.Close() }()
if respImport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respImport.Body)
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
}
var importBody struct {
Imported int `json:"imported"`
}
if err := json.NewDecoder(respImport.Body).Decode(&importBody); err != nil {
t.Fatal(err)
}
if importBody.Imported != 2 {
t.Fatalf("expected imported=2, got %d", importBody.Imported)
}
reqExport, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+modIP+"/entries.csv", nil)
reqExport.Header.Set("Authorization", "Bearer opkey")
respExport, err := client.Do(reqExport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respExport.Body.Close() }()
if respExport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respExport.Body)
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
}
if got := respExport.Header.Get("Content-Type"); !strings.Contains(got, "text/csv") {
t.Fatalf("unexpected content-type: %q", got)
}
raw, err := io.ReadAll(respExport.Body)
if err != nil {
t.Fatal(err)
}
records, err := csv.NewReader(strings.NewReader(string(raw))).ReadAll()
if err != nil {
t.Fatal(err)
}
if len(records) < 3 {
t.Fatalf("expected at least 3 csv rows, got %d", len(records))
}
if records[0][0] != "ipRange" || records[0][1] != "community" {
t.Fatalf("unexpected header: %#v", records[0])
}
}