Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Amélioration de la validation des champs url #1565

Merged
merged 5 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -40,7 +40,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 @@ -99,12 +99,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 @@ -647,7 +653,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 @@ -676,7 +682,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 @@ -685,7 +694,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
Loading