""" Tests for security/url_builder.py Tests cover: - normalize_bind_address() function - build_base_url_from_settings() function - build_full_url() function - validate_constructed_url() function - mask_sensitive_url() function """ 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, ) class TestNormalizeBindAddress: """Tests for normalize_bind_address function.""" def test_ipv4_bind_all_converts_to_localhost(self): """Test that 0.0.0.0 is converted to localhost.""" result = normalize_bind_address("0.0.0.0") assert result == "localhost" def test_ipv6_bind_all_converts_to_localhost(self): """Test that :: is converted to localhost.""" result = normalize_bind_address("::") assert result == "localhost" def test_regular_hostname_unchanged(self): """Test that regular hostnames are not modified.""" result = normalize_bind_address("myserver.example.com") assert result == "myserver.example.com" def test_localhost_unchanged(self): """Test that localhost is not modified.""" result = normalize_bind_address("localhost") assert result == "localhost" def test_regular_ip_unchanged(self): """Test that regular IP addresses are not modified.""" result = normalize_bind_address("192.168.1.100") assert result == "192.168.1.100" def test_127_0_0_1_unchanged(self): """Test that 127.0.0.1 is not modified (specific localhost IP).""" result = normalize_bind_address("127.0.0.1") assert result == "127.0.0.1" class TestBuildBaseUrlFromSettings: """Tests for build_base_url_from_settings function.""" def test_external_url_takes_priority(self): """Test that external_url is used when provided.""" result = build_base_url_from_settings( external_url="https://myapp.example.com", host="localhost", port=5000, ) assert result == "https://myapp.example.com" def test_external_url_strips_trailing_slash(self): """Test that trailing slashes are removed from external_url.""" result = build_base_url_from_settings( external_url="https://myapp.example.com/" ) assert result == "https://myapp.example.com" def test_external_url_strips_whitespace(self): """Test that whitespace is stripped from external_url.""" result = build_base_url_from_settings( external_url=" https://myapp.example.com " ) assert result == "https://myapp.example.com" def test_host_port_used_when_no_external_url(self): """Test that host and port are used when external_url is None.""" result = build_base_url_from_settings( external_url=None, host="myserver", port=8080, ) assert result == "http://myserver:8080" def test_host_port_normalizes_bind_address(self): """Test that bind addresses are normalized when building URL.""" result = build_base_url_from_settings( external_url=None, host="0.0.0.0", port=5000, ) assert result == "http://localhost:5000" def test_port_can_be_string(self): """Test that port can be provided as string.""" result = build_base_url_from_settings( external_url=None, host="localhost", port="3000", ) assert result == "http://localhost:3000" def test_fallback_used_when_no_host_port(self): """Test that fallback is used when no external_url or host/port.""" result = build_base_url_from_settings( external_url=None, host=None, port=None, fallback_base="http://fallback.local:9000", ) assert result == "http://fallback.local:9000" def test_default_fallback(self): """Test that default fallback is http://localhost:5000.""" result = build_base_url_from_settings() assert result == "http://localhost:5000" def test_fallback_strips_trailing_slash(self): """Test that trailing slashes are removed from fallback.""" result = build_base_url_from_settings( fallback_base="http://localhost:5000/" ) assert result == "http://localhost:5000" def test_empty_external_url_uses_fallback(self): """Test that empty string external_url triggers fallback.""" result = build_base_url_from_settings( external_url="", host=None, port=None, ) assert result == "http://localhost:5000" def test_whitespace_only_external_url_uses_fallback(self): """Test that whitespace-only external_url triggers fallback.""" result = build_base_url_from_settings( external_url=" ", host=None, port=None, ) assert result == "http://localhost:5000" def test_invalid_port_raises_error(self): """Test that invalid port raises URLBuilderError.""" with pytest.raises(URLBuilderError): build_base_url_from_settings( external_url=None, host="localhost", port="not_a_number", ) class TestBuildFullUrl: """Tests for build_full_url function.""" def test_simple_path_appending(self): """Test basic URL + path combination.""" result = build_full_url("https://example.com", "/api/test") assert result == "https://example.com/api/test" def test_path_without_leading_slash_normalized(self): """Test that paths without leading slash get one added.""" result = build_full_url("https://example.com", "api/test") assert result == "https://example.com/api/test" def test_base_url_trailing_slash_stripped(self): """Test that base URL trailing slash is removed.""" result = build_full_url("https://example.com/", "/api/test") assert result == "https://example.com/api/test" def test_validation_disabled(self): """Test that validation can be disabled.""" # This would normally fail validation (no scheme), but with validate=False it works result = build_full_url("example.com", "/test", validate=False) assert result == "example.com/test" def test_allowed_schemes_validation(self): """Test that scheme validation works.""" # https should work with default allowed schemes result = build_full_url("https://example.com", "/test") assert result == "https://example.com/test" def test_disallowed_scheme_raises_error(self): """Test that disallowed schemes raise error.""" with pytest.raises(URLBuilderError) as exc_info: build_full_url( "ftp://example.com", "/test", validate=True, allowed_schemes=["http", "https"], ) assert "not in allowed schemes" in str(exc_info.value) def test_empty_path(self): """Test with empty path.""" result = build_full_url("https://example.com", "", validate=False) assert result == "https://example.com/" class TestValidateConstructedUrl: """Tests for validate_constructed_url function.""" def test_valid_http_url(self): """Test that valid HTTP URL passes validation.""" result = validate_constructed_url("http://example.com/path") assert result is True def test_valid_https_url(self): """Test that valid HTTPS URL passes validation.""" result = validate_constructed_url("https://example.com/path") assert result is True def test_empty_url_raises_error(self): """Test that empty URL raises error.""" with pytest.raises(URLBuilderError) as exc_info: validate_constructed_url("") assert "non-empty string" in str(exc_info.value) def test_none_url_raises_error(self): """Test that None URL raises error.""" with pytest.raises(URLBuilderError) as exc_info: validate_constructed_url(None) assert "non-empty string" in str(exc_info.value) def test_url_without_scheme_raises_error(self): """Test that URL without scheme raises error.""" with pytest.raises(URLBuilderError) as exc_info: validate_constructed_url("example.com/path") assert "scheme" in str(exc_info.value) def test_url_without_hostname_raises_error(self): """Test that URL without hostname raises error.""" with pytest.raises(URLBuilderError) as exc_info: validate_constructed_url("http:///path") assert "hostname" in str(exc_info.value) def test_custom_allowed_schemes(self): """Test validation with custom allowed schemes.""" result = validate_constructed_url( "ftp://files.example.com", allowed_schemes=["ftp", "sftp"], ) assert result is True def test_scheme_not_in_allowed_raises_error(self): """Test that scheme not in allowed list raises error.""" with pytest.raises(URLBuilderError) as exc_info: validate_constructed_url( "http://example.com", allowed_schemes=["https"], ) assert "not in allowed schemes" in str(exc_info.value) def test_no_allowed_schemes_allows_all(self): """Test that None allowed_schemes allows any scheme.""" result = validate_constructed_url( "custom://example.com", allowed_schemes=None, ) assert result is True class TestMaskSensitiveUrl: """Tests for mask_sensitive_url function.""" def test_password_masked(self): """Test that password in URL is masked.""" result = mask_sensitive_url("https://user:secret123@example.com/path") assert "secret123" not in result assert "***" in result def test_long_path_tokens_masked(self): """Test that long tokens in path are masked (webhook tokens).""" result = mask_sensitive_url( "https://hooks.example.com/webhook/abcdefghij1234567890abcd" ) assert "abcdefghij1234567890abcd" not in result assert "/***" in result def test_query_string_masked(self): """Test that query strings are masked.""" result = mask_sensitive_url( "https://api.example.com/data?api_key=secret" ) assert "api_key=secret" not in result assert "?***" in result def test_regular_url_preserved(self): """Test that regular URLs without sensitive data are mostly preserved.""" result = mask_sensitive_url("https://example.com/api/users") assert "example.com" in result assert "https://" in result assert "users" in result def test_short_path_tokens_not_masked(self): """Test that short path segments are not masked.""" result = mask_sensitive_url("https://example.com/api/short") assert "/api/short" in result def test_invalid_url_returns_something(self): """Test that invalid URL returns something (doesn't crash).""" result = mask_sensitive_url("not a valid url at all") # Should return something without crashing - implementation may vary assert result is not None def test_url_with_port_preserved(self): """Test that port numbers are preserved.""" result = mask_sensitive_url("https://example.com:8443/api") assert ":8443" in result def test_combined_sensitive_data(self): """Test URL with multiple sensitive elements.""" result = mask_sensitive_url( "https://user:password@api.example.com/webhooks/" "aaaaaaaaaabbbbbbbbbbcccccccccc?token=test_value" ) assert "password" not in result assert "aaaaaaaaaabbbbbbbbbbcccccccccc" not in result assert "token=test_value" not in result class TestURLBuilderError: """Tests for URLBuilderError exception.""" def test_error_is_exception(self): """Test that URLBuilderError is an Exception.""" assert issubclass(URLBuilderError, Exception) def test_error_message_preserved(self): """Test that error message is preserved.""" error = URLBuilderError("Test error message") assert str(error) == "Test error message" def test_error_can_be_raised_and_caught(self): """Test that error can be raised and caught properly.""" with pytest.raises(URLBuilderError) as exc_info: raise URLBuilderError("Custom error") assert "Custom error" in str(exc_info.value) class TestURLBuilderIntegration: """Integration tests combining multiple URL builder functions.""" def test_build_and_validate_workflow(self): """Test typical workflow of building and validating a URL.""" base_url = build_base_url_from_settings( external_url="https://myapp.example.com" ) full_url = build_full_url(base_url, "/api/v1/users") assert validate_constructed_url(full_url) assert full_url == "https://myapp.example.com/api/v1/users" def test_build_from_host_port_and_validate(self): """Test building URL from host/port and validating.""" base_url = build_base_url_from_settings( host="0.0.0.0", port=8080, ) full_url = build_full_url(base_url, "/health") assert validate_constructed_url(full_url) assert "localhost" in full_url # 0.0.0.0 should be normalized def test_mask_built_url_with_credentials(self): """Test masking a dynamically built URL with credentials.""" base_url = "https://admin:supersecret@internal.example.com" full_url = build_full_url(base_url, "/api/data", validate=False) masked = mask_sensitive_url(full_url) assert "supersecret" not in masked assert "***" in masked