The "Scan Floating IP" button failed with a client timeout: the project now holds ~6.4k floating IPs and the scan listed them all in one unpaginated, timeout-less Neutron request on the HTTP request context. openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them small) with per-page retry/backoff on transport errors, 5xx and 429, and every request now has a timeout (also ends hangs inside the orchestrator tick). orchestrator: the scan is a single-flight background job on the process context with progress (clearing/listing/enqueuing/done/error), dry_run, full discovery before anything is enqueued, then SubmitIPs in chunks of 500 in ascending IP order; a failed read leaves the queue untouched. The auto-cycle gets a "scanning" phase that polls the job, so the control loop and autoCycleMu are never held across OpenStack/DB work; it recovers after a restart and waits for (instead of adopting) a scan started by someone else. db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY counters, EXISTS completion check, set-based ClearAllIPs. API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging and filters on /admin/ips and /admin/registry (bare arrays without limit), results_by_overall in /admin/status. dashboard: scan progress panel and dry-run button, paginated /ips and /registry with server-side filters, Overview on counters and capped lists with progress/ETA, "select all N by filter", hx-params fix for per-row buttons, real counts in confirmations. Also: docs (API, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
686 lines
28 KiB
Go
686 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)
|
||
}
|
||
}
|