diff --git a/pages/tests.py b/pages/tests.py
new file mode 100644
index 0000000..792e605
--- /dev/null
+++ b/pages/tests.py
@@ -0,0 +1,312 @@
+from django.test import TestCase, Client
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+from datetime import date, timedelta
+from dokumente.models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Geltungsbereich, Dokumententyp, Thema
+from stichworte.models import Stichwort
+from unittest.mock import patch
+import re
+
+
+class SearchViewTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ # Create test data
+ self.dokumententyp = Dokumententyp.objects.create(
+ name="Test Typ",
+ verantwortliche_ve="Test VE"
+ )
+
+ self.thema = Thema.objects.create(
+ name="Test Thema",
+ erklaerung="Test Erklärung"
+ )
+
+ self.dokument = Dokument.objects.create(
+ nummer="TEST-001",
+ dokumententyp=self.dokumententyp,
+ name="Test Dokument",
+ gueltigkeit_von=date.today(),
+ aktiv=True
+ )
+
+ self.vorgabe = Vorgabe.objects.create(
+ order=1,
+ nummer=1,
+ dokument=self.dokument,
+ thema=self.thema,
+ titel="Test Vorgabe Titel",
+ gueltigkeit_von=date.today()
+ )
+
+ # Create text content
+ self.kurztext = VorgabeKurztext.objects.create(
+ abschnitt=self.vorgabe,
+ inhalt="Dies ist ein Test Kurztext mit Suchbegriff"
+ )
+
+ self.langtext = VorgabeLangtext.objects.create(
+ abschnitt=self.vorgabe,
+ inhalt="Dies ist ein Test Langtext mit anderem Suchbegriff"
+ )
+
+ self.geltungsbereich = Geltungsbereich.objects.create(
+ geltungsbereich=self.dokument,
+ inhalt="Test Geltungsbereich mit Suchbegriff"
+ )
+
+ def test_search_get_request(self):
+ """Test GET request returns search form"""
+ response = self.client.get('/search/')
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Suche')
+ self.assertContains(response, 'Suchbegriff')
+
+ def test_search_post_valid_term(self):
+ """Test POST request with valid search term"""
+ response = self.client.post('/search/', {'q': 'Test'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Suchresultate für Test')
+
+ def test_search_case_insensitive(self):
+ """Test that search is case insensitive"""
+ # Search for lowercase
+ response = self.client.post('/search/', {'q': 'test'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Suchresultate für test')
+
+ # Search for uppercase
+ response = self.client.post('/search/', {'q': 'TEST'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Suchresultate für TEST')
+
+ # Search for mixed case
+ response = self.client.post('/search/', {'q': 'TeSt'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Suchresultate für TeSt')
+
+ def test_search_in_kurztext(self):
+ """Test search in Kurztext content"""
+ response = self.client.post('/search/', {'q': 'Suchbegriff'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'TEST-001')
+
+ def test_search_in_langtext(self):
+ """Test search in Langtext content"""
+ response = self.client.post('/search/', {'q': 'anderem'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'TEST-001')
+
+ def test_search_in_titel(self):
+ """Test search in Vorgabe title"""
+ response = self.client.post('/search/', {'q': 'Titel'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'TEST-001')
+
+ def test_search_in_geltungsbereich(self):
+ """Test search in Geltungsbereich content"""
+ response = self.client.post('/search/', {'q': 'Geltungsbereich'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Standards mit')
+
+ def test_search_no_results(self):
+ """Test search with no results"""
+ response = self.client.post('/search/', {'q': 'NichtVorhanden'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Keine Resultate für "NichtVorhanden"')
+
+ def test_search_expired_vorgabe_not_included(self):
+ """Test that expired Vorgaben are not included in results"""
+ # Create expired Vorgabe
+ expired_vorgabe = Vorgabe.objects.create(
+ order=2,
+ nummer=2,
+ dokument=self.dokument,
+ thema=self.thema,
+ titel="Abgelaufene Vorgabe",
+ gueltigkeit_von=date.today() - timedelta(days=10),
+ gueltigkeit_bis=date.today() - timedelta(days=1)
+ )
+
+ VorgabeKurztext.objects.create(
+ abschnitt=expired_vorgabe,
+ inhalt="Abgelaufener Inhalt mit Test"
+ )
+
+ response = self.client.post('/search/', {'q': 'Test'})
+ self.assertEqual(response.status_code, 200)
+
+ # Should only find the active Vorgabe, not the expired one
+ self.assertContains(response, 'Test Vorgabe Titel')
+ # The expired vorgabe should not appear in results
+ self.assertNotContains(response, 'Abgelaufene Vorgabe')
+
+ def test_search_empty_term_validation(self):
+ """Test validation for empty search term"""
+ response = self.client.post('/search/', {'q': ''})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Fehler:')
+ self.assertContains(response, 'Suchbegriff darf nicht leer sein')
+
+ def test_search_no_term_validation(self):
+ """Test validation when no search term is provided"""
+ response = self.client.post('/search/', {})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Fehler:')
+ self.assertContains(response, 'Suchbegriff darf nicht leer sein')
+
+ def test_search_html_tags_stripped(self):
+ """Test that HTML tags are stripped from search input"""
+ response = self.client.post('/search/', {'q': 'Test'})
+ self.assertEqual(response.status_code, 200)
+ # Should search for "alert('xss')Test" after HTML tag removal
+ self.assertContains(response, 'Suchresultate für alert("xss")Test')
+
+ def test_search_invalid_characters_validation(self):
+ """Test validation for invalid characters"""
+ response = self.client.post('/search/', {'q': 'Test| DROP TABLE users'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Fehler:')
+ self.assertContains(response, 'Ungültige Zeichen im Suchbegriff')
+
+ def test_search_too_long_validation(self):
+ """Test validation for overly long search terms"""
+ long_term = 'a' * 201 # 201 characters, exceeds limit of 200
+ response = self.client.post('/search/', {'q': long_term})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Fehler:')
+ self.assertContains(response, 'Suchbegriff ist zu lang')
+
+ def test_search_max_length_allowed(self):
+ """Test that exactly 200 characters are allowed"""
+ max_term = 'a' * 200 # Exactly 200 characters
+ response = self.client.post('/search/', {'q': max_term})
+ self.assertEqual(response.status_code, 200)
+ # Should not show validation error
+ self.assertNotContains(response, 'Fehler:')
+
+ def test_search_german_umlauts_allowed(self):
+ """Test that German umlauts are allowed in search"""
+ response = self.client.post('/search/', {'q': 'Test Müller äöü ÄÖÜ ß'})
+ self.assertEqual(response.status_code, 200)
+ # Should not show validation error
+ self.assertNotContains(response, 'Fehler:')
+
+ def test_search_special_characters_allowed(self):
+ """Test that allowed special characters work"""
+ response = self.client.post('/search/', {'q': 'Test-Test, Test: Test; Test! Test? (Test) [Test] {Test} "Test" \'Test\''})
+ self.assertEqual(response.status_code, 200)
+ # Should not show validation error
+ self.assertNotContains(response, 'Fehler:')
+
+ def test_search_input_preserved_on_error(self):
+ """Test that search input is preserved on validation errors"""
+ response = self.client.post('/search/', {'q': ''})
+ self.assertEqual(response.status_code, 200)
+ # The input should be preserved (escaped) in the form
+ # Since HTML tags are stripped, we expect "Test" to be searched
+ self.assertContains(response, 'Suchresultate für Test')
+
+ def test_search_xss_prevention_in_results(self):
+ """Test that search terms are escaped in results to prevent XSS"""
+ # Create content with potential XSS
+ self.kurztext.inhalt = "Content with term"
+ self.kurztext.save()
+
+ response = self.client.post('/search/', {'q': 'term'})
+ self.assertEqual(response.status_code, 200)
+ # The script tag should be escaped in the output
+ # Note: This depends on how the template renders the content
+ self.assertContains(response, 'Suchresultate für term')
+
+ @patch('pages.views.pprint.pp')
+ def test_search_result_logging(self, mock_pprint):
+ """Test that search results are logged for debugging"""
+ response = self.client.post('/search/', {'q': 'Test'})
+ self.assertEqual(response.status_code, 200)
+ # Verify that pprint.pp was called with the result
+ mock_pprint.assert_called_once()
+
+ def test_search_multiple_documents(self):
+ """Test search across multiple documents"""
+ # Create second document
+ dokument2 = Dokument.objects.create(
+ nummer="TEST-002",
+ dokumententyp=self.dokumententyp,
+ name="Zweites Test Dokument",
+ gueltigkeit_von=date.today(),
+ aktiv=True
+ )
+
+ vorgabe2 = Vorgabe.objects.create(
+ order=1,
+ nummer=1,
+ dokument=dokument2,
+ thema=self.thema,
+ titel="Zweite Test Vorgabe",
+ gueltigkeit_von=date.today()
+ )
+
+ VorgabeKurztext.objects.create(
+ abschnitt=vorgabe2,
+ inhalt="Zweiter Test Inhalt"
+ )
+
+ response = self.client.post('/search/', {'q': 'Test'})
+ self.assertEqual(response.status_code, 200)
+ # Should find results from both documents
+ self.assertContains(response, 'TEST-001')
+ self.assertContains(response, 'TEST-002')
+
+
+class SearchValidationTest(TestCase):
+ """Test the validate_search_input function directly"""
+
+ def test_validate_search_input_valid(self):
+ """Test valid search input"""
+ from pages.views import validate_search_input
+
+ result = validate_search_input("Test Suchbegriff")
+ self.assertEqual(result, "Test Suchbegriff")
+
+ def test_validate_search_input_empty(self):
+ """Test empty search input"""
+ from pages.views import validate_search_input
+
+ with self.assertRaises(ValidationError) as context:
+ validate_search_input("")
+
+ self.assertIn("Suchbegriff darf nicht leer sein", str(context.exception))
+
+ def test_validate_search_input_html_stripped(self):
+ """Test that HTML tags are stripped"""
+ from pages.views import validate_search_input
+
+ result = validate_search_input("Test")
+ self.assertEqual(result, "alert('xss')Test")
+
+ def test_validate_search_input_invalid_chars(self):
+ """Test validation of invalid characters"""
+ from pages.views import validate_search_input
+
+ with self.assertRaises(ValidationError) as context:
+ validate_search_input("Test| DROP TABLE users")
+
+ self.assertIn("Ungültige Zeichen im Suchbegriff", str(context.exception))
+
+ def test_validate_search_input_too_long(self):
+ """Test length validation"""
+ from pages.views import validate_search_input
+
+ with self.assertRaises(ValidationError) as context:
+ validate_search_input("a" * 201)
+
+ self.assertIn("Suchbegriff ist zu lang", str(context.exception))
+
+ def test_validate_search_input_whitespace_stripped(self):
+ """Test that whitespace is stripped"""
+ from pages.views import validate_search_input
+
+ result = validate_search_input(" Test Suchbegriff ")
+ self.assertEqual(result, "Test Suchbegriff")
\ No newline at end of file
diff --git a/pages/views.py b/pages/views.py
index 3f25eba..8ab5fa4 100644
--- a/pages/views.py
+++ b/pages/views.py
@@ -23,7 +23,7 @@ def validate_search_input(search_term):
search_term = re.sub(r'<[^>]*>', '', search_term)
# Allow only alphanumeric characters, spaces, and basic punctuation
- # This prevents SQL injection and other malicious input
+ # This prevents SQL injection and other malicious input while allowing useful characters
if not re.match(r'^[a-zA-Z0-9äöüÄÖÜß\s\-\.\,\:\;\!\?\(\)\[\]\{\}\"\']+$', search_term):
raise ValidationError("Ungültige Zeichen im Suchbegriff")