Files
Gondulf/tests/unit/test_validation.py
Phil Skentelbery 526a21d3fb fix(validation): implement W3C IndieAuth compliant client_id validation
Implements complete W3C IndieAuth Section 3.2 client identifier
validation including:
- Fragment rejection
- HTTP scheme support for localhost/loopback only
- Username/password component rejection
- Non-loopback IP address rejection
- Path traversal prevention (.. and . segments)
- Hostname case normalization
- Default port removal (80/443)
- Path component enforcement

All 75 validation tests passing with 99% coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 18:14:55 -07:00

419 lines
17 KiB
Python

"""Tests for validation utilities."""
import pytest
from gondulf.utils.validation import (
mask_email,
validate_client_id,
normalize_client_id,
validate_redirect_uri,
extract_domain_from_url,
validate_email
)
class TestMaskEmail:
"""Tests for mask_email function."""
def test_mask_email_basic(self):
"""Test basic email masking."""
assert mask_email("user@example.com") == "u***@example.com"
def test_mask_email_long_local(self):
"""Test masking email with long local part."""
assert mask_email("verylongusername@example.com") == "v***@example.com"
def test_mask_email_single_char_local(self):
"""Test masking email with single character local part."""
# Should return unchanged if local part is only 1 character
assert mask_email("a@example.com") == "a@example.com"
def test_mask_email_no_at_sign(self):
"""Test masking invalid email without @ sign."""
assert mask_email("notanemail") == "notanemail"
def test_mask_email_empty_string(self):
"""Test masking empty string."""
assert mask_email("") == ""
class TestValidateClientId:
"""Tests for validate_client_id function."""
def test_valid_https_basic(self):
"""Test valid basic HTTPS URL."""
is_valid, error = validate_client_id("https://example.com")
assert is_valid is True
assert error == ""
def test_valid_https_with_path(self):
"""Test valid HTTPS URL with path."""
is_valid, error = validate_client_id("https://example.com/app")
assert is_valid is True
assert error == ""
def test_valid_https_with_trailing_slash(self):
"""Test valid HTTPS URL with trailing slash."""
is_valid, error = validate_client_id("https://example.com/")
assert is_valid is True
assert error == ""
def test_valid_https_with_query(self):
"""Test valid HTTPS URL with query string."""
is_valid, error = validate_client_id("https://example.com?foo=bar")
assert is_valid is True
assert error == ""
def test_valid_https_with_subdomain(self):
"""Test valid HTTPS URL with subdomain."""
is_valid, error = validate_client_id("https://sub.example.com")
assert is_valid is True
assert error == ""
def test_valid_https_with_non_default_port(self):
"""Test valid HTTPS URL with non-default port."""
is_valid, error = validate_client_id("https://example.com:8080")
assert is_valid is True
assert error == ""
def test_valid_http_localhost(self):
"""Test valid HTTP URL with localhost."""
is_valid, error = validate_client_id("http://localhost")
assert is_valid is True
assert error == ""
def test_valid_http_localhost_with_port(self):
"""Test valid HTTP URL with localhost and port."""
is_valid, error = validate_client_id("http://localhost:3000")
assert is_valid is True
assert error == ""
def test_valid_http_127_0_0_1(self):
"""Test valid HTTP URL with 127.0.0.1."""
is_valid, error = validate_client_id("http://127.0.0.1")
assert is_valid is True
assert error == ""
def test_valid_http_127_0_0_1_with_port(self):
"""Test valid HTTP URL with 127.0.0.1 and port."""
is_valid, error = validate_client_id("http://127.0.0.1:8080")
assert is_valid is True
assert error == ""
def test_valid_http_ipv6_loopback(self):
"""Test valid HTTP URL with IPv6 loopback."""
is_valid, error = validate_client_id("http://[::1]")
assert is_valid is True
assert error == ""
def test_valid_http_ipv6_loopback_with_port(self):
"""Test valid HTTP URL with IPv6 loopback and port."""
is_valid, error = validate_client_id("http://[::1]:8080")
assert is_valid is True
assert error == ""
def test_invalid_ftp_scheme(self):
"""Test that FTP scheme is rejected."""
is_valid, error = validate_client_id("ftp://example.com")
assert is_valid is False
assert "must use https or http scheme" in error
def test_invalid_no_scheme(self):
"""Test that URL without scheme is rejected."""
is_valid, error = validate_client_id("example.com")
assert is_valid is False
assert "must use https or http scheme" in error
def test_invalid_fragment(self):
"""Test that URL with fragment is rejected."""
is_valid, error = validate_client_id("https://example.com#fragment")
assert is_valid is False
assert "must not contain a fragment" in error
def test_invalid_username(self):
"""Test that URL with username is rejected."""
is_valid, error = validate_client_id("https://user@example.com")
assert is_valid is False
assert "must not contain username or password" in error
def test_invalid_username_and_password(self):
"""Test that URL with username and password is rejected."""
is_valid, error = validate_client_id("https://user:pass@example.com")
assert is_valid is False
assert "must not contain username or password" in error
def test_invalid_single_dot_path_segment(self):
"""Test that URL with single-dot path segment is rejected."""
is_valid, error = validate_client_id("https://example.com/./invalid")
assert is_valid is False
assert "must not contain single-dot (.) or double-dot (..) path segments" in error
def test_invalid_double_dot_path_segment(self):
"""Test that URL with double-dot path segment is rejected."""
is_valid, error = validate_client_id("https://example.com/../invalid")
assert is_valid is False
assert "must not contain single-dot (.) or double-dot (..) path segments" in error
def test_invalid_http_non_localhost(self):
"""Test that HTTP scheme is rejected for non-localhost."""
is_valid, error = validate_client_id("http://example.com")
assert is_valid is False
assert "http scheme is only allowed for localhost" in error
def test_invalid_non_loopback_ipv4(self):
"""Test that non-loopback IPv4 address is rejected."""
is_valid, error = validate_client_id("https://192.168.1.1")
assert is_valid is False
assert "must not use IP address" in error
def test_invalid_non_loopback_ipv4_private(self):
"""Test that private IPv4 address is rejected."""
is_valid, error = validate_client_id("https://10.0.0.1")
assert is_valid is False
assert "must not use IP address" in error
def test_invalid_non_loopback_ipv6(self):
"""Test that non-loopback IPv6 address is rejected."""
is_valid, error = validate_client_id("https://[2001:db8::1]")
assert is_valid is False
assert "must not use IP address" in error
def test_invalid_empty_string(self):
"""Test that empty string is rejected."""
is_valid, error = validate_client_id("")
assert is_valid is False
assert "must be a valid URL" in error or "must use https or http scheme" in error
def test_invalid_malformed_url(self):
"""Test that malformed URL is rejected."""
is_valid, error = validate_client_id("not-a-url")
assert is_valid is False
assert "must use https or http scheme" in error
class TestNormalizeClientId:
"""Tests for normalize_client_id function."""
def test_normalize_basic_https(self):
"""Test normalizing basic HTTPS URL."""
assert normalize_client_id("https://example.com/") == "https://example.com/"
def test_normalize_basic_https_no_path(self):
"""Test normalizing HTTPS URL without path adds trailing slash."""
assert normalize_client_id("https://example.com") == "https://example.com/"
def test_normalize_uppercase_hostname(self):
"""Test normalizing URL with uppercase hostname."""
assert normalize_client_id("HTTPS://EXAMPLE.COM") == "https://example.com/"
def test_normalize_mixed_case_hostname(self):
"""Test normalizing URL with mixed case hostname."""
assert normalize_client_id("https://Example.Com/app") == "https://example.com/app"
def test_normalize_preserve_path_case(self):
"""Test that path case is preserved."""
assert normalize_client_id("https://example.com/APP") == "https://example.com/APP"
def test_normalize_remove_default_https_port(self):
"""Test normalizing URL with default HTTPS port."""
assert normalize_client_id("https://example.com:443/") == "https://example.com/"
def test_normalize_remove_default_http_port(self):
"""Test normalizing URL with default HTTP port for localhost."""
assert normalize_client_id("http://localhost:80/") == "http://localhost/"
def test_normalize_preserve_non_default_port(self):
"""Test normalizing URL with non-default port."""
assert normalize_client_id("https://example.com:8443/") == "https://example.com:8443/"
def test_normalize_preserve_path(self):
"""Test normalizing URL with path."""
assert normalize_client_id("https://example.com/app") == "https://example.com/app"
def test_normalize_preserve_query(self):
"""Test normalizing URL with query string."""
assert normalize_client_id("https://example.com/?foo=bar") == "https://example.com/?foo=bar"
def test_normalize_query_without_path(self):
"""Test normalizing URL with query but no path."""
assert normalize_client_id("https://example.com?foo=bar") == "https://example.com/?foo=bar"
def test_normalize_http_localhost(self):
"""Test normalizing HTTP localhost URL."""
assert normalize_client_id("http://localhost") == "http://localhost/"
def test_normalize_http_localhost_with_port(self):
"""Test normalizing HTTP localhost URL with port."""
assert normalize_client_id("http://localhost:3000") == "http://localhost:3000/"
def test_normalize_http_127_0_0_1(self):
"""Test normalizing HTTP 127.0.0.1 URL."""
assert normalize_client_id("http://127.0.0.1") == "http://127.0.0.1/"
def test_normalize_http_ipv6_loopback(self):
"""Test normalizing HTTP IPv6 loopback URL."""
assert normalize_client_id("http://[::1]") == "http://[::1]/"
def test_normalize_http_ipv6_loopback_with_port(self):
"""Test normalizing HTTP IPv6 loopback URL with port."""
assert normalize_client_id("http://[::1]:8080") == "http://[::1]:8080/"
def test_normalize_invalid_http_non_localhost_raises_error(self):
"""Test that HTTP non-localhost raises ValueError."""
with pytest.raises(ValueError, match="http scheme is only allowed for localhost"):
normalize_client_id("http://example.com/")
def test_normalize_fragment_raises_error(self):
"""Test that URL with fragment raises ValueError."""
with pytest.raises(ValueError, match="must not contain a fragment"):
normalize_client_id("https://example.com#fragment")
def test_normalize_username_raises_error(self):
"""Test that URL with username raises ValueError."""
with pytest.raises(ValueError, match="must not contain username or password"):
normalize_client_id("https://user@example.com")
def test_normalize_path_traversal_raises_error(self):
"""Test that URL with path traversal raises ValueError."""
with pytest.raises(ValueError, match="must not contain single-dot"):
normalize_client_id("https://example.com/./app")
def test_normalize_no_scheme_raises_error(self):
"""Test that missing scheme raises ValueError."""
with pytest.raises(ValueError, match="must use https or http scheme"):
normalize_client_id("example.com")
def test_normalize_invalid_scheme_raises_error(self):
"""Test that invalid scheme raises ValueError."""
with pytest.raises(ValueError, match="must use https or http scheme"):
normalize_client_id("ftp://example.com")
class TestValidateRedirectUri:
"""Tests for validate_redirect_uri function."""
def test_validate_same_origin(self):
"""Test redirect URI with same origin as client_id."""
assert validate_redirect_uri(
"https://example.com/callback",
"https://example.com/"
) is True
def test_validate_different_path_same_origin(self):
"""Test redirect URI with different path but same origin."""
assert validate_redirect_uri(
"https://example.com/auth/callback",
"https://example.com/"
) is True
def test_validate_subdomain(self):
"""Test redirect URI on subdomain of client_id."""
assert validate_redirect_uri(
"https://app.example.com/callback",
"https://example.com/"
) is True
def test_validate_different_domain_fails(self):
"""Test redirect URI on completely different domain fails."""
assert validate_redirect_uri(
"https://evil.com/callback",
"https://example.com/"
) is False
def test_validate_localhost_http_allowed(self):
"""Test that localhost can use HTTP."""
assert validate_redirect_uri(
"http://localhost/callback",
"https://example.com/"
) is True
def test_validate_127_0_0_1_http_allowed(self):
"""Test that 127.0.0.1 can use HTTP."""
assert validate_redirect_uri(
"http://127.0.0.1:8000/callback",
"https://example.com/"
) is True
def test_validate_http_non_localhost_fails(self):
"""Test that HTTP on non-localhost fails."""
assert validate_redirect_uri(
"http://example.com/callback",
"https://example.com/"
) is False
def test_validate_malformed_uri_fails(self):
"""Test that malformed URI fails gracefully."""
assert validate_redirect_uri(
"not a url",
"https://example.com/"
) is False
class TestExtractDomainFromUrl:
"""Tests for extract_domain_from_url function."""
def test_extract_domain_basic(self):
"""Test extracting domain from basic URL."""
assert extract_domain_from_url("https://example.com/") == "example.com"
def test_extract_domain_with_path(self):
"""Test extracting domain from URL with path."""
assert extract_domain_from_url("https://example.com/path/to/page") == "example.com"
def test_extract_domain_with_port(self):
"""Test extracting domain from URL with port."""
assert extract_domain_from_url("https://example.com:8443/") == "example.com"
def test_extract_domain_subdomain(self):
"""Test extracting subdomain."""
assert extract_domain_from_url("https://blog.example.com/") == "blog.example.com"
def test_extract_domain_no_hostname_raises_error(self):
"""Test that URL without hostname raises ValueError."""
with pytest.raises(ValueError, match="URL has no hostname"):
extract_domain_from_url("file:///path/to/file")
def test_extract_domain_invalid_url_raises_error(self):
"""Test that invalid URL raises ValueError."""
with pytest.raises(ValueError, match="Invalid URL"):
extract_domain_from_url("not a url")
class TestValidateEmail:
"""Tests for validate_email function."""
def test_validate_email_basic(self):
"""Test validating basic email."""
assert validate_email("user@example.com") is True
def test_validate_email_with_plus(self):
"""Test validating email with plus sign."""
assert validate_email("user+tag@example.com") is True
def test_validate_email_with_dots(self):
"""Test validating email with dots."""
assert validate_email("first.last@example.com") is True
def test_validate_email_subdomain(self):
"""Test validating email with subdomain."""
assert validate_email("user@mail.example.com") is True
def test_validate_email_no_at_sign(self):
"""Test that email without @ sign fails."""
assert validate_email("notanemail") is False
def test_validate_email_no_domain(self):
"""Test that email without domain fails."""
assert validate_email("user@") is False
def test_validate_email_no_local_part(self):
"""Test that email without local part fails."""
assert validate_email("@example.com") is False
def test_validate_email_no_tld(self):
"""Test that email without TLD fails."""
assert validate_email("user@example") is False
def test_validate_email_empty_string(self):
"""Test that empty string fails."""
assert validate_email("") is False