feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
9c60eaf3fe
commit
df7516e7ab
45 files changed
+7932
-1
No files matched your search
@@ -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.¬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"><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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user