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")