package web import ( "bytes" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" ) func TestInputNormalizers(t *testing.T) { t.Parallel() username, err := normalizeUsername("Alice_Admin") if err != nil || username != "alice_admin" { t.Fatalf("username normalization failed: %q, %v", username, err) } if _, err := normalizeUsername("not allowed!"); err == nil { t.Fatal("invalid username accepted") } zone, err := normalizeZone("BÜCHER.example.") if err != nil || zone != "xn--bcher-kva.example" { t.Fatalf("zone normalization failed: %q, %v", zone, err) } record, fqdn, err := normalizeRecord("home", "example.de") if err != nil || record != "home" || fqdn != "home.example.de" { t.Fatalf("record normalization failed: %q %q %v", record, fqdn, err) } destination, err := normalizeCNAMEDestination("BÜCHER.example.") if err != nil || destination != "xn--bcher-kva.example." { t.Fatalf("CNAME destination normalization failed: %q %v", destination, err) } if _, err := normalizeCNAMEDestination("198.51.100.20"); err == nil { t.Fatal("IP address accepted as CNAME destination") } if ttl, err := parseTTL("300"); err != nil || ttl != 300 { t.Fatalf("TTL parsing failed: %d %v", ttl, err) } if _, err := parseTTL("120"); err == nil { t.Fatal("unsupported TTL accepted") } if ttl, err := parseEditableTTL("120", 120); err != nil || ttl != 120 { t.Fatalf("existing unsupported TTL could not be preserved: %d %v", ttl, err) } staticIPv4, err := normalizeStaticIP("203.0.113.10", 4) if err != nil || staticIPv4 != "203.0.113.10" { t.Fatalf("static IPv4 normalization failed: %q %v", staticIPv4, err) } if _, err := normalizeStaticIP("203.0.113.10", 6); err == nil { t.Fatal("IPv4 accepted as static IPv6") } missingStaticIP := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=")) missingStaticIP.Header.Set("Content-Type", "application/x-www-form-urlencoded") if _, _, err := parseStaticTargetIPs(missingStaticIP, true, false); err == nil { t.Fatal("inactive DynDNS target accepted enabled IPv4 without a static address") } staticAddresses := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=203.0.113.20&static_ipv6=2001%3Adb8%3A%3A20")) staticAddresses.Header.Set("Content-Type", "application/x-www-form-urlencoded") ipv4, ipv6, err := parseStaticTargetIPs(staticAddresses, true, true) if err != nil || ipv4 != "203.0.113.20" || ipv6 != "2001:db8::20" { t.Fatalf("valid static addresses rejected: ipv4=%q ipv6=%q err=%v", ipv4, ipv6, err) } if targetDynDNSActive(true, false, false) { t.Fatal("DynDNS remained active without an address family") } if !targetDynDNSActive(true, true, false) { t.Fatal("IPv4-only DynDNS was rejected") } } func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) { t.Parallel() if got := unixSeconds(time.Time{}); got != 0 { t.Fatalf("zero time must be exposed as unix 0, got %d", got) } value := time.Date(2026, time.July, 11, 12, 30, 0, 0, time.UTC) if got := unixSeconds(value); got != value.Unix() { t.Fatalf("unexpected unix time: got %d, want %d", got, value.Unix()) } } func TestNoticeUsesValidatedKind(t *testing.T) { t.Parallel() request := httptest.NewRequest(http.MethodGet, "/login?notice=Passwort+nicht+korrekt.¬ice_kind=error", nil) got := notice(request) if got == nil || got.Message != "Passwort nicht korrekt." || got.Kind != "error" { t.Fatalf("unexpected notice: %#v", got) } request = httptest.NewRequest(http.MethodGet, "/login?notice=Hinweis¬ice_kind=not-a-valid-kind", nil) got = notice(request) if got == nil || got.Kind != "info" { t.Fatalf("invalid kind must fall back to info: %#v", got) } if got := notice(httptest.NewRequest(http.MethodGet, "/login", nil)); got != nil { t.Fatalf("empty notice must not render a toast: %#v", got) } } func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) { t.Parallel() response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/settings", nil) server := &Server{} server.redirectNotice(response, request, "/domains?view=active", "Gespeichert.") if response.Code != http.StatusSeeOther { t.Fatalf("unexpected status: got %d, want %d", response.Code, http.StatusSeeOther) } location, err := response.Result().Location() if err != nil { t.Fatalf("parse redirect location: %v", err) } if got := location.Query().Get("view"); got != "active" { t.Fatalf("existing query lost: %q", got) } if got := location.Query().Get("notice"); got != "Gespeichert." { t.Fatalf("unexpected notice: %q", got) } if got := location.Query().Get("notice_kind"); got != "success" { t.Fatalf("unexpected notice kind: %q", got) } } func TestLoginErrorRendersAsToast(t *testing.T) { t.Parallel() templates, err := loadTemplates() if err != nil { t.Fatalf("load templates: %v", err) } var rendered bytes.Buffer data := viewData{ Title: "Eingabe prüfen", Page: "login", Flash: newFlash("Passwort nicht korrekt.", "error"), } if err := templates["login"].ExecuteTemplate(&rendered, "login", data); err != nil { t.Fatalf("render login: %v", err) } body := rendered.String() for _, expected := range []string{`class="toast toast-error"`, `data-toast`, `role="alert"`, `Passwort nicht korrekt.`} { if !strings.Contains(body, expected) { t.Errorf("rendered login is missing %q", expected) } } if strings.Contains(body, `class="flash"`) { t.Error("legacy in-flow flash message is still rendered") } } func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) { t.Parallel() templates, err := loadTemplates() if err != nil { t.Fatalf("load templates: %v", err) } var rendered bytes.Buffer data := viewData{ Title: "example.de", Page: "domains", CSRF: "csrf", IsAdmin: true, Zone: model.Zone{ID: 1, Name: "example.de"}, Settings: model.Settings{IPv6Enabled: true}, Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}}, CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}}, } if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil { t.Fatalf("render domain detail: %v", err) } body := rendered.String() statusAt := strings.Index(body, `A unverändert`) timeAt := strings.Index(body, `class="target-meta">