feat: add resilient updates and access controls
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
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
+265
-2
@@ -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 & 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(¬ification, "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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user