Skip to content

Commit

Permalink
fix: Amélioration de la validation des champs url (#1565)
Browse files Browse the repository at this point in the history
  • Loading branch information
madjid-asa authored Dec 10, 2024
1 parent f41f5c5 commit fb26956
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
24 changes: 18 additions & 6 deletions lemarche/siaes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lemarche/tenders/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions lemarche/utils/tests_validators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import socket
from unittest.mock import patch

import requests
from django.core.exceptions import ValidationError
from django.test import TestCase

Expand Down Expand Up @@ -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.")
34 changes: 33 additions & 1 deletion lemarche/utils/validators.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down

0 comments on commit fb26956

Please sign in to comment.