Files
hetzner-api-dyndns-records-…/internal/web/server_test.go
T

672 lines
28 KiB
Go

package web
import (
"bytes"
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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/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.&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"`, `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)
}
}