685 lines
28 KiB
Go
685 lines
28 KiB
Go
package dashboard
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"html"
|
|||
|
|
"net/http"
|
|||
|
|
"net/url"
|
|||
|
|
"regexp"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Tests for the scale features: background scan panel, server-side
|
|||
|
|
// pagination/filters on /ips and /registry, bulk selection by filter, and the
|
|||
|
|
// bounded overview.
|
|||
|
|
|
|||
|
|
func ipRowLink(ip string) string { return `href="/ips/` + ip + `"` }
|
|||
|
|
|
|||
|
|
func countRows(body string) int { return strings.Count(body, `name="addresses" value="`) }
|
|||
|
|
|
|||
|
|
func hasHXTriggerEvery(body string) bool { return strings.Contains(body, `hx-trigger="every 2s"`) }
|
|||
|
|
|
|||
|
|
var hrefRe = regexp.MustCompile(`href="([^"]*)"[^>]*rel="(prev|next)"`)
|
|||
|
|
|
|||
|
|
// pagerLink extracts the (unescaped, parsed) prev/next link of the pager.
|
|||
|
|
func pagerLink(t *testing.T, body, rel string) *url.URL {
|
|||
|
|
t.Helper()
|
|||
|
|
for _, m := range hrefRe.FindAllStringSubmatch(body, -1) {
|
|||
|
|
if m[2] == rel {
|
|||
|
|
u, err := url.Parse(html.UnescapeString(m[1]))
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("parse pager link %q: %v", m[1], err)
|
|||
|
|
}
|
|||
|
|
return u
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsScan(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.scanFreeAddresses = []string{"5.5.5.5", "5.5.5.6"}
|
|||
|
|
fake.scanRunPolls = 2
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// Start: answers at once with the panel; it polls itself, the scan
|
|||
|
|
// buttons are disabled and nothing is queued yet.
|
|||
|
|
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
|
|||
|
|
if resp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{
|
|||
|
|
`id="scan-progress"`, `hx-get="/ips/scan/status"`, `hx-trigger="every 2s"`, "читаются страницы",
|
|||
|
|
`<progress aria-label="Сканирование"></progress>`, ` disabled`,
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the running panel, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if resp.Header.Get("HX-Trigger") != "" {
|
|||
|
|
t.Fatalf("a running scan must not fire scan-finished, got %q", resp.Header.Get("HX-Trigger"))
|
|||
|
|
}
|
|||
|
|
if len(fake.ips) != 0 {
|
|||
|
|
t.Fatalf("nothing should be queued before the job finishes, got %+v", fake.ips)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// First poll: still running, keeps polling.
|
|||
|
|
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
|
|||
|
|
if !hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "" {
|
|||
|
|
t.Fatalf("expected a still-polling panel without HX-Trigger, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Second poll: finished — no polling trigger, HX-Trigger tells the table to reload.
|
|||
|
|
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
|
|||
|
|
if hasHXTriggerEvery(body) || strings.Contains(body, `hx-get="/ips/scan/status"`) {
|
|||
|
|
t.Fatalf("a finished panel must stop polling, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
if got := resp.Header.Get("HX-Trigger"); got != "scan-finished" {
|
|||
|
|
t.Fatalf("HX-Trigger = %q, want scan-finished", got)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{"готово", "<dt>добавлено</dt><dd>2</dd>", `<progress max="1" value="1"`} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the finished panel, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if strings.Contains(body, " disabled") {
|
|||
|
|
t.Fatalf("buttons must be enabled again once finished, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
if len(fake.ips) != 2 {
|
|||
|
|
t.Fatalf("expected both scanned addresses queued, got %+v", fake.ips)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The table reload target: #ips-table-wrap listens for scan-finished and
|
|||
|
|
// re-fetches the current /ips URL.
|
|||
|
|
page := get(t, ts, "/ips")
|
|||
|
|
if !strings.Contains(page, `hx-trigger="scan-finished from:body"`) || !strings.Contains(page, `hx-select="#ips-table-wrap"`) {
|
|||
|
|
t.Fatalf("expected #ips-table-wrap to reload on scan-finished, got:\n%s", page)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(page, ipRowLink("5.5.5.5")) {
|
|||
|
|
t.Fatalf("expected the scanned address in the table, got:\n%s", page)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsScanErrorAndDryRunAndRefusal(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.scanFreeAddresses = []string{"5.5.5.5"}
|
|||
|
|
fake.scanFinalError = "openstack: list floating ips: boom"
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// A job that fails: the panel shows the error and stops polling.
|
|||
|
|
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
|
|||
|
|
for _, want := range []string{"ошибка", "openstack: list floating ips: boom", `class="scan-error"`} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the error panel, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "scan-finished" {
|
|||
|
|
t.Fatalf("an errored job must stop polling and signal scan-finished, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
|
|||
|
|
}
|
|||
|
|
if len(fake.ips) != 0 {
|
|||
|
|
t.Fatalf("a failed scan must not queue anything, got %+v", fake.ips)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dry run: the flag reaches control-api, the queue stays untouched.
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
fake.scanFinalError = ""
|
|||
|
|
fake.scan = scanStatusDTO{}
|
|||
|
|
fake.scanRunPolls = 1
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan?dry_run=true"})
|
|||
|
|
if !strings.Contains(body, "пробный запуск") {
|
|||
|
|
t.Fatalf("expected the dry-run marker, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
_, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
|
|||
|
|
if !strings.Contains(body, "готово") || len(fake.ips) != 0 {
|
|||
|
|
t.Fatalf("dry run must finish without queueing, queue=%+v body:\n%s", fake.ips, body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// control-api refuses to start: banner, no polling.
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
fake.scanStartStatus = http.StatusConflict
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
|
|||
|
|
if !strings.Contains(body, "alert-warning") || hasHXTriggerEvery(body) {
|
|||
|
|
t.Fatalf("expected a client-error banner and no polling, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsPageRendersRunningScanPanelOutsideForm(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6440, Added: 1200, StartedAt: &now}
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
page := get(t, ts, "/ips")
|
|||
|
|
for _, want := range []string{`hx-get="/ips/scan/status"`, "ставятся в очередь", `<progress max="6440" value="1200"`, "<dt>прочитано страниц</dt><dd>33</dd>"} {
|
|||
|
|
if !strings.Contains(page, want) {
|
|||
|
|
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if strings.Index(page, `id="scan-progress"`) > strings.Index(page, `<form id="ips-form"`) {
|
|||
|
|
t.Fatalf("the scan panel must sit above (outside) #ips-form")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestPageURLEscapesQuery(t *testing.T) {
|
|||
|
|
got := pageURL("/ips", url.Values{"q": {"a b&c=d"}, "state": {"queued"}}, 3, 100)
|
|||
|
|
u, err := url.Parse(got)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
q := u.Query()
|
|||
|
|
if u.Path != "/ips" || q.Get("q") != "a b&c=d" || q.Get("state") != "queued" || q.Get("page") != "3" || q.Get("per_page") != "100" || len(q) != 4 {
|
|||
|
|
t.Fatalf("unexpected URL %q (query %v)", got, q)
|
|||
|
|
}
|
|||
|
|
if first := pageURL("/ips", nil, 1, 50); strings.Contains(first, "page=") && !strings.Contains(first, "per_page=") {
|
|||
|
|
t.Fatalf("page 1 must not carry page=, got %q", first)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsPagination(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(120)
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
body := get(t, ts, "/ips")
|
|||
|
|
if !strings.Contains(body, "Показано 1–50 из 120") || countRows(body) != 50 {
|
|||
|
|
t.Fatalf("page 1: want «Показано 1–50 из 120» and 50 rows, got %d rows:\n%s", countRows(body), body)
|
|||
|
|
}
|
|||
|
|
if next := pagerLink(t, body, "next"); next == nil || next.Query().Get("page") != "2" {
|
|||
|
|
t.Fatalf("expected a next link to page 2, got %v", next)
|
|||
|
|
}
|
|||
|
|
if pagerLink(t, body, "prev") != nil {
|
|||
|
|
t.Fatalf("page 1 must have no prev link")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body = get(t, ts, "/ips?page=2")
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 120") || countRows(body) != 50 {
|
|||
|
|
t.Fatalf("page 2: got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
if prev := pagerLink(t, body, "prev"); prev == nil || prev.Query().Get("page") != "" {
|
|||
|
|
t.Fatalf("page 2 must link back to page 1 (no page param), got %v", prev)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Last page, and a page past the end clamps to it; junk is page 1.
|
|||
|
|
for _, p := range []string{"3", "99"} {
|
|||
|
|
body = get(t, ts, "/ips?page="+p)
|
|||
|
|
if !strings.Contains(body, "Показано 101–120 из 120") || countRows(body) != 20 || pagerLink(t, body, "next") != nil {
|
|||
|
|
t.Fatalf("page=%s: want the last page, got:\n%s", p, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, p := range []string{"0", "-4", "abc"} {
|
|||
|
|
if body = get(t, ts, "/ips?page="+p); !strings.Contains(body, "Показано 1–50 из 120") {
|
|||
|
|
t.Fatalf("page=%s: want page 1, got:\n%s", p, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// per_page: only 25/50/100/200 are accepted, anything else is the default.
|
|||
|
|
for per, want := range map[string]int{"25": 25, "100": 100, "200": 120, "77": 50, "": 50, "x": 50} {
|
|||
|
|
if body = get(t, ts, "/ips?per_page="+per); countRows(body) != want {
|
|||
|
|
t.Fatalf("per_page=%q: want %d rows, got %d", per, want, countRows(body))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsPaginationKeepsFilterInLinks(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(120)
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// 10.0.0.1 matches .1, .10-.19, .100-.120: 32 rows.
|
|||
|
|
body := get(t, ts, "/ips?q=10.0.0.1&state=queued&per_page=25")
|
|||
|
|
if !strings.Contains(body, "Показано 1–25 из 32") {
|
|||
|
|
t.Fatalf("expected the filtered total, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
next := pagerLink(t, body, "next")
|
|||
|
|
if next == nil {
|
|||
|
|
t.Fatalf("expected a next link")
|
|||
|
|
}
|
|||
|
|
q := next.Query()
|
|||
|
|
if q.Get("q") != "10.0.0.1" || q.Get("state") != "queued" || q.Get("per_page") != "25" || q.Get("page") != "2" {
|
|||
|
|
t.Fatalf("next link lost the filter: %v", next)
|
|||
|
|
}
|
|||
|
|
// The same filter is carried by the hidden inputs of #ips-form and by the
|
|||
|
|
// wrapper's own reload URL.
|
|||
|
|
for _, want := range []string{
|
|||
|
|
`name="q" value="10.0.0.1"`, `name="state" value="queued"`, `name="per_page" value="25"`, `name="page" value="1"`,
|
|||
|
|
`hx-get="/ips?per_page=25&q=10.0.0.1&state=queued"`,
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the page, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsFilters(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
now := time.Now()
|
|||
|
|
mk := func(ip, state, result string, seq int) ipQueueItem {
|
|||
|
|
return ipQueueItem{IPAddress: ip, State: state, OverallResult: result, Sequence: seq, AggregatedAt: &now, CreatedAt: now, UpdatedAt: now}
|
|||
|
|
}
|
|||
|
|
fake.ips = []ipQueueItem{
|
|||
|
|
mk("1.1.1.1", "queued", "", 1), mk("1.1.1.2", "checking", "", 2), mk("1.1.1.3", "awaiting_self_check", "", 3),
|
|||
|
|
mk("2.2.2.1", "done", "pass", 4), mk("2.2.2.2", "failed", "fail", 5), mk("2.2.2.3", "failed", "cancelled", 6),
|
|||
|
|
mk("3.3.3.3", "occupied", "", 7),
|
|||
|
|
}
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
cases := []struct {
|
|||
|
|
query string
|
|||
|
|
want []string
|
|||
|
|
}{
|
|||
|
|
{"", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
|
|||
|
|
{"state=queued", []string{"1.1.1.1"}},
|
|||
|
|
{"state=active", []string{"1.1.1.2", "1.1.1.3"}},
|
|||
|
|
{"state=done", []string{"2.2.2.1"}},
|
|||
|
|
{"state=failed", []string{"2.2.2.2", "2.2.2.3"}},
|
|||
|
|
{"state=occupied", []string{"3.3.3.3"}},
|
|||
|
|
{"state=result:fail", []string{"2.2.2.2"}},
|
|||
|
|
{"result=cancelled", []string{"2.2.2.3"}},
|
|||
|
|
{"state=failed&result=cancelled", []string{"2.2.2.3"}},
|
|||
|
|
{"q=1.1.1&state=active", []string{"1.1.1.2", "1.1.1.3"}},
|
|||
|
|
{"state=bogus", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
|
|||
|
|
}
|
|||
|
|
for _, c := range cases {
|
|||
|
|
body := get(t, ts, "/ips?"+c.query)
|
|||
|
|
got := map[string]bool{}
|
|||
|
|
for _, ip := range []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"} {
|
|||
|
|
got[ip] = strings.Contains(body, ipRowLink(ip))
|
|||
|
|
}
|
|||
|
|
for ip, in := range got {
|
|||
|
|
if want := containsStr(c.want, ip); in != want {
|
|||
|
|
t.Fatalf("?%s: row %s present=%v, want %v", c.query, ip, in, want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The select echoes the active filter; an empty filtered result says so.
|
|||
|
|
if body := get(t, ts, "/ips?state=result:fail"); !strings.Contains(body, `value="result:fail" selected`) {
|
|||
|
|
t.Fatalf("expected the result option to be selected, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
if body := get(t, ts, "/ips?q=zzz"); !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
|
|||
|
|
t.Fatalf("expected the filtered-empty message, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// An htmx filter request gets just the swappable wrapper, not the page.
|
|||
|
|
_, frag := doReq(t, ts, reqOpts{path: "/ips?state=queued", headers: map[string]string{"HX-Request": "true"}})
|
|||
|
|
if strings.Contains(frag, "<html") || !strings.Contains(frag, `id="ips-table-wrap"`) || !strings.Contains(frag, ipRowLink("1.1.1.1")) {
|
|||
|
|
t.Fatalf("expected the table wrapper fragment, got:\n%s", frag)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Filters are applied server-side: control-api got them as parameters.
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
defer fake.mu.Unlock()
|
|||
|
|
var sawState bool
|
|||
|
|
for _, q := range fake.ipsQueries {
|
|||
|
|
if strings.Contains(q, "state=queued") && strings.Contains(q, "limit=50") {
|
|||
|
|
sawState = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !sawState || fake.bareIPsCalls != 0 {
|
|||
|
|
t.Fatalf("expected paginated filtered requests only (bare=%d), got %v", fake.bareIPsCalls, fake.ipsQueries)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsMutationsKeepPageAndFilter(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
addrs := fake.seedIPs(120)
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// Per-row delete on page 2 (the URL carries only the context params).
|
|||
|
|
_, body := doReq(t, ts, reqOpts{method: http.MethodDelete, path: "/ips/" + addrs[60] + "?page=2&per_page=50&state=queued"})
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 119") || strings.Contains(body, ipRowLink(addrs[60])) {
|
|||
|
|
t.Fatalf("delete must re-render page 2 of the same filter, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{`name="page" value="2"`, `name="state" value="queued"`} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the re-rendered form, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Per-row recheck keeps the page too.
|
|||
|
|
body = postForm(t, ts, "POST", "/ips/"+addrs[70]+"/recheck", url.Values{"page": {"2"}, "per_page": {"50"}})
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 119") {
|
|||
|
|
t.Fatalf("recheck must stay on page 2, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Deleting every row of the last page clamps to the last non-empty page.
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
var last []string
|
|||
|
|
for _, ip := range fake.ips[100:] {
|
|||
|
|
last = append(last, ip.IPAddress)
|
|||
|
|
}
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"page": {"3"}, "per_page": {"50"}, "addresses": last})
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 100") || !strings.Contains(body, `name="page" value="2"`) {
|
|||
|
|
t.Fatalf("expected a clamp to page 2 after the last page emptied, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Adding addresses through the top form keeps the context hidden inputs' page too.
|
|||
|
|
body = postForm(t, ts, "POST", "/ips", url.Values{"addresses": {"9.9.9.9"}, "page": {"2"}, "per_page": {"50"}})
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 101") {
|
|||
|
|
t.Fatalf("expected page 2 of 101 after adding, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsBulkScopeAllResolvesByFilterInChunks(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(1300)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
for _, a := range []string{"2.2.2.1", "2.2.2.2", "2.2.2.3"} {
|
|||
|
|
fake.ips = append(fake.ips, ipQueueItem{IPAddress: a, State: "done", OverallResult: "pass", AggregatedAt: &now, CreatedAt: now, UpdatedAt: now})
|
|||
|
|
}
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// Recheck everything in state=done: three addresses, one chunk.
|
|||
|
|
postForm(t, ts, "POST", "/ips/recheck", url.Values{"scope": {"all"}, "state": {"done"}})
|
|||
|
|
if len(fake.submitChunks) != 1 || fake.submitChunks[0] != 3 {
|
|||
|
|
t.Fatalf("submit chunks = %v, want [3]", fake.submitChunks)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete all 1300 queued+rechecked rows matching q=10. (the checked
|
|||
|
|
// boxes of the page are ignored under scope=all): chunks of ≤500.
|
|||
|
|
body := postForm(t, ts, "POST", "/ips/delete", url.Values{
|
|||
|
|
"scope": {"all"}, "q": {"10."}, "state": {"queued"}, "per_page": {"50"}, "addresses": {"2.2.2.1"},
|
|||
|
|
})
|
|||
|
|
if got := fmt.Sprint(fake.deleteChunks); got != "[500 500 300]" {
|
|||
|
|
t.Fatalf("delete chunks = %s, want [500 500 300]", got)
|
|||
|
|
}
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
left := len(fake.ips)
|
|||
|
|
var big bool
|
|||
|
|
for _, q := range fake.ipsQueries {
|
|||
|
|
v, _ := url.ParseQuery(q)
|
|||
|
|
if n, _ := strconv.Atoi(v.Get("limit")); n > 1000 || n == 0 {
|
|||
|
|
big = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
bare := fake.bareIPsCalls
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
if left != 3 {
|
|||
|
|
t.Fatalf("expected only the 3 non-matching rows left, got %d", left)
|
|||
|
|
}
|
|||
|
|
if big || bare != 0 {
|
|||
|
|
t.Fatalf("every list call must be paginated with limit ≤ 1000, got %v (bare=%d)", fake.ipsQueries, bare)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
|
|||
|
|
t.Fatalf("expected the emptied filter view, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// scope=all with nothing matching is a client error, not a silent no-op.
|
|||
|
|
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"scope": {"all"}, "q": {"nothing"}})
|
|||
|
|
if !strings.Contains(body, "alert-warning") {
|
|||
|
|
t.Fatalf("expected a banner for an empty scope=all, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIPsTableControls(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(120)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
fake.ips[0].State = "checking"
|
|||
|
|
fake.ips[1].State = "done"
|
|||
|
|
fake.ips[1].OverallResult = "pass"
|
|||
|
|
fake.ips[1].AggregatedAt = &now
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
body := get(t, ts, "/ips")
|
|||
|
|
const params = `hx-params="page,per_page,q,state,result"`
|
|||
|
|
// Every per-row button and the clear/scan buttons restrict the request
|
|||
|
|
// params (htmx would otherwise put every checked checkbox into the URL).
|
|||
|
|
var rowButtons int
|
|||
|
|
for _, b := range strings.Split(body, "<button")[1:] {
|
|||
|
|
tag := b[:strings.Index(b, ">")]
|
|||
|
|
perRow := strings.Contains(tag, `hx-delete="/ips/`) ||
|
|||
|
|
(strings.Contains(tag, `hx-post="/ips/1`) && (strings.Contains(tag, "/recheck") || strings.Contains(tag, "/cancel")))
|
|||
|
|
if perRow {
|
|||
|
|
rowButtons++
|
|||
|
|
}
|
|||
|
|
if perRow || strings.Contains(tag, `hx-post="/ips/clear"`) || strings.Contains(tag, `hx-post="/ips/scan`) {
|
|||
|
|
if !strings.Contains(tag, params) {
|
|||
|
|
t.Fatalf("button without hx-params: <button%s>", tag)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if rowButtons != 100 { // delete + recheck/cancel on each of the 50 rows
|
|||
|
|
t.Fatalf("expected 100 per-row buttons, found %d", rowButtons)
|
|||
|
|
}
|
|||
|
|
// Bulk delete/recheck submit the whole form (checked boxes + scope), so no whitelist there.
|
|||
|
|
for _, b := range strings.Split(body, "<button")[1:] {
|
|||
|
|
tag := b[:strings.Index(b, ">")]
|
|||
|
|
if (strings.Contains(tag, `hx-post="/ips/delete"`) || strings.Contains(tag, `hx-post="/ips/recheck"`)) && strings.Contains(tag, "hx-params") {
|
|||
|
|
t.Fatalf("bulk buttons must submit the whole form: <button%s>", tag)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Bulk selection UI with real counts.
|
|||
|
|
for _, want := range []string{
|
|||
|
|
"Выбрано на странице: <b data-sel-count>0</b> из 50",
|
|||
|
|
"Выбрать все 120 по фильтру",
|
|||
|
|
"Удалить ВСЕ 120 адресов, включая идущие проверки? Действие необратимо.",
|
|||
|
|
"Удалить ВСЕ 120 адресов из очереди, включая идущие проверки? Действие необратимо.",
|
|||
|
|
`<input type="hidden" name="scope" value="">`,
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the page, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Under a filter the clear confirmation shows the whole queue, the
|
|||
|
|
// delete-all one the filtered count.
|
|||
|
|
body = get(t, ts, "/ips?q=10.0.0.1")
|
|||
|
|
for _, want := range []string{"Удалить ВСЕ 32 адреса по текущему фильтру, включая", "Удалить ВСЕ 120 адресов из очереди"} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q under a filter, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Everything on one page: no "select all by filter" offer.
|
|||
|
|
body = get(t, ts, "/ips?q=10.0.0.119")
|
|||
|
|
if strings.Contains(body, `class="select-all-link"`) {
|
|||
|
|
t.Fatalf("no select-all link when the filter fits on the page, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestOverviewBoundedWithThousandsQueued(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(5000)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
for i := 0; i < 3; i++ {
|
|||
|
|
fake.ips[i].State = "checking"
|
|||
|
|
}
|
|||
|
|
for i := 0; i < 30; i++ {
|
|||
|
|
at := now.Add(-time.Duration(i) * time.Minute)
|
|||
|
|
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
|
|||
|
|
}
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
for _, path := range []string{"/overview", "/overview/fragment"} {
|
|||
|
|
body := get(t, ts, path)
|
|||
|
|
// 3 active + the next 10 queued + the last 20 completed.
|
|||
|
|
if n := strings.Count(body, `href="/ips/`); n != 33 {
|
|||
|
|
t.Fatalf("%s: expected 33 address rows (3+10+20), got %d", path, n)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{"В очереди: <b>4997</b>", `href="/ips?state=queued"`, "Последние 20 завершённых", "Ближайшие в очереди"} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("%s: expected %q, got:\n%s", path, want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(body, ipRowLink("2.2.2.0")) || strings.Contains(body, ipRowLink("2.2.2.25")) {
|
|||
|
|
t.Fatalf("%s: expected only the 20 newest completed rows", path)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
defer fake.mu.Unlock()
|
|||
|
|
if fake.bareIPsCalls != 0 {
|
|||
|
|
t.Fatalf("the overview must never load the whole queue, bare calls = %d", fake.bareIPsCalls)
|
|||
|
|
}
|
|||
|
|
if len(fake.ipsQueries) == 0 {
|
|||
|
|
t.Fatalf("expected paginated list requests")
|
|||
|
|
}
|
|||
|
|
for _, q := range fake.ipsQueries {
|
|||
|
|
v, _ := url.ParseQuery(q)
|
|||
|
|
if n, _ := strconv.Atoi(v.Get("limit")); n < 1 || n > 100 {
|
|||
|
|
t.Fatalf("overview list request without a small limit: %q", q)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestOverviewProgressIndicatorAndETA(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(100)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
// 6 completed rows, one every 10 s (span 50 s): 10 s per address.
|
|||
|
|
for i := 0; i < 6; i++ {
|
|||
|
|
at := now.Add(-time.Duration(i) * 10 * time.Second)
|
|||
|
|
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
|
|||
|
|
}
|
|||
|
|
// "occupied" counts as finished work too.
|
|||
|
|
for i := 0; i < 2; i++ {
|
|||
|
|
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("3.3.3.%d", i), State: "occupied", CreatedAt: now, UpdatedAt: now})
|
|||
|
|
}
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
body := get(t, ts, "/overview")
|
|||
|
|
for _, want := range []string{
|
|||
|
|
"Готово 8 из 108 (7%) · в работе 0 · в очереди 100",
|
|||
|
|
`<progress max="108" value="8"`,
|
|||
|
|
"осталось ≈ 16 мин 40 с", // 100 queued × 10 s
|
|||
|
|
"Итоги проверок: pass 6",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q in the stats block, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// The ETA is not derived from a filtered window.
|
|||
|
|
if body = get(t, ts, "/overview?q=2.2.2"); strings.Contains(body, "осталось") {
|
|||
|
|
t.Fatalf("no ETA under a filter, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
// Too few samples: no ETA.
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
fake.ips = append(fake.ips[:100], fake.ips[100:103]...)
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
if body = get(t, ts, "/overview"); strings.Contains(body, "осталось") || !strings.Contains(body, "Готово 3 из 103") {
|
|||
|
|
t.Fatalf("expected progress without ETA for <5 samples, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestOverviewScanLineAndScanningPhase(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.seedIPs(2)
|
|||
|
|
now := time.Now()
|
|||
|
|
fake.autoCycle = autoCycleDTO{Enabled: true, IntervalSeconds: 3600, Phase: "scanning", NextRunAt: &now}
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
// No running scan: no scan line.
|
|||
|
|
if body := get(t, ts, "/overview"); strings.Contains(body, "Сканирование:") {
|
|||
|
|
t.Fatalf("no scan line while idle, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6439, Added: 500, StartedAt: &now}
|
|||
|
|
fake.mu.Unlock()
|
|||
|
|
body := get(t, ts, "/overview")
|
|||
|
|
for _, want := range []string{"Сканирование: ставятся в очередь", "прочитано страниц 33", "найдено адресов 6440", "свободных 6439", "сканирование Floating IP", "Автоцикл активен"} {
|
|||
|
|
if !strings.Contains(body, want) {
|
|||
|
|
t.Fatalf("expected %q, got:\n%s", want, body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// The polled fragment refreshes the line via the OOB stats block.
|
|||
|
|
frag := get(t, ts, "/overview/fragment")
|
|||
|
|
oob := strings.Index(frag, `id="overview-stats" hx-swap-oob="true"`)
|
|||
|
|
if oob < 0 || !strings.Contains(frag[oob:], "Сканирование: ставятся в очередь") {
|
|||
|
|
t.Fatalf("expected the scan line inside the OOB stats block, got:\n%s", frag)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestRegistryPaginationAndServerSideFilter(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
now := time.Now()
|
|||
|
|
results := []string{"pass", "fail", "partial"}
|
|||
|
|
for i := 0; i < 120; i++ {
|
|||
|
|
ip := fmt.Sprintf("10.0.0.%d", i+1)
|
|||
|
|
fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: results[i%3]}
|
|||
|
|
}
|
|||
|
|
ts := newTestServer(t, caURL)
|
|||
|
|
|
|||
|
|
body := get(t, ts, "/registry?page=2")
|
|||
|
|
if !strings.Contains(body, "Показано 51–100 из 120") || strings.Count(body, `href="/registry/10.`) != 50 {
|
|||
|
|
t.Fatalf("expected page 2 with 50 rows, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
if body = get(t, ts, "/registry?page=99"); !strings.Contains(body, "Показано 101–120 из 120") {
|
|||
|
|
t.Fatalf("expected a clamp to the last page, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 40 of the 120 are "fail"; q and status survive in the pager links and
|
|||
|
|
// reach control-api as parameters (no client-side filtering).
|
|||
|
|
body = get(t, ts, "/registry?status=fail&q=10.0.0&per_page=25")
|
|||
|
|
if !strings.Contains(body, "Показано 1–25 из 40") {
|
|||
|
|
t.Fatalf("expected the server-side filtered total, got:\n%s", body)
|
|||
|
|
}
|
|||
|
|
next := pagerLink(t, body, "next")
|
|||
|
|
if next == nil || next.Path != "/registry" || next.Query().Get("status") != "fail" || next.Query().Get("q") != "10.0.0" ||
|
|||
|
|
next.Query().Get("per_page") != "25" || next.Query().Get("page") != "2" {
|
|||
|
|
t.Fatalf("pager link lost the filter: %v", next)
|
|||
|
|
}
|
|||
|
|
fake.mu.Lock()
|
|||
|
|
defer fake.mu.Unlock()
|
|||
|
|
last := fake.registryQueries[len(fake.registryQueries)-1]
|
|||
|
|
for _, want := range []string{"last_result=fail", "q=10.0.0", "limit=25"} {
|
|||
|
|
if !strings.Contains(last, want) {
|
|||
|
|
t.Fatalf("control-api request %q lacks %s", last, want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestLongTimeoutForClearAndBulkCalls(t *testing.T) {
|
|||
|
|
// A short per-call timeout (50 ms) breaks plain reads of a slow control-api
|
|||
|
|
// but not clear/bulk operations, which use the long client.
|
|||
|
|
slow := newSlowAPI(t, 200*time.Millisecond)
|
|||
|
|
c := newClient(slow, 50*time.Millisecond)
|
|||
|
|
if _, err := c.Status(t.Context()); err == nil {
|
|||
|
|
t.Fatalf("expected the short timeout to fail a plain read")
|
|||
|
|
}
|
|||
|
|
if _, err := c.ClearQueue(t.Context()); err != nil {
|
|||
|
|
t.Fatalf("ClearQueue must use the long timeout: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := c.DeleteIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
|
|||
|
|
t.Fatalf("DeleteIPs must use the long timeout: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := c.SubmitIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
|
|||
|
|
t.Fatalf("SubmitIPs must use the long timeout: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestScanRoutesPassOriginCheckWhenAuthEnabled(t *testing.T) {
|
|||
|
|
fake, caURL := newFakeControlAPI(t)
|
|||
|
|
fake.scanRunPolls = 1
|
|||
|
|
_, ts := newAuthTestServer(t, caURL, nil)
|
|||
|
|
cookie := login(t, ts)
|
|||
|
|
|
|||
|
|
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie})
|
|||
|
|
if resp.StatusCode != http.StatusForbidden {
|
|||
|
|
t.Fatalf("scan without Origin: status=%d, want 403", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
resp, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie, headers: map[string]string{"Origin": ts.URL}})
|
|||
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="scan-progress"`) {
|
|||
|
|
t.Fatalf("scan with Origin: status=%d body:\n%s", resp.StatusCode, body)
|
|||
|
|
}
|
|||
|
|
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status", cookie: cookie})
|
|||
|
|
if resp.StatusCode != http.StatusOK || resp.Header.Get("HX-Trigger") != "scan-finished" {
|
|||
|
|
t.Fatalf("status poll: status=%d trigger=%q body:\n%s", resp.StatusCode, resp.Header.Get("HX-Trigger"), body)
|
|||
|
|
}
|
|||
|
|
}
|