Files
cloud-ip-validator/internal/dashboard/handlers_test.go
T
ayurishchevandClaude Sonnet 5.5 cd37b10f3b Add optional automatic check cycle (clear queue -> scan FIPs -> wait -> repeat)
An admin-controlled scenario that repeats what the operator does by hand:
clear the IP queue, scan and enqueue all free Floating IPs, wait until every
queued address reaches a terminal state (so results are in the Registry),
then wait a configurable interval and start over.

- control-api: new auto_cycle singleton table (migration 0008) holding
  enabled/interval/max-run settings and persisted phase state, so the cycle
  survives restarts; engine in orchestrator/autocycle.go driven from the
  existing loop tick with an injectable "now" for deterministic tests.
- Interval (default 1h, min 60s) and max wait (default unlimited, timeout
  outcome) are runtime settings, never hardcoded.
- The periodic fip_scan_interval_seconds scan is skipped while the cycle is
  enabled. An emptied queue mid-cycle counts as finished; stopping during
  the pause keeps the last cycle's outcome.
- API: GET/PUT /api/v1/admin/auto-cycle, POST .../start, POST .../stop.
- admin-dashboard: "Автоматический цикл" panel on /settings and an
  "Автоцикл активен" indicator on /overview.
- Tests for db, orchestrator, httpapi and dashboard; run-local-e2e.sh now
  exercises a full auto cycle; docs updated; bin/ rebuilt with refreshed
  SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:28:53 +03:00

