package web import ( "bytes" "context" "encoding/base64" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) 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 TestPiHoleReadOnlyErrorExplainsRequiredWritePermission(t *testing.T) { t.Parallel() message := piHoleErrorMessage(&pihole.APIError{Status: http.StatusForbidden, Key: "forbidden", Message: "Unable to change configuration (read-only)"}) for _, expected := range []string{"webserver.api.app_sudo=true", "misc.readOnly=false"} { if !strings.Contains(message, expected) { t.Errorf("actionable Pi-hole read-only message is missing %q: %s", expected, message) } } } func TestSecurityHeadersProtectSensitiveResponses(t *testing.T) { t.Parallel() server := &Server{config: config.Config{CookieSecure: true}} handler := server.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) for name, expected := range map[string]string{ "Content-Security-Policy": "default-src 'self'", "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Resource-Policy": "same-origin", "Strict-Transport-Security": "max-age=31536000", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", } { if !strings.Contains(response.Header().Get(name), expected) { t.Errorf("security header %s=%q does not contain %q", name, response.Header().Get(name), expected) } } } func TestPiHoleRecordNormalization(t *testing.T) { t.Parallel() value, err := normalizePiHoleRecord("192.168.1.20", "NAS.LAN nas") if err != nil || value != "192.168.1.20 nas.lan nas" { t.Fatalf("Pi-hole IPv4 record normalization failed: %q err=%v", value, err) } value, err = normalizePiHoleRecord("2001:0db8::20", "drucker.lan") if err != nil || value != "2001:db8::20 drucker.lan" { t.Fatalf("Pi-hole IPv6 record normalization failed: %q err=%v", value, err) } for _, invalid := range [][2]string{{"not-an-ip", "nas.lan"}, {"192.168.1.20", "bad_name"}, {"192.168.1.20", ""}} { if _, err := normalizePiHoleRecord(invalid[0], invalid[1]); err == nil { t.Errorf("invalid Pi-hole record accepted: %#v", invalid) } } if raw, err := validatePiHoleRawRecord("192.168.1.20 nas.lan"); err != nil || raw != "192.168.1.20 nas.lan" { t.Fatalf("valid raw Pi-hole record rejected: %q err=%v", raw, err) } } func TestPiHoleRecordsSortActiveEntriesFirst(t *testing.T) { t.Parallel() records := []piHoleRecordView{ {Hostnames: "aaa.lan", Active: false}, {Hostnames: "zzz.lan", Active: true}, {Hostnames: "bbb.lan", Active: true}, } sortPiHoleRecords(records) if records[0].Hostnames != "bbb.lan" || records[1].Hostnames != "zzz.lan" || records[2].Hostnames != "aaa.lan" { t.Fatalf("Pi-hole records not sorted by active state and hostname: %#v", records) } } 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">