|
10 | 10 |
|
11 | 11 | # Standard |
12 | 12 | import time |
| 13 | +from datetime import datetime, timedelta |
13 | 14 | from unittest.mock import AsyncMock, MagicMock, patch |
14 | 15 |
|
15 | 16 | # Third-Party |
@@ -591,3 +592,165 @@ async def _mark_side_effect(url, poll_type): |
591 | 592 |
|
592 | 593 | # Exception from mark_poll_completed(tool_discovery) must not propagate |
593 | 594 | await gateway_service._check_single_gateway_health(mock_gateway, user_email="test@example.com") |
| 595 | + |
| 596 | + |
| 597 | +def _make_http_client_context_manager(): |
| 598 | + """Build the async context manager chain used by tests that go through get_isolated_http_client.""" |
| 599 | + mock_response = MagicMock() |
| 600 | + mock_response.status_code = 200 |
| 601 | + mock_response.raise_for_status = MagicMock() |
| 602 | + mock_stream_cm = MagicMock() |
| 603 | + mock_stream_cm.__aenter__ = AsyncMock(return_value=mock_response) |
| 604 | + mock_stream_cm.__aexit__ = AsyncMock(return_value=False) |
| 605 | + mock_http_client = MagicMock() |
| 606 | + mock_http_client.stream = MagicMock(return_value=mock_stream_cm) |
| 607 | + mock_client_cm = MagicMock() |
| 608 | + mock_client_cm.__aenter__ = AsyncMock(return_value=mock_http_client) |
| 609 | + mock_client_cm.__aexit__ = AsyncMock(return_value=False) |
| 610 | + return mock_client_cm |
| 611 | + |
| 612 | + |
| 613 | +class TestBranchSpecificMissingLines: |
| 614 | + """Tests that cover lines identified as missing in this branch's changes.""" |
| 615 | + |
| 616 | + # ------------------------------------------------------------------ |
| 617 | + # Line 3590: last_refresh.replace(tzinfo=timezone.utc) — naive datetime |
| 618 | + # inside the new `should_auto_refresh` gate added by this branch. |
| 619 | + # ------------------------------------------------------------------ |
| 620 | + |
| 621 | + @pytest.mark.asyncio |
| 622 | + async def test_auto_refresh_naive_last_refresh_at_triggers_tzinfo_fix(self, gateway_service_with_classification, monkeypatch): |
| 623 | + """Naive datetime in last_refresh_at gets UTC tzinfo applied before comparison (line 3590).""" |
| 624 | + monkeypatch.setattr("mcpgateway.services.gateway_service.settings.auto_refresh_servers", True) |
| 625 | + monkeypatch.setattr("mcpgateway.services.gateway_service.settings.gateway_auto_refresh_interval", 300) |
| 626 | + |
| 627 | + gateway_service, mock_classification = gateway_service_with_classification |
| 628 | + # Disable classification so should_auto_refresh=True via the unconditional else branch |
| 629 | + gateway_service._classification_service = None |
| 630 | + |
| 631 | + mock_gateway = _make_mock_gateway(url="http://test-server:8000", name="test-gateway") |
| 632 | + # Naive datetime (no tzinfo), old enough that time_since_refresh > 300s |
| 633 | + mock_gateway.last_refresh_at = datetime.utcnow() - timedelta(hours=2) |
| 634 | + mock_gateway.refresh_interval_seconds = None |
| 635 | + |
| 636 | + mock_refresh = AsyncMock(return_value={"added": 0, "updated": 0, "removed": 0}) |
| 637 | + |
| 638 | + with ( |
| 639 | + patch.object(gateway_service, "_refresh_gateway_tools_resources_prompts", mock_refresh), |
| 640 | + patch("mcpgateway.services.gateway_service.fresh_db_session") as mock_fresh_db, |
| 641 | + patch("mcpgateway.services.gateway_service.get_isolated_http_client") as mock_get_client, |
| 642 | + ): |
| 643 | + mock_session = MagicMock() |
| 644 | + mock_fresh_db.return_value.__enter__.return_value = mock_session |
| 645 | + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_gateway |
| 646 | + mock_session.commit = MagicMock() |
| 647 | + mock_get_client.return_value = _make_http_client_context_manager() |
| 648 | + |
| 649 | + # Should not raise; tzinfo fix is applied and refresh proceeds |
| 650 | + await gateway_service._check_single_gateway_health(mock_gateway, user_email="test@example.com") |
| 651 | + |
| 652 | + # Refresh must have been called (naive datetime was handled correctly) |
| 653 | + mock_refresh.assert_awaited_once() |
| 654 | + |
| 655 | + @pytest.mark.asyncio |
| 656 | + async def test_auto_refresh_naive_last_refresh_at_within_interval_skips_refresh(self, gateway_service_with_classification, monkeypatch): |
| 657 | + """Naive datetime recent enough to skip refresh is still handled without error (line 3590 + throttle).""" |
| 658 | + monkeypatch.setattr("mcpgateway.services.gateway_service.settings.auto_refresh_servers", True) |
| 659 | + monkeypatch.setattr("mcpgateway.services.gateway_service.settings.gateway_auto_refresh_interval", 3600) |
| 660 | + |
| 661 | + gateway_service, mock_classification = gateway_service_with_classification |
| 662 | + gateway_service._classification_service = None |
| 663 | + |
| 664 | + mock_gateway = _make_mock_gateway(url="http://test-server:8000", name="test-gateway") |
| 665 | + # Naive datetime, refreshed 30 seconds ago (within 3600s interval) |
| 666 | + mock_gateway.last_refresh_at = datetime.utcnow() - timedelta(seconds=30) |
| 667 | + mock_gateway.refresh_interval_seconds = None |
| 668 | + |
| 669 | + mock_refresh = AsyncMock(return_value={"added": 0, "updated": 0, "removed": 0}) |
| 670 | + |
| 671 | + with ( |
| 672 | + patch.object(gateway_service, "_refresh_gateway_tools_resources_prompts", mock_refresh), |
| 673 | + patch("mcpgateway.services.gateway_service.fresh_db_session") as mock_fresh_db, |
| 674 | + patch("mcpgateway.services.gateway_service.get_isolated_http_client") as mock_get_client, |
| 675 | + ): |
| 676 | + mock_session = MagicMock() |
| 677 | + mock_fresh_db.return_value.__enter__.return_value = mock_session |
| 678 | + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_gateway |
| 679 | + mock_session.commit = MagicMock() |
| 680 | + mock_get_client.return_value = _make_http_client_context_manager() |
| 681 | + |
| 682 | + await gateway_service._check_single_gateway_health(mock_gateway, user_email="test@example.com") |
| 683 | + |
| 684 | + # Not due for refresh yet |
| 685 | + mock_refresh.assert_not_awaited() |
| 686 | + |
| 687 | + # ------------------------------------------------------------------ |
| 688 | + # Lines 3344-3345: decode_auth exception on query_param auth type |
| 689 | + # Inside _check_single_gateway_health, modified by this branch. |
| 690 | + # ------------------------------------------------------------------ |
| 691 | + |
| 692 | + @pytest.mark.asyncio |
| 693 | + async def test_health_check_query_param_decryption_failure_proceeds_fail_open(self, gateway_service_with_classification): |
| 694 | + """Health check proceeds when query-param auth decryption raises an exception (lines 3344-3345).""" |
| 695 | + gateway_service, mock_classification = gateway_service_with_classification |
| 696 | + mock_classification.should_poll_server = AsyncMock(return_value=True) |
| 697 | + mock_classification.mark_poll_completed = AsyncMock() |
| 698 | + |
| 699 | + mock_gateway = _make_mock_gateway(url="http://test-server:8000", name="test-gateway") |
| 700 | + mock_gateway.auth_type = "query_param" |
| 701 | + mock_gateway.auth_query_params = {"api_key": "badly_encrypted_value"} |
| 702 | + |
| 703 | + with ( |
| 704 | + patch("mcpgateway.services.gateway_service.decode_auth", side_effect=Exception("Decryption failed")), |
| 705 | + patch("mcpgateway.services.gateway_service.fresh_db_session") as mock_fresh_db, |
| 706 | + patch("mcpgateway.services.gateway_service.get_isolated_http_client") as mock_get_client, |
| 707 | + ): |
| 708 | + mock_session = MagicMock() |
| 709 | + mock_fresh_db.return_value.__enter__.return_value = mock_session |
| 710 | + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_gateway |
| 711 | + mock_session.commit = MagicMock() |
| 712 | + mock_get_client.return_value = _make_http_client_context_manager() |
| 713 | + |
| 714 | + # Must not raise; decryption failure is silently logged (line 3344-3345) |
| 715 | + await gateway_service._check_single_gateway_health(mock_gateway, user_email="test@example.com") |
| 716 | + |
| 717 | + # Health check still executed (get_isolated_http_client was called) |
| 718 | + mock_get_client.assert_called_once() |
| 719 | + |
| 720 | + # ------------------------------------------------------------------ |
| 721 | + # Line 3398: ssl_context = None (else branch) for https URL with no CA cert |
| 722 | + # Inside _check_single_gateway_health, modified by this branch. |
| 723 | + # ------------------------------------------------------------------ |
| 724 | + |
| 725 | + @pytest.mark.asyncio |
| 726 | + async def test_health_check_https_url_no_ca_cert_ssl_context_none(self, gateway_service_with_classification): |
| 727 | + """ssl_context is None for https URL with no CA certificate (line 3398 else branch).""" |
| 728 | + gateway_service, mock_classification = gateway_service_with_classification |
| 729 | + mock_classification.should_poll_server = AsyncMock(return_value=True) |
| 730 | + mock_classification.mark_poll_completed = AsyncMock() |
| 731 | + |
| 732 | + # https URL + no CA certificate → falls into the else: ssl_context = None branch (line 3398) |
| 733 | + mock_gateway = _make_mock_gateway(url="https://secure-server:8443", name="test-gateway") |
| 734 | + # ca_certificate is None by default in _make_mock_gateway — no valid cert, no ssl override |
| 735 | + |
| 736 | + ssl_context_captured = {} |
| 737 | + |
| 738 | + original_get_isolated = __import__("mcpgateway.services.gateway_service", fromlist=["get_isolated_http_client"]) |
| 739 | + |
| 740 | + def capture_ssl_context(*args, **kwargs): |
| 741 | + ssl_context_captured["verify"] = kwargs.get("verify") |
| 742 | + return _make_http_client_context_manager() |
| 743 | + |
| 744 | + with ( |
| 745 | + patch("mcpgateway.services.gateway_service.fresh_db_session") as mock_fresh_db, |
| 746 | + patch("mcpgateway.services.gateway_service.get_isolated_http_client", side_effect=capture_ssl_context), |
| 747 | + ): |
| 748 | + mock_session = MagicMock() |
| 749 | + mock_fresh_db.return_value.__enter__.return_value = mock_session |
| 750 | + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_gateway |
| 751 | + mock_session.commit = MagicMock() |
| 752 | + |
| 753 | + await gateway_service._check_single_gateway_health(mock_gateway, user_email="test@example.com") |
| 754 | + |
| 755 | + # ssl_context should be None (the else branch at line 3398) |
| 756 | + assert ssl_context_captured.get("verify") is None |
0 commit comments