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>
419 lines
17 KiB
Python
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
|