Files
cloud-ip-validator/internal/dashboard/handlers_test.go
T
ayurishchevandClaude Sonnet 5 78b20fa5be Remove IPs page auto-refresh; clear checkboxes after bulk recheck
The 5s auto-refresh (ec44d54) and the hx-sync fix on top of it (5a53705)
didn't resolve the issues seen in manual testing. Rather than keep
debugging htmx's polling/preserve/sync interaction, drop auto-refresh
entirely: handleIPsFragment, GET /ips/fragment, and the poll
attributes/PollSeconds plumbing are all removed. The table now only
updates when a button action re-renders it, as it did before auto-refresh
was added — the bulk-recheck feature itself (handleIPsRecheckSelected,
POST /ips/recheck, "Перепроверить выбранные") is untouched.

Also drops hx-preserve/id from the row checkboxes: it existed solely to
survive the auto-poll wiping a selection mid-task, so it has no purpose
left, and it was actively wrong for one case — after a successful
"Перепроверить выбранные", it kept the just-submitted addresses checked
instead of clearing them. Since the checkbox's checked state was never
server-rendered to begin with, removing hx-preserve alone makes every
table swap (including the recheck button's own) render fresh, unchecked
boxes, which is exactly the desired "selection clears once the action has
been applied" behavior.

Left hx-sync="#ips-table-wrap:queue last" on the action buttons/form —
still cheap protection against a double-click race between two real user
actions, independent of the now-removed polling.

Rebuilt bin/admin-dashboard and bin/SHA256SUMS per docs/SETUP.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:29:26 +03:00

476 lines
18 KiB
Go

package dashboard
import (
"reflect"
"strings"
"testing"
"time"
)
func TestOverviewFragment(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
older := now.Add(-time.Hour)
fake.validators = []validatorDTO{{ValidatorID: "v1", State: "idle"}}
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "checking", Sequence: 1, AssignedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "2.2.2.2", State: "done", OverallResult: "pass", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "3.3.3.3", State: "failed", OverallResult: "cancelled", AggregatedAt: &older, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/overview/fragment")
if !strings.Contains(body, "1.1.1.1") {
t.Fatalf("expected in-progress ip 1.1.1.1 in current-checking section, got:\n%s", body)
}
if strings.Contains(body, `href="/ips/1.1.1.1"`) {
// currently-checking rows link to /ips detail too; that's fine, just
// make sure the done one shows up in the "last completed" table.
}
if !strings.Contains(body, "2.2.2.2") || !strings.Contains(body, "3.3.3.3") {
t.Fatalf("expected both completed ips in last-completed section, got:\n%s", body)
}
if !strings.Contains(body, "pass <b>1</b>") || !strings.Contains(body, "cancelled <b>1</b>") {
t.Fatalf("expected breakdown pass:1 cancelled:1, got:\n%s", body)
}
}
func TestIPsSubmitAddAndForceRecheck(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
// Add a brand-new address.
body := postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"1.2.3.4"}})
if !strings.Contains(body, "1.2.3.4") {
t.Fatalf("expected new address in re-rendered table, got:\n%s", body)
}
// Force-recheck the already-done address via the same endpoint.
body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"9.9.9.9"}})
if !strings.Contains(body, "9.9.9.9") {
t.Fatalf("expected requeued address in table, got:\n%s", body)
}
if fake.ips[0].State != "queued" || fake.ips[0].AttemptNumber != 2 {
t.Fatalf("expected fake control-api state requeued with attempt_number=2, got %+v", fake.ips[0])
}
// Empty submission is a client error, surfaced via the banner, not a 500.
body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {""}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for empty submission, got:\n%s", body)
}
}
func TestIPRecheckSkippedInProgress(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{{IPAddress: "5.5.5.5", State: "checking", UpdatedAt: now, CreatedAt: now}}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/5.5.5.5/recheck", nil)
// SubmitIPs succeeds (200) but the address itself lands in
// skipped_in_progress — the dashboard shouldn't claim success without
// qualification; the table must still show it untouched (still checking).
if !strings.Contains(body, "5.5.5.5") {
t.Fatalf("expected 5.5.5.5 still listed, got:\n%s", body)
}
if fake.ips[0].State != "checking" {
t.Fatalf("expected state untouched (checking), got %s", fake.ips[0].State)
}
}
func TestIPCancel(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{{IPAddress: "7.7.7.7", State: "checking", UpdatedAt: now, CreatedAt: now}}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
if !strings.Contains(body, "cancelled") {
t.Fatalf("expected cancelled badge after cancel, got:\n%s", body)
}
if fake.ips[0].OverallResult != "cancelled" {
t.Fatalf("expected fake control-api state cancelled, got %+v", fake.ips[0])
}
// Cancelling an already-terminal ip is a 409 from control-api, surfaced
// as a banner, not a crash.
body = postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
if !strings.Contains(body, "error-banner") {
t.Fatalf("expected error banner for double-cancel, got:\n%s", body)
}
}
func TestIPDelete(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{{IPAddress: "7.7.7.7", State: "checking", UpdatedAt: now, CreatedAt: now}}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "DELETE", "/ips/7.7.7.7", nil)
if strings.Contains(body, "7.7.7.7") {
t.Fatalf("expected 7.7.7.7 removed from table, got:\n%s", body)
}
if len(fake.ips) != 0 {
t.Fatalf("expected fake control-api queue empty, got %+v", fake.ips)
}
// Deleting an already-gone address is a 404, surfaced via banner.
body = postForm(t, ts, "DELETE", "/ips/7.7.7.7", nil)
if !strings.Contains(body, "error-banner") {
t.Fatalf("expected error banner deleting unknown ip, got:\n%s", body)
}
}
func TestIPsDeleteSelected(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "queued", UpdatedAt: now, CreatedAt: now},
{IPAddress: "2.2.2.2", State: "queued", UpdatedAt: now, CreatedAt: now},
{IPAddress: "3.3.3.3", State: "queued", UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/delete", map[string][]string{"addresses": {"1.1.1.1", "2.2.2.2"}})
if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") {
t.Fatalf("expected selected addresses removed, got:\n%s", body)
}
if !strings.Contains(body, "3.3.3.3") {
t.Fatalf("expected untouched address still listed, got:\n%s", body)
}
if len(fake.ips) != 1 || fake.ips[0].IPAddress != "3.3.3.3" {
t.Fatalf("expected only 3.3.3.3 left in fake control-api, got %+v", fake.ips)
}
// Empty selection is a client error banner, not a 500.
body = postForm(t, ts, "POST", "/ips/delete", nil)
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for empty selection, got:\n%s", body)
}
}
// TestIPsRecheckSelectedClearsCheckboxes proves a successful bulk recheck
// re-renders the table with plain, unchecked checkboxes (no hx-preserve),
// so a user sees their selection cleared once the action has been applied
// rather than it silently staying checked.
func TestIPsRecheckSelectedClearsCheckboxes(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/recheck", map[string][]string{"addresses": {"9.9.9.9"}})
if strings.Contains(body, "hx-preserve") {
t.Fatalf("expected no hx-preserve on checkboxes after recheck, got:\n%s", body)
}
if !strings.Contains(body, `<input type="checkbox" name="addresses" value="9.9.9.9">`) {
t.Fatalf("expected a plain, unchecked checkbox for 9.9.9.9 after recheck, got:\n%s", body)
}
}
func TestIPsRecheckSelected(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "1.1.1.1", State: "queued", AttemptNumber: 1, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/recheck", map[string][]string{"addresses": {"9.9.9.9", "1.1.1.1"}})
if !strings.Contains(body, "9.9.9.9") || !strings.Contains(body, "1.1.1.1") {
t.Fatalf("expected both addresses still in re-rendered table, got:\n%s", body)
}
if fake.ips[0].State != "queued" || fake.ips[0].AttemptNumber != 2 {
t.Fatalf("expected done address requeued with attempt_number=2, got %+v", fake.ips[0])
}
// Empty selection is a client error, surfaced via the banner, not a 500.
body = postForm(t, ts, "POST", "/ips/recheck", nil)
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for empty selection, got:\n%s", body)
}
}
func TestIPsClear(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "checking", UpdatedAt: now, CreatedAt: now},
{IPAddress: "2.2.2.2", State: "queued", UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/clear", nil)
if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") {
t.Fatalf("expected all addresses removed, got:\n%s", body)
}
if !strings.Contains(body, "Очередь пуста") {
t.Fatalf("expected empty-queue message, got:\n%s", body)
}
if len(fake.ips) != 0 {
t.Fatalf("expected fake control-api queue empty, got %+v", fake.ips)
}
}
func TestSettingsGetAndPut(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/settings")
if !strings.Contains(page, `value="0"`) {
t.Fatalf("expected default 0 in the form, got:\n%s", page)
}
body := postForm(t, ts, "PUT", "/settings", map[string][]string{"fip_settle_seconds": {"15"}})
if !strings.Contains(body, `value="15"`) {
t.Fatalf("expected updated value 15 in re-rendered form, got:\n%s", body)
}
if fake.fipSettleSeconds != 15 {
t.Fatalf("expected fake control-api settings updated, got %d", fake.fipSettleSeconds)
}
// A control-api validation error (negative value here) surfaces via
// the banner, not a crash.
body = postForm(t, ts, "PUT", "/settings", map[string][]string{"fip_settle_seconds": {"-1"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for invalid value, got:\n%s", body)
}
// A non-numeric value is caught by the dashboard itself before it ever
// reaches control-api.
body = postForm(t, ts, "PUT", "/settings", map[string][]string{"fip_settle_seconds": {"not-a-number"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for non-numeric value, got:\n%s", body)
}
}
func TestSettingsPageShowsInboundChecks(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.inboundPorts = []int{22, 443}
fake.inboundICMP = true
ts := newTestServer(t, caURL)
page := get(t, ts, "/settings")
if !strings.Contains(page, `value="22, 443"`) {
t.Fatalf("expected seeded ports in the form, got:\n%s", page)
}
if !strings.Contains(page, "checked") {
t.Fatalf("expected icmp checkbox checked, got:\n%s", page)
}
}
func TestInboundChecksPutUpdatesForm(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/settings/inbound-checks", map[string][]string{"ports": {"8080, 8443"}})
if !strings.Contains(body, `value="8080, 8443"`) {
t.Fatalf("expected updated ports in re-rendered form, got:\n%s", body)
}
if !reflect.DeepEqual(fake.inboundPorts, []int{8080, 8443}) || fake.inboundICMP {
t.Fatalf("expected fake control-api updated to {[8080 8443] false}, got ports=%v icmp=%v", fake.inboundPorts, fake.inboundICMP)
}
// icmp checkbox checked, empty ports field.
body = postForm(t, ts, "PUT", "/settings/inbound-checks", map[string][]string{"ports": {""}, "icmp": {"on"}})
if len(fake.inboundPorts) != 0 || !fake.inboundICMP {
t.Fatalf("expected fake control-api updated to {[] true}, got ports=%v icmp=%v", fake.inboundPorts, fake.inboundICMP)
}
if !strings.Contains(body, "checked") {
t.Fatalf("expected icmp checkbox checked in re-rendered form, got:\n%s", body)
}
// A malformed ports field is caught by the dashboard itself.
body = postForm(t, ts, "PUT", "/settings/inbound-checks", map[string][]string{"ports": {"22, not-a-port"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for malformed ports, got:\n%s", body)
}
// A control-api validation error (port out of range) surfaces via the
// banner too.
body = postForm(t, ts, "PUT", "/settings/inbound-checks", map[string][]string{"ports": {"70000"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for out-of-range port, got:\n%s", body)
}
}
func TestIPsPageShowsSettleBadge(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.fipSettleSeconds = 60
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "awaiting_self_check", FIPAssociatedAt: &now, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
if !strings.Contains(body, "прогрев FIP") {
t.Fatalf("expected settle badge for a freshly-associated ip within the pause, got:\n%s", body)
}
// Once fip_settle_seconds is 0, the same row falls back to the plain
// state pill.
fake.fipSettleSeconds = 0
body = get(t, ts, "/ips")
if strings.Contains(body, "прогрев FIP") {
t.Fatalf("expected no settle badge once fip_settle_seconds is 0, got:\n%s", body)
}
}
func TestValidatorsCRUD(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-1"}})
if !strings.Contains(body, "val-1") || !strings.Contains(body, "port-1") {
t.Fatalf("expected new validator in table, got:\n%s", body)
}
// Conflict: creating the same validator_id again.
body = postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-x"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner on duplicate create, got:\n%s", body)
}
body = postForm(t, ts, "PUT", "/validators/val-1", map[string][]string{"os_port_id": {"port-2"}})
if !strings.Contains(body, "port-2") {
t.Fatalf("expected updated port in table, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/validators/val-1", nil)
if strings.Contains(body, "val-1") {
t.Fatalf("expected validator removed after delete, got:\n%s", body)
}
}
func TestSitesPutAndConflict(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/sites/1", map[string][]string{"site_id": {"site-1"}})
if !strings.Contains(body, "site-1") {
t.Fatalf("expected site-1 assigned to slot 1, got:\n%s", body)
}
// The table renders exactly as many rows as configured slots — no
// fixed cap, no padding to a fixed count.
page := get(t, ts, "/sites")
if strings.Count(page, `hx-put="/sites/`) != 1 {
t.Fatalf("expected exactly 1 site slot row, got:\n%s", page)
}
body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected conflict banner assigning a taken site_id to another slot, got:\n%s", body)
}
}
// TestSiteCreateAddsSlotBeyondThree proves there's no hardcoded cap on the
// number of prober slots — the "add site" form can create slot 4 and
// beyond.
func TestSiteCreateAddsSlotBeyondThree(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
postForm(t, ts, "PUT", "/sites/1", map[string][]string{"site_id": {"site-1"}})
postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-2"}})
postForm(t, ts, "PUT", "/sites/3", map[string][]string{"site_id": {"site-3"}})
body := postForm(t, ts, "POST", "/sites", map[string][]string{"index": {"4"}, "site_id": {"site-4"}})
if !strings.Contains(body, "site-4") {
t.Fatalf("expected slot 4 created, got:\n%s", body)
}
if strings.Count(body, `hx-put="/sites/`) != 4 {
t.Fatalf("expected exactly 4 site slot rows, got:\n%s", body)
}
// A malformed index is caught by the dashboard itself.
body = postForm(t, ts, "POST", "/sites", map[string][]string{"index": {"not-a-number"}, "site_id": {"site-5"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for non-numeric index, got:\n%s", body)
}
}
func TestSitesPageShowsProberBadge(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.sites[1] = "site-1"
fake.siteState[1] = "idle"
fake.siteHostname[1] = "probe-host-1"
fake.sites[2] = "site-2"
fake.siteState[2] = "unreachable"
ts := newTestServer(t, caURL)
page := get(t, ts, "/sites")
if !strings.Contains(page, "probe-host-1") {
t.Fatalf("expected hostname shown, got:\n%s", page)
}
if !strings.Contains(page, "pill-success") || !strings.Contains(page, ">idle<") {
t.Fatalf("expected idle badge for site-1, got:\n%s", page)
}
if !strings.Contains(page, "pill-danger") || !strings.Contains(page, ">unreachable<") {
t.Fatalf("expected unreachable badge for site-2, got:\n%s", page)
}
}
func TestValidatorsPageShowsHostname(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.validators = append(fake.validators, validatorDTO{ValidatorID: "val-1", Hostname: "validator-host-1", OSPortID: "port-1", State: "idle"})
ts := newTestServer(t, caURL)
page := get(t, ts, "/validators")
if !strings.Contains(page, "validator-host-1") {
t.Fatalf("expected hostname shown, got:\n%s", page)
}
}
func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/targets", map[string][]string{"name": {"web"}, "targets": {"https://a.test\nhttps://b.test"}})
if !strings.Contains(body, "web") {
t.Fatalf("expected new target group in table, got:\n%s", body)
}
body = postForm(t, ts, "POST", "/check-types", map[string][]string{"name": {"https"}, "enabled": {"1"}, "targets": {"web"}})
if !strings.Contains(body, "https") {
t.Fatalf("expected new check type in table, got:\n%s", body)
}
// Can't delete a target group still referenced by a check type.
body = postForm(t, ts, "DELETE", "/targets/web", nil)
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected conflict banner deleting in-use target group, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/check-types/https", nil)
if strings.Contains(body, "<td>https</td>") {
t.Fatalf("expected check type removed, got:\n%s", body)
}
body = postForm(t, ts, "DELETE", "/targets/web", nil)
if strings.Contains(body, "<td>web</td>") {
t.Fatalf("expected target group removable once unreferenced, got:\n%s", body)
}
}
func TestControlAPIUnreachable(t *testing.T) {
// Point the dashboard at an address nothing listens on, rather than a
// closed httptest.Server, to get a deterministic connection-refused
// transport failure.
ts := newTestServer(t, "http://127.0.0.1:1")
body := get(t, ts, "/overview")
if !strings.Contains(body, "alert-danger") {
t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body)
}
}