1156 lines
49 KiB
Go
1156 lines
49 KiB
Go
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, "<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}
|
|
checkedAt := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
|
|
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", LastCheckedAt: checkedAt, UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true, CredentialUsername: "registry-user"}
|
|
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", "v2.0.0 ist bereit", `data-software-update-banner="v2.0.0"`, `data-software-update-form`, `data-software-update-overlay`} {
|
|
if !strings.Contains(notification.String(), expected) {
|
|
t.Errorf("global update notification is missing %q", expected)
|
|
}
|
|
}
|
|
var staleNotification bytes.Buffer
|
|
staleData := accountData
|
|
staleData.SoftwareUpdate.AgentAvailable = false
|
|
staleData.SoftwareUpdate.AgentCompatible = false
|
|
if err := templates["security"].ExecuteTemplate(&staleNotification, "security", staleData); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(staleNotification.String(), `data-software-update-form`) {
|
|
t.Fatal("stale boot-time agent state hides the live-checked update button")
|
|
}
|
|
|
|
var page bytes.Buffer
|
|
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState, InstanceID: "instance-before-restart"}
|
|
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", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
|
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", "Status", "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")
|
|
}
|
|
var currentPage bytes.Buffer
|
|
currentData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.1", LatestVersion: "v1.4.1", LastCheckedAt: checkedAt, AgentAvailable: true, AgentCompatible: true, CredentialConfigured: true}}
|
|
if err := templates["system"].ExecuteTemplate(¤tPage, "system", currentData); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(currentPage.String(), `Status</span><strong>Aktuell</strong>`) || strings.Count(currentPage.String(), `>v1.4.1<`) != 1 {
|
|
t.Fatal("an installed current release is still rendered as a second available version")
|
|
}
|
|
var syncPage bytes.Buffer
|
|
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
|
|
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(syncPage.String(), "Compose einmal aktualisieren") || strings.Contains(syncPage.String(), "Jetzt aktualisieren") {
|
|
t.Fatal("compose synchronization state was not rendered cleanly")
|
|
}
|
|
}
|
|
|
|
func TestContainerRestartRequiresCSRFAndAuditsOneRequest(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() })
|
|
var restartCalls atomic.Int32
|
|
server := &Server{store: database, instanceID: "instance-before-restart", restartContainer: func() { restartCalls.Add(1) }}
|
|
session := &model.Session{CSRFToken: "valid-csrf", Username: "admin", Role: "admin"}
|
|
|
|
invalidResponse := httptest.NewRecorder()
|
|
invalidRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=wrong"))
|
|
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
server.containerRestart(invalidResponse, invalidRequest, session)
|
|
if invalidResponse.Code != http.StatusForbidden || restartCalls.Load() != 0 {
|
|
t.Fatalf("invalid CSRF triggered restart: status=%d calls=%d", invalidResponse.Code, restartCalls.Load())
|
|
}
|
|
|
|
requestRestart := func() *httptest.ResponseRecorder {
|
|
response := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request.Header.Set("Accept", "application/json")
|
|
server.containerRestart(response, request, session)
|
|
return response
|
|
}
|
|
first := requestRestart()
|
|
if first.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
|
t.Fatalf("valid restart was not accepted once: status=%d calls=%d body=%s", first.Code, restartCalls.Load(), first.Body.String())
|
|
}
|
|
var result map[string]any
|
|
if err := json.NewDecoder(first.Body).Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result["accepted"] != true || result["instance_id"] != "instance-before-restart" {
|
|
t.Fatalf("unexpected restart response: %#v", result)
|
|
}
|
|
second := requestRestart()
|
|
if second.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
|
t.Fatalf("duplicate restart was not idempotent: status=%d calls=%d", second.Code, restartCalls.Load())
|
|
}
|
|
events, err := database.RecentAudit(ctx, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 1 || events[0].Kind != "system.container_restart" || !strings.Contains(events[0].Message, "admin") {
|
|
t.Fatalf("restart audit is missing or duplicated: %#v", events)
|
|
}
|
|
|
|
unconfigured := &Server{store: database}
|
|
unconfiguredResponse := httptest.NewRecorder()
|
|
unconfiguredRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
|
unconfiguredRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
unconfigured.containerRestart(unconfiguredResponse, unconfiguredRequest, session)
|
|
if unconfiguredResponse.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("restart without process callback returned %d", unconfiguredResponse.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthExposesProcessInstanceWithoutCaching(t *testing.T) {
|
|
t.Parallel()
|
|
server := &Server{instanceID: "current-process-instance"}
|
|
response := httptest.NewRecorder()
|
|
server.health(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if response.Code != http.StatusOK || response.Header().Get("X-Domain-Control-Instance") != "current-process-instance" || response.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("health response does not identify the current process: status=%d headers=%v", response.Code, response.Header())
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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, 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}},
|
|
}
|
|
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 data-local-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-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)
|
|
}
|
|
}
|
|
for _, removed := range []string{"Entfernt nur die lokale DynDNS-Verknüpfung", "Löscht das Ziel und alle zugehörigen A-/AAAA-Records", "Der CNAME-Record bei Hetzner bleibt unverändert"} {
|
|
if strings.Contains(body, removed) {
|
|
t.Errorf("domain record action still contains removed description %q", removed)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestPiHolePageRendersConnectionAndLocalDNSControls(t *testing.T) {
|
|
t.Parallel()
|
|
templates, err := loadTemplates()
|
|
if err != nil {
|
|
t.Fatalf("load templates: %v", err)
|
|
}
|
|
var rendered bytes.Buffer
|
|
data := viewData{
|
|
Title: "Pi-hole",
|
|
Page: "pihole",
|
|
CSRF: "csrf",
|
|
IsAdmin: true,
|
|
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
|
|
PiHoleView: &piHoleView{
|
|
Integration: model.PiHoleIntegration{ID: 7, Name: "Pi-hole", BaseURL: "https://pi.hole/api"},
|
|
Records: []piHoleRecordView{{LinkID: 11, Active: true, Raw: "192.168.1.20 nas.lan", IP: "192.168.1.20", Hostnames: "nas.lan", Valid: true}},
|
|
ArchivedRecords: []piHoleRecordView{{LinkID: 12, Active: false, Raw: "192.168.1.30 old.lan", IP: "192.168.1.30", Hostnames: "old.lan", Valid: true, ArchivedAt: time.Now()}},
|
|
},
|
|
}
|
|
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
|
|
t.Fatalf("render Pi-hole page: %v", err)
|
|
}
|
|
body := rendered.String()
|
|
for _, expected := range []string{`href="/pihole"`, `aria-current="page"`, `action="/pihole/7/records"`, `action="/pihole/7/records/update"`, `action="/pihole/7/records/archive"`, `action="/pihole/7/records/restore"`, `action="/pihole/7/records/delete"`, `action="/pihole/7/remove"`, `action="/pihole/7/delete"`, `action="/pihole/7/save"`, `action="/pihole/7/password"`, `name="active"`, `name="link_id" value="11"`, `name="insecure_tls"`, `name="base_url" type="text"`, `data-dirty-form`, `data-dirty-submit disabled aria-disabled="true"`, `data-scroll-page-end`, `Anwendungspasswort anzeigen`, `Passwort entschlüsseln`, `Nur aus der WebUI entfernen`, `Anwendungspasswort`, `nas.lan`, `old.lan`, `192.168.1.20`} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Errorf("Pi-hole page is missing %q", expected)
|
|
}
|
|
}
|
|
if strings.Contains(body, "app-password") {
|
|
t.Fatal("Pi-hole application password leaked into rendered page")
|
|
}
|
|
if strings.Contains(body, `action="/pihole/connect"`) {
|
|
t.Fatal("connected Pi-hole page still offered a second integration")
|
|
}
|
|
if strings.Contains(body, ">A / AAAA<") {
|
|
t.Fatal("new Pi-hole record card still renders the removed A / AAAA badge")
|
|
}
|
|
if strings.Contains(body, "Schreibzugriff für das Anwendungspasswort") || strings.Contains(body, "Pi-hole benötigt webserver.api.app_sudo") {
|
|
t.Fatal("Pi-hole page still renders the permanent write-permission warning")
|
|
}
|
|
for _, removed := range []string{"Trennt nur die lokale Verknüpfung. Der DNS-Eintrag bei Pi-hole bleibt bestehen", "Löscht den Eintrag aus der WebUI und unmittelbar bei Pi-hole"} {
|
|
if strings.Contains(body, removed) {
|
|
t.Errorf("Pi-hole record action still contains removed description %q", removed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T) {
|
|
t.Parallel()
|
|
templates, err := loadTemplates()
|
|
if err != nil {
|
|
t.Fatalf("load templates: %v", err)
|
|
}
|
|
var rendered bytes.Buffer
|
|
data := viewData{
|
|
Title: "Pi-hole",
|
|
Page: "pihole",
|
|
CSRF: "csrf",
|
|
IsAdmin: true,
|
|
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
|
|
}
|
|
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
|
|
t.Fatalf("render disconnected Pi-hole page: %v", err)
|
|
}
|
|
body := rendered.String()
|
|
for _, expected := range []string{`class="pihole-connect-stage"`, `class="form-panel pihole-connect-panel"`, `action="/pihole/connect"`, `name="base_url" type="text"`, `10.0.20.100 oder https://pi.hole`} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Errorf("disconnected Pi-hole page is missing %q", expected)
|
|
}
|
|
}
|
|
for _, removed := range []string{"Noch kein Pi-hole verbunden", "Verbinde genau einen Pi-hole", `href="#pihole-connect"`, `class="empty-board`} {
|
|
if strings.Contains(body, removed) {
|
|
t.Errorf("disconnected Pi-hole page still renders removed empty-state content %q", removed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
|
t.Parallel()
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
|
|
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer api.Close()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
key := make([]byte, 32)
|
|
for index := range key {
|
|
key[index] = byte(index + 1)
|
|
}
|
|
keyFile := filepath.Join(dir, "master-key")
|
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cipher, err := secure.LoadCipher(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := &Server{store: database, cipher: cipher}
|
|
form := url.Values{
|
|
"csrf": {"csrf-token"},
|
|
"base_url": {api.URL},
|
|
"password": {"separate-app-password"},
|
|
}
|
|
request := httptest.NewRequest(http.MethodPost, "/pihole/connect", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
response := httptest.NewRecorder()
|
|
server.piHoleConnect(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("unexpected connect response: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
integrations, err := database.ListPiHoleIntegrations(ctx)
|
|
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" {
|
|
t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err)
|
|
}
|
|
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
decrypted, err := cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
|
if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") {
|
|
t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err)
|
|
}
|
|
}
|
|
|
|
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
adminHash, err := secure.HashPassword("correct admin password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", adminHash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := make([]byte, 32)
|
|
for index := range key {
|
|
key[index] = byte(index + 1)
|
|
}
|
|
keyFile := filepath.Join(dir, "master-key")
|
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cipher, err := secure.LoadCipher(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const secret = "super-secret-pihole-app-password"
|
|
ciphertext, nonce, err := cipher.Encrypt(secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
templates, err := loadTemplates()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := &Server{store: database, cipher: cipher, templates: templates}
|
|
session := &model.Session{UserID: 1, CSRFToken: "csrf-token", Username: "admin", DisplayName: "Administrator", Role: "admin"}
|
|
requestFor := func(password, csrf string) *http.Request {
|
|
form := url.Values{"csrf": {csrf}, "password": {password}}
|
|
request := httptest.NewRequest(http.MethodPost, "/pihole/1/password", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request.SetPathValue("id", "1")
|
|
return request
|
|
}
|
|
|
|
wrong := httptest.NewRecorder()
|
|
server.piHolePassword(wrong, requestFor("wrong admin password", "csrf-token"), session)
|
|
if wrong.Code != http.StatusSeeOther || strings.Contains(wrong.Body.String(), secret) || strings.Contains(wrong.Header().Get("Location"), secret) {
|
|
t.Fatalf("wrong admin password exposed secret or returned wrong status: status=%d location=%q", wrong.Code, wrong.Header().Get("Location"))
|
|
}
|
|
|
|
invalidCSRF := httptest.NewRecorder()
|
|
server.piHolePassword(invalidCSRF, requestFor("correct admin password", "wrong-csrf"), session)
|
|
if invalidCSRF.Code != http.StatusForbidden || strings.Contains(invalidCSRF.Body.String(), secret) {
|
|
t.Fatalf("invalid CSRF exposed secret or returned wrong status: status=%d", invalidCSRF.Code)
|
|
}
|
|
|
|
revealed := httptest.NewRecorder()
|
|
server.piHolePassword(revealed, requestFor("correct admin password", "csrf-token"), session)
|
|
if revealed.Code != http.StatusOK || !strings.Contains(revealed.Body.String(), secret) || !strings.Contains(revealed.Body.String(), "Pi-hole-Anwendungspasswort") {
|
|
t.Fatalf("valid reauthentication did not reveal secret: status=%d body=%s", revealed.Code, revealed.Body.String())
|
|
}
|
|
if cacheControl := revealed.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") {
|
|
t.Fatalf("secret response is cacheable: Cache-Control=%q", cacheControl)
|
|
}
|
|
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integration.ID)
|
|
if err != nil || loaded.ID != integration.ID || strings.Contains(string(credential.Ciphertext), secret) {
|
|
t.Fatalf("Pi-hole credential was not retained as ciphertext: %#v err=%v", loaded, err)
|
|
}
|
|
events, err := database.RecentAudit(ctx, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foundRevealAudit := false
|
|
for _, event := range events {
|
|
if strings.Contains(event.Message, secret) {
|
|
t.Fatalf("Pi-hole secret leaked into audit log: %#v", event)
|
|
}
|
|
if event.Kind == "pihole.credential_revealed" {
|
|
foundRevealAudit = true
|
|
}
|
|
}
|
|
if !foundRevealAudit {
|
|
t.Fatal("Pi-hole credential reveal was not audited")
|
|
}
|
|
}
|
|
|
|
func TestPiHoleRecordCanBeRemovedFromAndRestoredToWebUI(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() })
|
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const value = "192.168.1.20 nas.lan"
|
|
if _, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{value}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := &Server{store: database}
|
|
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
|
requestFor := func(path string) *http.Request {
|
|
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}}
|
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request.SetPathValue("id", "1")
|
|
return request
|
|
}
|
|
|
|
response := httptest.NewRecorder()
|
|
server.piHoleRecordArchive(response, requestFor("/pihole/1/records/archive"), session)
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("unexpected archive response: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
links, err := database.ListPiHoleRecordLinks(ctx, integration.ID)
|
|
if err != nil || len(links) != 1 || links[0].ArchivedAt.IsZero() {
|
|
t.Fatalf("Pi-hole record was not removed from active WebUI list: %#v err=%v", links, err)
|
|
}
|
|
|
|
response = httptest.NewRecorder()
|
|
server.piHoleRecordRestore(response, requestFor("/pihole/1/records/restore"), session)
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("unexpected restore response: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
links, err = database.ListPiHoleRecordLinks(ctx, integration.ID)
|
|
if err != nil || len(links) != 1 || !links[0].ArchivedAt.IsZero() {
|
|
t.Fatalf("Pi-hole record was not restored to active WebUI list: %#v err=%v", links, err)
|
|
}
|
|
}
|
|
|
|
func TestPiHoleRecordActiveSwitchDeletesAndRecreatesRemoteHost(t *testing.T) {
|
|
t.Parallel()
|
|
remoteHosts := map[string]bool{"192.168.1.20 nas.lan": true}
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"switch-sid","message":"ok"}}`))
|
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete:
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Header.Get("X-FTL-SID") == "switch-sid":
|
|
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
remoteHosts[value] = true
|
|
w.WriteHeader(http.StatusCreated)
|
|
case http.MethodDelete:
|
|
if !remoteHosts[value] {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
delete(remoteHosts, value)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
|
}
|
|
default:
|
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer api.Close()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
key := make([]byte, 32)
|
|
for index := range key {
|
|
key[index] = byte(index + 1)
|
|
}
|
|
keyFile := filepath.Join(dir, "master-key")
|
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cipher, err := secure.LoadCipher(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ciphertext, nonce, err := cipher.Encrypt("app-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
links, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.20 nas.lan"})
|
|
if err != nil || len(links) != 1 {
|
|
t.Fatalf("record link setup failed: %#v err=%v", links, err)
|
|
}
|
|
server := &Server{store: database, cipher: cipher}
|
|
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
|
update := func(ip string, active bool) *httptest.ResponseRecorder {
|
|
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}, "ip": {ip}, "hostnames": {"nas.lan"}}
|
|
if active {
|
|
form.Set("active", "on")
|
|
}
|
|
request := httptest.NewRequest(http.MethodPost, "/pihole/1/records/update", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request.SetPathValue("id", "1")
|
|
response := httptest.NewRecorder()
|
|
server.piHoleRecordUpdate(response, request, session)
|
|
return response
|
|
}
|
|
|
|
if response := update("192.168.1.20", false); response.Code != http.StatusSeeOther {
|
|
t.Fatalf("unexpected deactivate response: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
link, err := database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
|
if err != nil || link.Active || remoteHosts[link.Value] {
|
|
t.Fatalf("deactivated record remained active: %#v remote=%#v err=%v", link, remoteHosts, err)
|
|
}
|
|
|
|
if response := update("192.168.1.21", true); response.Code != http.StatusSeeOther {
|
|
t.Fatalf("unexpected activate response: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
link, err = database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
|
if err != nil || !link.Active || link.Value != "192.168.1.21 nas.lan" || !remoteHosts[link.Value] {
|
|
t.Fatalf("reactivated record was not recreated: %#v remote=%#v err=%v", link, remoteHosts, err)
|
|
}
|
|
}
|