Files
cloud-ip-validator/internal/dashboard/scale_test.go
T
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
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>
2026-10-01 19:31:11 +03:00

686 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&amp;q=10.0.0.1&amp;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)
}
}