Skip to content

Commit a052215

Browse files
authored
Merge pull request #5181 from harshit-ojha0324/fix/webhook-trailing-slash
fix(integrations): don't append a trailing slash when api_call path is '/'
2 parents d8d98ca + a50e30c commit a052215

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

src/integrations.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,14 @@ def _normalize_integration_base_url(base_url: Any) -> str:
216216

217217

218218
def _join_integration_url(base_url: str, path: str) -> str:
219-
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
219+
base = base_url.rstrip("/")
220+
rel = path.lstrip("/")
221+
if not rel:
222+
# A bare "/" must resolve to the base URL itself, not base + "/".
223+
# POST-to-base integrations (e.g. Discord webhooks) 404 on the
224+
# trailing-slash variant of their URL.
225+
return base
226+
return urljoin(base + "/", rel)
220227

221228

222229
def load_integrations() -> List[Dict[str, Any]]:
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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

Comments
 (0)