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 1") || !strings.Contains(body, "cancelled 1") { 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, ``) { 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, "