feat(F.7b): tool access UI + unified keys page (v3.9.0)
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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 21:00:42 +02:00
parent 3fc02b5734
commit d8a0805412
30 changed files with 2965 additions and 72 deletions

View File

@@ -374,37 +374,15 @@ class TestDashboardCookieManagement:
def test_dashboard_connect_page(monkeypatch):
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
from server import create_multi_endpoint_app
from starlette.testclient import TestClient
import core.dashboard.routes
import core.site_api
import core.user_keys
app = create_multi_endpoint_app()
client = TestClient(app)
def mock_req(*args):
return {"user_id": "abc", "type": "user"}, None
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
async def mock_sites(*args):
return [{"alias": "Test", "plugin_type": "dummy"}]
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
class MockKeyMgr:
async def list_keys(self, *a):
return [
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
]
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
client = TestClient(app, follow_redirects=False)
resp = client.get("/dashboard/connect")
assert resp.status_code == 200
assert "Test" in resp.text
assert "Key" in resp.text
# /dashboard/connect now redirects 301 to /dashboard/keys
assert resp.status_code == 301
assert "/dashboard/keys" in resp.headers["location"]

View File

@@ -0,0 +1,81 @@
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
import core.dashboard.routes as routes_module
import core.database as db_module
from core.database import Database
@pytest.fixture
async def patched_db(tmp_path, monkeypatch):
database = Database(str(tmp_path / "keys.db"))
await database.initialize()
monkeypatch.setattr(db_module, "_database", database)
yield database
await database.close()
monkeypatch.setattr(db_module, "_database", None)
@pytest.fixture
async def user_row(patched_db):
return await patched_db.create_user(
email="keys@example.com",
name="keysuser",
provider="github",
provider_id="gh-keys-user",
)
@pytest.fixture
def user_client(monkeypatch, user_row, patched_db):
from server import create_multi_endpoint_app
def fake_user_session(_request):
return {"user_id": user_row["id"], "type": "user"}, None
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
# Also patch auth so dashboard_keys_unified finds the user session
class FakeAuth:
def get_session_from_request(self, _r):
return None # not admin
def get_user_session_from_request(self, _r):
return {"user_id": user_row["id"], "type": "user"}
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
app = create_multi_endpoint_app()
return TestClient(app, follow_redirects=False)
class TestUnifiedKeysUserView:
def test_get_keys_page_returns_200(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
assert "API Key" in r.text or "کلید" in r.text
def test_old_connect_redirects_301(self, user_client):
r = user_client.get("/dashboard/connect")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
def test_old_api_keys_redirects_301(self, user_client):
r = user_client.get("/dashboard/api-keys")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
def test_user_view_has_scope_selector(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
# Scope dropdown options should be present
assert "read:sensitive" in r.text or "Read" in r.text
def test_user_view_shows_create_button(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
assert "Create" in r.text or "ایجاد" in r.text

View File

@@ -538,6 +538,72 @@ class TestEmptyResults:
# ---------------------------------------------------------------------------
class TestSiteToolToggles:
"""F.7b: per-site tool toggle and tool_scope helpers."""
@pytest.mark.unit
async def test_empty_toggles_by_default(self, db, site_row):
assert await db.get_site_tool_toggles(site_row["id"]) == {}
@pytest.mark.unit
async def test_set_and_get_toggle(self, db, site_row):
await db.set_site_tool_toggle(
site_row["id"], "coolify_list_applications", False, reason="not needed"
)
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {"coolify_list_applications": False}
@pytest.mark.unit
async def test_toggle_is_upsert(self, db, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", True)
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {"coolify_get_server": True}
@pytest.mark.unit
async def test_delete_toggle(self, db, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
removed = await db.delete_site_tool_toggle(site_row["id"], "coolify_get_server")
assert removed is True
assert await db.get_site_tool_toggles(site_row["id"]) == {}
@pytest.mark.unit
async def test_bulk_set(self, db, site_row):
n = await db.bulk_set_site_tool_toggles(
site_row["id"],
[("coolify_list_applications", False), ("coolify_start_application", False)],
reason="bulk:deploy",
)
assert n == 2
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {
"coolify_list_applications": False,
"coolify_start_application": False,
}
@pytest.mark.unit
async def test_toggle_cascades_on_site_delete(self, db, user_row, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
await db.delete_site(site_row["id"], user_row["id"])
rows = await db.fetchall(
"SELECT * FROM site_tool_toggles WHERE site_id = ?", (site_row["id"],)
)
assert rows == []
@pytest.mark.unit
async def test_default_tool_scope_is_admin(self, db, site_row):
assert await db.get_site_tool_scope(site_row["id"]) == "admin"
@pytest.mark.unit
async def test_set_tool_scope(self, db, site_row):
await db.set_site_tool_scope(site_row["id"], "read")
assert await db.get_site_tool_scope(site_row["id"]) == "read"
@pytest.mark.unit
async def test_unknown_site_tool_scope_defaults_admin(self, db):
assert await db.get_site_tool_scope("does-not-exist") == "admin"
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""

View File

@@ -0,0 +1,264 @@
"""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"]

View File

@@ -0,0 +1,110 @@
"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2)."""
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 / "ui.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="ui@example.com",
name="uitester",
provider="github",
provider_id="gh-ui-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="ui-prod",
url="https://coolify.example.com",
credentials=b"x",
)
@pytest.fixture
def client(monkeypatch, user_row, patched_db, patched_access):
from server import create_multi_endpoint_app
def fake_user_session(_request):
return {"user_id": user_row["id"], "type": "user"}, None
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
# Patch auth for dashboard_keys_unified
class FakeAuth:
def get_session_from_request(self, _r):
return None
def get_user_session_from_request(self, _r):
return {"user_id": user_row["id"], "type": "user"}
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
app = create_multi_endpoint_app()
tc = TestClient(app, follow_redirects=False)
tc.cookies.set("dashboard_csrf", "test-csrf")
tc.headers.update({"x-csrf-token": "test-csrf"})
return tc
class TestSitesEditToolAccess:
def test_edit_page_renders_without_500(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
assert r.status_code == 200
def test_edit_page_contains_tool_access_card(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
assert r.status_code == 200
assert "tool-access-card" in r.text or "Tool Access" in r.text
def test_edit_page_has_scope_select(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
assert r.status_code == 200
assert "tool-scope-select" in r.text
class TestSitesViewPage:
def test_view_page_renders_without_500(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
def test_view_page_shows_mcp_url(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "mcp-url" in r.text
assert coolify_site["alias"] in r.text
def test_view_page_has_client_selector(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "config-client" in r.text
def test_nonexistent_site_redirects(self, client):
r = client.get("/dashboard/sites/nonexistent-id")
assert r.status_code in (302, 303)

364
tests/test_tool_access.py Normal file
View File

@@ -0,0 +1,364 @@
"""Tests for site-scoped tool visibility & per-site toggles (F.7b).
Covers:
* ``ToolDefinition`` backward-compatible defaults
* ``ToolAccessManager.apply_scope_filter`` for each scope
* Per-site toggle filtering
* Site-level ``tool_scope`` preset as a second restrictive layer
* ``bulk_toggle_by_scope`` scoped to a single plugin
"""
from __future__ import annotations
from collections.abc import AsyncGenerator, Generator
import pytest
import core.database as db_module
import core.tool_access as tool_access_module
import core.tool_registry as tool_registry_module
from core.database import Database
from core.tool_access import (
SCOPE_TO_CATEGORIES,
ToolAccessManager,
scopes_to_categories,
)
from core.tool_registry import ToolDefinition, ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
async def _noop_handler(**_kwargs):
return "ok"
def _make_tool(name: str, category: str = "read", plugin_type: str = "coolify") -> ToolDefinition:
return ToolDefinition(
name=name,
description=f"desc {name}",
input_schema={"type": "object", "properties": {}},
handler=_noop_handler,
required_scope="read",
plugin_type=plugin_type,
category=category,
)
_SAMPLE_TOOLS: list[ToolDefinition] = [
_make_tool("coolify_list_applications", "read"),
_make_tool("coolify_get_application_logs", "read_sensitive"),
_make_tool("coolify_start_application", "lifecycle"),
_make_tool("coolify_stop_application", "lifecycle"),
_make_tool("coolify_create_application_public", "crud"),
_make_tool("coolify_create_application_env", "env"),
_make_tool("coolify_get_database_backups", "backup"),
_make_tool("coolify_delete_server", "system"),
# Legacy plugin tool without a category annotation (defaults to "read").
_make_tool("wordpress_list_posts", "read", plugin_type="wordpress"),
]
@pytest.fixture
def fresh_registry(monkeypatch) -> Generator[ToolRegistry, None, None]:
"""Install a clean ToolRegistry populated with _SAMPLE_TOOLS."""
registry = ToolRegistry()
for tool in _SAMPLE_TOOLS:
registry.register(tool)
monkeypatch.setattr(tool_registry_module, "_tool_registry", registry)
yield registry
@pytest.fixture
async def db(tmp_path, monkeypatch) -> AsyncGenerator[Database, None]:
path = str(tmp_path / "toolacc.db")
database = Database(path)
await database.initialize()
monkeypatch.setattr(db_module, "_database", database)
yield database
await database.close()
monkeypatch.setattr(db_module, "_database", None)
@pytest.fixture
def access_mgr(monkeypatch) -> ToolAccessManager:
monkeypatch.setattr(tool_access_module, "_manager", None)
return ToolAccessManager()
@pytest.fixture
async def coolify_site(db):
user = await db.create_user(
email="toolacc@example.com",
name="ToolAcc",
provider="github",
provider_id="gh-toolacc-1",
)
return await db.create_site(
user_id=user["id"],
plugin_type="coolify",
alias="prod",
url="https://coolify.example.com",
credentials=b"x",
)
@pytest.fixture
async def wordpress_site(db):
user = await db.create_user(
email="wp@example.com",
name="wp",
provider="github",
provider_id="gh-wp-1",
)
return await db.create_site(
user_id=user["id"],
plugin_type="wordpress",
alias="blog",
url="https://blog.example.com",
credentials=b"x",
)
# ---------------------------------------------------------------------------
# ToolDefinition defaults
# ---------------------------------------------------------------------------
class TestToolDefinitionDefaults:
def test_default_category_and_sensitivity(self):
t = ToolDefinition(
name="legacy_tool",
description="legacy",
handler=_noop_handler,
plugin_type="wordpress",
)
assert t.category == "read"
assert t.sensitivity == "normal"
def test_explicit_category(self):
t = ToolDefinition(
name="new_tool",
description="x",
handler=_noop_handler,
plugin_type="coolify",
category="crud",
sensitivity="sensitive",
)
assert t.category == "crud"
assert t.sensitivity == "sensitive"
# ---------------------------------------------------------------------------
# Scope → category mapping
# ---------------------------------------------------------------------------
class TestScopesToCategories:
def test_read_only(self):
assert scopes_to_categories(["read"]) == {"read"}
def test_write_is_superset_of_read(self):
cats = scopes_to_categories(["write"])
assert "read" in cats and "crud" in cats and "lifecycle" in cats
assert "system" not in cats
def test_admin_includes_everything(self):
assert scopes_to_categories(["admin"]) == SCOPE_TO_CATEGORIES["admin"]
def test_additive_scopes(self):
cats = scopes_to_categories(["read", "deploy"])
assert cats == {"read", "lifecycle"}
def test_unknown_scope_ignored(self):
assert scopes_to_categories(["bogus"]) == set()
# ---------------------------------------------------------------------------
# apply_scope_filter
# ---------------------------------------------------------------------------
class TestScopeFilter:
def test_read_scope_drops_lifecycle_crud_system(self, access_mgr):
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read"])}
assert "coolify_list_applications" in out
assert "wordpress_list_posts" in out # legacy default category
assert "coolify_start_application" not in out
assert "coolify_create_application_public" not in out
assert "coolify_delete_server" not in out
assert "coolify_get_application_logs" not in out
def test_read_sensitive_includes_logs_and_backups(self, access_mgr):
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read:sensitive"])}
assert "coolify_get_application_logs" in out
assert "coolify_get_database_backups" in out
assert "coolify_start_application" not in out
assert "coolify_create_application_public" not in out
def test_deploy_scope_includes_lifecycle_only(self, access_mgr):
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["deploy"])}
assert "coolify_start_application" in out
assert "coolify_stop_application" in out
assert "coolify_list_applications" in out
assert "coolify_create_application_public" not in out
assert "coolify_delete_server" not in out
def test_write_scope_excludes_system(self, access_mgr):
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["write"])}
assert "coolify_create_application_public" in out
assert "coolify_start_application" in out
assert "coolify_create_application_env" in out
assert "coolify_delete_server" not in out
assert "coolify_get_application_logs" not in out
def test_admin_keeps_everything(self, access_mgr):
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"])}
assert out == {t.name for t in _SAMPLE_TOOLS}
# ---------------------------------------------------------------------------
# Per-site toggles
# ---------------------------------------------------------------------------
class TestSiteToggles:
async def test_disable_hides_tool(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_list_applications" not in names
assert "coolify_start_application" in names
async def test_toggles_are_per_site(
self, db, coolify_site, wordpress_site, access_mgr, fresh_registry
):
# Disabling a tool on one site must not affect another site.
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
# Second coolify site inherits nothing.
other_site = await db.create_site(
user_id=coolify_site["user_id"],
plugin_type="coolify",
alias="staging",
url="https://staging.example.com",
credentials=b"x",
)
tools = await access_mgr.get_visible_tools(
site_id=other_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert "coolify_list_applications" in {t.name for t in tools}
async def test_toggle_independent_of_scope(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["read"], plugin_type="coolify"
)
assert "coolify_list_applications" not in {t.name for t in tools}
# ---------------------------------------------------------------------------
# Site-level tool_scope preset
# ---------------------------------------------------------------------------
class TestSiteToolScope:
async def test_default_admin_shows_all(self, db, coolify_site, access_mgr, fresh_registry):
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert {t.name for t in tools} == {
t.name for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"
}
async def test_read_preset_restricts_even_admin_key(
self, db, coolify_site, access_mgr, fresh_registry
):
"""Site scope is restrictive — admin key but site=read → only read tools."""
await db.set_site_tool_scope(coolify_site["id"], "read")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert {t.name for t in tools} == {"coolify_list_applications"}
async def test_key_scope_and_site_scope_intersect(
self, db, coolify_site, access_mgr, fresh_registry
):
"""Key=write + site=deploy → intersection = lifecycle + read."""
await db.set_site_tool_scope(coolify_site["id"], "deploy")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["write"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_list_applications" in names # read
assert "coolify_start_application" in names # lifecycle
assert "coolify_stop_application" in names # lifecycle
assert "coolify_create_application_public" not in names # crud (not in deploy)
assert "coolify_create_application_env" not in names # env (not in deploy)
async def test_custom_preset_skips_site_filter(
self, db, coolify_site, access_mgr, fresh_registry
):
"""tool_scope='custom' means per-tool toggles only — no category gate."""
await db.set_site_tool_scope(coolify_site["id"], "custom")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
# All coolify tools visible because no site-scope filter applied.
assert "coolify_delete_server" in {t.name for t in tools}
# ---------------------------------------------------------------------------
# Bulk toggle (scoped to a plugin)
# ---------------------------------------------------------------------------
class TestBulkToggle:
async def test_bulk_disable_by_scope_affects_only_plugin(
self, db, coolify_site, access_mgr, fresh_registry
):
n = await access_mgr.bulk_toggle_by_scope(
coolify_site["id"], "deploy", enabled=False, plugin_type="coolify"
)
# deploy → read + lifecycle. In sample: list + 2 lifecycle = 3
assert n == 3
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_start_application" not in names
assert "coolify_stop_application" not in names
assert "coolify_list_applications" not in names
assert "coolify_create_application_public" in names
assert "coolify_delete_server" in names
async def test_unknown_scope_raises(self, db, coolify_site, access_mgr, fresh_registry):
with pytest.raises(ValueError):
await access_mgr.bulk_toggle_by_scope(
coolify_site["id"], "does_not_exist", enabled=False
)
# ---------------------------------------------------------------------------
# list_tools_for_site end-to-end
# ---------------------------------------------------------------------------
class TestListToolsForSite:
async def test_returns_plugin_tools_with_enabled_flag(
self, db, coolify_site, access_mgr, fresh_registry
):
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
by_name = {t["name"]: t for t in tools}
assert "coolify_list_applications" in by_name
assert by_name["coolify_list_applications"]["enabled"] is True
assert by_name["coolify_delete_server"]["category"] == "system"
async def test_respects_toggles(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_delete_server", enabled=False)
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
by_name = {t["name"]: t for t in tools}
assert by_name["coolify_delete_server"]["enabled"] is False

View File

@@ -85,12 +85,12 @@ def _clear_rate_limits():
@pytest.fixture(autouse=True)
def _clear_tool_cache():
"""Clear the tool schema cache between tests."""
import core.user_endpoints as mod
"""No-op: the per-plugin tool schema cache was removed in F.7.
mod._tool_schema_cache.clear()
Retained so the rest of the test fixtures stay unchanged; the scope-filter
pipeline runs on every ``tools/list`` call so there is nothing to clear.
"""
yield
mod._tool_schema_cache.clear()
@pytest.fixture