package web import ( "bytes" "context" "encoding/base64" "encoding/json" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "sync/atomic" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "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/scheduler" "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 TestParseFormAcceptsMultipartUpdateFields(t *testing.T) { t.Parallel() var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("csrf", "csrf-token"); err != nil { t.Fatal(err) } if err := writer.WriteField("version", "v1.4.2"); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } request := httptest.NewRequest(http.MethodPost, "/system/update", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() server := &Server{} if err := server.parseForm(response, request); err != nil { t.Fatalf("multipart update form was rejected: status=%d body=%s", response.Code, response.Body.String()) } if request.FormValue("csrf") != "csrf-token" || request.FormValue("version") != "v1.4.2" { t.Fatalf("multipart fields were not parsed: %#v", request.Form) } } 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") } for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} { if strings.Contains(body, removed) { t.Errorf("login still renders removed security marketing copy %q", removed) } } } func TestDashboardRendersLiveUpdateHooks(t *testing.T) { t.Parallel() templates, err := loadTemplates() if err != nil { t.Fatalf("load templates: %v", err) } var rendered bytes.Buffer now := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC) data := viewData{ Title: "Übersicht", Page: "dashboard", CSRF: "csrf-token", User: model.User{DisplayName: "Admin", Username: "admin"}, IsAdmin: true, Dashboard: model.Dashboard{ Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true}, SchedulerRunning: true, RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: now, Status: "running"}}, Zones: []model.Zone{{ID: 7, Name: "example.de", LastSyncAt: now}}, }, } if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil { t.Fatalf("render dashboard: %v", err) } body := rendered.String() for _, expected := range []string{ `data-run-now-button disabled`, `data-dashboard-ipv4`, `data-dashboard-ipv6`, `data-recent-runs aria-live="polite" aria-busy="true"`, `Aktualisierung läuft`, `features-29`, `data-local-time="1788001920"`, `dashboard-zone-item`, `DNS-Zone · example.de`, } { if !strings.Contains(body, expected) { t.Errorf("rendered dashboard is missing %q", expected) } } } func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) { t.Parallel() ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) runID, err := database.CreateUpdateRun(ctx) if err != nil { t.Fatal(err) } if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil { t.Fatal(err) } schedule := scheduler.New(database, nil, nil, nil, slog.Default()) server := &Server{store: database, scheduler: schedule} adminResponse := httptest.NewRecorder() adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil) server.schedulerAPI(adminResponse, adminRequest, &model.Session{Role: "admin"}) if adminResponse.Code != http.StatusOK { t.Fatalf("unexpected admin response: status=%d body=%s", adminResponse.Code, adminResponse.Body.String()) } if got := adminResponse.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("unexpected cache policy: %q", got) } var adminState schedulerResponse if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil { t.Fatal(err) } if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 1 { t.Fatalf("administrator did not receive recent runs: %#v", adminState.RecentRuns) } if run := (*adminState.RecentRuns)[0]; run.ID != runID || run.Status != "ok" || run.Summary != "1 Ziel aktualisiert" { t.Fatalf("unexpected recent run: %#v", run) } memberResponse := httptest.NewRecorder() memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil) server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"}) var memberState schedulerResponse if err := json.NewDecoder(memberResponse.Body).Decode(&memberState); err != nil { t.Fatal(err) } if memberState.RecentRuns != nil { t.Fatal("member received administrator-only update runs") } } func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) { t.Parallel() limiter := newLoginLimiter() limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)} limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second) if !limiter.Allow("fresh") { t.Fatal("fresh client was rejected after expired entries should have been pruned") } if _, exists := limiter.attempts["expired"]; exists { t.Fatal("expired login limiter entry was not pruned") } now := time.Now() for index := 0; index < loginLimiterMaxTrackedIPs; index++ { limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now} } limiter.lastCleanup = now if limiter.Allow("overflow") { t.Fatal("untracked client was accepted after the bounded limiter reached capacity") } } func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) { t.Parallel() ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) hash, err := secure.HashPassword("a sufficiently secure test password") if err != nil { t.Fatal(err) } if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil { t.Fatal(err) } admin, _, err := database.GetUserByUsername(ctx, "admin") if err != nil { t.Fatal(err) } member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member") if err != nil { t.Fatal(err) } const rawToken = "member-session-token" if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { t.Fatal(err) } server := &Server{config: config.Config{}, store: database} handler := server.Handler() requests := []struct { method string path string body string }{ {method: http.MethodGet, path: "/access"}, {method: http.MethodGet, path: "/users"}, {method: http.MethodGet, path: "/users/new"}, {method: http.MethodGet, path: "/system"}, {method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"}, {method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"}, } for _, test := range requests { request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken}) if test.body != "" { request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code) } } templates, err := loadTemplates() if err != nil { t.Fatal(err) } var rendered bytes.Buffer data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false} if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil { t.Fatal(err) } body := rendered.String() if !strings.Contains(body, `href="/account"`) { t.Error("member navigation does not expose the personal account") } for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} { if strings.Contains(body, forbidden) { t.Errorf("member UI exposes administrator control %q", forbidden) } } } func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) { t.Parallel() templates, err := loadTemplates() if err != nil { t.Fatal(err) } admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} var rendered bytes.Buffer data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}} if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil { t.Fatal(err) } body := rendered.String() for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} { if !strings.Contains(body, expected) { t.Errorf("immutable administrator access UI is missing %q", expected) } } for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} { if strings.Contains(body, forbidden) { t.Errorf("administrator detail renders editable permission input %q", forbidden) } } } func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) { t.Parallel() templates, err := loadTemplates() if err != nil { t.Fatal(err) } admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} var rendered bytes.Buffer data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}} if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil { t.Fatal(err) } body := rendered.String() navStart, navEnd := strings.Index(body, "