7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""
|
|
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
|