Files
cloud-ip-validator/internal/httpapi/handlers_analytics_test.go
T

549 lines
24 KiB
Go
Raw Normal View History

package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
"time"
"cloudipvalidator/internal/db"
)
// finishedRun builds one finished run of two addresses through the real
// queue paths and returns its id: 9.9.9.1 passes, 9.9.9.2 has a failed ssh.
func finishedRun(t *testing.T, d *db.DB) int64 {
t.Helper()
ctx := context.Background()
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.1", "9.9.9.2"}, db.RunManual); err != nil {
t.Fatal(err)
}
for _, addr := range []string{"9.9.9.1", "9.9.9.2"} {
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip, _ = d.GetIP(ctx, ip.ID)
put := func(src, typ, target, detail string, ok bool) {
if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: detail, CheckedAt: db.Now()}); err != nil {
t.Fatal(err)
}
}
put(db.SourceEgress, "https", "https://a.test", "", true)
put(db.InboundSource(1), "icmp", addr, "", true)
sshOK := addr == "9.9.9.1"
put(db.InboundSource(1), "ssh", addr, "dial tcp: i/o timeout", sshOK)
verdict := db.ResultPass
if !sshOK {
verdict = db.ResultPartial
}
if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil {
t.Fatal(err)
}
}
rs, err := d.ListRuns(ctx)
if err != nil || len(rs) != 1 || rs[0].State != db.RunFinalized {
t.Fatalf("expected one finalized run: %+v %v", rs, err)
}
return rs[0].ID
}
func TestAnalyticsEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
id := finishedRun(t, d)
base := "/api/v1/admin/analytics/runs"
resp, body := fc.do(http.MethodGet, base, nil)
var list []analyticsRunDTO
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &list) != nil || len(list) != 1 ||
list[0].State != "finalized" || list[0].Addresses != 2 || list[0].Pass != 1 || list[0].Partial != 1 {
t.Fatalf("runs: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id), nil)
var rep struct {
Summary struct {
Addresses int `json:"addresses"`
IngressSSHAny int `json:"ingress_ssh_any_failed"`
IngressSSHAll int `json:"ingress_ssh_all_failed"`
} `json:"summary"`
Errors []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"errors"`
Sites struct {
Rows []struct {
Site string `json:"site"`
} `json:"rows"`
} `json:"sites"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Summary.Addresses != 2 ||
rep.Summary.IngressSSHAny != 1 || rep.Summary.IngressSSHAll != 1 ||
len(rep.Errors) != 1 || rep.Errors[0].Name != "SSH: таймаут" || len(rep.Sites.Rows) != 1 || rep.Sites.Rows[0].Site != "rxmsk" {
t.Fatalf("report: %d %s", resp.StatusCode, body)
}
// The list as JSON and as a CSV file with BOM and a download name.
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any", nil)
var l struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any?format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") ||
!strings.Contains(string(body), "9.9.9.2") || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
// A verdict list: 9.9.9.2 is the only partial address.
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" || l.Columns[len(l.Columns)-1] != "Причина" {
t.Fatalf("verdict list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial?format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "9.9.9.2") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="verdict_partial_run`+itoa64(id)+`.csv"`) {
t.Fatalf("verdict csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil)
if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") {
t.Fatalf("error csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
for path, want := range map[string]int{
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
base + "/" + itoa64(id) + "/lists/verdict_cancelled": http.StatusNotFound,
base + "/9999": http.StatusNotFound,
base + "/abc": http.StatusBadRequest,
base + "/9999/lists/ingress_ssh_any": http.StatusNotFound,
} {
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != want {
t.Errorf("%s: %d %s, want %d", path, resp.StatusCode, body, want)
}
}
}
2026-10-04 10:26:37 +03:00
// secondRun builds the run after finishedRun's and returns its id: 9.9.9.1 is
// not in it, 9.9.9.2 is checked again and passes now, 9.9.9.3 is new and has a
// failed ssh. Against the first run: one new, one left, one changed address.
func secondRun(t *testing.T, d *db.DB) int64 {
t.Helper()
ctx := context.Background()
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.2", "9.9.9.3"}, db.RunManual); err != nil {
t.Fatal(err)
}
for _, addr := range []string{"9.9.9.2", "9.9.9.3"} {
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip, _ = d.GetIP(ctx, ip.ID)
put := func(src, typ, target string, ok bool) {
if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: "dial tcp: i/o timeout", CheckedAt: db.Now()}); err != nil {
t.Fatal(err)
}
}
put(db.SourceEgress, "https", "https://a.test", true)
put(db.InboundSource(1), "icmp", addr, true)
sshOK := addr == "9.9.9.2"
put(db.InboundSource(1), "ssh", addr, sshOK)
verdict := db.ResultPass
if !sshOK {
verdict = db.ResultPartial
}
if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil {
t.Fatal(err)
}
}
rs, err := d.ListRuns(ctx)
if err != nil || len(rs) != 2 || rs[0].State != db.RunFinalized {
t.Fatalf("expected two finalized runs: %+v %v", rs, err)
}
return rs[0].ID
}
func TestAnalyticsCompareEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
a := finishedRun(t, d)
b := secondRun(t, d)
cmp := "/api/v1/admin/analytics/compare"
q := "?base=" + itoa64(a) + "&target=" + itoa64(b)
resp, body := fc.do(http.MethodGet, cmp+q, nil)
var rep struct {
Runs struct {
Base struct{ ID, Addresses int64 } `json:"base"`
Target struct{ ID, Addresses int64 } `json:"target"`
} `json:"runs"`
Groups struct {
New, Left, Common, Changed, Same int
} `json:"groups"`
Indicators []struct {
Key string
Base, Target, Delta, New, Left, Entered, Exited int
} `json:"indicators"`
Transitions struct {
Matrix [][]int `json:"matrix"`
} `json:"transitions"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Runs.Base.ID != a || rep.Runs.Target.ID != b ||
rep.Runs.Base.Addresses != 2 || rep.Runs.Target.Addresses != 2 ||
rep.Groups.New != 1 || rep.Groups.Left != 1 || rep.Groups.Common != 1 || rep.Groups.Changed != 1 || rep.Groups.Same != 0 ||
len(rep.Indicators) != 7 || len(rep.Transitions.Matrix) != 3 || rep.Transitions.Matrix[1][0] != 1 {
t.Fatalf("compare: %d %s", resp.StatusCode, body)
}
for _, ind := range rep.Indicators {
if ind.Delta != ind.New-ind.Left+ind.Entered-ind.Exited || ind.Delta != ind.Target-ind.Base {
t.Errorf("indicator %s: %+v", ind.Key, ind)
}
if ind.Key == "ingress_ssh_any" && (ind.Base != 1 || ind.Target != 1 || ind.New != 1 || ind.Exited != 1) {
t.Errorf("ssh any: %+v", ind)
}
}
// Lists: JSON, then CSV with BOM and the name of the file.
var l struct {
Group string `json:"group"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || l.Group != "changed" || len(l.Rows) != 1 ||
l.Rows[0][0] != "9.9.9.2" || !strings.HasPrefix(l.Rows[0][2], "partial → pass") {
t.Fatalf("changed list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.3" {
t.Fatalf("new list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/left"+q+"&indicator=verdict_pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("left list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("matrix cell list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/exited"+q+"&indicator=ingress_ssh_any", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("exited list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q+"&format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") || !strings.Contains(string(body), "9.9.9.2") ||
!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="compare_changed_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_new_verdict_partial_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with an indicator: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_common_partial-pass_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with verdicts: %d %v %q", resp.StatusCode, resp.Header, body)
}
// The order is the caller's: swapped, the new address is the one that left.
swapped := "?base=" + itoa64(b) + "&target=" + itoa64(a)
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+swapped, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("swapped: %d %s", resp.StatusCode, body)
}
// An open run cannot be compared.
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
rs, _ := d.ListRuns(context.Background())
open := itoa64(rs[0].ID)
if rs[0].State == db.RunFinalized {
t.Fatalf("expected an open run: %+v", rs[0])
}
ida, idb := itoa64(a), itoa64(b)
for _, c := range []struct {
path string
want int
}{
{cmp, http.StatusBadRequest}, // no ids
{cmp + "?base=" + ida, http.StatusBadRequest},
{cmp + "?target=" + ida, http.StatusBadRequest},
{cmp + "?base=abc&target=" + ida, http.StatusBadRequest},
{cmp + "?base=0&target=" + ida, http.StatusBadRequest},
{cmp + "?base=" + ida + "&target=" + ida, http.StatusBadRequest}, // the same run twice
{cmp + "/lists/changed?base=" + ida + "&target=" + ida, http.StatusBadRequest},
{cmp + "/lists/changed", http.StatusBadRequest},
{cmp + "?base=9999&target=" + ida, http.StatusNotFound},
{cmp + "?base=" + ida + "&target=9999", http.StatusNotFound},
{cmp + "?base=" + ida + "&target=" + open, http.StatusConflict},
{cmp + "/lists/changed?base=" + open + "&target=" + idb, http.StatusConflict},
{cmp + "/lists/nonsense" + q, http.StatusNotFound},
{cmp + "/lists/new" + q + "&indicator=nonsense", http.StatusNotFound},
{cmp + "/lists/entered" + q, http.StatusNotFound}, // an indicator is required
{cmp + "/lists/common" + q + "&from=pass", http.StatusNotFound},
{cmp + "/lists/common" + q + "&from=pass&to=cancelled", http.StatusNotFound},
} {
if resp, body := fc.do(http.MethodGet, c.path, nil); resp.StatusCode != c.want {
t.Errorf("%s: %d %s, want %d", c.path, resp.StatusCode, body, c.want)
}
}
}
// An open run has no analytics yet.
func TestAnalyticsOfOpenRunIsRefused(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.1"}); err != nil {
t.Fatal(err)
}
rs, _ := d.ListRuns(context.Background())
resp, body := fc.do(http.MethodGet, "/api/v1/admin/analytics/runs/"+itoa64(rs[0].ID), nil)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("open run: %d %s", resp.StatusCode, body)
}
}
// The result is cached while the run is unchanged and recomputed when a
// check of the run is written or the subnet list changes.
func TestAnalyticsCacheFollowsData(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d)
path := "/api/v1/admin/analytics/runs/" + itoa64(id)
subnetsOf := func() []string {
_, body := fc.do(http.MethodGet, path, nil)
var rep struct {
Subnets []struct {
CIDR string `json:"cidr"`
} `json:"subnets"`
}
if err := json.Unmarshal(body, &rep); err != nil {
t.Fatal(err)
}
var out []string
for _, s := range rep.Subnets {
out = append(out, s.CIDR)
}
return out
}
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.9.0/24" {
t.Fatalf("without a list addresses group by /24: %v", got)
}
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "9.9.0.0/16", Label: "девятые"}}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("put subnets: %d %s", resp.StatusCode, body)
}
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.0.0/16" {
t.Fatalf("a changed subnet list must change the report: %v", got)
}
}
// ?subnet= narrows the report and the lists to the addresses inside it, a
// malformed CIDR is a 400, and the cache keeps the subnets apart and bounded.
func TestAnalyticsSubnetFilter(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d) // 9.9.9.1 passes, 9.9.9.2 is partial
base := "/api/v1/admin/analytics/runs/" + itoa64(id)
report := func(subnet string) (addresses, partial int, scope string) {
resp, body := fc.do(http.MethodGet, base+"?subnet="+url.QueryEscape(subnet), nil)
var rep struct {
Summary struct{ Addresses, Partial int } `json:"summary"`
Scope *struct {
Subnet string `json:"subnet"`
RunAddresses int `json:"run_addresses"`
} `json:"scope"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil {
t.Fatalf("report %q: %d %s", subnet, resp.StatusCode, body)
}
if rep.Scope != nil {
scope = fmt.Sprintf("%s/%d", rep.Scope.Subnet, rep.Scope.RunAddresses)
}
return rep.Summary.Addresses, rep.Summary.Partial, scope
}
for _, c := range []struct {
subnet string
addrs, partial int
scope string
}{
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"},
{"9.9.9.1/32", 1, 0, "9.9.9.1/32/2"},
{"9.9.9.0/24", 2, 1, "9.9.9.0/24/2"},
{"9.9.9.77/24", 2, 1, "9.9.9.0/24/2"}, // host bits are masked
{"10.0.0.0/8", 0, 0, "10.0.0.0/8/2"},
{"", 2, 1, ""},
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"}, // the first subnet again: not mixed up with the others
} {
if a, p, sc := report(c.subnet); a != c.addrs || p != c.partial || sc != c.scope {
t.Errorf("subnet %q: addresses %d, partial %d, scope %q; want %d, %d, %q", c.subnet, a, p, sc, c.addrs, c.partial, c.scope)
}
}
var l struct {
Rows [][]string `json:"rows"`
}
resp, body := fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.2/32", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("list in the subnet: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.1/32", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 0 {
t.Fatalf("list outside the subnet: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?format=csv&subnet=9.9.9.1/32", nil)
if resp.StatusCode != http.StatusOK || strings.Contains(string(body), "9.9.9.2") {
t.Fatalf("csv outside the subnet: %d %q", resp.StatusCode, body)
}
for _, path := range []string{base + "?subnet=nonsense", base + "/lists/verdict_pass?subnet=9.9.9.0", base + "/lists/verdict_pass?subnet=9.9.9.0/33"} {
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: %d %s, want 400", path, resp.StatusCode, body)
}
}
// The cache holds a bounded number of entries.
s := &Server{DB: d}
for i := 0; i < analyticsCacheMax+5; i++ {
rec := httptest.NewRecorder()
subnet := netip.PrefixFrom(netip.AddrFrom4([4]byte{9, 9, byte(i), 0}), 24)
if an := s.analysisByID(rec, httptest.NewRequest(http.MethodGet, "/", nil), id, subnet); an == nil {
t.Fatalf("analysis of %s: %d %s", subnet, rec.Code, rec.Body)
}
}
if n := len(s.analytics.entries); n != analyticsCacheMax {
t.Errorf("cache entries = %d, want %d", n, analyticsCacheMax)
}
}
func TestSubnetsConfigEndpoints(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}})
var got subnetsDTO
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 || got.Subnets[0].CIDR != "10.0.0.0/8" || got.Subnets[1].CIDR != "10.1.2.0/24" {
t.Fatalf("put: %d %s", resp.StatusCode, body)
}
if resp, _ = fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "garbage"}}}); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("garbage must be a 400, got %d", resp.StatusCode)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/subnets", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 {
t.Fatalf("a rejected list must leave the old one: %d %s", resp.StatusCode, body)
}
}
func TestRegistryRunAndSubnetFilters(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d)
// an address outside the run
if _, err := d.SubmitIPs(context.Background(), []string{"8.8.8.8"}); err != nil {
t.Fatal(err)
}
count := func(q string) int {
t.Helper()
resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=50&"+q, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: %d %s", q, resp.StatusCode, body)
}
var p registryPageResponse
if err := json.Unmarshal(body, &p); err != nil {
t.Fatal(err)
}
return p.Total
}
if n := count("run=" + itoa64(id)); n != 2 {
t.Errorf("run filter: %d", n)
}
if n := count("subnet=" + url.QueryEscape("9.9.9.0/24")); n != 2 {
t.Errorf("subnet filter: %d", n)
}
if n := count("run=" + itoa64(id) + "&subnet=" + url.QueryEscape("8.8.8.0/24")); n != 0 {
t.Errorf("run and subnet together: %d", n)
}
for _, bad := range []string{"run=abc", "run=0", "subnet=nonsense"} {
if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=5&"+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: %d, want 400", bad, resp.StatusCode)
}
}
}
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
// The breakdown endpoints: 400 without direction and protocol, the shape of the
// chart rows (site names, run slice), the list behind a row as JSON and CSV, 404
// for an unknown key.
func TestRegistryBreakdownEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
id := finishedRun(t, d)
const base = "/api/v1/admin/registry/breakdown"
for _, bad := range []string{"", "?direction=egress", "?protocol=ssh", "?direction=up&protocol=ssh", "?direction=egress&protocol=dns"} {
if resp, _ := fc.do(http.MethodGet, base+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%q: %d, want 400", bad, resp.StatusCode)
}
}
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh", nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("list without key: %d, want 400", resp.StatusCode)
}
resp, body := fc.do(http.MethodGet, base+"?direction=ingress&protocol=ssh&run="+itoa64(id), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("breakdown: %d %s", resp.StatusCode, body)
}
var b registryBreakdownDTO
if err := json.Unmarshal(body, &b); err != nil {
t.Fatal(err)
}
want := registryBreakdownDTO{Group: "site", Direction: "ingress", Protocol: "ssh", Run: id, Addresses: 2,
Rows: []breakdownRowDTO{{Key: "inbound-site-1", Label: "rxmsk", Total: 2, OK: 1}}}
if !reflect.DeepEqual(b, want) {
t.Errorf("breakdown = %+v, want %+v", b, want)
}
resp, body = fc.do(http.MethodGet, base+"?direction=egress&protocol=https", nil)
if err := json.Unmarshal(body, &b); err != nil || resp.StatusCode != http.StatusOK || b.Group != "target" ||
len(b.Rows) != 1 || b.Rows[0].Key != "https://a.test" || b.Rows[0].Label != "a.test" || b.Rows[0].OK != 2 {
t.Errorf("egress breakdown: %d %s", resp.StatusCode, body)
}
listURL := base + "/list?direction=ingress&protocol=ssh&run=" + itoa64(id) + "&key=inbound-site-1"
resp, body = fc.do(http.MethodGet, listURL, nil)
var l struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
if err := json.Unmarshal(body, &l); err != nil || resp.StatusCode != http.StatusOK || len(l.Rows) != 2 || len(l.Rows[0]) != len(l.Columns) {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
if r := l.Rows[0]; r[0] != "9.9.9.2" || r[1] != "провал" || r[3] != "rxmsk" || r[5] != "dial tcp: i/o timeout" {
t.Errorf("failure must come first: %v", r)
}
resp, body = fc.do(http.MethodGet, listURL+"&format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_ssh_rxmsk.csv") || !strings.Contains(string(body), "9.9.9.2") {
t.Errorf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
}
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh&key=inbound-site-9", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown key: %d, want 404", resp.StatusCode)
}
}