910 lines
37 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)
}
}
// TestOverviewFragmentUpdatesStatsOutOfBand proves /overview/fragment keeps
// refreshing the stat-grid even though #overview-stats lives outside the
// polled #overview-tables div (see overview.html — the stat panel sits
// above the filter form, which must survive polling, so it can't be part
// of the swapped tables block). This guards the DOM layout fix: stats,
// then a gap, then the filter panel, then everything else.
func TestOverviewFragmentUpdatesStatsOutOfBand(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.validators = []validatorDTO{{ValidatorID: "v1", State: "idle"}}
fake.ips = []ipQueueItem{{IPAddress: "1.1.1.1", State: "queued"}}
ts := newTestServer(t, caURL)
body := get(t, ts, "/overview/fragment")
if !strings.Contains(body, `id="overview-stats" hx-swap-oob="true"`) {
t.Fatalf("expected an out-of-band update for #overview-stats, got:\n%s", body)
}
if !strings.Contains(body, "всего IP") {
t.Fatalf("expected the stat-grid labels in the OOB block, got:\n%s", body)
}
page := get(t, ts, "/overview")
statsIdx := strings.Index(page, `id="overview-stats"`)
filterIdx := strings.Index(page, `id="overview-filter"`)
tablesIdx := strings.Index(page, `id="overview-tables"`)
if statsIdx < 0 || filterIdx < 0 || tablesIdx < 0 {
t.Fatalf("expected all three sections present, got:\n%s", page)
}
if !(statsIdx < filterIdx && filterIdx < tablesIdx) {
t.Fatalf("expected DOM order stats -> filter -> tables, got indices stats=%d filter=%d tables=%d",
statsIdx, filterIdx, tablesIdx)
}
}
// TestOverviewFilterByQueryAndStatus proves the ?q=&status= params on both
// /overview and /overview/fragment narrow the current-checking and
// last-completed tables to matching rows only, and that the filter form
// echoes the applied values back (so they survive the recurring poll,
// which only re-renders #overview-tables, not the form around it).
func TestOverviewFilterByQueryAndStatus(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "checking", Sequence: 1, AssignedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "1.1.1.2", State: "checking", Sequence: 2, 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: "fail", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
// Search narrows both tables by IP substring.
body := get(t, ts, "/overview/fragment?q=1.1.1.1")
if !strings.Contains(body, "1.1.1.1") || strings.Contains(body, "1.1.1.2") {
t.Fatalf("expected search to match only 1.1.1.1, got:\n%s", body)
}
// Status filter narrows the completed table and hides in-progress rows
// entirely (they have no OverallResult yet).
body = get(t, ts, "/overview/fragment?status=fail")
if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") {
t.Fatalf("expected in-progress and passing rows hidden under status=fail, got:\n%s", body)
}
if !strings.Contains(body, "3.3.3.3") {
t.Fatalf("expected failing row to remain under status=fail, got:\n%s", body)
}
if !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the empty current-checking table to show the filtered empty state, got:\n%s", body)
}
// The full page echoes q/status back into the filter form's inputs.
page := get(t, ts, "/overview?q=1.1.1.1&status=pass")
if !strings.Contains(page, `value="1.1.1.1"`) {
t.Fatalf("expected search box to echo back the query, got:\n%s", page)
}
if !strings.Contains(page, `value="pass" selected`) {
t.Fatalf("expected status select to echo back the selection, got:\n%s", page)
}
}
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"}, "history_retention_cycles": {"10"},
})
if !strings.Contains(body, `value="15"`) || !strings.Contains(body, `value="10"`) {
t.Fatalf("expected updated values 15/10 in re-rendered form, got:\n%s", body)
}
if fake.fipSettleSeconds != 15 {
t.Fatalf("expected fake control-api settings updated, got %d", fake.fipSettleSeconds)
}
if fake.historyRetentionCycles != 10 {
t.Fatalf("expected fake control-api history_retention_cycles updated, got %d", fake.historyRetentionCycles)
}
// 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"}, "history_retention_cycles": {"10"},
})
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"}, "history_retention_cycles": {"10"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for non-numeric value, got:\n%s", body)
}
// Same for a non-numeric retention value.
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"not-a-number"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for non-numeric retention 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 TestIPsScan(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5"}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/scan", nil)
if !strings.Contains(body, "5.5.5.5") {
t.Fatalf("expected scanned address in re-rendered table, got:\n%s", body)
}
if len(fake.ips) != 1 || fake.ips[0].IPAddress != "5.5.5.5" {
t.Fatalf("expected fake control-api queue to contain the scanned address, got %+v", fake.ips)
}
}
func TestRegistryPageAndDetail(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.registry["9.9.9.9"] = registryItem{
IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now,
TotalCycles: 2, LastResult: "pass", InQueue: false,
}
fake.registryChecks["9.9.9.9"] = []check{
{CycleID: 2, Source: "egress", CheckType: "https", Target: "https://example.test", Success: true, CheckedAt: now},
{CycleID: 1, Source: "egress", CheckType: "https", Target: "https://example.test", Success: false, CheckedAt: now},
}
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry")
if !strings.Contains(page, "9.9.9.9") || !strings.Contains(page, "/registry/9.9.9.9") {
t.Fatalf("expected registry list to show the address with a detail link, got:\n%s", page)
}
detail := get(t, ts, "/registry/9.9.9.9")
if !strings.Contains(detail, "9.9.9.9") {
t.Fatalf("expected detail page to show the address, got:\n%s", detail)
}
if !strings.Contains(detail, "https://example.test") {
t.Fatalf("expected detail page to list retained checks, got:\n%s", detail)
}
notFound := get(t, ts, "/registry/1.2.3.4")
if !strings.Contains(notFound, "alert-warning") {
t.Fatalf("expected client error banner for unknown registry address, got:\n%s", notFound)
}
}
// TestRegistryFilterByQueryAndStatus proves the ?q=&status= params on
// /registry narrow the list by address substring and by LastResult, and
// that the filter form echoes the applied values back into its inputs.
func TestRegistryFilterByQueryAndStatus(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.registry["1.1.1.1"] = registryItem{IPAddress: "1.1.1.1", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass"}
fake.registry["1.1.1.2"] = registryItem{IPAddress: "1.1.1.2", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "fail"}
fake.registry["2.2.2.2"] = registryItem{IPAddress: "2.2.2.2", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass"}
ts := newTestServer(t, caURL)
// Table rows link to /registry/{ip} — check for that instead of the
// bare IP substring, since the filter form echoes q/status back into
// its own input's value="..." attribute, which would otherwise cause
// a false match/mismatch for whatever address happens to equal q.
rowLink := func(ip string) string { return `href="/registry/` + ip + `"` }
// Search by IP substring.
body := get(t, ts, "/registry?q=1.1.1")
if !strings.Contains(body, rowLink("1.1.1.1")) || !strings.Contains(body, rowLink("1.1.1.2")) || strings.Contains(body, rowLink("2.2.2.2")) {
t.Fatalf("expected only the 1.1.1.x addresses to match the search, got:\n%s", body)
}
// Filter by status.
body = get(t, ts, "/registry?status=fail")
if strings.Contains(body, rowLink("1.1.1.1")) || strings.Contains(body, rowLink("2.2.2.2")) {
t.Fatalf("expected only the failing address under status=fail, got:\n%s", body)
}
if !strings.Contains(body, rowLink("1.1.1.2")) {
t.Fatalf("expected the failing address to remain under status=fail, got:\n%s", body)
}
// Both together (AND): 2.2.2.2 matches q but not status=fail (it's
// pass), so no row should match.
body = get(t, ts, "/registry?q=2.2.2.2&status=fail")
if strings.Contains(body, rowLink("1.1.1.2")) || strings.Contains(body, rowLink("2.2.2.2")) {
t.Fatalf("expected no match for q=2.2.2.2&status=fail (2.2.2.2 is pass), got:\n%s", body)
}
if !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the filtered-empty state, got:\n%s", body)
}
// The page echoes q/status back into the filter form's inputs.
page := get(t, ts, "/registry?q=1.1.1&status=pass")
if !strings.Contains(page, `value="1.1.1"`) {
t.Fatalf("expected search box to echo back the query, got:\n%s", page)
}
if !strings.Contains(page, `value="pass" selected`) {
t.Fatalf("expected status select to echo back the selection, got:\n%s", page)
}
}
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)
}
}
func TestFilterQueueItems(t *testing.T) {
items := []ipQueueItem{
{IPAddress: "1.1.1.1", OverallResult: "pass"},
{IPAddress: "1.1.1.2", OverallResult: "fail"},
{IPAddress: "2.2.2.2", OverallResult: "pass"},
}
if got := filterQueueItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected exact-substring match, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 {
t.Fatalf("expected search to be case/substring based, got %+v", got)
}
if got := filterQueueItems(items, "", "pass"); len(got) != 2 {
t.Fatalf("expected 2 matches for status=pass, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1", "pass"); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected q+status combined with AND, got %+v", got)
}
if got := filterQueueItems(items, "9.9.9.9", ""); len(got) != 0 {
t.Fatalf("expected no matches, got %+v", got)
}
// Case-insensitivity, via a query with mixed-case letters (IP octets
// are numeric, so exercise it through IPv6-shaped input instead).
mixed := []ipQueueItem{{IPAddress: "fe80::AbCd"}}
if got := filterQueueItems(mixed, "abcd", ""); len(got) != 1 {
t.Fatalf("expected case-insensitive search to match, got %+v", got)
}
}
func TestFilterRegistryItems(t *testing.T) {
items := []registryItem{
{IPAddress: "1.1.1.1", LastResult: "pass"},
{IPAddress: "1.1.1.2", LastResult: "partial"},
{IPAddress: "2.2.2.2", LastResult: ""},
}
if got := filterRegistryItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
}
if got := filterRegistryItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
}
if got := filterRegistryItems(items, "", "partial"); len(got) != 1 || got[0].IPAddress != "1.1.1.2" {
t.Fatalf("expected exactly the partial-result address, got %+v", got)
}
if got := filterRegistryItems(items, "2.2.2", "partial"); len(got) != 0 {
t.Fatalf("expected q+status combined with AND to exclude non-matching, got %+v", got)
}
}
func TestAutoCyclePanelRenders(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
finished := time.Now().Add(-time.Hour)
fake.autoCycle = autoCycleDTO{
IntervalSeconds: 5400, MaxRunSeconds: 1800, Phase: "waiting",
LastRunFinishedAt: &finished, LastOutcome: "timeout", LastError: "checks did not finish", RunsTotal: 4,
}
ts := newTestServer(t, caURL)
page := get(t, ts, "/settings")
for _, want := range []string{
"Автоматический цикл",
"Интервал между циклами (мин)",
"Максимальная длительность проверки (мин, 0 = без лимита)",
`name="interval_minutes" min="1" step="any" value="90"`,
`name="max_run_minutes" min="0" step="any" value="30"`,
"Автоцикл выключен",
"пауза между циклами",
"превышено время ожидания",
"checks did not finish",
`hx-put="/settings/auto-cycle"`,
`hx-post="/settings/auto-cycle/start"`,
"Включить",
"Сохранить",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q on the settings page, got:\n%s", want, page)
}
}
if strings.Contains(page, "Выключить") {
t.Fatalf("stop button must be hidden while the auto-cycle is disabled, got:\n%s", page)
}
}
func TestAutoCycleStartAndStopButtons(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/settings/auto-cycle/start", nil)
if !fake.autoCycle.Enabled {
t.Fatalf("expected the fake control-api auto-cycle enabled after start")
}
if !strings.Contains(body, "Автоцикл включён") || !strings.Contains(body, "Выключить") ||
!strings.Contains(body, `hx-post="/settings/auto-cycle/stop"`) {
t.Fatalf("expected the re-rendered panel to show the enabled state with a stop button, got:\n%s", body)
}
if strings.Contains(body, "alert-") {
t.Fatalf("expected no error banner after a successful start, got:\n%s", body)
}
body = postForm(t, ts, "POST", "/settings/auto-cycle/stop", nil)
if fake.autoCycle.Enabled {
t.Fatalf("expected the fake control-api auto-cycle disabled after stop")
}
if !strings.Contains(body, "Автоцикл выключен") || !strings.Contains(body, "Включить") ||
!strings.Contains(body, "остановлен") {
t.Fatalf("expected the re-rendered panel to show the stopped state, got:\n%s", body)
}
}
func TestAutoCyclePutConvertsMinutesToSeconds(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"30"}, "max_run_minutes": {"10"},
})
if fake.autoCycle.IntervalSeconds != 1800 || fake.autoCycle.MaxRunSeconds != 600 {
t.Fatalf("expected 1800/600 seconds at control-api, got %d/%d",
fake.autoCycle.IntervalSeconds, fake.autoCycle.MaxRunSeconds)
}
if !strings.Contains(body, `name="interval_minutes" min="1" step="any" value="30"`) ||
!strings.Contains(body, `name="max_run_minutes" min="0" step="any" value="10"`) {
t.Fatalf("expected updated minutes in the re-rendered form, got:\n%s", body)
}
if fake.autoCycle.Enabled {
t.Fatalf("saving parameters must not toggle the auto-cycle")
}
// 0 = no limit.
postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"30"}, "max_run_minutes": {"0"},
})
if fake.autoCycle.MaxRunSeconds != 0 {
t.Fatalf("expected max_run_seconds 0, got %d", fake.autoCycle.MaxRunSeconds)
}
}
func TestAutoCyclePutValidationErrors(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
// Below the control-api minimum (60 s): rejected by control-api with
// 400, surfaced as a warning banner; stored value is untouched.
body := postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"0.5"}, "max_run_minutes": {"0"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for a too-short interval, got:\n%s", body)
}
if fake.autoCycle.IntervalSeconds != 3600 {
t.Fatalf("expected interval unchanged, got %d", fake.autoCycle.IntervalSeconds)
}
// Negative max run.
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"10"}, "max_run_minutes": {"-5"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for a negative max run, got:\n%s", body)
}
// Non-numeric input is caught by the dashboard itself.
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"abc"}, "max_run_minutes": {"0"},
})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "интервал должен быть числом минут") {
t.Fatalf("expected a Russian banner for a non-numeric interval, got:\n%s", body)
}
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"10"}, "max_run_minutes": {"abc"},
})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "максимальная длительность") {
t.Fatalf("expected a Russian banner for a non-numeric max run, got:\n%s", body)
}
}
func TestOverviewShowsAutoCycleIndicatorWhenEnabled(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
next := time.Now().Add(30 * time.Minute)
fake.autoCycle = autoCycleDTO{Enabled: true, IntervalSeconds: 3600, Phase: "waiting", NextRunAt: &next}
ts := newTestServer(t, caURL)
page := get(t, ts, "/overview")
for _, want := range []string{"Автоцикл активен", "пауза между циклами", "следующий запуск"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the overview indicator, got:\n%s", want, page)
}
}
// The indicator sits inside the stats block, so the DOM order
// stats -> filter -> tables is preserved.
indIdx := strings.Index(page, "Автоцикл активен")
filterIdx := strings.Index(page, `id="overview-filter"`)
tablesIdx := strings.Index(page, `id="overview-tables"`)
if !(indIdx < filterIdx && filterIdx < tablesIdx) {
t.Fatalf("expected indicator -> filter -> tables order, got %d/%d/%d", indIdx, filterIdx, tablesIdx)
}
// The polled fragment refreshes it through the stats OOB block.
frag := get(t, ts, "/overview/fragment")
oobIdx := strings.Index(frag, `id="overview-stats" hx-swap-oob="true"`)
if oobIdx < 0 || !strings.Contains(frag[oobIdx:], "Автоцикл активен") {
t.Fatalf("expected the indicator inside the OOB stats block, got:\n%s", frag)
}
}
func TestOverviewHidesAutoCycleIndicatorWhenDisabledOrUnavailable(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
// Disabled: no indicator.
if page := get(t, ts, "/overview"); strings.Contains(page, "Автоцикл активен") {
t.Fatalf("indicator must be hidden while the auto-cycle is disabled, got:\n%s", page)
}
// Enabled but the auto-cycle API fails: the overview still renders
// (non-fatal), without the indicator and without an error banner.
fake.mu.Lock()
fake.autoCycle.Enabled = true
fake.autoCycleDown = true
fake.mu.Unlock()
page := get(t, ts, "/overview")
if strings.Contains(page, "Автоцикл активен") {
t.Fatalf("indicator must be hidden when the auto-cycle API fails, got:\n%s", page)
}
if strings.Contains(page, "alert-") {
t.Fatalf("an auto-cycle failure must not raise an error banner on the overview, got:\n%s", page)
}
if !strings.Contains(page, "всего IP") {
t.Fatalf("expected the overview to still render its stats, got:\n%s", page)
}
}