Files
mcphub/tests/test_site_tools_api.py
airano-ir d8a0805412
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(F.7b): tool access UI + unified keys page (v3.9.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:00:42 +02:00

265 lines
9.4 KiB
Python

"""Integration tests for the per-site tool visibility API (F.7b).
Exercises the five routes registered in server.py:
* ``GET /api/sites/{site_id}/tools``
* ``PATCH /api/sites/{site_id}/tools/{tool_name}``
* ``POST /api/sites/{site_id}/tools/bulk-toggle``
* ``PATCH /api/sites/{site_id}/tool-scope``
* ``GET /api/scope-presets``
Uses the real ``create_multi_endpoint_app()`` so routes wire to the actual
Coolify tool registry. User session auth is stubbed.
"""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
import core.dashboard.routes as routes_module
import core.database as db_module
import core.tool_access as tool_access_module
from core.database import Database
@pytest.fixture
async def patched_db(tmp_path, monkeypatch):
database = Database(str(tmp_path / "api.db"))
await database.initialize()
monkeypatch.setattr(db_module, "_database", database)
yield database
await database.close()
monkeypatch.setattr(db_module, "_database", None)
@pytest.fixture
def patched_access(monkeypatch):
monkeypatch.setattr(tool_access_module, "_manager", None)
@pytest.fixture
async def user_row(patched_db):
return await patched_db.create_user(
email="api@example.com",
name="api",
provider="github",
provider_id="gh-api-user",
)
@pytest.fixture
async def coolify_site(patched_db, user_row):
return await patched_db.create_site(
user_id=user_row["id"],
plugin_type="coolify",
alias="prod",
url="https://coolify.example.com",
credentials=b"x",
)
@pytest.fixture
async def other_user_site(patched_db):
"""A site belonging to a different user — for ownership-check tests."""
other = await patched_db.create_user(
email="other@example.com",
name="other",
provider="github",
provider_id="gh-other",
)
return await patched_db.create_site(
user_id=other["id"],
plugin_type="coolify",
alias="theirs",
url="https://other.example.com",
credentials=b"x",
)
@pytest.fixture
def client(monkeypatch, user_row, patched_db, patched_access):
"""Build the Starlette app and patch user session auth.
Also pre-sets a matching CSRF cookie + default X-CSRF-Token header so that
mutating requests to ``/api/sites/*`` bypass the Double-Submit CSRF guard
in ``DashboardCSRFMiddleware``.
"""
from server import create_multi_endpoint_app
def fake_require_user_session(_request):
return {"user_id": user_row["id"], "type": "user"}, None
monkeypatch.setattr(routes_module, "_require_user_session", fake_require_user_session)
app = create_multi_endpoint_app()
tc = TestClient(app)
tc.cookies.set("dashboard_csrf", "test-csrf-token")
tc.headers.update({"x-csrf-token": "test-csrf-token"})
return tc
# ---------------------------------------------------------------------------
# GET /api/sites/{site_id}/tools
# ---------------------------------------------------------------------------
class TestListSiteTools:
def test_returns_plugin_tools_all_enabled(self, client, coolify_site):
resp = client.get(f"/api/sites/{coolify_site['id']}/tools")
assert resp.status_code == 200
body = resp.json()
assert body["site_id"] == coolify_site["id"]
assert body["plugin_type"] == "coolify"
assert body["tool_scope"] == "admin"
tools = body["tools"]
by_name = {t["name"]: t for t in tools}
assert "coolify_list_applications" in by_name
assert "coolify_delete_server" in by_name
assert all(t["enabled"] for t in tools)
def test_carries_category_and_sensitivity(self, client, coolify_site):
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t for t in tools}
assert by_name["coolify_list_applications"]["category"] == "read"
assert by_name["coolify_delete_server"]["category"] == "system"
assert by_name["coolify_get_application_logs"]["sensitivity"] == "sensitive"
def test_only_own_sites_visible(self, client, other_user_site):
"""A site owned by a different user must 404."""
resp = client.get(f"/api/sites/{other_user_site['id']}/tools")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /api/sites/{site_id}/tools/{tool_name}
# ---------------------------------------------------------------------------
class TestPatchSiteTool:
def test_disable_reflected_in_list(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
json={"enabled": False, "reason": "not needed"},
)
assert r.status_code == 200
assert r.json()["enabled"] is False
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is False
assert by_name["coolify_start_application"] is True
def test_reenable_round_trip(self, client, coolify_site):
base = f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications"
client.patch(base, json={"enabled": False})
client.patch(base, json={"enabled": True})
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is True
def test_unknown_tool_404(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_nonsense",
json={"enabled": False},
)
assert r.status_code == 404
def test_wrong_plugin_400(self, client, coolify_site):
"""Tool from another plugin should be rejected."""
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/wordpress_list_posts",
json={"enabled": False},
)
assert r.status_code in (400, 404)
def test_missing_enabled_400(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
json={},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/sites/{site_id}/tools/bulk-toggle
# ---------------------------------------------------------------------------
class TestBulkToggle:
def test_bulk_disable_deploy_scope(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "deploy", "enabled": False},
)
assert r.status_code == 200
assert r.json()["affected"] >= 5
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is False
assert by_name["coolify_start_application"] is False
assert by_name["coolify_create_application_public"] is True
assert by_name["coolify_delete_server"] is True
def test_unknown_scope_400(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "bogus", "enabled": False},
)
assert r.status_code == 400
def test_bad_body_400(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "read"},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# PATCH /api/sites/{site_id}/tool-scope
# ---------------------------------------------------------------------------
class TestSetSiteToolScope:
def test_set_read_scope(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": "read"},
)
assert r.status_code == 200
assert r.json()["tool_scope"] == "read"
listing = client.get(f"/api/sites/{coolify_site['id']}/tools").json()
assert listing["tool_scope"] == "read"
def test_invalid_scope_400(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": "superadmin"},
)
assert r.status_code == 400
def test_accepts_all_known_presets(self, client, coolify_site):
for scope in ("read", "read:sensitive", "deploy", "write", "admin", "custom"):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": scope},
)
assert r.status_code == 200, scope
# ---------------------------------------------------------------------------
# GET /api/scope-presets
# ---------------------------------------------------------------------------
class TestScopePresets:
def test_returns_all_scopes(self, client):
body = client.get("/api/scope-presets").json()
assert "presets" in body
presets = body["presets"]
assert set(presets.keys()) == {"read", "read:sensitive", "deploy", "write", "admin"}
assert "read" in presets["read"]
assert "system" in presets["admin"]