Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
9.6 KiB
Python
273 lines
9.6 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"
|
|
assert [p["value"] for p in body["scope_presets"]] == [
|
|
"read",
|
|
"read:sensitive",
|
|
"deploy",
|
|
"write",
|
|
"admin",
|
|
"custom",
|
|
]
|
|
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"]
|