feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+265 -2
View File
@@ -9,10 +9,12 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"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"
@@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
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 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: "/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, "<nav "), strings.Index(body, "</nav>")
if navStart < 0 || navEnd <= navStart {
t.Fatal("administrator navigation was not rendered")
}
navigation := body[navStart:navEnd]
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff &amp; Sicherheit") {
t.Fatal("combined access administration entry is missing")
}
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
t.Fatal("removed credential policy card is still rendered")
}
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
if strings.Contains(navigation, legacy) {
t.Errorf("legacy separate navigation entry remains: %q", legacy)
}
}
}
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(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}
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
var notification bytes.Buffer
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected)
}
}
var page bytes.Buffer
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} {
if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", expected)
}
}
var freshPage bytes.Buffer
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected)
}
}
if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered")
}
}
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(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: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
if !strings.Contains(body, expected) {
t.Errorf("user creation form is missing %q", expected)
}
}
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
start := strings.Index(body, field)
if start < 0 {
continue
}
end := strings.Index(body[start:], ">")
if end < 0 {
t.Fatalf("password input %q has no closing bracket", field)
}
if strings.Contains(body[start:start+end], "minlength=") {
t.Errorf("password input %q still has a minimum length", field)
}
}
}
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "Ersteinrichtung",
Page: "setup",
Mode: "lan",
CSRF: "csrf",
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
}
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
t.Fatalf("render setup: %v", err)
}
body := rendered.String()
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
if !strings.Contains(body, expected) {
t.Errorf("first-run onboarding is missing %q", expected)
}
}
if strings.Contains(body, "Kein E-Mail-Reset") {
t.Error("first-run onboarding still renders the removed password reset footnote")
}
}
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
@@ -238,7 +501,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
CSRF: "csrf",
IsAdmin: true,
Zone: model.Zone{ID: 1, Name: "example.de"},
Settings: model.Settings{IPv6Enabled: true},
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
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}},
}
@@ -251,7 +514,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
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"`, `data-scroll-page-end`, `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.`} {
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-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `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)
}