feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
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>
This commit is contained in:
143
tests/test_csrf_interceptor.py
Normal file
143
tests/test_csrf_interceptor.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Regression tests for the global CSRF interceptor (F.7c).
|
||||
|
||||
Background: previously, dashboard pages defined a local ``getCsrf()`` JS helper
|
||||
that read the dashboard CSRF cookie via ``document.cookie``. The cookie is
|
||||
``HttpOnly``, so JS could never read it and every non-GET fetch sent an empty
|
||||
``X-CSRF-Token`` header — leading to 403 errors when toggling tools or
|
||||
changing the access scope.
|
||||
|
||||
The fix moved CSRF handling into a single global interceptor in
|
||||
``head_assets.html`` that reads from a ``<meta name="csrf-token">`` tag rendered
|
||||
server-side in ``base.html``. This file pins down the contract so the bug
|
||||
cannot silently regress:
|
||||
|
||||
1. Every dashboard page renders a non-empty ``<meta name="csrf-token">`` tag.
|
||||
2. The global ``htmx:configRequest`` + ``window.fetch`` interceptor is
|
||||
present in ``head_assets.html``.
|
||||
3. No dashboard template defines its own ``getCsrf()`` helper or sets an
|
||||
explicit ``x-csrf-token`` header from JS — those would short-circuit the
|
||||
global interceptor again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "core" / "templates"
|
||||
DASHBOARD_DIR = TEMPLATES_DIR / "dashboard"
|
||||
|
||||
|
||||
# ── Static template checks ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestStaticTemplateContract:
|
||||
"""Lint-style checks that don't require a running app."""
|
||||
|
||||
def test_head_assets_has_global_csrf_interceptor(self):
|
||||
head = (DASHBOARD_DIR / "partials" / "head_assets.html").read_text()
|
||||
# The HTMX hook
|
||||
assert "htmx:configRequest" in head, "missing global HTMX CSRF hook"
|
||||
# The fetch monkey-patch
|
||||
assert "window.fetch" in head, "missing global fetch CSRF hook"
|
||||
# Both must read from the meta tag — not the cookie
|
||||
assert 'meta[name="csrf-token"]' in head, "interceptor must read from meta tag"
|
||||
# Sanity: must set the X-CSRF-Token header
|
||||
assert "X-CSRF-Token" in head
|
||||
|
||||
def test_base_template_renders_csrf_meta_tag(self):
|
||||
base = (DASHBOARD_DIR / "base.html").read_text()
|
||||
assert '<meta name="csrf-token"' in base
|
||||
assert (
|
||||
"request.state.csrf_token" in base
|
||||
), "CSRF meta must be populated from request.state.csrf_token"
|
||||
|
||||
def test_no_dashboard_template_defines_local_getcsrf(self):
|
||||
"""Local getCsrf() helpers reintroduce the httponly cookie bug."""
|
||||
offenders = []
|
||||
for path in DASHBOARD_DIR.rglob("*.html"):
|
||||
text = path.read_text()
|
||||
if "function getCsrf" in text or "const getCsrf =" in text:
|
||||
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
|
||||
assert not offenders, (
|
||||
f"templates must rely on the global CSRF interceptor — "
|
||||
f"found local getCsrf() in: {offenders}"
|
||||
)
|
||||
|
||||
def test_no_dashboard_template_sets_explicit_csrf_header(self):
|
||||
"""Explicit ``x-csrf-token`` headers from JS skip the global interceptor.
|
||||
|
||||
Allowed exception: head_assets.html, which IS the interceptor.
|
||||
"""
|
||||
offenders = []
|
||||
for path in DASHBOARD_DIR.rglob("*.html"):
|
||||
if path.name == "head_assets.html":
|
||||
continue
|
||||
text = path.read_text().lower()
|
||||
if "'x-csrf-token'" in text or '"x-csrf-token"' in text:
|
||||
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
|
||||
assert not offenders, (
|
||||
f"templates must not set X-CSRF-Token explicitly — let the "
|
||||
f"global interceptor handle it. Offenders: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
# ── Live render check ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def patched_db(tmp_path, monkeypatch):
|
||||
database = Database(str(tmp_path / "csrf.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="csrf@example.com",
|
||||
name="csrfuser",
|
||||
provider="github",
|
||||
provider_id="gh-csrf-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)
|
||||
|
||||
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()
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
class TestRenderedCsrfMeta:
|
||||
def test_keys_page_has_non_empty_csrf_meta(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
assert r.status_code == 200
|
||||
# The meta tag must be present *and* populated with a real token.
|
||||
assert '<meta name="csrf-token"' in r.text
|
||||
# An empty content="" would re-introduce the bug.
|
||||
assert 'content=""' not in r.text.split('<meta name="csrf-token"', 1)[1].split(">", 1)[0]
|
||||
@@ -69,11 +69,11 @@ class TestUnifiedKeysUserView:
|
||||
assert r.status_code == 301
|
||||
assert "/dashboard/keys" in r.headers["location"]
|
||||
|
||||
def test_user_view_has_scope_selector(self, user_client):
|
||||
def test_user_view_has_full_access_badge(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
|
||||
# F.7c: No scope selector — shows "Full Access" badge instead
|
||||
assert "Full Access" in r.text or "دسترسی کامل" in r.text
|
||||
|
||||
def test_user_view_shows_create_button(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2)."""
|
||||
"""Smoke tests for unified site management page (F.7c redesign)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -73,38 +73,50 @@ def client(monkeypatch, user_row, patched_db, patched_access):
|
||||
return tc
|
||||
|
||||
|
||||
class TestSitesEditToolAccess:
|
||||
def test_edit_page_renders_without_500(self, client, coolify_site):
|
||||
class TestSitesEditRedirect:
|
||||
def test_edit_page_redirects_to_manage(self, client, coolify_site):
|
||||
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}."""
|
||||
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
|
||||
assert r.status_code == 301
|
||||
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
|
||||
|
||||
|
||||
class TestSitesViewPage:
|
||||
def test_view_page_renders_without_500(self, client, coolify_site):
|
||||
class TestUnifiedSiteManagePage:
|
||||
def test_manage_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):
|
||||
def test_manage_page_has_connection_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "connection-section" in r.text
|
||||
|
||||
def test_manage_page_has_tool_access_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "tool-access-content" in r.text or "Tool Access" in r.text
|
||||
|
||||
def test_manage_page_has_scope_tiers(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "scope-tiers" in r.text
|
||||
|
||||
def test_manage_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):
|
||||
def test_manage_page_has_config_snippets(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_manage_page_has_quick_key_create(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "quick-key-btn" in r.text
|
||||
|
||||
def test_nonexistent_site_redirects(self, client):
|
||||
r = client.get("/dashboard/sites/nonexistent-id")
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
@@ -181,24 +181,41 @@ class TestScopesToCategories:
|
||||
|
||||
|
||||
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):
|
||||
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read"])}
|
||||
# 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 "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"])}
|
||||
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):
|
||||
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["deploy"])}
|
||||
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
|
||||
@@ -206,7 +223,11 @@ class TestScopeFilter:
|
||||
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"])}
|
||||
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
|
||||
@@ -214,9 +235,22 @@ class TestScopeFilter:
|
||||
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"])}
|
||||
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
|
||||
|
||||
@@ -112,6 +112,8 @@ def mock_key_mgr():
|
||||
def mock_db():
|
||||
"""Patch get_database to return a mock."""
|
||||
db = AsyncMock()
|
||||
db.get_site_tool_scope = AsyncMock(return_value="admin")
|
||||
db.get_site_tool_toggles = AsyncMock(return_value={})
|
||||
db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
@@ -148,6 +150,10 @@ def mock_tool_registry():
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_list_posts"
|
||||
tool_def.description = "List WordPress posts"
|
||||
tool_def.plugin_type = "wordpress"
|
||||
tool_def.required_scope = "read"
|
||||
tool_def.category = "read"
|
||||
tool_def.sensitivity = "normal"
|
||||
tool_def.input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -221,6 +227,62 @@ class TestAuthentication:
|
||||
body = json.loads(response.body)
|
||||
assert "does not match" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_site_scoped_key_wrong_site(self, mock_key_mgr, mock_db):
|
||||
"""Site-scoped key (site_id=A) used for site B should return 403."""
|
||||
# Key is scoped to site-A
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read write",
|
||||
"site_id": "site-uuid-A",
|
||||
}
|
||||
# The site looked up by site_id (A) has alias "blog-a", but the request
|
||||
# is for alias "myblog" (which is site-B in get_site_by_alias).
|
||||
mock_db.get_site = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-A",
|
||||
"alias": "blog-a",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"url": "https://blog-a.example.com",
|
||||
"credentials": b"x",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
request = _make_request(alias="myblog")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 403
|
||||
body = json.loads(response.body)
|
||||
assert "scoped to a different site" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_site_scoped_key_matching_site(self, mock_key_mgr, mock_db, mock_tool_registry):
|
||||
"""Site-scoped key used for the matching alias should pass auth."""
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read write",
|
||||
"site_id": "site-uuid-001",
|
||||
}
|
||||
mock_db.get_site = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"alias": "myblog",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"x",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
request = _make_request(alias="myblog", method_name="tools/list")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert "result" in body
|
||||
assert "tools" in body["result"]
|
||||
|
||||
|
||||
# ── Site Lookup Tests ────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user