|
| 1 | +"""Tests for integration URL construction in execute_api_call. |
| 2 | +
|
| 3 | +Covers the trailing-slash regression from #5138: a bare "/" path must |
| 4 | +resolve to the base URL itself, not base + "/". Discord webhook URLs |
| 5 | +404 on the trailing-slash variant, so api_call against a |
| 6 | +POST-to-base integration silently failed. |
| 7 | +""" |
| 8 | +import sys |
| 9 | +import types |
| 10 | +from unittest.mock import AsyncMock, MagicMock, patch |
| 11 | + |
| 12 | +import pytest |
| 13 | + |
| 14 | +# --------------------------------------------------------------------------- |
| 15 | +# Minimal stubs so src.integrations can be imported without heavy deps |
| 16 | +# --------------------------------------------------------------------------- |
| 17 | + |
| 18 | +for mod_name in ("core", "core.atomic_io", "core.platform_compat"): |
| 19 | + if mod_name not in sys.modules: |
| 20 | + sys.modules[mod_name] = types.ModuleType(mod_name) |
| 21 | + |
| 22 | +core_atomic = sys.modules["core.atomic_io"] |
| 23 | +if not hasattr(core_atomic, "atomic_write_json"): |
| 24 | + core_atomic.atomic_write_json = lambda *a, **kw: None # type: ignore |
| 25 | + |
| 26 | +core_compat = sys.modules["core.platform_compat"] |
| 27 | +if not hasattr(core_compat, "safe_chmod"): |
| 28 | + core_compat.safe_chmod = lambda *a, **kw: None # type: ignore |
| 29 | + |
| 30 | +if "src.secret_storage" not in sys.modules: |
| 31 | + stub = types.ModuleType("src.secret_storage") |
| 32 | + stub.encrypt = lambda s: s # type: ignore |
| 33 | + stub.decrypt = lambda s: s # type: ignore |
| 34 | + stub.is_encrypted = lambda s: False # type: ignore |
| 35 | + sys.modules["src.secret_storage"] = stub |
| 36 | + |
| 37 | +if "src.constants" not in sys.modules: |
| 38 | + stub_c = types.ModuleType("src.constants") |
| 39 | + stub_c.DATA_DIR = "/tmp" # type: ignore |
| 40 | + stub_c.INTEGRATIONS_FILE = "/tmp/integrations_test.json" # type: ignore |
| 41 | + stub_c.SETTINGS_FILE = "/tmp/settings_test.json" # type: ignore |
| 42 | + sys.modules["src.constants"] = stub_c |
| 43 | + |
| 44 | +from src import integrations # noqa: E402 |
| 45 | + |
| 46 | + |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | +# _join_integration_url unit tests |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | + |
| 51 | +WEBHOOK_BASE = "https://discord.com/api/webhooks/123/tokentokentoken" |
| 52 | + |
| 53 | + |
| 54 | +@pytest.mark.parametrize( |
| 55 | + "base,path,expected", |
| 56 | + [ |
| 57 | + # Bare "/" (the minimum path execute_api_call accepts) must not |
| 58 | + # grow a trailing slash — Discord webhooks 404 on it (#5138). |
| 59 | + (WEBHOOK_BASE, "/", WEBHOOK_BASE), |
| 60 | + (WEBHOOK_BASE + "/", "/", WEBHOOK_BASE), |
| 61 | + (WEBHOOK_BASE, "", WEBHOOK_BASE), |
| 62 | + # Normal paths keep joining exactly as before. |
| 63 | + ("http://api.example.com", "/items", "http://api.example.com/items"), |
| 64 | + ("http://api.example.com/", "/items", "http://api.example.com/items"), |
| 65 | + ("http://host/base", "/v1/me", "http://host/base/v1/me"), |
| 66 | + # A deliberate trailing slash inside a non-empty path is preserved |
| 67 | + # (e.g. linkding's /api/tags/, Home Assistant's /api/). |
| 68 | + ("http://host", "/api/tags/", "http://host/api/tags/"), |
| 69 | + ("http://host", "/api/", "http://host/api/"), |
| 70 | + ], |
| 71 | +) |
| 72 | +def test_join_integration_url(base, path, expected): |
| 73 | + assert integrations._join_integration_url(base, path) == expected |
| 74 | + |
| 75 | + |
| 76 | +# --------------------------------------------------------------------------- |
| 77 | +# Behavioral test through execute_api_call |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | + |
| 80 | +DISCORD_INTEGRATION = { |
| 81 | + "id": "discord_test", |
| 82 | + "name": "Discord Webhook", |
| 83 | + "enabled": True, |
| 84 | + "base_url": WEBHOOK_BASE, |
| 85 | + "auth_type": "none", |
| 86 | + "api_key": "", |
| 87 | + "auth_header": "", |
| 88 | + "auth_param": "", |
| 89 | + "description": "", |
| 90 | + "preset": "discord_webhook", |
| 91 | +} |
| 92 | + |
| 93 | + |
| 94 | +@pytest.mark.asyncio |
| 95 | +async def test_api_call_root_path_has_no_trailing_slash(): |
| 96 | + mock_resp = MagicMock() |
| 97 | + mock_resp.status_code = 204 |
| 98 | + mock_resp.headers = {"content-type": "text/plain"} |
| 99 | + mock_resp.text = "" |
| 100 | + |
| 101 | + mock_client = AsyncMock() |
| 102 | + mock_client.__aenter__ = AsyncMock(return_value=mock_client) |
| 103 | + mock_client.__aexit__ = AsyncMock(return_value=None) |
| 104 | + mock_client.request = AsyncMock(return_value=mock_resp) |
| 105 | + |
| 106 | + with ( |
| 107 | + patch.object(integrations, "_find_integration", return_value=DISCORD_INTEGRATION), |
| 108 | + patch("httpx.AsyncClient", return_value=mock_client), |
| 109 | + ): |
| 110 | + result = await integrations.execute_api_call( |
| 111 | + "discord_test", "POST", "/", body={"content": "test"} |
| 112 | + ) |
| 113 | + |
| 114 | + assert result.get("exit_code") == 0 |
| 115 | + requested_url = mock_client.request.call_args.args[1] |
| 116 | + assert requested_url == WEBHOOK_BASE |
0 commit comments