Ctrl K
ComunidadGuías…

backend/tests/test_http_session.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Tests for providers/http_session module.

Covers: create_session, RetryingSession (default timeout, rate limit handling,
429 response, auth errors, X-RateLimit headers).
"""

import logging
import time
from unittest.mock import MagicMock, patch

import pytest
import requests

from providers.http_session import RetryingSession, create_session

# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------


class TestCreateSession:
    def test_returns_retrying_session(self):
        session = create_session()
        assert isinstance(session, RetryingSession)

    def test_default_user_agent(self):
        session = create_session()
        assert session.headers["User-Agent"] == "Sublarr/1.0"

    def test_custom_user_agent(self):
        session = create_session(user_agent="TestAgent/2.0")
        assert session.headers["User-Agent"] == "TestAgent/2.0"

    def test_default_timeout(self):
        session = create_session()
        assert session.default_timeout == 15

    def test_custom_timeout(self):
        session = create_session(timeout=30)
        assert session.default_timeout == 30

    def test_https_adapter_mounted(self):
        session = create_session()
        assert "https://" in session.adapters

    def test_http_adapter_mounted(self):
        session = create_session()
        assert "http://" in session.adapters


# ---------------------------------------------------------------------------
# RetryingSession — basics
# ---------------------------------------------------------------------------


class TestRetryingSessionBasics:
    def test_default_timeout_applied(self):
        session = RetryingSession(timeout=10)
        assert session.default_timeout == 10

    def test_rate_limit_remaining_no_throttle(self):
        session = RetryingSession()
        assert session.rate_limit_remaining_seconds == 0.0

    def test_rate_limit_remaining_when_throttled(self):
        session = RetryingSession()
        session._rate_limit_until = time.time() + 30
        remaining = session.rate_limit_remaining_seconds
        assert 29 < remaining <= 30

    def test_rate_limit_remaining_expired(self):
        session = RetryingSession()
        session._rate_limit_until = time.time() - 10
        assert session.rate_limit_remaining_seconds == 0.0


# ---------------------------------------------------------------------------
# RetryingSession.request — timeout handling
# ---------------------------------------------------------------------------


class TestRetryingSessionTimeout:
    @patch.object(requests.Session, "request")
    def test_default_timeout_added(self, mock_request):
        mock_request.return_value = MagicMock(status_code=200, headers={})
        session = RetryingSession(timeout=20)
        session.request("GET", "https://example.com")
        _, kwargs = mock_request.call_args
        assert kwargs["timeout"] == 20

    @patch.object(requests.Session, "request")
    def test_explicit_timeout_preserved(self, mock_request):
        mock_request.return_value = MagicMock(status_code=200, headers={})
        session = RetryingSession(timeout=20)
        session.request("GET", "https://example.com", timeout=5)
        _, kwargs = mock_request.call_args
        assert kwargs["timeout"] == 5


# ---------------------------------------------------------------------------
# RetryingSession.request — error handling
# ---------------------------------------------------------------------------


class TestRetryingSessionErrors:
    @patch.object(requests.Session, "request")
    def test_connection_error_raised(self, mock_request):
        mock_request.side_effect = requests.ConnectionError("conn failed")
        session = RetryingSession()
        with pytest.raises(requests.ConnectionError):
            session.request("GET", "https://example.com")

    @patch.object(requests.Session, "request")
    def test_timeout_error_raised(self, mock_request):
        mock_request.side_effect = requests.Timeout("timed out")
        session = RetryingSession()
        with pytest.raises(requests.Timeout):
            session.request("GET", "https://example.com")


# ---------------------------------------------------------------------------
# RetryingSession.request — 429 rate limiting
# ---------------------------------------------------------------------------


class TestRetryingSession429:
    @patch.object(requests.Session, "request")
    def test_429_with_retry_after_header(self, mock_request):
        from providers.base import ProviderRateLimitError

        resp = MagicMock(status_code=429, headers={"Retry-After": "120"})
        mock_request.return_value = resp
        session = RetryingSession()

        with pytest.raises(ProviderRateLimitError, match="retry after 120s"):
            session.request("GET", "https://example.com")

        assert session._rate_limit_until is not None
        assert session._rate_limit_until > time.time()

    @patch.object(requests.Session, "request")
    def test_429_without_retry_after_defaults_60s(self, mock_request):
        from providers.base import ProviderRateLimitError

        resp = MagicMock(status_code=429, headers={})
        mock_request.return_value = resp
        session = RetryingSession()

        with pytest.raises(ProviderRateLimitError):
            session.request("GET", "https://example.com")

        # Default wait is 60 seconds
        assert session._rate_limit_until >= time.time() + 59

    @patch.object(requests.Session, "request")
    def test_429_non_integer_retry_after(self, mock_request):
        from providers.base import ProviderRateLimitError

        resp = MagicMock(status_code=429, headers={"Retry-After": "not-a-number"})
        mock_request.return_value = resp
        session = RetryingSession()

        with pytest.raises(ProviderRateLimitError):
            session.request("GET", "https://example.com")

        # Falls back to 60 seconds
        assert session._rate_limit_until >= time.time() + 59


# ---------------------------------------------------------------------------
# RetryingSession.request — auth errors
# ---------------------------------------------------------------------------


class TestRetryingSessionAuth:
    @patch.object(requests.Session, "request")
    def test_401_raises_auth_error(self, mock_request):
        from providers.base import ProviderAuthError

        resp = MagicMock(status_code=401, headers={})
        mock_request.return_value = resp
        session = RetryingSession()

        with pytest.raises(ProviderAuthError, match="Authentication failed"):
            session.request("GET", "https://example.com")

    @patch.object(requests.Session, "request")
    def test_403_raises_auth_error(self, mock_request):
        from providers.base import ProviderAuthError

        resp = MagicMock(status_code=403, headers={})
        mock_request.return_value = resp
        session = RetryingSession()

        with pytest.raises(ProviderAuthError, match="HTTP 403"):
            session.request("GET", "https://example.com")

    @pytest.mark.parametrize("status", [401, 403])
    @patch.object(requests.Session, "request")
    def test_the_status_travels_on_the_error(self, mock_request, status):
        """Callers recover differently from 401 and 403, and need to tell them apart.

        OpenSubtitles re-logs-in and retries once on 401, because its download
        token expires after 24h while search keeps working on the API key alone.
        On 403 it must not: the account simply may not have that file, and a
        second login only spends a request to be refused again.

        This assertion is the contract that makes that possible. Without the
        status on the exception the only discriminator is the message text, so
        rewording the line below would silently disable the recovery — and the
        provider's own tests, which build the exception themselves, would not
        notice.
        """
        from providers.base import ProviderAuthError

        mock_request.return_value = MagicMock(status_code=status, headers={})
        session = RetryingSession()

        with pytest.raises(ProviderAuthError) as excinfo:
            session.request("GET", "https://example.com")

        assert excinfo.value.status_code == status


# ---------------------------------------------------------------------------
# RetryingSession.request — X-RateLimit headers
# ---------------------------------------------------------------------------


class TestRetryingSessionRateLimitHeaders:
    @patch.object(requests.Session, "request")
    def test_low_remaining_sets_throttle(self, mock_request):
        resp = MagicMock(
            status_code=200,
            headers={
                "X-RateLimit-Remaining": "1",
                "X-RateLimit-Reset": "5",  # relative seconds
            },
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        assert session._rate_limit_until is not None
        assert session._rate_limit_until > time.time()

    @patch.object(requests.Session, "request")
    def test_remaining_above_1_no_throttle(self, mock_request):
        resp = MagicMock(
            status_code=200,
            headers={"X-RateLimit-Remaining": "10"},
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        assert session._rate_limit_until is None

    @patch.object(requests.Session, "request")
    def test_remaining_0_throttles(self, mock_request):
        resp = MagicMock(
            status_code=200,
            headers={"X-RateLimit-Remaining": "0"},
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        # Should set throttle with 5s default (no Reset header)
        assert session._rate_limit_until is not None

    @patch.object(requests.Session, "request")
    def test_unix_timestamp_reset(self, mock_request):
        """X-RateLimit-Reset as Unix timestamp (>1e9) -> absolute time."""
        future = time.time() + 60
        resp = MagicMock(
            status_code=200,
            headers={
                "X-RateLimit-Remaining": "0",
                "X-RateLimit-Reset": str(future),
            },
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        assert session._rate_limit_until is not None
        # Should be close to the future timestamp
        assert abs(session._rate_limit_until - future) < 2

    @patch.object(requests.Session, "request")
    def test_millisecond_timestamp_reset(self, mock_request):
        """X-RateLimit-Reset as millisecond timestamp (>1e12) -> converted."""
        future_ms = (time.time() + 60) * 1000
        resp = MagicMock(
            status_code=200,
            headers={
                "X-RateLimit-Remaining": "0",
                "X-RateLimit-Reset": str(future_ms),
            },
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        assert session._rate_limit_until is not None

    @patch.object(requests.Session, "request")
    def test_lowercase_header_names(self, mock_request):
        """Handles lowercase x-ratelimit-remaining header."""
        resp = MagicMock(
            status_code=200,
            headers={"x-ratelimit-remaining": "0", "x-ratelimit-reset": "10"},
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")

        assert session._rate_limit_until is not None

    @patch.object(requests.Session, "request")
    def test_non_numeric_remaining_ignored(self, mock_request):
        """Non-numeric X-RateLimit-Remaining is silently ignored."""
        resp = MagicMock(
            status_code=200,
            headers={"X-RateLimit-Remaining": "abc"},
        )
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")
        # No exception, no throttle set
        assert session._rate_limit_until is None

    @patch.object(requests.Session, "request")
    def test_200_without_ratelimit_headers(self, mock_request):
        """Normal 200 without rate limit headers -> no throttle."""
        resp = MagicMock(status_code=200, headers={})
        mock_request.return_value = resp
        session = RetryingSession()
        session.request("GET", "https://example.com")
        assert session._rate_limit_until is None


# ---------------------------------------------------------------------------
# Secret redaction (#212: the SubDL key reached sublarr.log via these lines)
# ---------------------------------------------------------------------------

_SECRET = "SECRETKEY1234567890"


class TestSecretRedaction:
    @pytest.mark.parametrize(
        "text",
        [
            f"https://api.subdl.com/api/v1/subtitles?api_key={_SECRET}&x=1",
            f"url: /api/v1/subtitles?subs_per_page=30&apikey={_SECRET}",
            f"https://h/p?token={_SECRET}",
            f"https://h/p?a=1&API_KEY={_SECRET}",
            f"https://h/p?access_token={_SECRET}&b=2",
        ],
    )
    def test_redact_url_secrets(self, text):
        from providers.http_session import redact_url_secrets

        redacted = redact_url_secrets(text)
        assert _SECRET not in redacted
        assert "***" in redacted

    def test_redact_keeps_other_params(self):
        from providers.http_session import redact_url_secrets

        assert redact_url_secrets("https://h/p?api_key=abc&film_name=x") == (
            "https://h/p?api_key=***&film_name=x"
        )

    @patch.object(requests.Session, "request")
    def test_connection_error_log_is_redacted(self, mock_request, caplog):
        mock_request.side_effect = requests.ConnectionError(
            f"Max retries exceeded with url: /api/v1/subtitles?api_key={_SECRET}&subs_per_page=30"
        )
        session = RetryingSession()
        caplog.set_level(logging.DEBUG)
        with pytest.raises(requests.ConnectionError):
            session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
        assert "Connection error" in caplog.text
        assert _SECRET not in caplog.text

    @patch.object(requests.Session, "request")
    def test_timeout_log_is_redacted(self, mock_request, caplog):
        mock_request.side_effect = requests.Timeout("timed out")
        session = RetryingSession()
        caplog.set_level(logging.DEBUG)
        with pytest.raises(requests.Timeout):
            session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
        assert _SECRET not in caplog.text

    @patch.object(requests.Session, "request")
    def test_rate_limit_message_is_redacted(self, mock_request, caplog):
        from providers.base import ProviderRateLimitError

        mock_request.return_value = MagicMock(status_code=429, headers={})
        session = RetryingSession()
        caplog.set_level(logging.DEBUG)
        with pytest.raises(ProviderRateLimitError) as excinfo:
            session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
        assert _SECRET not in str(excinfo.value)
        assert _SECRET not in caplog.text

    @patch.object(requests.Session, "request")
    def test_auth_error_message_is_redacted(self, mock_request):
        from providers.base import ProviderAuthError

        mock_request.return_value = MagicMock(status_code=401, headers={})
        session = RetryingSession()
        with pytest.raises(ProviderAuthError) as excinfo:
            session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
        assert _SECRET not in str(excinfo.value)

    def test_urllib3_retry_warning_is_redacted(self, caplog):
        import providers.http_session  # noqa: F401 — installs the filter

        caplog.set_level(logging.WARNING)
        logging.getLogger("urllib3.connectionpool").warning(
            "Retrying (%r) after connection broken by '%r': %s",
            "Retry(total=1)",
            "NewConnectionError",
            f"/api/v1/subtitles?api_key={_SECRET}&subs_per_page=30",
        )
        assert "Retrying" in caplog.text
        assert _SECRET not in caplog.text