package server import ( "context" "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "pdns_admin/internal/appdb" "pdns_admin/internal/pdns" ) type fakeClient struct { deletedRRSetType string createdRRSet pdns.RRSet replacedRRSet pdns.RRSet replacedZoneID string zone pdns.Zone } type fakeAuth struct { allowed bool err error username string password string } func (f *fakeAuth) Authenticate(_ context.Context, username, password string) (bool, error) { f.username = username f.password = password return f.allowed, f.err } func (fakeClient) GetServer(context.Context) (pdns.Server, error) { return pdns.Server{}, nil } func (fakeClient) ListZones(context.Context) ([]pdns.Zone, error) { return nil, nil } func (fakeClient) CreateZone(context.Context, pdns.Zone) (pdns.Zone, error) { return pdns.Zone{}, nil } func (fakeClient) DeleteZone(context.Context, string) error { return nil } func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) { return f.zone, nil } func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error { f.createdRRSet = rrset return nil } func (f *fakeClient) ReplaceRRSet(_ context.Context, zoneID string, rrset pdns.RRSet) error { f.replacedZoneID = zoneID f.replacedRRSet = rrset return nil } func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error { f.deletedRRSetType = recordType return nil } type fakeDynamicStore struct { records map[string]appdb.DynamicRecord } func (f *fakeDynamicStore) ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error) { records := make([]appdb.DynamicRecord, 0, len(f.records)) for _, record := range f.records { if record.Enabled { records = append(records, record) } } return records, nil } func (f *fakeDynamicStore) FindDynamicRecordByNameAndTokenHash(_ context.Context, name, tokenHash string) (appdb.DynamicRecord, error) { for _, record := range f.records { if record.Name == name && record.TokenHash == tokenHash && record.Enabled { return record, nil } } return appdb.DynamicRecord{}, appdb.ErrNotFound } func (f *fakeDynamicStore) GetDynamicRecord(_ context.Context, zoneID, name, recordType string) (appdb.DynamicRecord, error) { record, ok := f.records[dynamicRecordKey(name, recordType)] if !ok || record.ZoneID != zoneID || !record.Enabled { return appdb.DynamicRecord{}, appdb.ErrNotFound } return record, nil } func (f *fakeDynamicStore) UpsertDynamicRecord(_ context.Context, record appdb.DynamicRecord) error { if f.records == nil { f.records = make(map[string]appdb.DynamicRecord) } record.Enabled = true f.records[dynamicRecordKey(record.Name, record.Type)] = record return nil } func (f *fakeDynamicStore) DisableDynamicRecord(_ context.Context, _, name, recordType string) error { record, ok := f.records[dynamicRecordKey(name, recordType)] if ok { record.Enabled = false f.records[dynamicRecordKey(name, recordType)] = record } return nil } func TestNewRequiresClient(t *testing.T) { if _, err := New(Config{}, nil, nil); err == nil { t.Fatal("expected error for missing client") } } func TestNewBuildsServer(t *testing.T) { srv, err := New(Config{Addr: ":0"}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } if srv.addr != ":0" { t.Fatalf("unexpected addr: %s", srv.addr) } } func TestAuthCanBeDisabled(t *testing.T) { srv, err := New(Config{}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status: %d", rec.Code) } } func TestServesEmbeddedTablerAssets(t *testing.T) { srv, err := New(Config{}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/static/vendor/tabler.min.css", nil) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Tabler v1.4.0") { t.Fatal("expected vendored Tabler CSS") } } func TestAuthRedirectsProtectedRoutesToLogin(t *testing.T) { srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/zones", nil) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if got := rec.Header().Get("Location"); got != "/login?next=%2Fzones" { t.Fatalf("unexpected location: %q", got) } } func TestLoginCreatesSession(t *testing.T) { auth := &fakeAuth{allowed: true} srv, err := New(Config{Authenticator: auth}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader("username=alice&password=secret&next=/zones") req := httptest.NewRequest(http.MethodPost, "/login", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if auth.username != "alice" || auth.password != "secret" { t.Fatalf("unexpected credentials: %q %q", auth.username, auth.password) } cookies := rec.Result().Cookies() if len(cookies) == 0 || cookies[0].Name != sessionCookieName { t.Fatalf("expected session cookie, got %#v", cookies) } if !cookies[0].HttpOnly { t.Fatal("session cookie must be HttpOnly") } if !cookies[0].Secure { t.Fatal("session cookie must be Secure") } if cookies[0].SameSite != http.SameSiteStrictMode { t.Fatalf("unexpected SameSite policy: %v", cookies[0].SameSite) } req = httptest.NewRequest(http.MethodGet, "/zones", nil) req.AddCookie(cookies[0]) rec = httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected authenticated status: %d", rec.Code) } } func TestLoginReportsBackendErrors(t *testing.T) { srv, err := New(Config{Authenticator: &fakeAuth{err: errors.New("ldap down")}}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader("username=alice&password=secret") req := httptest.NewRequest(http.MethodPost, "/login", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if !strings.HasPrefix(rec.Header().Get("Location"), "/login?error=") { t.Fatalf("unexpected location: %q", rec.Header().Get("Location")) } } func TestLogoutClearsSession(t *testing.T) { srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } token, err := srv.createSession("alice") if err != nil { t.Fatalf("createSession returned error: %v", err) } csrfToken := srv.sessions[token].CSRFToken body := strings.NewReader("csrf_token=" + csrfToken) req := httptest.NewRequest(http.MethodPost, "/logout", body) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if _, ok := srv.sessions[token]; ok { t.Fatal("session was not deleted") } } func TestLogoutRequiresCSRF(t *testing.T) { srv, err := New(Config{Authenticator: &fakeAuth{allowed: true}}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } token, err := srv.createSession("alice") if err != nil { t.Fatalf("createSession returned error: %v", err) } req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("unexpected status: %d", rec.Code) } } func TestSecurityHeadersAreSet(t *testing.T) { srv, err := New(Config{}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) for _, header := range []string{ "Content-Security-Policy", "Referrer-Policy", "Strict-Transport-Security", "X-Content-Type-Options", "X-Frame-Options", } { if rec.Header().Get(header) == "" { t.Fatalf("expected %s header", header) } } } func TestParseLinesSkipsBlankLines(t *testing.T) { values := parseLines("192.0.2.1\n\n192.0.2.2\n") if len(values) != 2 { t.Fatalf("unexpected values: %#v", values) } } func TestDeleteRRSetRejectsSOA(t *testing.T) { client := &fakeClient{} srv, err := New(Config{}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader("name=example.org.&type=SOA") req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if client.deletedRRSetType != "" { t.Fatalf("SOA delete reached client") } } func TestDeleteRRSetDisablesDynamicDNS(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 300, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader("name=www.example.org.&type=A") req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } record := store.records[dynamicRecordKey("www.example.org.", "A")] if record.Enabled { t.Fatalf("dynamic record was not disabled: %#v", record) } } func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) { client := &fakeClient{} srv, err := New(Config{}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader("name=evil.example.org.&type=AAAA&ttl=300&records=192.0.2.10") req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/edit?name=www.example.org.&type=A", body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } if client.replacedRRSet.Name != "www.example.org." { t.Fatalf("name came from form: %#v", client.replacedRRSet) } if client.replacedRRSet.Type != "A" { t.Fatalf("type came from form: %#v", client.replacedRRSet) } } func TestSaveRRSetEnablesDynamicDNS(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{} srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } form := url.Values{} form.Set("name", "www.example.org.") form.Set("type", "A") form.Set("ttl", "300") form.Set("records", "192.0.2.10") form.Set("dynamic_dns", "on") req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } record, err := store.GetDynamicRecord(context.Background(), "example.org.", "www.example.org.", "A") if err != nil { t.Fatalf("GetDynamicRecord returned error: %v", err) } if record.TokenHash == "" || record.TTL != 300 { t.Fatalf("unexpected dynamic record: %#v", record) } if len(rec.Result().Cookies()) == 0 { t.Fatal("expected flash cookie") } } func TestRegenerateDynamicToken(t *testing.T) { store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 300, TokenHash: tokenHash("old-token"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, &fakeClient{}, nil) if err != nil { t.Fatalf("New returned error: %v", err) } form := url.Values{} form.Set("name", "www.example.org.") form.Set("type", "A") req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/dyndns/regenerate", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("unexpected status: %d", rec.Code) } record := store.records[dynamicRecordKey("www.example.org.", "A")] if record.TokenHash == tokenHash("old-token") || record.TokenHash == "" { t.Fatalf("token hash was not regenerated: %#v", record) } } func TestDynamicUpdateEndpointUpdatesRecord(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 120, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`) req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body) req.RemoteAddr = "198.51.100.20:12345" rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } if client.replacedZoneID != "example.org." { t.Fatalf("unexpected zone id: %q", client.replacedZoneID) } if client.replacedRRSet.Name != "www.example.org." || client.replacedRRSet.Type != "A" || client.replacedRRSet.TTL != 120 { t.Fatalf("unexpected rrset: %#v", client.replacedRRSet) } if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.20" { t.Fatalf("unexpected update address: %q", got) } if !strings.Contains(rec.Body.String(), `"status":"OK"`) { t.Fatalf("unexpected response: %s", rec.Body.String()) } } func TestDynamicUpdateEndpointUsesForwardedFor(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 120, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`) req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body) req.RemoteAddr = "10.0.0.10:12345" req.Header.Set("X-Forwarded-For", "198.51.100.30, 10.0.0.10") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.30" { t.Fatalf("unexpected update address: %q", got) } } func TestDynamicUpdateEndpointUsesRealIP(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 120, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`) req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body) req.RemoteAddr = "10.0.0.10:12345" req.Header.Set("X-Real-IP", "198.51.100.31") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.31" { t.Fatalf("unexpected update address: %q", got) } } func TestDynamicUpdateEndpointAddressOverridesForwardedHeaders(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 120, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"198.51.100.40"}`) req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body) req.RemoteAddr = "10.0.0.10:12345" req.Header.Set("X-Forwarded-For", "198.51.100.30") req.Header.Set("X-Real-IP", "198.51.100.31") rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.40" { t.Fatalf("unexpected update address: %q", got) } } func TestDynamicUpdateEndpointRejectsAddressTypeMismatch(t *testing.T) { client := &fakeClient{} store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}} store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{ ZoneID: "example.org.", Name: "www.example.org.", Type: "A", TTL: 120, TokenHash: tokenHash("secret"), Enabled: true, } srv, err := New(Config{DynamicStore: store}, client, nil) if err != nil { t.Fatalf("New returned error: %v", err) } body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"2001:db8::1"}`) req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body) rec := httptest.NewRecorder() srv.routes().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) } if client.replacedRRSet.Name != "" { t.Fatalf("unexpected PowerDNS update: %#v", client.replacedRRSet) } }