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

614 lines
27 KiB
Go
Raw Normal View History

package dashboard
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func fakeRun(id int64, state string, addresses, pass int) analyticsRun {
start := time.Date(2026, 10, 2, 13, 47, 0, 0, time.UTC)
end := start.Add(8*time.Hour + 42*time.Minute)
r := analyticsRun{ID: id, Kind: "manual", State: state, StartedAt: start, Addresses: addresses, Pass: pass,
Partial: addresses - pass, Total: addresses}
if state == "finalized" {
r.FinalizedAt = &end
} else {
r.Pending = 80
r.Total = addresses + r.Pending
}
return r
}
// reportWith is the minimal report JSON the page needs; addresses is a marker
// that tells the runs apart in the page source.
func reportWith(addresses string) string {
return `{"run":{"rechecked":0},"summary":{"addresses":` + addresses + `,"pass":1,"partial":0,"fail":0,"cancelled":0,` +
`"egress_ok":1,"ingress_ok":1,"egress_https_any_failed":0,"egress_https_all_failed":0,"egress_https_all_targets_failed":0,` +
`"ingress_ssh_any_failed":0,"ingress_ssh_all_failed":0,"addresses_per_minute":1},"reasons":[],"quality":{},"subnets":[],` +
`"targets":{"types":[],"targets":[],"failed":{}},"matrix":{},"sites":{"types":[],"rows":[]},"errors":[],"validators":[]}`
}
func analyticsFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(2, "finalized", 900, 300), fakeRun(1, "finalized", 6440, 1962)}
fake.reports = map[int64]string{1: reportWith("6440"), 2: reportWith("900")}
fake.lists = map[string]string{
"1/egress_https_any": `{"kind":"egress_https_any","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1/error/SSH: таймаут": `{"kind":"error","class":"SSH: таймаут","columns":["Адрес"],"rows":[["1.2.3.4"]]}`,
}
return fake, newTestServer(t, caURL)
}
// The page shows one run, by default the newest finished one, and asks
// control-api for that run only; the selector lists every run, the open one
// disabled.
func TestAnalyticsPageShowsOneRun(t *testing.T) {
fake, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
for _, want := range []string{
`id="an-run"`, `id="analytics-data"`, `"addresses":900`, // newest finished run (2)
"идёт · ручной · 120 из 200 · недоступен",
"6 440 адр. · 30% pass", // run 1's option, from the run list
`/analytics?run=1`, // the older run is one step back
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Contains(page, `"addresses":6440`) {
t.Fatalf("the data of run 1 is on the page of run 2")
}
if !strings.Contains(page, `value="3" disabled`) {
t.Fatalf("the open run must be listed but disabled:\n%s", page)
}
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/runs/1") || strings.Contains(r, "/runs/3") {
t.Fatalf("the page must request only the chosen run, got %v", fake.analyticsReqs)
}
}
other := get(t, ts, "/analytics?run=1")
if !strings.Contains(other, `"addresses":6440`) || strings.Contains(other, `"addresses":900`) {
t.Fatalf("run 1 page must carry only run 1's data:\n%s", other)
}
}
// The analytics filters: the subnet goes to control-api with the report and the
// lists, direction and protocol focus the page and, both given, add the chart of
// the registry for the run and subnet; every link of the page keeps the filter.
func TestAnalyticsPageFilters(t *testing.T) {
fake, ts := analyticsFake(t)
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
fake.breakdown = registryBreakdown{Group: "target", Direction: "egress", Protocol: "https", Addresses: 5, Rows: []breakdownRow{{Key: "a.test", Label: "a.test", Total: 5, OK: 4}}}
lastReq := func(reqs []string) string { fake.mu.Lock(); defer fake.mu.Unlock(); return reqs[len(reqs)-1] }
page := get(t, ts, "/analytics?run=1&subnet=9.9.9.0/24&direction=egress&protocol=https")
for _, want := range []string{
`<option value="9.9.9.0/24" selected>9.9.9.0/24 — Офис</option>`, `<option value="egress" selected>`, `<option value="https" selected>`,
`"subnet":"9.9.9.0/24"`, `"direction":"egress"`, `"protocol":"https"`, "сбросить фильтры",
`id="registry-breakdown"`, "Успешные проверки по целям · Egress https", `data-slice="запуск 1 · подсеть 9.9.9.0/24"`,
`data-qs="direction=egress&amp;protocol=https&amp;run=1&amp;subnet=9.9.9.0%2F24"`,
// the newer run is one step forward, the comparison is opened for this run; both keep the filter
`href="/analytics?direction=egress&amp;protocol=https&amp;run=2&amp;subnet=9.9.9.0%2F24"`,
`href="/analytics/compare?direction=egress&amp;protocol=https&amp;subnet=9.9.9.0%2F24&amp;target=1"`,
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if got := lastReq(fake.analyticsReqs); !strings.HasSuffix(got, "/runs/1?subnet=9.9.9.0%2F24") {
t.Errorf("the report must be requested for the subnet, got %q", got)
}
req := lastReq(fake.breakdownReqs)
for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=egress", "protocol=https"} {
if !strings.Contains(req, want) {
t.Errorf("chart request %q lacks %s", req, want)
}
}
// No chart without both direction and protocol (and no request for it); a
// malformed subnet is dropped and the whole run is shown.
fake.mu.Lock()
fake.breakdownReqs = nil
fake.mu.Unlock()
for _, q := range []string{"run=1", "run=1&direction=ingress", "run=1&protocol=tls&subnet=garbage"} {
page = get(t, ts, "/analytics?"+q)
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
t.Errorf("%s: no chart and no hint expected:\n%s", q, page)
}
}
if len(fake.breakdownReqs) != 0 {
t.Errorf("the chart was requested without direction and protocol: %v", fake.breakdownReqs)
}
if got := lastReq(fake.analyticsReqs); strings.Contains(got, "subnet") {
t.Errorf("a malformed subnet must be dropped, got %q", got)
}
// The lists keep the subnet, a malformed one is passed on for control-api to refuse.
for _, path := range []string{"/analytics/lists/egress_https_any?run=1&subnet=9.9.9.0/24", "/analytics/csv/egress_https_any?run=1&subnet=9.9.9.0/24"} {
if page = get(t, ts, path); page == "" {
t.Fatalf("%s: empty answer", path)
}
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=9.9.9.0%2F24") {
t.Errorf("%s: control-api request %q lacks the subnet", path, got)
}
}
get(t, ts, "/analytics/lists/egress_https_any?run=1&subnet=bad")
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=bad") {
t.Errorf("a malformed subnet must reach control-api, got %q", got)
}
}
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/analytics")
if !strings.Contains(page, "Запусков проверки пока нет") || strings.Contains(page, `id="analytics-data"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
_, ts = analyticsFake(t)
for _, run := range []string{"99", "3"} { // unknown, and the open one
page = get(t, ts, "/analytics?run="+run)
if !strings.Contains(page, "не найден или ещё не завершён") {
t.Fatalf("run %s: expected a warning, got:\n%s", run, page)
}
}
}
func TestAnalyticsListProxyAndCSV(t *testing.T) {
_, ts := analyticsFake(t)
resp, err := http.Get(ts.URL + "/analytics/lists/egress_https_any?run=1")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.2.3.4"`) {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
q := url.Values{"run": {"1"}, "class": {"SSH: таймаут"}}
resp, err = http.Get(ts.URL + "/analytics/lists/error?" + q.Encode())
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"class":"SSH: таймаут"`) {
t.Fatalf("error list: %d %s", resp.StatusCode, body)
}
resp, err = http.Get(ts.URL + "/analytics/csv/egress_https_any?run=1")
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="egress_https_any_run1.csv"`) {
t.Fatalf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
}
for path, want := range map[string]int{
"/analytics/lists/egress_https_any": http.StatusBadRequest, // no run
"/analytics/csv/egress_https_any?run=abc": http.StatusBadRequest,
"/analytics/lists/nonsense?run=1": http.StatusNotFound, // control-api says unknown list
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
}
}
}
2026-10-04 10:26:37 +03:00
// compareWith is the minimal comparison JSON the page needs; new is a marker
// that tells the pairs of runs apart in the page source.
func compareWith(newAddrs string) string {
return `{"runs":{"base":{"id":1,"rechecked":0,"addresses":6440},"target":{"id":2,"rechecked":0,"addresses":900}},` +
`"groups":{"new":` + newAddrs + `,"left":2,"common":3,"changed":1,"same":2},"indicators":[],` +
`"transitions":{"verdicts":["pass","partial","fail"],"matrix":[[0,0,0],[0,0,0],[0,0,0]],"new":[0,0,0],"left":[0,0,0]},"cancelled":{"base":0,"target":0}}`
}
// compareFake is analyticsFake with the comparisons of runs 1 and 2 (run 3 is open).
func compareFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, ts := analyticsFake(t)
fake.compares = map[string]string{"1-2": compareWith("111"), "2-1": compareWith("222")}
fake.compareLists = map[string]string{
"1-2/changed": `{"group":"changed","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1-2/new/verdict_pass": `{"group":"new","indicator":"verdict_pass","columns":["Адрес"],"rows":[["5.6.7.8"]]}`,
}
return fake, ts
}
func compareRequests(fake *fakeControlAPI) []string {
var out []string
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/compare") {
out = append(out, r)
}
}
return out
}
// By default B is the newest finished run and A the one before it; the open
// run is not offered; the page asks control-api for that pair only.
func TestAnalyticsComparePageDefaultPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare")
for _, want := range []string{
`id="an-base"`, `id="an-target"`, `id="an-swap"`, `id="analytics-data"`, `"new":111`,
`<option value="1" selected>`, `<option value="2" selected>`, // A is run 1, B is run 2
`id="an-dlg"`, `/static/analytics-dialog.js`, `/static/analytics-compare.js`,
"6\u00a0440 адр. · 30% pass",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Count(page, `<option value="1" selected>`) != 1 || strings.Count(page, `<option value="2" selected>`) != 1 {
t.Fatalf("one run is chosen in each list:\n%s", page)
}
if strings.Contains(page, `value="3"`) {
t.Fatalf("an open run must not be offered:\n%s", page)
}
if got := compareRequests(fake); len(got) != 1 || !strings.Contains(got[0], "base=1") || !strings.Contains(got[0], "target=2") {
t.Fatalf("expected one request for base=1&target=2, got %v", got)
}
}
// The pair in the address: both ends, only the new one (the base is the run
// before it), only the old one (the target is the newest other run).
func TestAnalyticsComparePageExplicitPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare?base=2&target=1")
if !strings.Contains(page, `"new":222`) || !strings.Contains(page, `<option value="2" selected>`) {
t.Fatalf("swapped pair:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=2"); !strings.Contains(page, `"new":111`) {
t.Fatalf("only target=2 must compare with run 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?base=2"); !strings.Contains(page, `"new":222`) {
t.Fatalf("only base=2 must compare with the newest other run, 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=1"); strings.Contains(page, `id="analytics-data"`) ||
!strings.Contains(page, "нет второго запуска") {
t.Fatalf("run 1 is the oldest, nothing to compare it with:\n%s", page)
}
if got := compareRequests(fake); len(got) != 3 {
t.Fatalf("the refused pair must not reach control-api, got %v", got)
}
}
func TestAnalyticsComparePageWarnings(t *testing.T) {
fake, ts := compareFake(t)
for _, c := range []struct{ query, want string }{
{"base=1&target=1", "Выберите два разных запуска"},
{"base=99&target=1", "Запуск 99 не найден или ещё не завершён"},
// the open run
{"base=1&target=3", "Запуск 3 не найден или ещё не завершён"},
{"base=abc&target=1", "Неверный номер запуска"},
{"base=0&target=1", "Неверный номер запуска"},
} {
page := get(t, ts, "/analytics/compare?"+c.query)
if !strings.Contains(page, c.want) || strings.Contains(page, `id="analytics-data"`) || !strings.Contains(page, `id="an-base"`) {
t.Fatalf("%s: expected the warning %q and the run lists without data, got:\n%s", c.query, c.want, page)
}
}
if got := compareRequests(fake); len(got) != 0 {
t.Fatalf("a bad pair must not reach control-api, got %v", got)
}
// Fewer than two finished runs: nothing to compare.
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(1, "finalized", 6440, 1962)}
page := get(t, newTestServer(t, caURL), "/analytics/compare")
if !strings.Contains(page, "минимум два завершённых запуска") || strings.Contains(page, `id="an-base"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
}
func TestAnalyticsCompareListProxyAndCSV(t *testing.T) {
_, ts := compareFake(t)
fetch := func(path string) (int, http.Header, string) {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header, string(body)
}
code, _, body := fetch("/analytics/compare/lists/changed?base=1&target=2")
if code != http.StatusOK || !strings.Contains(body, `"1.2.3.4"`) {
t.Fatalf("list: %d %s", code, body)
}
// The indicator and the verdicts travel to control-api.
code, _, body = fetch("/analytics/compare/lists/new?base=1&target=2&indicator=verdict_pass")
if code != http.StatusOK || !strings.Contains(body, `"5.6.7.8"`) {
t.Fatalf("list with an indicator: %d %s", code, body)
}
code, header, body := fetch("/analytics/compare/csv/changed?base=1&target=2")
if code != http.StatusOK || !strings.HasPrefix(header.Get("Content-Type"), "text/csv") ||
!strings.Contains(header.Get("Content-Disposition"), `attachment; filename="compare_changed_run1-2.csv"`) {
t.Fatalf("csv: %d %v %s", code, header, body)
}
for _, c := range []struct {
path string
want int
}{
// no runs
{"/analytics/compare/lists/changed", http.StatusBadRequest},
{"/analytics/compare/lists/changed?base=1", http.StatusBadRequest},
{"/analytics/compare/csv/changed?base=abc&target=2", http.StatusBadRequest},
// control-api says unknown list
{"/analytics/compare/lists/nonsense?base=1&target=2", http.StatusNotFound},
{"/analytics/compare/csv/nonsense?base=1&target=2", http.StatusNotFound},
} {
if code, _, _ := fetch(c.path); code != c.want {
t.Errorf("%s: %d, want %d", c.path, code, c.want)
}
}
}
// The single-run page after the dialog moved to analytics-dialog.js: it still
// carries the dialog, loads the shared script before its own, offers the
// comparison, and both scripts are served.
func TestAnalyticsPageUsesSharedDialog(t *testing.T) {
_, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
iDialog, iPage := strings.Index(page, "/static/analytics-dialog.js"), strings.Index(page, "/static/analytics.js")
if iDialog < 0 || iPage < 0 || iDialog > iPage {
t.Fatalf("analytics-dialog.js must come before analytics.js (%d, %d):\n%s", iDialog, iPage, page)
}
for _, want := range []string{`id="an-dlg"`, `id="an-dlg-tbl"`, `id="an-dlg-csv"`, `id="an-tip"`, `href="/analytics/compare?target=2"`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
for path, want := range map[string]string{
"/static/analytics-dialog.js": "window.AnalyticsDialog =",
"/static/analytics.js": "window.AnalyticsDialog",
"/static/analytics-compare.js": "window.AnalyticsDialog",
} {
if body := get(t, ts, path); !strings.Contains(body, want) {
t.Errorf("%s does not contain %q", path, want)
}
}
}
// The sidebar: no window-chrome dots next to the logo, the Analytics entry,
// and the link state, theme toggle and logout above the navigation.
func TestSidebarSessionBlockOnTop(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
cookie := login(t, ts)
_, page := doReq(t, ts, reqOpts{path: "/overview", cookie: cookie})
if strings.Contains(page, "brand-chrome") {
t.Fatalf("the three dots next to the logo are back")
}
iBrand := strings.Index(page, `class="brand"`)
iAPI := strings.Index(page, `class="session-api"`)
iLogout := strings.Index(page, `action="/logout"`)
iNav := strings.Index(page, `class="nav-groups"`)
iFoot := strings.Index(page, "sidebar-foot")
if !(iBrand >= 0 && iBrand < iAPI && iAPI < iLogout && iLogout < iNav) || iFoot >= 0 {
t.Fatalf("expected brand < control-api state < logout < navigation (and no old footer): %d %d %d %d foot=%d", iBrand, iAPI, iLogout, iNav, iFoot)
}
for _, link := range []string{`href="/analytics"`, `href="/registry"`, `href="/settings"`} {
if !strings.Contains(page, link) {
t.Fatalf("missing nav link %s", link)
}
}
if strings.Contains(page, "pulse-dot down") {
t.Fatalf("the link state must be fine while control-api answers")
}
}
// The link indicator turns red when control-api cannot be reached.
func TestSidebarShowsLostControlAPI(t *testing.T) {
ts := newTestServer(t, "http://127.0.0.1:1") // nothing listens there
page := get(t, ts, "/overview")
if !strings.Contains(page, "pulse-dot down") || !strings.Contains(page, "нет связи") {
t.Fatalf("expected the lost-link indicator, got:\n%s", page)
}
}
func TestSettingsSubnetsForm(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"83.166.248.0/21 москва\n\n 10.0.0.0/8\n"}})
if len(fake.subnets.Subnets) != 2 ||
fake.subnets.Subnets[0] != (subnetEntry{CIDR: "83.166.248.0/21", Label: "москва"}) ||
fake.subnets.Subnets[1] != (subnetEntry{CIDR: "10.0.0.0/8"}) {
t.Fatalf("subnets saved: %+v", fake.subnets)
}
if !strings.Contains(body, "83.166.248.0/21 москва") || !strings.Contains(body, "10.0.0.0/8") {
t.Fatalf("the saved list must come back in the form:\n%s", body)
}
body = postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"not-a-cidr"}})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "not a CIDR") {
t.Fatalf("expected the validation error in the banner:\n%s", body)
}
}
// The drill-down from the analytics page: run and subnet go to control-api,
// come back in the filter fields and the reset link; a malformed subnet is dropped.
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry?run=2&subnet="+url.QueryEscape("10.0.0.0/24"))
if len(fake.registryQueries) == 0 {
t.Fatal("no registry request")
}
last := fake.registryQueries[len(fake.registryQueries)-1]
if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") {
t.Fatalf("control-api request %q lacks the run or subnet", last)
}
for _, want := range []string{`<option value="2" selected>`, `<option value="10.0.0.0/24" selected>10.0.0.0/24 (нет в списке)</option>`,
`href="/analytics?run=2&amp;subnet=10.0.0.0%2F24"`, "сбросить фильтры"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
page = get(t, ts, "/registry?subnet=garbage")
last = fake.registryQueries[len(fake.registryQueries)-1]
if strings.Contains(last, "subnet=") || strings.Contains(page, `garbage`) {
t.Fatalf("a malformed subnet must be ignored: %q", last)
}
}
2026-10-04 10:26:37 +03:00
// The filter of a comparison list goes to control-api as it is.
func TestAnalyticsCompareListPath(t *testing.T) {
got := analyticsCompareListPath(1, 2, "common", compareFilter{Indicator: "verdict_pass", From: "partial", To: "pass"}, true)
want := "/api/v1/admin/analytics/compare/lists/common?base=1&format=csv&from=partial&indicator=verdict_pass&target=2&to=pass"
if got != want {
t.Errorf("path = %q, want %q", got, want)
}
if got := analyticsCompareListPath(3, 4, "new", compareFilter{}, false); got != "/api/v1/admin/analytics/compare/lists/new?base=3&target=4" {
t.Errorf("path = %q", got)
}
}
// The "Подсеть" selector: configured subnets as "CIDR — label", disabled with a
// link to /settings when there are none.
func TestRegistrySubnetSelect(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry")
for _, want := range []string{`<select id="registry-subnet" name="subnet" disabled`, `<option value="">Все подсети</option>`, `href="/settings">добавить в настройках`} {
if !strings.Contains(page, want) {
t.Fatalf("no subnets configured: expected %q in:\n%s", want, page)
}
}
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
page = get(t, ts, "/registry?subnet=10.0.0.0/8")
for _, want := range []string{`<option value="9.9.9.0/24" >9.9.9.0/24 — Офис</option>`, `<option value="10.0.0.0/8" selected>10.0.0.0/8</option>`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
if strings.Contains(page, "disabled") || strings.Contains(page, "нет в списке") {
t.Fatalf("a configured subnet must neither disable the selector nor be added again:\n%s", page)
}
}
// The chart above the registry table: only with a direction and a protocol (a
// hint for the missing one), rows as control-api sorted them, an error inside
// the block that leaves the table in place; the list proxies forward the filter.
func TestRegistryBreakdownChartAndProxy(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.registry["9.9.9.9"] = registryItem{IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now}
fake.breakdown = registryBreakdown{Group: "site", Direction: "ingress", Protocol: "tls", Addresses: 1, Rows: []breakdownRow{
{Key: "inbound-site-2", Label: "rxspb", Total: 1250, OK: 1234}, {Key: "inbound-site-1", Label: "rxmsk", Total: 617, OK: 617},
}}
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry")
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
t.Fatalf("no chart and no hint without filters:\n%s", page)
}
page = get(t, ts, "/registry?direction=ingress")
if !strings.Contains(page, "Выберите протокол, чтобы увидеть распределение по площадкам") || strings.Contains(page, `id="registry-breakdown"`) {
t.Fatalf("a direction alone gives a hint:\n%s", page)
}
page = get(t, ts, "/registry?direction=ingress&protocol=tls&run=3&subnet=9.9.9.0/24")
for _, want := range []string{
"Успешные проверки по площадкам · Ingress tls", `data-bd-key="inbound-site-2"`, `data-bd-label="rxspb"`,
"1\u00a0234 из 1\u00a0250 · 98,7%", "617 из 617 · 100%", `style="width:100.0%"`, `style="width:50.0%"`,
`data-slice="запуск 3 · подсеть 9.9.9.0/24"`, `data-qs="direction=ingress&amp;protocol=tls&amp;run=3&amp;subnet=9.9.9.0%2F24"`, "Адресов под фильтром: 1",
"9.9.9.9", // the table is still there
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
if strings.Index(page, "rxspb") > strings.Index(page, "rxmsk") {
t.Fatalf("rows must keep the order control-api gave:\n%s", page)
}
fake.mu.Lock()
req := fake.breakdownReqs[len(fake.breakdownReqs)-1]
fake.mu.Unlock()
for _, want := range []string{"direction=ingress", "protocol=tls", "run=3", "subnet=9.9.9.0%2F24"} {
if !strings.Contains(req, want) {
t.Fatalf("breakdown request %q lacks %s", req, want)
}
}
fake.breakdown.Rows = nil
if page = get(t, ts, "/registry?direction=egress&protocol=tls"); !strings.Contains(page, "Для этого сочетания проверок нет") {
t.Fatalf("an empty chart says so:\n%s", page)
}
fake.breakdownStatus = http.StatusInternalServerError
page = get(t, ts, "/registry?direction=egress&protocol=https")
if !strings.Contains(page, "Не удалось получить распределение") || !strings.Contains(page, "9.9.9.9") {
t.Fatalf("a chart error is shown in its block and the table stays:\n%s", page)
}
// The list proxies: filter and key reach control-api; the filter is checked.
fake.breakdownList = `{"columns":["Адрес"],"rows":[["1.2.3.4"]]}`
for path, want := range map[string]int{
"/registry/breakdown/list?direction=ingress&protocol=tls&key=inbound-site-1&run=3&status=fail": http.StatusOK,
"/registry/breakdown/csv?direction=ingress&protocol=tls&key=inbound-site-1": http.StatusOK,
"/registry/breakdown/list?direction=ingress&key=inbound-site-1": http.StatusBadRequest, // no protocol
"/registry/breakdown/csv?direction=ingress&protocol=tls": http.StatusBadRequest, // no key
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
}
if strings.Contains(path, "/csv") && want == http.StatusOK &&
(!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_tls_rxmsk.csv")) {
t.Fatalf("csv: %v %s", resp.Header, body)
}
if strings.Contains(path, "/list") && want == http.StatusOK && !strings.Contains(string(body), `"1.2.3.4"`) {
t.Fatalf("list: %s", body)
}
}
fake.mu.Lock()
var list string
for _, r := range fake.breakdownReqs {
if strings.Contains(r, "/breakdown/list") && !strings.Contains(r, "format=csv") {
list = r
}
}
fake.mu.Unlock()
for _, want := range []string{"key=inbound-site-1", "run=3", "last_result=fail", "direction=ingress", "protocol=tls"} {
if !strings.Contains(list, want) {
t.Fatalf("list request %q lacks %s", list, want)
}
}
}