mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
fix(integrations): don't append a trailing slash when api_call path is '/'
_join_integration_url built urljoin(base + '/', '') for a bare '/'
path — the minimum execute_api_call accepts — so every request against
a POST-to-base integration went to base_url + '/'. Discord webhook
URLs 404 ('Unknown Webhook') on the trailing-slash variant, which made
the integration look broken even though the stored base URL was
correct.
Resolve a bare '/' (or empty) path to the base URL itself and keep all
other paths joining exactly as before, including deliberate trailing
slashes inside non-empty paths (linkding /api/tags/, Home Assistant
/api/). The reminder webhook sender and the discord_webhook
connectivity test already posted to the bare base URL; execute_api_call
was the remaining path that re-added the slash.
Fixes #5138
This commit is contained in:
+8
-1
@@ -216,7 +216,14 @@ def _normalize_integration_base_url(base_url: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _join_integration_url(base_url: str, path: str) -> str:
|
def _join_integration_url(base_url: str, path: str) -> str:
|
||||||
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
base = base_url.rstrip("/")
|
||||||
|
rel = path.lstrip("/")
|
||||||
|
if not rel:
|
||||||
|
# A bare "/" must resolve to the base URL itself, not base + "/".
|
||||||
|
# POST-to-base integrations (e.g. Discord webhooks) 404 on the
|
||||||
|
# trailing-slash variant of their URL.
|
||||||
|
return base
|
||||||
|
return urljoin(base + "/", rel)
|
||||||
|
|
||||||
|
|
||||||
def load_integrations() -> List[Dict[str, Any]]:
|
def load_integrations() -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Tests for integration URL construction in execute_api_call.
|
||||||
|
|
||||||
|
Covers the trailing-slash regression from #5138: a bare "/" path must
|
||||||
|
resolve to the base URL itself, not base + "/". Discord webhook URLs
|
||||||
|
404 on the trailing-slash variant, so api_call against a
|
||||||
|
POST-to-base integration silently failed.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal stubs so src.integrations can be imported without heavy deps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
for mod_name in ("core", "core.atomic_io", "core.platform_compat"):
|
||||||
|
if mod_name not in sys.modules:
|
||||||
|
sys.modules[mod_name] = types.ModuleType(mod_name)
|
||||||
|
|
||||||
|
core_atomic = sys.modules["core.atomic_io"]
|
||||||
|
if not hasattr(core_atomic, "atomic_write_json"):
|
||||||
|
core_atomic.atomic_write_json = lambda *a, **kw: None # type: ignore
|
||||||
|
|
||||||
|
core_compat = sys.modules["core.platform_compat"]
|
||||||
|
if not hasattr(core_compat, "safe_chmod"):
|
||||||
|
core_compat.safe_chmod = lambda *a, **kw: None # type: ignore
|
||||||
|
|
||||||
|
if "src.secret_storage" not in sys.modules:
|
||||||
|
stub = types.ModuleType("src.secret_storage")
|
||||||
|
stub.encrypt = lambda s: s # type: ignore
|
||||||
|
stub.decrypt = lambda s: s # type: ignore
|
||||||
|
stub.is_encrypted = lambda s: False # type: ignore
|
||||||
|
sys.modules["src.secret_storage"] = stub
|
||||||
|
|
||||||
|
if "src.constants" not in sys.modules:
|
||||||
|
stub_c = types.ModuleType("src.constants")
|
||||||
|
stub_c.DATA_DIR = "/tmp" # type: ignore
|
||||||
|
stub_c.INTEGRATIONS_FILE = "/tmp/integrations_test.json" # type: ignore
|
||||||
|
stub_c.SETTINGS_FILE = "/tmp/settings_test.json" # type: ignore
|
||||||
|
sys.modules["src.constants"] = stub_c
|
||||||
|
|
||||||
|
from src import integrations # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _join_integration_url unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WEBHOOK_BASE = "https://discord.com/api/webhooks/123/tokentokentoken"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"base,path,expected",
|
||||||
|
[
|
||||||
|
# Bare "/" (the minimum path execute_api_call accepts) must not
|
||||||
|
# grow a trailing slash — Discord webhooks 404 on it (#5138).
|
||||||
|
(WEBHOOK_BASE, "/", WEBHOOK_BASE),
|
||||||
|
(WEBHOOK_BASE + "/", "/", WEBHOOK_BASE),
|
||||||
|
(WEBHOOK_BASE, "", WEBHOOK_BASE),
|
||||||
|
# Normal paths keep joining exactly as before.
|
||||||
|
("http://api.example.com", "/items", "http://api.example.com/items"),
|
||||||
|
("http://api.example.com/", "/items", "http://api.example.com/items"),
|
||||||
|
("http://host/base", "/v1/me", "http://host/base/v1/me"),
|
||||||
|
# A deliberate trailing slash inside a non-empty path is preserved
|
||||||
|
# (e.g. linkding's /api/tags/, Home Assistant's /api/).
|
||||||
|
("http://host", "/api/tags/", "http://host/api/tags/"),
|
||||||
|
("http://host", "/api/", "http://host/api/"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_join_integration_url(base, path, expected):
|
||||||
|
assert integrations._join_integration_url(base, path) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Behavioral test through execute_api_call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DISCORD_INTEGRATION = {
|
||||||
|
"id": "discord_test",
|
||||||
|
"name": "Discord Webhook",
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": WEBHOOK_BASE,
|
||||||
|
"auth_type": "none",
|
||||||
|
"api_key": "",
|
||||||
|
"auth_header": "",
|
||||||
|
"auth_param": "",
|
||||||
|
"description": "",
|
||||||
|
"preset": "discord_webhook",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_call_root_path_has_no_trailing_slash():
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 204
|
||||||
|
mock_resp.headers = {"content-type": "text/plain"}
|
||||||
|
mock_resp.text = ""
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.request = AsyncMock(return_value=mock_resp)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(integrations, "_find_integration", return_value=DISCORD_INTEGRATION),
|
||||||
|
patch("httpx.AsyncClient", return_value=mock_client),
|
||||||
|
):
|
||||||
|
result = await integrations.execute_api_call(
|
||||||
|
"discord_test", "POST", "/", body={"content": "test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.get("exit_code") == 0
|
||||||
|
requested_url = mock_client.request.call_args.args[1]
|
||||||
|
assert requested_url == WEBHOOK_BASE
|
||||||
|
assert not requested_url.endswith("/")
|
||||||
Reference in New Issue
Block a user