feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-07-12 20:56:45 +02:00
1 parent 9c60eaf3fe
commit df7516e7ab
45 files changed
+7932 -1

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
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.&notice_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&notice_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"><time>`)
if timeAt < 0 || statusAt < timeAt {
t.Fatalf("target time must be rendered before the status pill: %s", body)
}
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
if !strings.Contains(body, expected) {
t.Errorf("domain detail is missing %q", expected)
}
}
}
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(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: false},
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, TTL: 60}},
}
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
t.Fatalf("render domain detail: %v", err)
}
body := rendered.String()
for _, expected := range []string{`data-ipv6-enabled="false"`, `IPv6 / AAAA pausiert`, `data-static-ipv6`} {
if !strings.Contains(body, expected) {
t.Errorf("globally-disabled IPv6 UI is missing %q", expected)
}
}
inputAt := strings.Index(body, `name="update_ipv6"`)
if inputAt < 0 || !strings.Contains(body[inputAt:inputAt+min(100, len(body)-inputAt)], "disabled") {
t.Error("globally-disabled IPv6 checkbox remained enabled")
}
}