diff --git a/lemarche/siaes/migrations/0078_alter_historicalsiae_contact_social_website_and_more.py b/lemarche/siaes/migrations/0078_alter_historicalsiae_contact_social_website_and_more.py new file mode 100644 index 000000000..c0eb92863 --- /dev/null +++ b/lemarche/siaes/migrations/0078_alter_historicalsiae_contact_social_website_and_more.py @@ -0,0 +1,92 @@ +# Generated by Django 4.2.15 on 2024-12-04 16:51 + +from django.db import migrations, models + +import lemarche.utils.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("siaes", "0077_remove_siaeactivity_location_siaeactivity_locations_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalsiae", + name="contact_social_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Réseau social", + ), + ), + migrations.AlterField( + model_name="historicalsiae", + name="contact_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Site internet", + ), + ), + migrations.AlterField( + model_name="historicalsiae", + name="website", + field=models.URLField( + blank=True, + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Site internet", + ), + ), + migrations.AlterField( + model_name="siae", + name="contact_social_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Réseau social", + ), + ), + migrations.AlterField( + model_name="siae", + name="contact_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Site internet", + ), + ), + migrations.AlterField( + model_name="siae", + name="website", + field=models.URLField( + blank=True, + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Site internet", + ), + ), + migrations.AlterField( + model_name="siaegroup", + name="contact_social_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Réseau social", + ), + ), + migrations.AlterField( + model_name="siaegroup", + name="contact_website", + field=models.URLField( + blank=True, + help_text="Doit commencer par http:// ou https://", + validators=[lemarche.utils.validators.OptionalSchemeURLValidator()], + verbose_name="Site internet", + ), + ), + ] diff --git a/lemarche/siaes/models.py b/lemarche/siaes/models.py index e3344c7a0..05ff2ef4d 100644 --- a/lemarche/siaes/models.py +++ b/lemarche/siaes/models.py @@ -42,7 +42,7 @@ from lemarche.utils.data import choice_array_to_values, phone_number_display, round_by_base from lemarche.utils.fields import ChoiceArrayField from lemarche.utils.urls import get_object_admin_url -from lemarche.utils.validators import validate_naf, validate_post_code, validate_siret +from lemarche.utils.validators import OptionalSchemeURLValidator, validate_naf, validate_post_code, validate_siret def get_region_filter(perimeter): @@ -119,12 +119,18 @@ class SiaeGroup(models.Model): contact_first_name = models.CharField(verbose_name="Prénom", max_length=150, blank=True) contact_last_name = models.CharField(verbose_name="Nom", max_length=150, blank=True) contact_website = models.URLField( - verbose_name="Site internet", help_text="Doit commencer par http:// ou https://", blank=True + verbose_name="Site internet", + validators=[OptionalSchemeURLValidator()], + help_text="Doit commencer par http:// ou https://", + blank=True, ) contact_email = models.EmailField(verbose_name="E-mail", blank=True, db_index=True) contact_phone = PhoneNumberField(verbose_name="Téléphone", max_length=150, blank=True) contact_social_website = models.URLField( - verbose_name="Réseau social", help_text="Doit commencer par http:// ou https://", blank=True + verbose_name="Réseau social", + validators=[OptionalSchemeURLValidator()], + help_text="Doit commencer par http:// ou https://", + blank=True, ) logo_url = models.URLField(verbose_name="Lien vers le logo", max_length=500, blank=True) @@ -646,7 +652,7 @@ class Siae(models.Model): db_index=True, ) - website = models.URLField(verbose_name="Site internet", blank=True) + website = models.URLField(verbose_name="Site internet", validators=[OptionalSchemeURLValidator()], blank=True) email = models.EmailField(verbose_name="E-mail", blank=True) phone = models.CharField(verbose_name="Téléphone", max_length=20, blank=True) @@ -675,7 +681,10 @@ class Siae(models.Model): contact_first_name = models.CharField(verbose_name="Prénom", max_length=150, blank=True) contact_last_name = models.CharField(verbose_name="Nom", max_length=150, blank=True) contact_website = models.URLField( - verbose_name="Site internet", help_text="Doit commencer par http:// ou https://", blank=True + verbose_name="Site internet", + validators=[OptionalSchemeURLValidator()], + help_text="Doit commencer par http:// ou https://", + blank=True, ) contact_email = models.EmailField( verbose_name="E-mail", @@ -684,7 +693,10 @@ class Siae(models.Model): ) contact_phone = PhoneNumberField(verbose_name="Téléphone", max_length=150, blank=True) contact_social_website = models.URLField( - verbose_name="Réseau social", help_text="Doit commencer par http:// ou https://", blank=True + verbose_name="Réseau social", + validators=[OptionalSchemeURLValidator()], + help_text="Doit commencer par http:// ou https://", + blank=True, ) image_name = models.CharField(verbose_name="Nom de l'image", max_length=255, blank=True) diff --git a/lemarche/tenders/factories.py b/lemarche/tenders/factories.py index 19113cf74..7efd9b807 100644 --- a/lemarche/tenders/factories.py +++ b/lemarche/tenders/factories.py @@ -33,7 +33,7 @@ class Meta: deadline_date = date.today() + timedelta(days=10) start_working_date = date.today() + timedelta(days=random.randint(12, 90)) author = factory.SubFactory(UserFactory) - external_link = factory.Sequence("https://{0}example.com".format) + external_link = "https://www.example.com" # Contact fields contact_first_name = factory.Sequence("first_name{0}".format) contact_last_name = factory.Sequence("last_name{0}".format) diff --git a/lemarche/utils/tests_validators.py b/lemarche/utils/tests_validators.py index 385083078..cbc618fc4 100644 --- a/lemarche/utils/tests_validators.py +++ b/lemarche/utils/tests_validators.py @@ -1,3 +1,7 @@ +import socket +from unittest.mock import patch + +import requests from django.core.exceptions import ValidationError from django.test import TestCase @@ -55,3 +59,119 @@ def test_naf_validator(self): NAF_NOT_OK = ["1234", "12345", "ABCDE"] for item in NAF_NOT_OK: self.assertRaises(ValidationError, validator, item) + + +class StrictURLValidatorTests(TestCase): + """ + Test suite for the `StrictURLValidator` class. + Covers syntax validation, DNS resolution, and HTTP response checks. + """ + + def setUp(self): + """Set up a reusable instance of the validator.""" + self.validator = OptionalSchemeURLValidator() + + @patch("socket.gethostbyname") + @patch("requests.get") + def test_valid_url(self, mock_requests_get, mock_socket_gethostbyname): + """ + Test: Valid URL with proper DNS resolution and HTTP server response. + Expected: No exception raised. + """ + mock_socket_gethostbyname.return_value = "127.0.0.1" + mock_requests_get.return_value.status_code = 200 + + try: + self.validator("http://example.com") + except ValidationError: + self.fail("StrictURLValidator raised ValidationError for a valid URL.") + + def test_invalid_syntax(self): + """ + Test: URL with invalid syntax (e.g., missing scheme or malformed domain). + Expected: ValidationError with a user-friendly message. + """ + with self.assertRaises(ValidationError) as context: + self.validator("http://en cours") + self.assertIn("Saisissez une URL valide.", str(context.exception)) + + @patch("socket.gethostbyname") + def test_dns_failure(self, mock_socket_gethostbyname): + """ + Test: URL with a domain that cannot be resolved (DNS failure). + Expected: ValidationError with a message indicating the domain is invalid. + """ + mock_socket_gethostbyname.side_effect = socket.gaierror + + with self.assertRaises(ValidationError) as context: + self.validator("http://invalid-domain.com") + self.assertIn("Le site web associé à cette adresse n'existe pas", str(context.exception)) + + @patch("socket.gethostbyname") + @patch("requests.get") + def test_http_error(self, mock_requests_get, mock_socket_gethostbyname): + """ + Test: URL with a valid domain but the HTTP server returns an error response. + Expected: ValidationError with a message indicating a server issue. + """ + mock_socket_gethostbyname.return_value = "127.0.0.1" + mock_requests_get.return_value.status_code = 500 + + with self.assertRaises(ValidationError) as context: + self.validator("http://example.com") + self.assertIn("Le site web semble rencontrer un problème", str(context.exception)) + + @patch("socket.gethostbyname") + @patch("requests.get") + def test_http_timeout(self, mock_requests_get, mock_socket_gethostbyname): + """ + Test: URL with a valid domain but the HTTP server takes too long to respond (timeout). + Expected: ValidationError with a message indicating a timeout. + """ + mock_socket_gethostbyname.return_value = "127.0.0.1" + mock_requests_get.side_effect = requests.Timeout + + with self.assertRaises(ValidationError) as context: + self.validator("http://example.com") + self.assertIn("Une erreur est survenue en essayant de vérifier cette adresse", str(context.exception)) + + def test_real_world_integration(self): + """ + Integration Test: Real-world validation using actual DNS and HTTP requests. + Note: Slower and depends on external resources being available. + """ + try: + self.validator("http://example.com") # Known valid domain + except ValidationError: + self.fail("StrictURLValidator raised ValidationError for a valid real-world URL.") + + with self.assertRaises(ValidationError) as context: + self.validator("http://invalid-domain-123456789.com") + self.assertIn("Le site web associé à cette adresse n'existe pas", str(context.exception)) + + @patch("requests.get") + def test_localhost_url(self, mock_requests_get): + """ + Test: Localhost URL to verify compatibility with development or internal environments. + Expected: No exception raised. + """ + mock_requests_get.return_value.status_code = 200 + + try: + self.validator("http://localhost") + except ValidationError: + self.fail("StrictURLValidator raised ValidationError for a valid localhost URL.") + + def test_missing_scheme(self): + """ + Test: URL without a scheme (e.g., 'example.com'). + Expected: Automatically prepend 'https://' and validate successfully. + """ + with patch("socket.gethostbyname") as mock_socket_gethostbyname, patch("requests.get") as mock_requests_get: + mock_socket_gethostbyname.return_value = "127.0.0.1" + mock_requests_get.return_value.status_code = 200 + + try: + self.validator("example.com") # Should be treated as https://example.com + except ValidationError: + self.fail("StrictURLValidator raised ValidationError for a valid URL without scheme.") diff --git a/lemarche/utils/validators.py b/lemarche/utils/validators.py index a36d5ec60..17367bb15 100644 --- a/lemarche/utils/validators.py +++ b/lemarche/utils/validators.py @@ -1,17 +1,49 @@ # https://github.com/betagouv/itou/blob/master/itou/utils/validators.py +import socket +from urllib.parse import urlparse +import requests from django.core.exceptions import ValidationError from django.core.validators import URLValidator class OptionalSchemeURLValidator(URLValidator): - # https://stackoverflow.com/a/49983649/4293684 + def __init__(self, timeout=5, *args, **kwargs): + self.timeout = timeout + super().__init__(*args, **kwargs) + def __call__(self, value): if "://" not in value: # Validate as if it were https:// + # https://stackoverflow.com/a/49983649/4293684 value = "https://" + value + + # call the classic URLValidator super(OptionalSchemeURLValidator, self).__call__(value) + # Validate DNS resolution + domain = urlparse(value).netloc + try: + socket.gethostbyname(domain) # DNS resolution check + except socket.gaierror: + raise ValidationError( + "Le site web associé à cette adresse n'existe pas ou est inaccessible pour le moment." + ) + + # Validate HTTP response + try: + response = requests.get(value, timeout=5) # Adjust timeout as needed + if response.status_code >= 400: + raise ValidationError( + """Le site web semble rencontrer un problème. + Êtes-vous sûr que le site existe ? Si oui, réessayez plus tard ou contactez-nous.""" + ) + except requests.RequestException: + raise ValidationError( + """Une erreur est survenue en essayant de vérifier cette adresse. + Êtes-vous sûr que le site existe ? Si oui, réessayez plus tard ou contactez-nous.""" + ) + def validate_post_code(post_code): if not post_code.isdigit() or len(post_code) != 5: