""" Extended tests for security/url_builder.py Covers additional edge cases, boundary conditions, and integration scenarios beyond the base test suite for all five public functions and the URLBuilderError exception. """ import pytest from local_deep_research.security.url_builder import ( URLBuilderError, normalize_bind_address, build_base_url_from_settings, build_full_url, validate_constructed_url, mask_sensitive_url, ) # --------------------------------------------------------------------------- # normalize_bind_address -- address normalization # --------------------------------------------------------------------------- class TestNormalizeBindAddressExtended: """Extended tests for normalize_bind_address.""" def test_ipv4_bind_all_to_localhost(self): """0.0.0.0 is converted to localhost.""" assert normalize_bind_address("0.0.0.0") == "localhost" def test_ipv6_bind_all_to_localhost(self): """:: is converted to localhost.""" assert normalize_bind_address("::") == "localhost" def test_localhost_unchanged(self): """localhost passes through without modification.""" assert normalize_bind_address("localhost") == "localhost" def test_specific_ipv4_unchanged(self): """A specific IPv4 address is returned as-is.""" assert normalize_bind_address("192.168.1.1") == "192.168.1.1" def test_local_hostname_unchanged(self): """A .local hostname is returned as-is.""" assert normalize_bind_address("myhost.local") == "myhost.local" def test_loopback_127_unchanged(self): """127.0.0.1 is not converted (only 0.0.0.0 and :: are).""" assert normalize_bind_address("127.0.0.1") == "127.0.0.1" def test_fqdn_unchanged(self): """A fully-qualified domain name passes through unchanged.""" assert normalize_bind_address("app.example.com") == "app.example.com" def test_ipv6_loopback_unchanged(self): """::1 (IPv6 loopback) is not treated as bind-all.""" assert normalize_bind_address("::1") == "::1" def test_empty_string_unchanged(self): """Empty string is returned as-is (caller is responsible for validation).""" assert normalize_bind_address("") == "" def test_10_network_address_unchanged(self): """Private 10.x.x.x address is returned unchanged.""" assert normalize_bind_address("10.0.0.1") == "10.0.0.1" def test_172_network_address_unchanged(self): """Private 172.16.x.x address is returned unchanged.""" assert normalize_bind_address("172.16.0.1") == "172.16.0.1" # --------------------------------------------------------------------------- # build_base_url_from_settings -- URL construction from config # --------------------------------------------------------------------------- class TestBuildBaseUrlFromSettingsExtended: """Extended tests for build_base_url_from_settings.""" def test_external_url_priority_over_host_port(self): """external_url takes priority even when host and port are provided.""" result = build_base_url_from_settings( external_url="https://public.example.com", host="192.168.1.50", port=8080, ) assert result == "https://public.example.com" def test_external_url_stripped_and_trailing_slash_removed(self): """external_url is stripped of whitespace and trailing slash.""" result = build_base_url_from_settings( external_url=" https://app.example.com/ " ) assert result == "https://app.example.com" def test_host_port_constructs_http_url(self): """host + port constructs an http:// URL.""" result = build_base_url_from_settings( external_url=None, host="myserver.local", port=3000, ) assert result == "http://myserver.local:3000" def test_bind_all_host_normalized_to_localhost(self): """0.0.0.0 host is normalized to localhost in the URL.""" result = build_base_url_from_settings( external_url=None, host="0.0.0.0", port=5000, ) assert result == "http://localhost:5000" def test_fallback_used_when_no_external_or_host_port(self): """fallback_base is used when external_url and host/port are absent.""" result = build_base_url_from_settings( external_url=None, host=None, port=None, fallback_base="http://custom-fallback:9090", ) assert result == "http://custom-fallback:9090" def test_fallback_trailing_slash_stripped(self): """Trailing slash on fallback_base is removed.""" result = build_base_url_from_settings( external_url=None, host=None, port=None, fallback_base="http://fallback.local:9090/", ) assert result == "http://fallback.local:9090" def test_empty_external_url_treated_as_not_provided(self): """Empty string external_url falls through to host/port or fallback.""" result = build_base_url_from_settings( external_url="", host=None, port=None, ) assert result == "http://localhost:5000" def test_whitespace_only_external_url_treated_as_not_provided(self): """Whitespace-only external_url falls through.""" result = build_base_url_from_settings( external_url=" ", host=None, port=None, ) assert result == "http://localhost:5000" def test_port_as_string_is_accepted(self): """Port provided as string is converted to int.""" result = build_base_url_from_settings( external_url=None, host="localhost", port="8080", ) assert result == "http://localhost:8080" def test_invalid_port_raises_url_builder_error(self): """Non-numeric port string raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="Failed to build base URL"): build_base_url_from_settings( external_url=None, host="localhost", port="abc", ) def test_ipv6_bind_all_host_normalized(self): """:: host is normalized to localhost in the URL.""" result = build_base_url_from_settings( external_url=None, host="::", port=5000, ) assert result == "http://localhost:5000" def test_external_url_with_multiple_trailing_slashes(self): """Multiple trailing slashes are stripped from external_url.""" result = build_base_url_from_settings( external_url="https://example.com///" ) assert result == "https://example.com" def test_external_url_with_path(self): """external_url that includes a path keeps the path (minus trailing /).""" result = build_base_url_from_settings( external_url="https://example.com/app/" ) assert result == "https://example.com/app" def test_default_fallback_value(self): """Default fallback_base is http://localhost:5000.""" result = build_base_url_from_settings() assert result == "http://localhost:5000" def test_host_without_port_uses_fallback(self): """If host is given but port is None, fallback is used.""" result = build_base_url_from_settings( external_url=None, host="somehost", port=None, fallback_base="http://default:7777", ) assert result == "http://default:7777" def test_port_without_host_uses_fallback(self): """If port is given but host is None, fallback is used.""" result = build_base_url_from_settings( external_url=None, host=None, port=5000, fallback_base="http://default:7777", ) assert result == "http://default:7777" # --------------------------------------------------------------------------- # build_full_url -- base + path combination # --------------------------------------------------------------------------- class TestBuildFullUrlExtended: """Extended tests for build_full_url.""" def test_combines_base_and_path(self): """Base URL and path are joined correctly.""" result = build_full_url("https://example.com", "/api/v1", validate=True) assert result == "https://example.com/api/v1" def test_trailing_slash_removed_from_base(self): """Trailing slash on base_url is stripped before joining.""" result = build_full_url( "https://example.com/", "/endpoint", validate=True ) assert result == "https://example.com/endpoint" def test_leading_slash_added_to_path(self): """Path without leading slash gets one prepended.""" result = build_full_url( "https://example.com", "path/here", validate=True ) assert result == "https://example.com/path/here" def test_validate_true_raises_on_invalid_url(self): """validate=True raises URLBuilderError for a schemeless base.""" with pytest.raises(URLBuilderError): build_full_url("not-a-url", "/path", validate=True) def test_validate_false_skips_validation(self): """validate=False allows any string combination through.""" result = build_full_url("no-scheme", "/path", validate=False) assert result == "no-scheme/path" def test_allowed_schemes_rejects_wrong_scheme(self): """URL with disallowed scheme raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="not in allowed schemes"): build_full_url( "ftp://files.example.com", "/data", validate=True, allowed_schemes=["http", "https"], ) def test_allowed_schemes_accepts_correct_scheme(self): """URL with allowed scheme passes validation.""" result = build_full_url( "https://example.com", "/secure", validate=True, allowed_schemes=["https"], ) assert result == "https://example.com/secure" def test_path_already_has_leading_slash(self): """Path that already starts with / is not double-slashed.""" result = build_full_url( "https://example.com", "/already/slashed", validate=True ) assert "//already" not in result assert result == "https://example.com/already/slashed" def test_base_url_with_path_component(self): """Base URL that includes a path appends new path correctly.""" result = build_full_url( "https://example.com/app", "/api/test", validate=False ) assert result == "https://example.com/app/api/test" def test_empty_path_gets_leading_slash(self): """Empty path results in trailing slash after base.""" result = build_full_url("https://example.com", "", validate=False) assert result == "https://example.com/" def test_path_with_query_string(self): """Path containing query string is appended as-is.""" result = build_full_url( "https://example.com", "/search?q=test", validate=True ) assert result == "https://example.com/search?q=test" def test_path_with_fragment(self): """Path containing fragment is appended as-is.""" result = build_full_url( "https://example.com", "/page#section", validate=True ) assert result == "https://example.com/page#section" # --------------------------------------------------------------------------- # validate_constructed_url -- URL validation # --------------------------------------------------------------------------- class TestValidateConstructedUrlExtended: """Extended tests for validate_constructed_url.""" def test_empty_string_raises(self): """Empty string raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="non-empty string"): validate_constructed_url("") def test_none_raises(self): """None raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="non-empty string"): validate_constructed_url(None) def test_no_scheme_raises(self): """URL without scheme raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="scheme"): validate_constructed_url("example.com/path") def test_no_hostname_raises(self): """URL without hostname (empty netloc) raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="hostname"): validate_constructed_url("http:///path/only") def test_valid_http_url_returns_true(self): """Valid HTTP URL returns True.""" assert validate_constructed_url("http://example.com/api") is True def test_valid_https_url_returns_true(self): """Valid HTTPS URL returns True.""" assert validate_constructed_url("https://secure.example.com") is True def test_wrong_scheme_with_allowed_schemes_raises(self): """Scheme not in allowed_schemes list raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="not in allowed schemes"): validate_constructed_url( "http://example.com", allowed_schemes=["https"] ) def test_correct_scheme_with_allowed_schemes_passes(self): """Scheme in allowed_schemes list passes.""" assert ( validate_constructed_url( "https://example.com", allowed_schemes=["https"] ) is True ) def test_none_allowed_schemes_accepts_any(self): """None for allowed_schemes accepts any scheme.""" assert ( validate_constructed_url( "custom://something.local", allowed_schemes=None ) is True ) def test_ftp_scheme_with_ftp_allowed(self): """ftp:// passes when ftp is in allowed_schemes.""" assert ( validate_constructed_url( "ftp://files.example.com/data", allowed_schemes=["ftp", "sftp"], ) is True ) def test_url_with_port(self): """URL with port validates successfully.""" assert validate_constructed_url("https://example.com:8443/api") is True def test_url_with_auth_info(self): """URL containing user:pass@host validates (netloc is present).""" assert ( validate_constructed_url("https://user:pass@example.com/path") is True ) def test_integer_input_raises(self): """Non-string input raises URLBuilderError.""" with pytest.raises(URLBuilderError, match="non-empty string"): validate_constructed_url(42) def test_url_with_query_and_fragment(self): """URL with query string and fragment validates.""" assert ( validate_constructed_url("https://example.com/path?key=val#section") is True ) # --------------------------------------------------------------------------- # mask_sensitive_url -- sensitive data masking for logging # --------------------------------------------------------------------------- class TestMaskSensitiveUrlExtended: """Extended tests for mask_sensitive_url.""" def test_password_masked(self): """Password in user:pass@host URL is replaced with ***.""" result = mask_sensitive_url("https://admin:s3cret@example.com/api") assert "s3cret" not in result assert "admin" in result assert "***" in result assert "example.com" in result def test_long_path_token_masked(self): """Path segments >= 20 alphanumeric chars are masked.""" token = "a" * 20 # exactly 20 chars result = mask_sensitive_url(f"https://hooks.slack.com/services/{token}") assert token not in result assert "/***" in result def test_query_string_masked(self): """Query string is replaced with ?***.""" result = mask_sensitive_url( "https://api.example.com/data?api_key=supersecret&format=json" ) assert "api_key" not in result assert "supersecret" not in result assert "?***" in result def test_url_without_sensitive_parts_unchanged(self): """URL with no password, long tokens, or query is mostly preserved.""" url = "https://example.com/api/users" result = mask_sensitive_url(url) assert "example.com" in result assert "/api/users" in result assert "https://" in result def test_short_path_segments_not_masked(self): """Path segments shorter than 20 chars are NOT masked.""" result = mask_sensitive_url("https://example.com/short/path/here") assert "/short/path/here" in result def test_invalid_url_returns_partial_mask(self): """Malformed URL without netloc does not crash and returns a string. urlparse handles most strings without raising, so the function reconstructs from whatever components it can extract. The except branch (returning 'scheme://***') only fires for truly exceptional inputs. Here we verify graceful handling rather than the exact fallback format. """ result = mask_sensitive_url("http:not-valid-at-all") assert result is not None assert isinstance(result, str) assert "http" in result def test_webhook_url_with_long_token_masked(self): """Webhook URL with a long token in the path gets the token masked.""" token = "T0123456789abcdefghij" # 21 chars result = mask_sensitive_url( f"https://hooks.slack.com/services/{token}/more" ) assert token not in result assert "/***" in result assert "hooks.slack.com" in result def test_url_with_port_preserved(self): """Port number in URL is preserved after masking.""" result = mask_sensitive_url("https://example.com:8443/api/short") assert ":8443" in result def test_multiple_long_path_segments_masked(self): """Multiple long path segments are each masked independently.""" token1 = "abcdefghijklmnopqrstu" # 21 chars token2 = "12345678901234567890XX" # 22 chars result = mask_sensitive_url( f"https://hooks.example.com/{token1}/{token2}" ) assert token1 not in result assert token2 not in result def test_combined_password_token_and_query(self): """URL with password, long token, and query gets all masked.""" token = "aabbccddee1122334455ff" # 22 chars result = mask_sensitive_url( f"https://user:password@api.example.com/hook/{token}?secret=yes" ) assert "password" not in result assert token not in result assert "secret=yes" not in result assert "?***" in result assert "user" in result def test_scheme_preserved(self): """The URL scheme is always preserved.""" result = mask_sensitive_url("http://user:pass@example.com/path") assert result.startswith("http://") def test_path_segment_exactly_19_chars_not_masked(self): """A path segment of exactly 19 chars is NOT masked (threshold is 20).""" segment = "a" * 19 result = mask_sensitive_url(f"https://example.com/{segment}") assert segment in result def test_path_segment_exactly_20_chars_masked(self): """A path segment of exactly 20 chars IS masked.""" segment = "b" * 20 result = mask_sensitive_url(f"https://example.com/{segment}") assert segment not in result assert "/***" in result def test_url_with_only_query_no_password(self): """URL with query but no password masks only the query.""" result = mask_sensitive_url("https://example.com/api?token=abc123") assert "token=abc123" not in result assert "?***" in result assert "example.com" in result assert "/api" in result def test_url_with_hyphen_underscore_token_masked(self): """Long tokens containing hyphens and underscores are masked.""" token = "abc-def_ghi-jkl_mno-pqrs" # 24 chars with hyphens/underscores result = mask_sensitive_url(f"https://example.com/hook/{token}") assert token not in result def test_empty_path_no_crash(self): """URL with empty path does not crash.""" result = mask_sensitive_url("https://example.com") assert "example.com" in result # --------------------------------------------------------------------------- # Integration -- combining multiple functions # --------------------------------------------------------------------------- class TestURLBuilderIntegrationExtended: """Integration scenarios combining multiple url_builder functions.""" def test_settings_to_full_url_to_mask_workflow(self): """Full workflow: settings -> base URL -> full URL -> masked for logging.""" base = build_base_url_from_settings( external_url="https://app.example.com/" ) full = build_full_url(base, "/api/v1/research/42") assert validate_constructed_url(full) is True masked = mask_sensitive_url(full) assert "app.example.com" in masked def test_host_port_to_full_url_normalized(self): """Build from 0.0.0.0 host, construct full URL, validate.""" base = build_base_url_from_settings(host="0.0.0.0", port=8080) assert "localhost" in base full = build_full_url(base, "health/check") assert full == "http://localhost:8080/health/check" assert validate_constructed_url(full) is True def test_fallback_to_full_url(self): """Fallback base URL + path produces valid URL.""" base = build_base_url_from_settings( external_url="", host=None, port=None, fallback_base="http://fallback:7000/", ) full = build_full_url(base, "/status") assert full == "http://fallback:7000/status" assert validate_constructed_url(full) is True def test_mask_url_with_credentials_from_build(self): """Masking a URL built with embedded credentials hides the password.""" full = build_full_url( "https://admin:topsecret@internal.host.com", "/api/data", validate=False, ) masked = mask_sensitive_url(full) assert "topsecret" not in masked assert "admin" in masked assert "***" in masked def test_validate_rejects_then_build_without_validate(self): """Demonstrate that validate=False allows construction that would fail.""" # This should fail with validation with pytest.raises(URLBuilderError): build_full_url("noscheme", "/path", validate=True) # But succeeds without validation result = build_full_url("noscheme", "/path", validate=False) assert result == "noscheme/path"