From 797dd2df47bcd279a6c4e866cdeb0b2041caac88 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 23 Jul 2026 18:52:32 +0200 Subject: [PATCH] feat: add HttpUrlStr shared URL validator --- .../app/capabilities/core/validation.py | 24 ++++++++++++++ .../tests/unit/capabilities/core/__init__.py | 0 .../unit/capabilities/core/test_validation.py | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 surfsense_backend/app/capabilities/core/validation.py create mode 100644 surfsense_backend/tests/unit/capabilities/core/__init__.py create mode 100644 surfsense_backend/tests/unit/capabilities/core/test_validation.py diff --git a/surfsense_backend/app/capabilities/core/validation.py b/surfsense_backend/app/capabilities/core/validation.py new file mode 100644 index 000000000..3e0885488 --- /dev/null +++ b/surfsense_backend/app/capabilities/core/validation.py @@ -0,0 +1,24 @@ +"""Shared Pydantic field types for capability I/O schemas.""" + +from __future__ import annotations + +from typing import Annotated +from urllib.parse import urlsplit + +import validators +from pydantic import AfterValidator +from pydantic_core import PydanticCustomError + +_HTTP_SCHEMES = frozenset({"http", "https"}) + + +def _validate_http_url(value: str) -> str: + """Accept only well-formed http(s) URLs, returned trimmed and unchanged.""" + url = value.strip() + if not validators.url(url) or urlsplit(url).scheme.lower() not in _HTTP_SCHEMES: + raise PydanticCustomError("http_url", "must be a valid http(s) URL") + return url + + +HttpUrlStr = Annotated[str, AfterValidator(_validate_http_url)] +"""A request URL validated as http(s) and kept as ``str`` (no normalization).""" diff --git a/surfsense_backend/tests/unit/capabilities/core/__init__.py b/surfsense_backend/tests/unit/capabilities/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/capabilities/core/test_validation.py b/surfsense_backend/tests/unit/capabilities/core/test_validation.py new file mode 100644 index 000000000..03db512ba --- /dev/null +++ b/surfsense_backend/tests/unit/capabilities/core/test_validation.py @@ -0,0 +1,32 @@ +"""``HttpUrlStr`` boundary type: accept well-formed http(s) URLs, reject the rest.""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel, ValidationError + +from app.capabilities.core.validation import HttpUrlStr + +pytestmark = pytest.mark.unit + + +class _Model(BaseModel): + urls: list[HttpUrlStr] + + +def test_accepts_http_and_https_urls_unchanged() -> None: + model = _Model(urls=["https://example.com/path?q=1", "http://a.co"]) + assert model.urls == ["https://example.com/path?q=1", "http://a.co"] + + +def test_trims_surrounding_whitespace() -> None: + assert _Model(urls=[" https://example.com "]).urls == ["https://example.com"] + + +@pytest.mark.parametrize( + "bad", + ["not-a-url", "example.com", "ftp://example.com", "http://", "javascript:alert(1)"], +) +def test_rejects_malformed_or_non_http_urls(bad: str) -> None: + with pytest.raises(ValidationError): + _Model(urls=[bad])