package server import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "pdns_admin/internal/pdns" ) type fakeClient struct { deletedRRSetType string createdRRSet pdns.RRSet } 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 (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) { return pdns.Zone{}, nil } func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error { f.createdRRSet = rrset return nil } func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error { f.deletedRRSetType = recordType 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 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.createdRRSet.Name != "www.example.org." { t.Fatalf("name came from form: %#v", client.createdRRSet) } if client.createdRRSet.Type != "A" { t.Fatalf("type came from form: %#v", client.createdRRSet) } }