Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo. Platform - Total tools: 565 → 633 (+68) across 10 plugins (Coolify added) - Tests: 481 → 828 passing New plugin: Coolify (67 tools, Track F.17) - Applications (17): CRUD, lifecycle, logs, env vars - Deployments (5): list/get/cancel/deploy, app history - Servers (8): CRUD, resources, domains, validation - Projects (8), Databases (16, 6 DB types + backups), Services (13) Tool access system (Track F.7 → F.7d) - Scope → category mapping with per-tool `category` + `sensitivity` - Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column - Schema v8: per-site API keys (`api_keys.site_id`) - Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel / Coolify 5-tier) - Credential-requirement notice tailored per plugin and tier - Admin Tools count card on service page - Dropped redundant `write` tier on WP / WP Advanced / WooCommerce (admin-scope tool count = 0 → identical to admin tier) Dashboard - Unified site manage page (Connection / Tool Access / Connect) - /dashboard/keys unified (was /api-keys and /connect) - CSRF interceptor via meta-tag; removed conflicting cookie reader - Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN Docs - README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins - CHANGELOG entries for v3.7.0 → v3.11.0 - FastMCP compatibility note updated to 3.x (post-v3.5 upgrade) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""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:
|
|
"""F.7c: Coolify uses legacy category filter (plugin_type='coolify'),
|
|
other plugins use universal required_scope filter."""
|
|
|
|
def test_read_scope_drops_lifecycle_crud_system(self, access_mgr):
|
|
# Coolify tools filtered by category
|
|
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
|
out = {
|
|
t.name
|
|
for t in access_mgr.apply_scope_filter(coolify_tools, ["read"], plugin_type="coolify")
|
|
}
|
|
assert "coolify_list_applications" in out
|
|
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):
|
|
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
|
out = {
|
|
t.name
|
|
for t in access_mgr.apply_scope_filter(
|
|
coolify_tools, ["read:sensitive"], plugin_type="coolify"
|
|
)
|
|
}
|
|
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):
|
|
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
|
out = {
|
|
t.name
|
|
for t in access_mgr.apply_scope_filter(coolify_tools, ["deploy"], plugin_type="coolify")
|
|
}
|
|
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):
|
|
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
|
out = {
|
|
t.name
|
|
for t in access_mgr.apply_scope_filter(coolify_tools, ["write"], plugin_type="coolify")
|
|
}
|
|
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"], plugin_type="coolify")
|
|
}
|
|
assert out == {t.name for t in _SAMPLE_TOOLS}
|
|
|
|
def test_universal_read_filters_by_required_scope(self, access_mgr):
|
|
"""Non-Coolify plugins use universal required_scope filter."""
|
|
wp_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "wordpress"]
|
|
out = {
|
|
t.name
|
|
for t in access_mgr.apply_scope_filter(wp_tools, ["read"], plugin_type="wordpress")
|
|
}
|
|
# All wordpress sample tools have required_scope="read"
|
|
assert "wordpress_list_posts" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|