2026-08-23 20:39:22 +03:00
|
|
|
package dashboard
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-26 19:45:48 +03:00
|
|
|
"reflect"
|
2026-08-23 20:39:22 +03:00
|
|
|
"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)
|
|
|
|
|
}
|
2026-08-23 21:58:19 +03:00
|
|
|
if !strings.Contains(body, "pass <b>1</b>") || !strings.Contains(body, "cancelled <b>1</b>") {
|
2026-08-23 20:39:22 +03:00
|
|
|
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": {""}})
|
2026-08-23 21:15:31 +03:00
|
|
|
if !strings.Contains(body, "alert-warning") {
|
2026-08-23 20:39:22 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 22:24:55 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 18:47:11 +03:00
|
|
|
func TestIPsPagePollsForFragment(t *testing.T) {
|
2026-09-18 19:10:05 +03:00
|
|
|
fake, caURL := newFakeControlAPI(t)
|
|
|
|
|
now := time.Now()
|
|
|
|
|
fake.ips = []ipQueueItem{
|
|
|
|
|
{IPAddress: "1.1.1.1", State: "done", OverallResult: "pass", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
|
|
|
|
|
}
|
2026-09-18 18:47:11 +03:00
|
|
|
ts := newTestServer(t, caURL)
|
|
|
|
|
|
|
|
|
|
body := get(t, ts, "/ips")
|
|
|
|
|
if !strings.Contains(body, `hx-get="/ips/fragment"`) || !strings.Contains(body, `hx-trigger="every 5s"`) {
|
|
|
|
|
t.Fatalf("expected ips-table-wrap to auto-poll every 5s, got:\n%s", body)
|
|
|
|
|
}
|
2026-09-18 19:10:05 +03:00
|
|
|
// hx-sync must coordinate the poll with every action that also swaps
|
|
|
|
|
// #ips-table-wrap (bulk buttons, per-row buttons, the add-address
|
|
|
|
|
// form) so a slow mutation and a poll tick can never race and let a
|
|
|
|
|
// stale response clobber a just-applied action (recheck-selected
|
|
|
|
|
// appearing to silently fail with many rows selected) — 1 wrapper div
|
|
|
|
|
// + 1 add-address form + 3 bulk buttons + 2 per-row buttons (one
|
|
|
|
|
// terminal row: "Перепроверить" + "Удалить") = 7.
|
|
|
|
|
if got := strings.Count(body, `hx-sync="#ips-table-wrap:queue last"`); got != 7 {
|
|
|
|
|
t.Fatalf("expected 7 elements to declare hx-sync, got %d in:\n%s", got, body)
|
|
|
|
|
}
|
2026-09-18 18:47:11 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIPsFragment(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: "checking", UpdatedAt: now, CreatedAt: now},
|
|
|
|
|
}
|
|
|
|
|
ts := newTestServer(t, caURL)
|
|
|
|
|
|
|
|
|
|
body := get(t, ts, "/ips/fragment")
|
|
|
|
|
if !strings.Contains(body, "1.1.1.1") || !strings.Contains(body, "2.2.2.2") {
|
|
|
|
|
t.Fatalf("expected both addresses in fragment, 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 22:24:55 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 10:29:08 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 19:45:48 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 10:29:08 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 20:39:22 +03:00
|
|
|
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"}})
|
2026-08-23 21:15:31 +03:00
|
|
|
if !strings.Contains(body, "alert-warning") {
|
2026-08-23 20:39:22 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 20:47:54 +03:00
|
|
|
// The table renders exactly as many rows as configured slots — no
|
|
|
|
|
// fixed cap, no padding to a fixed count.
|
2026-08-23 20:39:22 +03:00
|
|
|
page := get(t, ts, "/sites")
|
2026-08-26 20:47:54 +03:00
|
|
|
if strings.Count(page, `hx-put="/sites/`) != 1 {
|
|
|
|
|
t.Fatalf("expected exactly 1 site slot row, got:\n%s", page)
|
2026-08-23 20:39:22 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
|
2026-08-23 21:15:31 +03:00
|
|
|
if !strings.Contains(body, "alert-warning") {
|
2026-08-23 20:39:22 +03:00
|
|
|
t.Fatalf("expected conflict banner assigning a taken site_id to another slot, got:\n%s", body)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 20:47:54 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 10:43:00 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 20:39:22 +03:00
|
|
|
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)
|
2026-08-23 21:15:31 +03:00
|
|
|
if !strings.Contains(body, "alert-warning") {
|
2026-08-23 20:39:22 +03:00
|
|
|
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")
|
2026-08-23 21:15:31 +03:00
|
|
|
if !strings.Contains(body, "alert-danger") {
|
2026-08-23 20:39:22 +03:00
|
|
|
t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body)
|
|
|
|
|
}
|
|
|
|
|
}
|