Files
cloud-ip-validator/internal/dashboard/handlers_analytics_test.go
T
ayurishchevandClaude Sonnet 5.5 ded196ec8d Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart
Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
  (drop-down of configured subnets), direction (egress/ingress) and
  protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
  the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
  direction and protocol are chosen; a row opens the list of addresses
  (dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
  GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)

Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
  inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
  is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)

Docs: plans and summaries in docs/changes, README, API, USAGE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:23:48 +03:00

615 lines
27 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
// 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)
}
}
// 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)
}
}
}