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

685 lines
28 KiB
Go
Raw Normal View History

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)
}
}