"""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"]