feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats

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>
This commit is contained in:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -360,3 +360,80 @@ class TestMetadataHelpers:
def test_allowed_mimes_includes_common_types():
for m in ("image/jpeg", "image/png", "image/webp", "application/pdf"):
assert m in ALLOWED_MIMES
# --- set_featured fallback (issue #90 gap 5) -------------------------------
class TestSetFeaturedFallback:
@pytest.mark.asyncio
async def test_falls_back_to_pages_when_post_id_invalid(self):
from plugins.wordpress.handlers.media import _set_featured_with_fallback
client = _client()
async def fake_set_featured(_c, _post_id, _media_id):
raise RuntimeError("Invalid post ID")
with (
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
new=AsyncMock(side_effect=fake_set_featured),
),
patch.object(client, "post", new=AsyncMock(return_value={"id": 87})) as mock_post,
):
ctx = await _set_featured_with_fallback(client, 87, 94)
assert ctx == "page"
mock_post.assert_awaited_once_with("pages/87", json_data={"featured_media": 94})
@pytest.mark.asyncio
async def test_falls_back_to_thumbnail_supporting_cpt(self):
from plugins.wordpress.handlers.media import _set_featured_with_fallback
client = _client()
async def fail_post(path, **_):
if path.startswith(("posts/", "pages/")):
raise RuntimeError("Invalid post ID")
return {"id": 200}
async def fake_get(path, **_):
assert path == "types"
return {
"post": {"rest_base": "posts", "supports": {"thumbnail": True}},
"page": {"rest_base": "pages", "supports": {"thumbnail": True}},
"portfolio": {"rest_base": "portfolio", "supports": {"thumbnail": True}},
"no_thumb_cpt": {"rest_base": "no_thumb_cpt", "supports": {}},
}
with (
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
new=AsyncMock(side_effect=RuntimeError("Invalid post ID")),
),
patch.object(client, "post", new=AsyncMock(side_effect=fail_post)) as mock_post,
patch.object(client, "get", new=AsyncMock(side_effect=fake_get)),
):
ctx = await _set_featured_with_fallback(client, 200, 9)
assert ctx == "portfolio"
# Should have tried pages/200 then portfolio/200 (no_thumb_cpt skipped).
called_paths = [c.args[0] for c in mock_post.await_args_list]
assert "pages/200" in called_paths
assert "portfolio/200" in called_paths
assert "no_thumb_cpt/200" not in called_paths
@pytest.mark.asyncio
async def test_returns_none_when_nothing_accepts(self):
from plugins.wordpress.handlers.media import _set_featured_with_fallback
client = _client()
with (
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
new=AsyncMock(side_effect=RuntimeError("Invalid post ID")),
),
patch.object(client, "post", new=AsyncMock(side_effect=RuntimeError("nope"))),
patch.object(client, "get", new=AsyncMock(return_value={})),
):
ctx = await _set_featured_with_fallback(client, 9999, 1)
assert ctx is None

View File

@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.providers.base import GenerationRequest
from plugins.ai_image.providers.openrouter import (
_MODEL_PRICING,
OpenRouterProvider,
@@ -145,13 +145,35 @@ class TestGenerateAttributesCost:
class TestDeprecatedModelGuard:
@pytest.mark.asyncio
async def test_preview_model_raises_model_deprecated(self):
async def test_preview_model_silently_substitutes_to_ga(self, caplog):
"""Issue #90 — deprecated model id is auto-rewritten to the GA alias."""
provider = OpenRouterProvider()
with pytest.raises(ProviderError) as e:
await provider.generate(
"k",
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
b64 = base64.b64encode(_PNG_1x1).decode()
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
},
)
sess = _mock_session([ok])
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
assert e.value.code == "PROVIDER_MODEL_DEPRECATED"
assert "google/gemini-2.5-flash-image" in str(e.value.message)
assert e.value.details.get("replacement_model") == "google/gemini-2.5-flash-image"
with caplog.at_level("WARNING", logger="mcphub.ai_image.openrouter"):
result = await provider.generate(
"k",
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
)
assert result.meta["model"] == "google/gemini-2.5-flash-image"
assert any(
"is deprecated" in rec.message and "google/gemini-2.5-flash-image" in rec.message
for rec in caplog.records
)

View File

@@ -0,0 +1,312 @@
"""F.19.3.2-.3 — Tests for the WordPress Specialist bulk handler.
Mocks ``WordPressClient.request`` and asserts:
* tool spec contract: 2 tools both on scope=editor
* fan-out behaviour: each item in ``updates`` triggers one stock REST
call to ``posts/{id}`` or ``{taxonomy}/{id}`` (no companion route);
per-item ``id`` is stripped from the body
* per-item failure is captured as
``{id, status:'error', error}`` instead of failing the whole call
* S-26 client-side cap: 50-item limit enforced as ``bulk_too_large``
before any HTTP traffic
* taxonomy slug shape validation
"""
from __future__ import annotations
from unittest.mock import AsyncMock, call
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.bulk import (
BulkHandler,
_validate_taxonomy,
_validate_updates,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f1933():
specs = get_tool_specifications()
assert len(specs) == 2, "F.19.3.3 advertises 2 bulk tools"
names = {s["name"] for s in specs}
assert names == {"wp_bulk_post_update", "wp_bulk_term_update"}
def test_all_bulk_tools_on_editor_tier():
"""Bulk write surface = editor tier (mirror of page edits)."""
for spec in get_tool_specifications():
assert spec["scope"] == "editor", f"{spec['name']} should be scope=editor"
def test_every_spec_has_the_full_contract():
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_bulk_post_update_required_and_caps():
by_name = {s["name"]: s for s in get_tool_specifications()}
schema = by_name["wp_bulk_post_update"]["schema"]
assert schema["required"] == ["updates"]
assert schema["properties"]["updates"]["maxItems"] == 50
assert schema["properties"]["updates"]["items"]["required"] == ["id"]
def test_bulk_term_update_requires_taxonomy_and_updates():
by_name = {s["name"]: s for s in get_tool_specifications()}
schema = by_name["wp_bulk_term_update"]["schema"]
assert set(schema["required"]) == {"taxonomy", "updates"}
assert schema["properties"]["updates"]["maxItems"] == 50
# ───── Validators ────────────────────────────────────────────────────
def test_validate_updates_accepts_valid_shape():
items = [{"id": 1, "title": "x"}, {"id": 2, "status": "publish"}]
assert _validate_updates(items) is items
def test_validate_updates_rejects_empty_list():
with pytest.raises(ValueError, match="at least one"):
_validate_updates([])
def test_validate_updates_rejects_non_list():
with pytest.raises(ValueError, match="must be a list"):
_validate_updates({"id": 1})
def test_validate_updates_rejects_oversize_payload():
"""S-26 client cap — 50-item ceiling."""
items = [{"id": i + 1} for i in range(51)]
with pytest.raises(ValueError, match="bulk_too_large"):
_validate_updates(items)
def test_validate_updates_rejects_missing_id():
with pytest.raises(ValueError, match="id must be a positive integer"):
_validate_updates([{"title": "x"}])
def test_validate_updates_rejects_negative_or_zero_id():
with pytest.raises(ValueError, match="positive integer"):
_validate_updates([{"id": 0}])
with pytest.raises(ValueError, match="positive integer"):
_validate_updates([{"id": -1}])
def test_validate_updates_rejects_non_int_id():
with pytest.raises(ValueError, match="positive integer"):
_validate_updates([{"id": "1"}])
with pytest.raises(ValueError, match="positive integer"):
_validate_updates([{"id": True}]) # bool is rejected
def test_validate_updates_rejects_non_dict_item():
with pytest.raises(ValueError, match="must be an object"):
_validate_updates(["not a dict"])
def test_validate_taxonomy_accepts_common_slugs():
assert _validate_taxonomy("categories") == "categories"
assert _validate_taxonomy("tags") == "tags"
assert _validate_taxonomy("product_cat") == "product_cat"
assert _validate_taxonomy("post-tag") == "post-tag"
def test_validate_taxonomy_rejects_bad_shape():
with pytest.raises(ValueError, match="non-empty"):
_validate_taxonomy("")
with pytest.raises(ValueError, match="must match"):
_validate_taxonomy("Cat With Spaces")
with pytest.raises(ValueError, match="must match"):
_validate_taxonomy("../escape")
with pytest.raises(ValueError, match="must be a string"):
_validate_taxonomy(42)
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.request = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return BulkHandler(client)
# ───── Fan-out shape — wp_bulk_post_update ───────────────────────────
@pytest.mark.asyncio
async def test_bulk_post_update_fans_out_to_stock_rest(handler, client):
updates = [
{"id": 11, "status": "publish"},
{"id": 12, "title": "New title"},
]
result = await handler.wp_bulk_post_update(updates=updates)
# One request per item, against stock REST (no use_custom_namespace).
assert client.request.await_count == 2
calls = client.request.await_args_list
paths = sorted(c.args[1] for c in calls)
assert paths == ["posts/11", "posts/12"]
# Per-item body strips ``id``.
bodies = {c.args[1]: c.kwargs.get("json_data") for c in calls}
assert bodies["posts/11"] == {"status": "publish"}
assert bodies["posts/12"] == {"title": "New title"}
# Per-item status array.
assert result["total"] == 2
assert result["ok"] == 2
assert result["errors"] == 0
statuses = sorted(r["status"] for r in result["results"])
assert statuses == ["ok", "ok"]
@pytest.mark.asyncio
async def test_bulk_post_update_uses_post_method_not_put(handler, client):
"""Stock REST uses POST for updates (matches WP-CLI / docs)."""
await handler.wp_bulk_post_update(updates=[{"id": 1, "status": "draft"}])
method = client.request.call_args.args[0]
assert method == "POST"
@pytest.mark.asyncio
async def test_bulk_post_update_does_not_use_custom_namespace(handler, client):
"""Stock REST means the bare wp/v2 base — no custom namespace flag."""
await handler.wp_bulk_post_update(updates=[{"id": 1, "status": "draft"}])
kwargs = client.request.call_args.kwargs
assert "use_custom_namespace" not in kwargs or kwargs["use_custom_namespace"] is False
@pytest.mark.asyncio
async def test_bulk_post_update_id_only_item_omits_body(handler, client):
"""If only ``id`` is provided, json_data should be None (no-op stock REST POST)."""
await handler.wp_bulk_post_update(updates=[{"id": 5}])
kwargs = client.request.call_args.kwargs
assert kwargs.get("json_data") is None
@pytest.mark.asyncio
async def test_bulk_post_update_captures_per_item_failure(handler, client):
"""One failing item should not fail the whole call."""
async def side_effect(method, endpoint, json_data=None, **kw):
if endpoint == "posts/12":
raise Exception("[rest_cannot_edit] Sorry, you cannot edit this post.")
return {"id": int(endpoint.split("/")[-1])}
client.request = AsyncMock(side_effect=side_effect) # type: ignore[method-assign]
handler.client = client
result = await handler.wp_bulk_post_update(
updates=[{"id": 11, "status": "publish"}, {"id": 12, "status": "publish"}]
)
assert result["total"] == 2
assert result["ok"] == 1
assert result["errors"] == 1
by_id = {r["id"]: r for r in result["results"]}
assert by_id[11]["status"] == "ok"
assert by_id[12]["status"] == "error"
assert "rest_cannot_edit" in by_id[12]["error"]
@pytest.mark.asyncio
async def test_bulk_post_update_rejects_empty(handler, client):
with pytest.raises(ValueError, match="at least one"):
await handler.wp_bulk_post_update(updates=[])
client.request.assert_not_awaited()
@pytest.mark.asyncio
async def test_bulk_post_update_rejects_oversize_before_http(handler, client):
"""S-26: bulk_too_large is raised client-side without any network call."""
items = [{"id": i + 1, "status": "publish"} for i in range(51)]
with pytest.raises(ValueError, match="bulk_too_large"):
await handler.wp_bulk_post_update(updates=items)
client.request.assert_not_awaited()
@pytest.mark.asyncio
async def test_bulk_post_update_at_exact_50_limit(handler, client):
"""50 is the documented ceiling — must succeed."""
items = [{"id": i + 1, "status": "publish"} for i in range(50)]
result = await handler.wp_bulk_post_update(updates=items)
assert result["total"] == 50
assert client.request.await_count == 50
# ───── Fan-out shape — wp_bulk_term_update ───────────────────────────
@pytest.mark.asyncio
async def test_bulk_term_update_uses_taxonomy_in_path(handler, client):
updates = [{"id": 7, "name": "Renamed"}]
await handler.wp_bulk_term_update(taxonomy="categories", updates=updates)
path = client.request.call_args.args[1]
assert path == "categories/7"
@pytest.mark.asyncio
async def test_bulk_term_update_returns_taxonomy_in_envelope(handler, client):
result = await handler.wp_bulk_term_update(
taxonomy="product_cat", updates=[{"id": 1, "name": "x"}]
)
assert result["taxonomy"] == "product_cat"
assert result["total"] == 1
assert result["ok"] == 1
@pytest.mark.asyncio
async def test_bulk_term_update_rejects_bad_taxonomy(handler, client):
with pytest.raises(ValueError, match="must match"):
await handler.wp_bulk_term_update(taxonomy="../escape", updates=[{"id": 1, "name": "x"}])
client.request.assert_not_awaited()
@pytest.mark.asyncio
async def test_bulk_term_update_validates_updates_too(handler, client):
"""Taxonomy + updates are independent gates; updates still validated."""
with pytest.raises(ValueError, match="bulk_too_large"):
await handler.wp_bulk_term_update(
taxonomy="categories",
updates=[{"id": i + 1, "name": str(i)} for i in range(51)],
)
client.request.assert_not_awaited()
# ───── Concurrency bound (smoke check) ───────────────────────────────
@pytest.mark.asyncio
async def test_bulk_fanout_runs_concurrently_not_serially(handler, client, monkeypatch):
"""The fan-out should issue all 10 calls without waiting on the previous to settle.
We don't time anything; we just verify ``asyncio.gather`` is the
dispatcher (every call ends up in await_args_list and matches our
expected paths).
"""
items = [{"id": i + 1, "status": "publish"} for i in range(10)]
await handler.wp_bulk_post_update(updates=items)
paths = sorted(c.args[1] for c in client.request.await_args_list)
assert paths == sorted(f"posts/{i + 1}" for i in range(10))
assert client.request.await_count == 10
# ───── ``call`` import smoke (pytest discovery) ──────────────────────
def test_call_helper_imported():
"""Sanity: ``unittest.mock.call`` import lives so we can use it elsewhere."""
assert call is not None

View File

@@ -0,0 +1,250 @@
"""F.19.3.2-.3 — Tests for the WordPress Specialist database handler.
Mocks ``WordPressClient.get`` / ``post`` and asserts:
* tool spec contract: 3 tools all on scope=read
* each handler method targets the correct ``airano-mcp/v1/admin/db/*``
route with ``use_custom_namespace=True`` and the right HTTP verb
* client-side validators (S-25 query length cap, limit cap at 100,
empty query refusal, post_type / status filter shape)
* server-returned error envelopes (500 db_size_query_failed, 400
invalid_query) are relayed untouched — the companion is the binding
gate
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.database import (
DatabaseHandler,
_normalise_filter,
_normalise_limit,
_normalise_query,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f1932():
specs = get_tool_specifications()
assert len(specs) == 3, "F.19.3.2-.3 advertises 3 read tools"
names = {s["name"] for s in specs}
assert names == {"wp_db_size", "wp_db_tables", "wp_db_search"}
def test_all_database_tools_on_read_tier():
"""db inspection is non-destructive — read tier per F.19.3.2 spec."""
for spec in get_tool_specifications():
assert spec["scope"] == "read", f"{spec['name']} should be scope=read"
def test_every_spec_has_the_full_contract():
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_db_search_required_and_schema_caps():
by_name = {s["name"]: s for s in get_tool_specifications()}
schema = by_name["wp_db_search"]["schema"]
assert schema["required"] == ["query"]
assert schema["properties"]["query"]["maxLength"] == 200
assert schema["properties"]["limit"]["maximum"] == 100
def test_db_size_and_tables_take_no_args():
by_name = {s["name"]: s for s in get_tool_specifications()}
assert by_name["wp_db_size"]["schema"]["properties"] == {}
assert by_name["wp_db_tables"]["schema"]["properties"] == {}
# ───── Validators ────────────────────────────────────────────────────
def test_normalise_query_strips_and_caps():
assert _normalise_query(" hello ") == "hello"
long = "x" * 250
assert _normalise_query(long) == "x" * 200
def test_normalise_query_rejects_empty_or_whitespace():
with pytest.raises(ValueError, match="non-empty"):
_normalise_query(" ")
with pytest.raises(ValueError, match="non-empty"):
_normalise_query("")
def test_normalise_query_rejects_non_string():
with pytest.raises(ValueError, match="must be a string"):
_normalise_query(123)
with pytest.raises(ValueError, match="must be a string"):
_normalise_query(["hi"])
def test_normalise_limit_default_is_20():
assert _normalise_limit(None) == 20
def test_normalise_limit_caps_at_100():
assert _normalise_limit(500) == 100
assert _normalise_limit(100) == 100
assert _normalise_limit(50) == 50
def test_normalise_limit_rejects_zero_and_negative():
with pytest.raises(ValueError, match=">= 1"):
_normalise_limit(0)
with pytest.raises(ValueError, match=">= 1"):
_normalise_limit(-3)
def test_normalise_limit_rejects_non_int():
with pytest.raises(ValueError, match="must be an integer"):
_normalise_limit("20")
with pytest.raises(ValueError, match="must be an integer"):
_normalise_limit(True) # bool is rejected even though it's an int subtype
def test_normalise_filter_accepts_string_and_list():
assert _normalise_filter("post", "post_type") == "post"
assert _normalise_filter(["post", "page"], "post_type") == ["post", "page"]
assert _normalise_filter(None, "post_type") is None
assert _normalise_filter("", "post_type") is None
def test_normalise_filter_rejects_bad_shape():
with pytest.raises(ValueError, match="string or array"):
_normalise_filter(42, "post_type")
with pytest.raises(ValueError, match="only strings"):
_normalise_filter(["post", 42], "post_type")
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return DatabaseHandler(client)
# ───── Routing — db/size, db/tables ──────────────────────────────────
@pytest.mark.asyncio
async def test_db_size_calls_route(handler, client):
await handler.wp_db_size()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/db/size",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_db_tables_calls_route(handler, client):
await handler.wp_db_tables()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/db/tables",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_db_size_relays_server_envelope(handler, client):
"""500 db_size_query_failed should pass through untouched."""
client.get = AsyncMock( # type: ignore[method-assign]
return_value={"code": "db_size_query_failed", "status": 500}
)
handler.client = client
result = await handler.wp_db_size()
assert result == {"code": "db_size_query_failed", "status": 500}
# ───── Routing — db/search ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_db_search_minimal_call(handler, client):
await handler.wp_db_search(query="hello")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/db/search",
json_data={"query": "hello", "limit": 20},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_db_search_passes_post_type_string(handler, client):
await handler.wp_db_search(query="x", post_type="post")
args, kwargs = client.post.call_args
assert kwargs["json_data"]["post_type"] == "post"
@pytest.mark.asyncio
async def test_db_search_passes_post_type_array(handler, client):
await handler.wp_db_search(query="x", post_type=["post", "page"])
args, kwargs = client.post.call_args
assert kwargs["json_data"]["post_type"] == ["post", "page"]
@pytest.mark.asyncio
async def test_db_search_passes_status(handler, client):
await handler.wp_db_search(query="x", status="draft")
args, kwargs = client.post.call_args
assert kwargs["json_data"]["status"] == "draft"
@pytest.mark.asyncio
async def test_db_search_caps_limit_client_side(handler, client):
await handler.wp_db_search(query="x", limit=999)
args, kwargs = client.post.call_args
assert kwargs["json_data"]["limit"] == 100, "client should cap limit at 100 before round-trip"
@pytest.mark.asyncio
async def test_db_search_caps_query_length_client_side(handler, client):
"""S-25 length cap mirrors the server-side 200-char cap."""
await handler.wp_db_search(query="a" * 500)
args, kwargs = client.post.call_args
assert len(kwargs["json_data"]["query"]) == 200
@pytest.mark.asyncio
async def test_db_search_rejects_empty_query(handler, client):
with pytest.raises(ValueError, match="non-empty"):
await handler.wp_db_search(query=" ")
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_db_search_omits_blank_filters(handler, client):
"""Empty string filters must not be relayed — they'd confuse WP_Query."""
await handler.wp_db_search(query="x", post_type="", status="")
args, kwargs = client.post.call_args
assert "post_type" not in kwargs["json_data"]
assert "status" not in kwargs["json_data"]
@pytest.mark.asyncio
async def test_db_search_relays_invalid_query_envelope(handler, client):
"""400 invalid_query is server-bound; client relays."""
client.post = AsyncMock( # type: ignore[method-assign]
return_value={"code": "invalid_query", "status": 400}
)
handler.client = client
result = await handler.wp_db_search(query="hi")
assert result == {"code": "invalid_query", "status": 400}

View File

@@ -0,0 +1,188 @@
"""F.19.1 — Tests for the WordPress Specialist read-only management handler.
Mocks ``WordPressClient.get`` and asserts:
* tool spec shape (name + scope + schema present, no leakage of write
operations into this iteration)
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route with ``use_custom_namespace=True``
* the option-name client-side guard rejects path-traversal / null-byte
payloads before any request goes to the wire
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.management import (
ManagementHandler,
get_tool_specifications,
)
# ---------- Tool spec contract ----------
def test_tool_specs_are_all_read_scope():
"""F.19.1 + F.19.3.1 ship read-only tools — assert nothing snuck into write/admin."""
specs = get_tool_specifications()
assert len(specs) == 9, "F.19.1 (6) + F.19.3.1 ports (3) advertise nine read tools"
names = {s["name"] for s in specs}
assert names == {
# F.19.1
"wp_plugin_list",
"wp_theme_list",
"wp_user_list",
"wp_option_get",
"wp_cron_list",
"wp_maintenance_status",
# F.19.3.1 system info ports (companion v2.12.0+)
"wp_system_info",
"wp_php_info",
"wp_disk_usage",
}
for spec in specs:
assert spec["scope"] == "read", f"{spec['name']} scope must be read"
assert spec["method_name"] == spec["name"]
assert "description" in spec and spec["description"]
assert "schema" in spec and isinstance(spec["schema"], dict)
def test_user_list_schema_supports_role_search_pagination():
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_user_list")
props = spec["schema"]["properties"]
assert {"role", "search", "page", "per_page"} <= set(props.keys())
assert props["per_page"]["maximum"] == 200
def test_option_get_schema_requires_name():
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_option_get")
assert spec["schema"].get("required") == ["name"]
# ---------- Handler routing ----------
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return ManagementHandler(client)
@pytest.mark.asyncio
async def test_wp_plugin_list_calls_admin_plugins_route(handler, client):
await handler.wp_plugin_list()
client.get.assert_awaited_once_with("airano-mcp/v1/admin/plugins", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_wp_theme_list_calls_admin_themes_route(handler, client):
await handler.wp_theme_list()
client.get.assert_awaited_once_with("airano-mcp/v1/admin/themes", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_wp_user_list_passes_pagination_and_filters(handler, client):
await handler.wp_user_list(role="editor", search="alice", page=2, per_page=25)
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/users",
params={"page": 2, "per_page": 25, "role": "editor", "search": "alice"},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_user_list_omits_optional_filters_when_unset(handler, client):
await handler.wp_user_list()
args, kwargs = client.get.call_args
assert args[0] == "airano-mcp/v1/admin/users"
assert kwargs["use_custom_namespace"] is True
assert kwargs["params"] == {"page": 1, "per_page": 50}
@pytest.mark.asyncio
async def test_wp_option_get_calls_named_route(handler, client):
await handler.wp_option_get(name="blogname")
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/options/blogname", use_custom_namespace=True
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_name",
[
"../../../etc/passwd",
"foo/bar",
"secret\x00key",
],
)
async def test_wp_option_get_rejects_suspicious_names_before_wire(handler, client, bad_name):
with pytest.raises(ValueError, match="suspicious"):
await handler.wp_option_get(name=bad_name)
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_option_get_requires_non_empty_name(handler, client):
with pytest.raises(ValueError, match="non-empty"):
await handler.wp_option_get(name="")
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_cron_list_calls_admin_cron_route(handler, client):
await handler.wp_cron_list()
client.get.assert_awaited_once_with("airano-mcp/v1/admin/cron", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_wp_maintenance_status_calls_admin_maintenance_route(handler, client):
await handler.wp_maintenance_status()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/maintenance", use_custom_namespace=True
)
# F.19.3.1 system info ports (companion v2.12.0+)
@pytest.mark.asyncio
async def test_wp_system_info_calls_admin_system_info_route(handler, client):
await handler.wp_system_info()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/system-info", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_php_info_calls_admin_phpinfo_route(handler, client):
await handler.wp_php_info()
client.get.assert_awaited_once_with("airano-mcp/v1/admin/phpinfo", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_wp_disk_usage_calls_admin_disk_usage_route(handler, client):
await handler.wp_disk_usage()
client.get.assert_awaited_once_with("airano-mcp/v1/admin/disk-usage", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_handler_returns_companion_response_unchanged(handler, client):
payload = {
"plugins": [{"file": "x/x.php", "name": "X", "active": True}],
"total": 1,
"active_count": 1,
"multisite": False,
}
client.get = AsyncMock(return_value=payload) # type: ignore[method-assign]
handler.client = client
result = await handler.wp_plugin_list()
assert result is payload, "Handler must not reshape the companion payload in F.19.1"

View File

@@ -0,0 +1,414 @@
"""F.19.5 — Tests for the WordPress Specialist page-editing handler.
Mocks ``WordPressClient.get`` and ``WordPressClient.post`` and asserts:
* tool spec contract: 11 tools in three buckets (4 Gutenberg + 6 Elementor
+ 1 Classic), with reads at ``scope=read`` and writes at ``scope=editor``
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route (or stock ``wp/v2/{type}/{id}``) with ``use_custom_namespace``
set correctly
* client-side guards reject obviously-bad input (negative post_id,
oversized block array, oversized Elementor tree) before the wire
* ``wp_blocks_get`` runs the block parser server-side in MCPHub —
no companion route is consulted on reads
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.pages import (
PagesHandler,
_count_elementor_nodes,
_parse_blocks_python,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f195():
specs = get_tool_specifications()
assert len(specs) == 11, "F.19.5 advertises 4 Gutenberg + 6 Elementor + 1 Classic"
names = {s["name"] for s in specs}
assert names == {
# Gutenberg
"wp_blocks_get",
"wp_blocks_replace",
"wp_blocks_insert_at",
"wp_blocks_remove_at",
# Elementor
"wp_elementor_detect",
"wp_elementor_get",
"wp_elementor_set",
"wp_elementor_render_css",
"wp_elementor_template_list",
"wp_elementor_template_apply",
# Classic
"wp_classic_html_replace",
}
def test_reads_are_read_scope_writes_are_editor_scope():
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_blocks_get": "read",
"wp_blocks_replace": "editor",
"wp_blocks_insert_at": "editor",
"wp_blocks_remove_at": "editor",
"wp_elementor_detect": "read",
"wp_elementor_get": "read",
"wp_elementor_set": "editor",
"wp_elementor_render_css": "editor",
"wp_elementor_template_list": "read",
"wp_elementor_template_apply": "editor",
"wp_classic_html_replace": "editor",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
"""Each F.19.5 spec must carry name, method_name, description, schema."""
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_blocks_replace_schema_caps_at_200():
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_blocks_replace")
assert spec["schema"]["properties"]["blocks"]["maxItems"] == 200
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return PagesHandler(client)
# ───── Gutenberg routing ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_blocks_get_reads_via_stock_rest_no_companion(handler, client):
"""Reads MUST go through stock REST so non-companion sites work."""
client.get = AsyncMock( # type: ignore[method-assign]
return_value={
"id": 42,
"content": {
"raw": ("<!-- wp:paragraph -->\n<p>Hello world.</p>\n<!-- /wp:paragraph -->"),
"rendered": "<p>Hello world.</p>",
},
}
)
handler.client = client
result = await handler.wp_blocks_get(post_id=42)
client.get.assert_awaited_once_with("posts/42", params={"context": "edit"})
assert result["post_id"] == 42
assert result["count"] == 1
assert result["blocks"][0]["blockName"] == "core/paragraph"
@pytest.mark.asyncio
async def test_wp_blocks_get_supports_pages_collection(handler, client):
client.get = AsyncMock(return_value={"content": {"raw": ""}}) # type: ignore[method-assign]
handler.client = client
await handler.wp_blocks_get(post_id=12, post_type="pages")
client.get.assert_awaited_once_with("pages/12", params={"context": "edit"})
@pytest.mark.asyncio
async def test_wp_blocks_get_rejects_bogus_post_type(handler):
with pytest.raises(ValueError, match="post_type"):
await handler.wp_blocks_get(post_id=1, post_type="products")
@pytest.mark.asyncio
async def test_wp_blocks_replace_calls_companion_route(handler, client):
blocks = [
{
"blockName": "core/paragraph",
"attrs": {},
"innerBlocks": [],
"innerHTML": "<p>hi</p>",
"innerContent": ["<p>hi</p>"],
}
]
await handler.wp_blocks_replace(post_id=7, blocks=blocks)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/blocks/replace",
json_data={"post_id": 7, "blocks": blocks, "raw_html": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_blocks_replace_rejects_oversized_payload(handler, client):
too_many = [{"blockName": "core/paragraph"} for _ in range(201)]
with pytest.raises(ValueError, match="exceeds 200"):
await handler.wp_blocks_replace(post_id=1, blocks=too_many)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_blocks_replace_passes_raw_html_flag_when_true(handler, client):
await handler.wp_blocks_replace(post_id=1, blocks=[], raw_html=True)
args, kwargs = client.post.call_args
assert kwargs["json_data"]["raw_html"] is True
@pytest.mark.asyncio
async def test_wp_blocks_insert_at_passes_index(handler, client):
await handler.wp_blocks_insert_at(post_id=3, block={"blockName": "core/paragraph"}, index=2)
args, kwargs = client.post.call_args
assert args[0] == "airano-mcp/v1/admin/blocks/insert"
assert kwargs["json_data"]["index"] == 2
@pytest.mark.asyncio
async def test_wp_blocks_insert_at_omits_index_when_unset(handler, client):
await handler.wp_blocks_insert_at(post_id=3, block={"blockName": "core/paragraph"})
args, kwargs = client.post.call_args
assert "index" not in kwargs["json_data"], "missing index → companion appends"
@pytest.mark.asyncio
async def test_wp_blocks_remove_at_calls_remove_route(handler, client):
await handler.wp_blocks_remove_at(post_id=3, index=1)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/blocks/remove",
json_data={"post_id": 3, "index": 1},
use_custom_namespace=True,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_id", [0, -1, "1", 1.5, True])
async def test_block_writes_reject_bad_post_id(handler, client, bad_id):
with pytest.raises(ValueError):
await handler.wp_blocks_replace(post_id=bad_id, blocks=[])
client.post.assert_not_awaited()
# ───── Elementor routing ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_elementor_detect_calls_status_route(handler, client):
await handler.wp_elementor_detect()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/status", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_get_calls_per_post_route(handler, client):
await handler.wp_elementor_get(post_id=99)
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/99", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_set_posts_data_array(handler, client):
data = [{"id": "abc", "elType": "section", "settings": {}, "elements": []}]
await handler.wp_elementor_set(post_id=99, data=data)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/99",
json_data={"data": data},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_elementor_set_rejects_oversized_tree_before_wire(handler, client):
"""S-14 — the companion enforces 5,000 nodes; mirror in MCPHub."""
def _make_tree(width: int):
return [
{"id": str(i), "elType": "section", "settings": {}, "elements": []}
for i in range(width)
]
too_many = _make_tree(5001)
with pytest.raises(ValueError, match="exceeds 5000"):
await handler.wp_elementor_set(post_id=1, data=too_many)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_wp_elementor_render_css_calls_regen_route(handler, client):
await handler.wp_elementor_render_css(post_id=12)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/12/regen-css",
json_data={},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_elementor_template_list_calls_templates_route(handler, client):
await handler.wp_elementor_template_list()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/templates", use_custom_namespace=True
)
@pytest.mark.asyncio
async def test_wp_elementor_template_apply_passes_both_ids(handler, client):
await handler.wp_elementor_template_apply(template_id=1, post_id=2)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/elementor/templates/apply",
json_data={"template_id": 1, "post_id": 2},
use_custom_namespace=True,
)
# ───── Classic routing ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_wp_classic_html_replace_calls_classic_route(handler, client):
await handler.wp_classic_html_replace(post_id=5, html="<p>x</p>")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/classic/5/replace",
json_data={"html": "<p>x</p>", "raw_html": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_wp_classic_html_replace_rejects_non_string_html(handler, client):
with pytest.raises(ValueError, match="html must be a string"):
await handler.wp_classic_html_replace(post_id=5, html=42) # type: ignore[arg-type]
client.post.assert_not_awaited()
# ───── Block parser ──────────────────────────────────────────────────
def test_parse_blocks_handles_empty_string():
assert _parse_blocks_python("") == []
def test_parse_blocks_returns_freeform_for_classic_html():
blocks = _parse_blocks_python("<p>Pre-block-editor content</p>")
assert len(blocks) == 1
assert blocks[0]["blockName"] is None
assert blocks[0]["innerHTML"] == "<p>Pre-block-editor content</p>"
def test_parse_blocks_extracts_top_level_paragraph():
html = "<!-- wp:paragraph -->\n" "<p>Hello world.</p>\n" "<!-- /wp:paragraph -->"
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/paragraph"
assert "Hello world" in blocks[0]["innerHTML"]
def test_parse_blocks_decodes_attributes_json():
html = '<!-- wp:heading {"level":3} -->\n' "<h3>Title</h3>\n" "<!-- /wp:heading -->"
blocks = _parse_blocks_python(html)
assert blocks[0]["attrs"] == {"level": 3}
def test_parse_blocks_handles_nested_blocks():
html = (
"<!-- wp:group -->\n"
'<div class="wp-block-group">\n'
"<!-- wp:paragraph -->\n"
"<p>Inner.</p>\n"
"<!-- /wp:paragraph -->\n"
"</div>\n"
"<!-- /wp:group -->"
)
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/group"
assert len(blocks[0]["innerBlocks"]) == 1
assert blocks[0]["innerBlocks"][0]["blockName"] == "core/paragraph"
def test_parse_blocks_handles_self_closing_block():
html = "<!-- wp:separator /-->"
blocks = _parse_blocks_python(html)
assert len(blocks) == 1
assert blocks[0]["blockName"] == "core/separator"
assert blocks[0]["innerBlocks"] == []
# ───── Node counter ──────────────────────────────────────────────────
def test_count_elementor_nodes_walks_recursively():
tree = [
{
"id": "a",
"elType": "section",
"settings": {},
"elements": [
{
"id": "b",
"elType": "column",
"settings": {},
"elements": [{"id": "c", "elType": "widget", "settings": {}}],
}
],
},
{"id": "d", "elType": "section", "settings": {}, "elements": []},
]
assert _count_elementor_nodes(tree) == 4
# ───── Plugin-level wiring ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_plugin_returns_combined_specs_count():
"""The wordpress_specialist plugin must merge management + pages."""
from plugins.wordpress_specialist import WordPressSpecialistPlugin
specs = WordPressSpecialistPlugin.get_tool_specifications()
assert len(specs) == 51, (
"9 management + 11 page + 7 theme + 6 plugin-write + "
"6 site-config + 7 site-layout + 3 db + 2 bulk tools"
)
@pytest.mark.asyncio
async def test_plugin_serialises_handler_response_to_json():
"""plugin.py wraps each handler response in json.dumps(..., indent=2)."""
from plugins.wordpress_specialist import WordPressSpecialistPlugin
plugin = WordPressSpecialistPlugin(
config={"url": "https://wp.example.com", "username": "u", "app_password": "p"},
)
plugin.pages.client.get = AsyncMock( # type: ignore[method-assign]
return_value={"installed": False, "version": None, "pro": False, "post_types": []}
)
serialised = await plugin.wp_elementor_detect()
assert isinstance(serialised, str)
assert json.loads(serialised) == {
"installed": False,
"version": None,
"pro": False,
"post_types": [],
}

View File

@@ -0,0 +1,250 @@
"""F.19.2.1 — Tests for the WordPress Specialist plugin write handler.
Mocks ``WordPressClient.post`` / ``delete`` and asserts:
* tool spec contract: 6 tools (4 install-tier + 2 admin-tier), with
the install/admin tier split matching the F.19.2.0 ladder
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route with ``use_custom_namespace=True``
* client-side guards reject malformed slugs (S-15), oversized zip
payloads (S-18), and the mutually-exclusive zip_url / zip_base64
contract on install_from_zip
"""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.plugins import (
PluginsHandler,
_validate_plugin_slug,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f1921():
specs = get_tool_specifications()
assert len(specs) == 6, "F.19.2.1 advertises 4 install-tier + 2 admin-tier"
names = {s["name"] for s in specs}
assert names == {
"wp_plugin_install_from_slug",
"wp_plugin_install_from_zip",
"wp_plugin_activate",
"wp_plugin_deactivate",
"wp_plugin_update",
"wp_plugin_delete",
}
def test_install_admin_tier_split_matches_risk_class():
"""install tier = wp.org curated; admin tier = arbitrary zip + delete."""
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_plugin_install_from_slug": "install",
"wp_plugin_activate": "install",
"wp_plugin_deactivate": "install",
"wp_plugin_update": "install",
"wp_plugin_install_from_zip": "admin",
"wp_plugin_delete": "admin",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
# ───── Slug validation (S-15 client-side) ────────────────────────────
@pytest.mark.parametrize(
"good",
["akismet", "woocommerce", "yoast-seo", "wp_super_cache", "P1"],
)
def test_validate_plugin_slug_accepts_well_formed(good):
assert _validate_plugin_slug(good) == good
@pytest.mark.parametrize(
"bad",
[
"",
"../etc",
"slug.with.dot",
"-leading-dash",
"slug with spaces",
"x" * 65,
None,
42,
],
)
def test_validate_plugin_slug_rejects_malformed(bad):
with pytest.raises(ValueError):
_validate_plugin_slug(bad)
@pytest.mark.parametrize(
"raw,expected",
[
("woocommerce/woocommerce.php", "woocommerce"),
("airano-mcp-bridge/airano-mcp-bridge.php", "airano-mcp-bridge"),
("akismet/akismet.php", "akismet"),
(" woocommerce/woocommerce.php ", "woocommerce"),
],
)
def test_validate_plugin_slug_normalizes_folder_file_form(raw, expected):
"""Capabilities probe returns ``folder/file.php`` — normalise to folder."""
assert _validate_plugin_slug(raw) == expected
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.delete = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return PluginsHandler(client)
# ───── Install-tier routing ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_install_from_slug_routes_to_install(handler, client):
await handler.wp_plugin_install_from_slug(slug="akismet", activate=True)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/install",
json_data={"slug": "akismet", "activate": True},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_install_from_slug_rejects_bad_slug(handler, client):
with pytest.raises(ValueError):
await handler.wp_plugin_install_from_slug(slug="../etc")
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_activate_calls_activate_route(handler, client):
await handler.wp_plugin_activate(slug="akismet")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/akismet/activate",
json_data={"network_wide": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_activate_passes_network_wide_when_true(handler, client):
await handler.wp_plugin_activate(slug="akismet", network_wide=True)
args, kwargs = client.post.call_args
assert kwargs["json_data"]["network_wide"] is True
@pytest.mark.asyncio
async def test_deactivate_calls_deactivate_route(handler, client):
await handler.wp_plugin_deactivate(slug="akismet")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/akismet/deactivate",
json_data={"network_wide": False},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_update_calls_update_route(handler, client):
await handler.wp_plugin_update(slug="akismet")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/akismet/update",
json_data={},
use_custom_namespace=True,
)
# ───── Admin-tier routing ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_install_from_zip_url_routes_to_install(handler, client):
await handler.wp_plugin_install_from_zip(
zip_url="https://example.com/plugin.zip", activate=True, overwrite=False
)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/install",
json_data={
"zip_url": "https://example.com/plugin.zip",
"activate": True,
"overwrite": False,
},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_install_from_zip_base64_routes_to_install(handler, client):
payload = base64.b64encode(b"PK\x03\x04 fake plugin zip").decode()
await handler.wp_plugin_install_from_zip(zip_base64=payload, overwrite=True)
args, kwargs = client.post.call_args
assert kwargs["json_data"]["zip_base64"] == payload
assert kwargs["json_data"]["overwrite"] is True
assert "zip_url" not in kwargs["json_data"]
@pytest.mark.asyncio
async def test_install_from_zip_requires_one_of_url_or_base64(handler, client):
with pytest.raises(ValueError, match="zip_url or zip_base64"):
await handler.wp_plugin_install_from_zip()
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_install_from_zip_rejects_both_url_and_base64(handler, client):
with pytest.raises(ValueError, match="not both"):
await handler.wp_plugin_install_from_zip(
zip_url="https://example.com/x.zip", zip_base64="aGVsbG8="
)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_install_from_zip_rejects_oversized_payload(handler, client):
huge = "A" * (70 * 1024 * 1024) # > 50 MB after b64 decode upper bound
with pytest.raises(ValueError, match=r"exceeds .* byte cap \(S-18\)"):
await handler.wp_plugin_install_from_zip(zip_base64=huge)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_calls_delete_route(handler, client):
await handler.wp_plugin_delete(slug="oldplugin")
client.delete.assert_awaited_once_with(
"airano-mcp/v1/admin/plugins/oldplugin",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_delete_rejects_bad_slug(handler, client):
with pytest.raises(ValueError):
await handler.wp_plugin_delete(slug="../etc")
client.delete.assert_not_awaited()

View File

@@ -0,0 +1,257 @@
"""F.19.6.A — Tests for the WordPress Specialist site config handler.
Mocks ``WordPressClient.get`` / ``post`` and asserts:
* tool spec contract: 6 tools split read (3) + settings (3)
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route with ``use_custom_namespace=True``
* client-side guards reject bad permalink structure shapes (S-18-style
cheap pre-check) + bad enum values (show_on_front) + posts_per_page
out of bounds
* setters refuse calls with no fields supplied (must update at least one)
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.site_config import (
SiteConfigHandler,
_validate_permalink_structure,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f196a():
specs = get_tool_specifications()
assert len(specs) == 6, "F.19.6.A advertises 3 read + 3 settings"
names = {s["name"] for s in specs}
assert names == {
"wp_site_identity_get",
"wp_site_identity_set",
"wp_reading_settings_get",
"wp_reading_settings_set",
"wp_permalinks_get",
"wp_permalinks_set",
}
def test_read_settings_tier_split():
"""Reads at scope=read, writes at scope=settings (the new tier)."""
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_site_identity_get": "read",
"wp_reading_settings_get": "read",
"wp_permalinks_get": "read",
"wp_site_identity_set": "settings",
"wp_reading_settings_set": "settings",
"wp_permalinks_set": "settings",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
# ───── Permalink structure validator ─────────────────────────────────
@pytest.mark.parametrize(
"good",
[
"", # plain permalinks
"/%postname%/",
"/%year%/%monthnum%/%postname%/",
"/%category%/%postname%/",
"/blog/%postname%/",
"/posts/%post_id%-%postname%",
],
)
def test_validate_permalink_structure_accepts(good):
assert _validate_permalink_structure(good) == good
@pytest.mark.parametrize(
"bad",
[
None,
42,
"/%postname%/\x00", # null byte
"/" + ("a" * 300), # too long
"/%postname%/<script>", # angle brackets
"/?p=N", # ? not allowed in our cheap pre-check
],
)
def test_validate_permalink_structure_rejects(bad):
with pytest.raises(ValueError):
_validate_permalink_structure(bad)
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return SiteConfigHandler(client)
# ───── Identity routing ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_identity_get_calls_identity_route(handler, client):
await handler.wp_site_identity_get()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/site/identity",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_identity_set_passes_subset(handler, client):
await handler.wp_site_identity_set(title="Hello", site_icon_id=42)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/site/identity",
json_data={"title": "Hello", "site_icon_id": 42},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_identity_set_clears_logo_when_zero(handler, client):
await handler.wp_site_identity_set(custom_logo_id=0)
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {"custom_logo_id": 0}
@pytest.mark.asyncio
async def test_identity_set_rejects_empty_call(handler, client):
with pytest.raises(ValueError, match="at least one field"):
await handler.wp_site_identity_set()
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_identity_set_rejects_negative_attachment_id(handler, client):
with pytest.raises(ValueError, match="non-negative"):
await handler.wp_site_identity_set(site_icon_id=-1)
client.post.assert_not_awaited()
# ───── Reading routing ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_reading_get_calls_reading_route(handler, client):
await handler.wp_reading_settings_get()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/site/reading",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_reading_set_passes_subset(handler, client):
await handler.wp_reading_settings_set(show_on_front="page", page_on_front=12, posts_per_page=20)
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {
"show_on_front": "page",
"page_on_front": 12,
"posts_per_page": 20,
}
@pytest.mark.asyncio
async def test_reading_set_rejects_bad_show_on_front(handler, client):
with pytest.raises(ValueError, match="show_on_front"):
await handler.wp_reading_settings_set(show_on_front="archive")
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_reading_set_rejects_oversized_posts_per_page(handler, client):
with pytest.raises(ValueError, match="between 1 and 100"):
await handler.wp_reading_settings_set(posts_per_page=500)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_reading_set_passes_blog_public_bool(handler, client):
await handler.wp_reading_settings_set(blog_public=False)
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {"blog_public": False}
@pytest.mark.asyncio
async def test_reading_set_rejects_empty_call(handler, client):
with pytest.raises(ValueError, match="at least one field"):
await handler.wp_reading_settings_set()
client.post.assert_not_awaited()
# ───── Permalinks routing ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_permalinks_get_calls_route(handler, client):
await handler.wp_permalinks_get()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/permalinks",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_permalinks_set_passes_structure(handler, client):
await handler.wp_permalinks_set(structure="/%postname%/")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/permalinks",
json_data={"structure": "/%postname%/"},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_permalinks_set_passes_category_and_tag_base(handler, client):
await handler.wp_permalinks_set(
structure="/%postname%/", category_base="topics", tag_base="labels"
)
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {
"structure": "/%postname%/",
"category_base": "topics",
"tag_base": "labels",
}
@pytest.mark.asyncio
async def test_permalinks_set_accepts_plain_empty_structure(handler, client):
await handler.wp_permalinks_set(structure="")
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {"structure": ""}
@pytest.mark.asyncio
async def test_permalinks_set_rejects_bad_structure(handler, client):
with pytest.raises(ValueError):
await handler.wp_permalinks_set(structure="/?p=N") # ? not allowed
client.post.assert_not_awaited()

View File

@@ -0,0 +1,370 @@
"""F.19.6.B — Tests for the WordPress Specialist site layout handler.
Mocks ``WordPressClient.get`` / ``post`` / ``put`` and asserts:
* tool spec contract: 7 tools split read (4) + settings (3)
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route with ``use_custom_namespace=True`` and the right HTTP verb
* client-side guards reject bad menu item shapes (S-22 pre-check),
bad customizer actions, missing menu_id / area_id, and non-list
items / widgets
* ``wp_widget_set`` strips a caller-side ``kind`` field — area kind
is determined by the area, not the request
* ``custom`` URL menu items skip the ``object_id`` check client-side
(S-22 dispatcher honours it server-side)
* server-returned error envelopes (forbidden_object_id 403,
unsupported_legacy_widget 400, etc.) are relayed untouched — the
companion is the binding gate
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.site_layout import (
SiteLayoutHandler,
_validate_menu_item,
_validate_post_id,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f196b():
specs = get_tool_specifications()
assert len(specs) == 7, "F.19.6.B advertises 4 read + 3 settings"
names = {s["name"] for s in specs}
assert names == {
"wp_menu_list",
"wp_menu_get",
"wp_menu_set",
"wp_widget_areas_list",
"wp_widget_get",
"wp_widget_set",
"wp_customizer_changeset",
}
def test_read_settings_tier_split():
"""Reads at scope=read, writes (incl. customizer) at scope=settings."""
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_menu_list": "read",
"wp_menu_get": "read",
"wp_widget_areas_list": "read",
"wp_widget_get": "read",
"wp_menu_set": "settings",
"wp_widget_set": "settings",
"wp_customizer_changeset": "settings",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_required_args_declared_on_setters():
by_name = {s["name"]: s for s in get_tool_specifications()}
assert by_name["wp_menu_get"]["schema"]["required"] == ["menu_id"]
assert set(by_name["wp_menu_set"]["schema"]["required"]) == {"menu_id", "items"}
assert by_name["wp_widget_get"]["schema"]["required"] == ["area_id"]
assert set(by_name["wp_widget_set"]["schema"]["required"]) == {"area_id", "widgets"}
assert by_name["wp_customizer_changeset"]["schema"]["required"] == ["action"]
# ───── Validators ────────────────────────────────────────────────────
@pytest.mark.parametrize("good", [0, 1, 42, 9999])
def test_validate_post_id_accepts(good):
assert _validate_post_id(good, "x") == good
@pytest.mark.parametrize("bad", [-1, "1", 1.5, None, True, False])
def test_validate_post_id_rejects(bad):
with pytest.raises(ValueError):
_validate_post_id(bad, "x")
def test_validate_menu_item_accepts_post_type():
item = {"type": "post_type", "object": "post", "object_id": 12, "title": "Hi"}
assert _validate_menu_item(item, 0) is item
def test_validate_menu_item_accepts_taxonomy():
item = {"type": "taxonomy", "object": "category", "object_id": 5}
assert _validate_menu_item(item, 0) is item
def test_validate_menu_item_custom_skips_object_id():
"""``custom`` URL items must NOT require object_id (S-22)."""
item = {"type": "custom", "url": "https://example.com", "title": "Ext"}
assert _validate_menu_item(item, 0) is item
def test_validate_menu_item_rejects_bad_type():
with pytest.raises(ValueError, match="type must be one of"):
_validate_menu_item({"type": "magic", "object_id": 1}, 0)
def test_validate_menu_item_rejects_post_type_without_object_id():
with pytest.raises(ValueError, match="object_id"):
_validate_menu_item({"type": "post_type", "object": "post"}, 0)
def test_validate_menu_item_rejects_non_dict():
with pytest.raises(ValueError, match="must be an object"):
_validate_menu_item("not a dict", 3)
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.put = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return SiteLayoutHandler(client)
# ───── Menu routing ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_menu_list_calls_route(handler, client):
await handler.wp_menu_list()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/menus",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_menu_get_calls_route(handler, client):
await handler.wp_menu_get(menu_id=42)
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/menus/42",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_menu_get_rejects_zero_id(handler, client):
with pytest.raises(ValueError, match="menu_id"):
await handler.wp_menu_get(menu_id=0)
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_menu_set_passes_items(handler, client):
items = [
{"type": "post_type", "object": "page", "object_id": 7, "title": "About"},
{"type": "custom", "url": "https://x", "title": "Ext"},
]
await handler.wp_menu_set(menu_id=3, items=items)
client.put.assert_awaited_once_with(
"airano-mcp/v1/admin/menus/3",
json_data={"items": items},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_menu_set_passes_optional_name(handler, client):
await handler.wp_menu_set(
menu_id=3, items=[{"type": "custom", "url": "/", "title": "Home"}], name="Footer"
)
args, kwargs = client.put.call_args
assert kwargs["json_data"]["name"] == "Footer"
@pytest.mark.asyncio
async def test_menu_set_rejects_bad_item_shape(handler, client):
with pytest.raises(ValueError, match="must be one of"):
await handler.wp_menu_set(menu_id=3, items=[{"type": "weird"}])
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_menu_set_rejects_non_list_items(handler, client):
with pytest.raises(ValueError, match="items must be a list"):
await handler.wp_menu_set(menu_id=3, items="not a list") # type: ignore[arg-type]
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_menu_set_rejects_empty_name(handler, client):
with pytest.raises(ValueError, match="non-empty"):
await handler.wp_menu_set(menu_id=3, items=[], name=" ")
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_menu_set_relays_server_forbidden_object_id(handler, client):
"""S-22 enforcement is server-side; client just relays the envelope."""
client.put = AsyncMock( # type: ignore[method-assign]
return_value={"code": "forbidden_object_id", "status": 403}
)
handler.client = client
result = await handler.wp_menu_set(
menu_id=3,
items=[{"type": "post_type", "object": "post", "object_id": 999}],
)
assert result == {"code": "forbidden_object_id", "status": 403}
# ───── Widget routing ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_widget_areas_list_calls_route(handler, client):
await handler.wp_widget_areas_list()
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/widgets/areas",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_widget_get_calls_route(handler, client):
await handler.wp_widget_get(area_id="sidebar-1")
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/widgets/sidebar-1",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_widget_get_rejects_empty_area_id(handler, client):
with pytest.raises(ValueError, match="area_id"):
await handler.wp_widget_get(area_id="")
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_widget_set_strips_caller_kind(handler, client):
"""Caller-side ``kind`` is ignored — area kind is set by the area."""
await handler.wp_widget_set(
area_id="sidebar-1",
widgets=[
{
"type": "block",
"raw": "<!-- wp:paragraph -->Hi<!-- /wp:paragraph -->",
"kind": "block",
}
],
)
args, kwargs = client.put.call_args
body = kwargs["json_data"]
assert "kind" not in body["widgets"][0], "caller kind must not be relayed"
assert body["widgets"][0]["type"] == "block"
assert "raw" in body["widgets"][0]
@pytest.mark.asyncio
async def test_widget_set_passes_full_block_widget(handler, client):
await handler.wp_widget_set(
area_id="sidebar-1",
widgets=[{"type": "block", "raw": "<!-- wp:paragraph -->X<!-- /wp:paragraph -->"}],
)
client.put.assert_awaited_once_with(
"airano-mcp/v1/admin/widgets/sidebar-1",
json_data={
"widgets": [{"type": "block", "raw": "<!-- wp:paragraph -->X<!-- /wp:paragraph -->"}]
},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_widget_set_rejects_non_list_widgets(handler, client):
with pytest.raises(ValueError, match="widgets must be a list"):
await handler.wp_widget_set(area_id="sidebar-1", widgets={"wrong": "shape"}) # type: ignore[arg-type]
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_widget_set_relays_unsupported_legacy_widget_envelope(handler, client):
"""Server returns unsupported_legacy_widget; client relays untouched."""
client.put = AsyncMock( # type: ignore[method-assign]
return_value={"code": "unsupported_legacy_widget", "status": 400}
)
handler.client = client
result = await handler.wp_widget_set(
area_id="legacy-sidebar",
widgets=[{"type": "recent-posts", "settings": {"title": "Recent"}}],
)
assert result == {"code": "unsupported_legacy_widget", "status": 400}
# ───── Customizer routing ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_customizer_get_calls_route(handler, client):
await handler.wp_customizer_changeset(action="get")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/customizer/changeset",
json_data={"action": "get"},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_customizer_apply_includes_action_in_body(handler, client):
await handler.wp_customizer_changeset(action="apply")
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {"action": "apply"}
@pytest.mark.asyncio
async def test_customizer_discard_includes_action_in_body(handler, client):
await handler.wp_customizer_changeset(action="discard")
args, kwargs = client.post.call_args
assert kwargs["json_data"] == {"action": "discard"}
@pytest.mark.asyncio
async def test_customizer_rejects_bad_action(handler, client):
with pytest.raises(ValueError, match="action must be one of"):
await handler.wp_customizer_changeset(action="apply_now")
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_customizer_relays_s24_forbidden_envelope(handler, client):
"""S-24 customize cap missing — server returns 403; client relays."""
client.post = AsyncMock( # type: ignore[method-assign]
return_value={"code": "rest_forbidden", "status": 403}
)
handler.client = client
result = await handler.wp_customizer_changeset(action="apply")
assert result == {"code": "rest_forbidden", "status": 403}
@pytest.mark.asyncio
async def test_customizer_relays_empty_changeset_envelope(handler, client):
"""Server maps no-pending-changeset to {status: empty} 200."""
client.post = AsyncMock( # type: ignore[method-assign]
return_value={"status": "empty", "changeset": None}
)
handler.client = client
result = await handler.wp_customizer_changeset(action="get")
assert result == {"status": "empty", "changeset": None}

View File

@@ -0,0 +1,376 @@
"""F.19.7 — Tests for the WordPress Specialist theme dev handler.
Mocks ``WordPressClient.get`` / ``post`` / ``put`` / ``delete`` and
asserts:
* tool spec contract: 7 tools (3 management + 4 file CRUD), reads at
``scope=read``, writes at ``scope=editor``
* each handler method targets the correct ``airano-mcp/v1/admin/*``
route with ``use_custom_namespace=True``
* client-side guards reject malformed slugs (S-15), traversal-shaped
paths (S-16), oversized payloads (S-18), and bad expected_sha256
(S-19)
* mutually-exclusive zip_url / zip_base64 contract is enforced
"""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_specialist.handlers.themes import (
ThemesHandler,
_validate_theme_file_path,
_validate_theme_slug,
get_tool_specifications,
)
# ───── Tool spec contract ────────────────────────────────────────────
def test_tool_specs_count_and_names_match_f197():
specs = get_tool_specifications()
assert len(specs) == 7, "F.19.7 advertises 3 management + 4 file CRUD"
names = {s["name"] for s in specs}
assert names == {
"wp_theme_install_from_zip",
"wp_theme_activate",
"wp_theme_delete",
"wp_theme_file_list",
"wp_theme_file_read",
"wp_theme_file_write",
"wp_theme_file_delete",
}
def test_reads_are_read_scope_writes_are_editor_scope():
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
expected_scope = {
"wp_theme_install_from_zip": "editor",
"wp_theme_activate": "editor",
"wp_theme_delete": "editor",
"wp_theme_file_list": "read",
"wp_theme_file_read": "read",
"wp_theme_file_write": "editor",
"wp_theme_file_delete": "editor",
}
for name, scope in expected_scope.items():
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
def test_every_spec_has_the_full_contract():
"""Each F.19.7 spec must carry name, method_name, description, schema."""
for spec in get_tool_specifications():
assert spec["name"] == spec["method_name"]
assert spec["description"]
assert isinstance(spec["schema"], dict)
assert spec["schema"].get("type") == "object"
def test_file_list_schema_caps_max_files_at_1000():
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_theme_file_list")
assert spec["schema"]["properties"]["max_files"]["maximum"] == 1000
# ───── Slug + path validation (S-15 + S-16 client-side) ──────────────
@pytest.mark.parametrize(
"good",
[
"twentytwentyfive",
"palebluedot",
"my-child-theme",
"theme_v2",
"T1",
],
)
def test_validate_theme_slug_accepts_well_formed(good):
assert _validate_theme_slug(good) == good
@pytest.mark.parametrize(
"bad",
[
"",
"../etc",
"slug/with/slash",
"slug.with.dot",
"-leading-dash",
"_leading-underscore-fine?", # actually valid? No: starts with _, our regex requires alnum start
"slug with spaces",
"x" * 65,
None,
42,
],
)
def test_validate_theme_slug_rejects_malformed(bad):
with pytest.raises(ValueError):
_validate_theme_slug(bad)
@pytest.mark.parametrize(
"good",
[
"style.css",
"parts/header.html",
"templates/page-home.html",
"assets/img/hero.jpg",
"..foo/bar", # `..foo` is a real filename, not traversal
],
)
def test_validate_theme_file_path_accepts_clean_paths(good):
out = _validate_theme_file_path(good)
assert "/" in out or out == good
assert ".." not in out.split("/")
@pytest.mark.parametrize(
"bad",
[
"",
"../etc/passwd",
"/absolute/path",
"parts/../../escape",
"parts/\x00null",
"parts\\windows.html",
"parts/..", # `..` segment
],
)
def test_validate_theme_file_path_rejects_traversal(bad):
with pytest.raises(ValueError):
_validate_theme_file_path(bad)
# ───── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.put = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
c.delete = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
return c
@pytest.fixture
def handler(client):
return ThemesHandler(client)
# ───── Theme management routing ──────────────────────────────────────
@pytest.mark.asyncio
async def test_install_from_zip_url_calls_install_route(handler, client):
await handler.wp_theme_install_from_zip(
zip_url="https://example.com/theme.zip", activate=True, overwrite=False
)
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/install",
json_data={
"zip_url": "https://example.com/theme.zip",
"activate": True,
"overwrite": False,
},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_install_from_zip_base64_calls_install_route(handler, client):
payload = base64.b64encode(b"PK\x03\x04 fake zip").decode()
await handler.wp_theme_install_from_zip(zip_base64=payload)
args, kwargs = client.post.call_args
assert args[0] == "airano-mcp/v1/admin/themes/install"
assert kwargs["json_data"]["zip_base64"] == payload
assert kwargs["json_data"]["activate"] is False
assert "zip_url" not in kwargs["json_data"]
@pytest.mark.asyncio
async def test_install_requires_one_of_url_or_base64(handler, client):
with pytest.raises(ValueError, match="zip_url or zip_base64"):
await handler.wp_theme_install_from_zip()
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_install_rejects_both_url_and_base64(handler, client):
with pytest.raises(ValueError, match="not both"):
await handler.wp_theme_install_from_zip(
zip_url="https://example.com/x.zip", zip_base64="aGVsbG8="
)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_install_rejects_oversized_zip_base64(handler, client):
# 60 MB worth of base64 chars (~45 MB decoded? no — 60M chars × 3/4 = 45M).
# We need the upper bound > 50 MB, so 70M chars should do it.
huge = "A" * (70 * 1024 * 1024)
with pytest.raises(ValueError, match=r"exceeds .* byte cap \(S-18\)"):
await handler.wp_theme_install_from_zip(zip_base64=huge)
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_activate_calls_activate_route(handler, client):
await handler.wp_theme_activate(slug="palebluedot")
client.post.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/palebluedot/activate",
json_data={},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_activate_rejects_bad_slug(handler, client):
with pytest.raises(ValueError):
await handler.wp_theme_activate(slug="../etc")
client.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_calls_delete_route(handler, client):
await handler.wp_theme_delete(slug="oldtheme")
client.delete.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/oldtheme",
use_custom_namespace=True,
)
# ───── Theme file CRUD routing ───────────────────────────────────────
@pytest.mark.asyncio
async def test_file_list_calls_list_route_with_glob_and_max(handler, client):
await handler.wp_theme_file_list(theme_slug="palebluedot", glob="**/*.php", max_files=500)
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/files/palebluedot",
params={"glob": "**/*.php", "max_files": 500},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_file_list_uses_default_glob_and_max(handler, client):
await handler.wp_theme_file_list(theme_slug="palebluedot")
args, kwargs = client.get.call_args
assert kwargs["params"]["glob"] == "**/*"
assert kwargs["params"]["max_files"] == 1000
@pytest.mark.asyncio
async def test_file_list_rejects_oversized_max_files(handler, client):
with pytest.raises(ValueError, match="exceeds the 1000"):
await handler.wp_theme_file_list(theme_slug="palebluedot", max_files=2000)
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_read_calls_read_route_with_quoted_path(handler, client):
await handler.wp_theme_file_read(theme_slug="palebluedot", path="parts/header.html")
client.get.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/files/palebluedot/parts/header.html",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_file_read_quotes_special_characters_in_path(handler, client):
"""Spaces and other URL-unsafe chars must be percent-encoded; / stays literal."""
await handler.wp_theme_file_read(theme_slug="palebluedot", path="parts/hero image.html")
args, kwargs = client.get.call_args
# Forward slashes preserved; space encoded.
assert "parts/hero%20image.html" in args[0]
@pytest.mark.asyncio
async def test_file_read_rejects_traversal(handler, client):
with pytest.raises(ValueError):
await handler.wp_theme_file_read(theme_slug="palebluedot", path="../wp-config.php")
client.get.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_write_calls_put_route_with_body(handler, client):
payload = base64.b64encode(b"body { color: red; }").decode()
await handler.wp_theme_file_write(
theme_slug="palebluedot", path="style.css", content_base64=payload
)
client.put.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/files/palebluedot/style.css",
json_data={"content_base64": payload, "create_dirs": True},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_file_write_passes_expected_sha256_when_supplied(handler, client):
sha = "a" * 64
payload = base64.b64encode(b"hi").decode()
await handler.wp_theme_file_write(
theme_slug="palebluedot",
path="style.css",
content_base64=payload,
expected_sha256=sha,
)
args, kwargs = client.put.call_args
assert kwargs["json_data"]["expected_sha256"] == sha
@pytest.mark.asyncio
async def test_file_write_rejects_invalid_expected_sha256(handler, client):
with pytest.raises(ValueError, match="64-char hex"):
await handler.wp_theme_file_write(
theme_slug="palebluedot",
path="style.css",
content_base64=base64.b64encode(b"hi").decode(),
expected_sha256="not-a-hash",
)
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_write_rejects_oversized_content(handler, client):
huge = "A" * (8 * 1024 * 1024) # ~8 MB base64 → ~6 MB decoded > 5 MB cap
with pytest.raises(ValueError, match=r"exceeds .* byte cap \(S-18\)"):
await handler.wp_theme_file_write(
theme_slug="palebluedot", path="style.css", content_base64=huge
)
client.put.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_write_passes_create_dirs_false(handler, client):
payload = base64.b64encode(b"x").decode()
await handler.wp_theme_file_write(
theme_slug="palebluedot",
path="parts/new.html",
content_base64=payload,
create_dirs=False,
)
args, kwargs = client.put.call_args
assert kwargs["json_data"]["create_dirs"] is False
@pytest.mark.asyncio
async def test_file_delete_calls_delete_route(handler, client):
await handler.wp_theme_file_delete(theme_slug="palebluedot", path="style.css")
client.delete.assert_awaited_once_with(
"airano-mcp/v1/admin/themes/files/palebluedot/style.css",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_file_delete_rejects_traversal(handler, client):
with pytest.raises(ValueError):
await handler.wp_theme_file_delete(theme_slug="palebluedot", path="../config.php")
client.delete.assert_not_awaited()

View File

@@ -4,11 +4,13 @@ Tests covering dashboard authentication, session management, rate limiting,
language detection, translations, and utility functions.
"""
import inspect
import time
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from starlette.requests import Request
from core.dashboard.auth import DashboardAuth, DashboardSession
from core.dashboard.routes import (
@@ -19,6 +21,29 @@ from core.dashboard.routes import (
get_translations,
)
def _make_request(path: str, *, query_string: str = "", path_params: dict[str, str] | None = None):
"""Construct a minimal GET Request for direct route-handler tests."""
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"scheme": "http",
"method": "GET",
"path": path,
"raw_path": path.encode(),
"query_string": query_string.encode(),
"headers": [],
"path_params": path_params or {},
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
# --- Plugin Display Names ---
@@ -36,26 +61,27 @@ class TestPluginDisplayNames:
assert get_plugin_display_name("appwrite") == "Appwrite"
assert get_plugin_display_name("directus") == "Directus"
def test_wordpress_advanced(self):
def test_wordpress_specialist(self):
"""Should handle underscore-separated names."""
assert get_plugin_display_name("wordpress_advanced") == "WordPress Advanced"
assert get_plugin_display_name("wordpress_specialist") == "WordPress Specialist"
def test_unknown_plugin_titlecased(self):
"""Should title-case unknown plugin types."""
assert get_plugin_display_name("my_custom_plugin") == "My Custom Plugin"
def test_all_nine_plugins_mapped(self):
"""All 9 plugin types should be in the display names map."""
def test_all_plugins_mapped(self):
"""Every registered plugin type must be in the display names map."""
expected = {
"wordpress",
"woocommerce",
"wordpress_advanced",
"wordpress_specialist",
"gitea",
"n8n",
"supabase",
"openpanel",
"appwrite",
"directus",
"coolify",
}
assert expected == set(PLUGIN_DISPLAY_NAMES.keys())
@@ -373,16 +399,46 @@ class TestDashboardCookieManagement:
assert "mcp_dashboard_session=" in cookie_header
def test_dashboard_connect_page(monkeypatch):
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
from server import create_multi_endpoint_app
from starlette.testclient import TestClient
@pytest.mark.asyncio
async def test_dashboard_connect_page(monkeypatch):
"""Legacy /dashboard-legacy/connect should redirect to legacy keys."""
from core.dashboard.routes import register_dashboard_routes
app = create_multi_endpoint_app()
client = TestClient(app, follow_redirects=False)
class FakeMCP:
def __init__(self):
self.routes = {}
resp = client.get("/dashboard/connect")
def custom_route(self, path, methods=None):
def decorator(func):
self.routes[(path, tuple(methods or []))] = func
return func
# /dashboard/connect now redirects 301 to /dashboard/keys
assert resp.status_code == 301
assert "/dashboard/keys" in resp.headers["location"]
return decorator
mcp = FakeMCP()
register_dashboard_routes(mcp)
handler = mcp.routes[("/dashboard-legacy/connect", ("GET",))]
response = handler(_make_request("/dashboard-legacy/connect"))
if inspect.isawaitable(response):
response = await response
assert response.status_code == 301
assert response.headers["location"] == "/dashboard-legacy/keys"
@pytest.mark.asyncio
async def test_dashboard_v2_redirects_to_dashboard(monkeypatch):
"""Old SPA URLs should 308 redirect to the cutover /dashboard prefix."""
from core.dashboard.spa_routes import redirect_dashboard_v2
response = await redirect_dashboard_v2(
_make_request(
"/dashboard-v2/overview",
query_string="lang=fa",
path_params={"path": "overview"},
)
)
assert response.status_code == 308
assert response.headers["location"] == "/dashboard/overview?lang=fa"

View File

@@ -1,4 +1,4 @@
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
"""Tests for the unified legacy /dashboard-legacy/keys page (F.7b session 2)."""
from __future__ import annotations
@@ -55,27 +55,27 @@ def user_client(monkeypatch, user_row, patched_db):
class TestUnifiedKeysUserView:
def test_get_keys_page_returns_200(self, user_client):
r = user_client.get("/dashboard/keys")
r = user_client.get("/dashboard-legacy/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")
r = user_client.get("/dashboard-legacy/connect")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
assert "/dashboard-legacy/keys" in r.headers["location"]
def test_old_api_keys_redirects_301(self, user_client):
r = user_client.get("/dashboard/api-keys")
r = user_client.get("/dashboard-legacy/api-keys")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
assert "/dashboard-legacy/keys" in r.headers["location"]
def test_user_view_has_full_access_badge(self, user_client):
r = user_client.get("/dashboard/keys")
r = user_client.get("/dashboard-legacy/keys")
assert r.status_code == 200
# 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")
r = user_client.get("/dashboard-legacy/keys")
assert r.status_code == 200
assert "Create" in r.text or "ایجاد" in r.text

685
tests/test_dashboard_v2.py Normal file
View File

@@ -0,0 +1,685 @@
"""
Track G.12 — backend tests for SPA support routes.
Covers direct handler contracts for:
* /api/me
* /api/i18n/{lang}
* /dashboard/* SPA serving
* /dashboard-v2/* -> /dashboard/* redirects
* /api/dashboard/login
"""
from __future__ import annotations
import json
import os
from urllib.parse import urlencode
import pytest
from starlette.requests import Request
from starlette.responses import FileResponse
pytestmark = pytest.mark.frontend
def _make_request(
path: str,
*,
method: str = "GET",
headers: dict[str, str] | None = None,
query_string: str = "",
path_params: dict[str, str] | None = None,
body: bytes | dict | None = None,
) -> Request:
"""Construct a minimal Starlette Request for direct handler tests."""
body_bytes = body if isinstance(body, bytes) else json.dumps(body).encode() if body else b""
header_pairs = [
(key.lower().encode("latin-1"), value.encode("latin-1"))
for key, value in (headers or {}).items()
]
sent = False
async def receive():
nonlocal sent
if sent:
return {"type": "http.request", "body": b"", "more_body": False}
sent = True
return {"type": "http.request", "body": body_bytes, "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"scheme": "http",
"method": method,
"path": path,
"raw_path": path.encode(),
"query_string": query_string.encode(),
"headers": header_pairs,
"path_params": path_params or {},
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
def _make_form_request(path: str, body: dict[str, str]) -> Request:
"""Construct a form-encoded POST Request."""
return _make_request(
path,
method="POST",
headers={"content-type": "application/x-www-form-urlencoded"},
body=urlencode(body).encode(),
)
class _AnonAuth:
def get_session_from_request(self, _request):
return None
def get_user_session_from_request(self, _request):
return None
async def test_api_me_unauthenticated_returns_anonymous(monkeypatch):
from core.dashboard import spa_routes
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
request = _make_request("/api/me", headers={"accept-language": "en-US,en;q=0.9"})
response = await spa_routes.api_me(request)
body = json.loads(response.body)
assert response.status_code == 200
assert body["authenticated"] is False
assert body["is_admin"] is False
assert body["lang"] in ("en", "fa")
assert "csrf_token" in body
assert "master_key_login_enabled" in body
async def test_api_me_master_key_enabled_by_default(monkeypatch):
from core.dashboard import spa_routes
monkeypatch.delenv("DISABLE_MASTER_KEY_LOGIN", raising=False)
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
response = await spa_routes.api_me(_make_request("/api/me"))
body = json.loads(response.body)
assert body["master_key_login_enabled"] is True
async def test_api_me_master_key_disabled_when_env_true(monkeypatch):
from core.dashboard import spa_routes
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
monkeypatch.setenv("GITHUB_CLIENT_ID", "github-client")
monkeypatch.setenv("GITHUB_CLIENT_SECRET", "github-secret")
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "google-secret")
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
response = await spa_routes.api_me(_make_request("/api/me"))
body = json.loads(response.body)
assert body["master_key_login_enabled"] is False
def test_dashboard_auth_rejects_master_key_when_disabled(monkeypatch):
from core.dashboard.auth import DashboardAuth
class FakeApiKeyManager:
def validate_key(self, *_args, **_kwargs):
return None
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
monkeypatch.setattr("core.api_keys.get_api_key_manager", lambda: FakeApiKeyManager())
auth = DashboardAuth(secret_key="test-secret", master_api_key="master-secret")
assert auth.validate_api_key("master-secret") == (False, "", None)
async def test_api_me_csrf_token_surfaces_when_middleware_sets_it(monkeypatch):
from core.dashboard import spa_routes
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
request = _make_request("/api/me")
request.state.csrf_token = "csrf-fixture-token-abc"
response = await spa_routes.api_me(request)
body = json.loads(response.body)
assert body["csrf_token"] == "csrf-fixture-token-abc"
async def test_api_i18n_returns_known_keys_for_en():
from core.dashboard import spa_routes
response = await spa_routes.api_i18n(_make_request("/api/i18n/en", path_params={"lang": "en"}))
body = json.loads(response.body)
assert response.status_code == 200
assert isinstance(body, dict)
assert len(body) > 0
async def test_api_i18n_falls_back_to_en_for_unknown_lang():
from core.dashboard import spa_routes
en_response = await spa_routes.api_i18n(
_make_request("/api/i18n/en", path_params={"lang": "en"})
)
zz_response = await spa_routes.api_i18n(
_make_request("/api/i18n/zz", path_params={"lang": "zz"})
)
assert zz_response.status_code == 200
assert json.loads(zz_response.body) == json.loads(en_response.body)
async def test_dashboard_serves_index_or_fallback():
from core.dashboard import spa_routes
for path in ("/dashboard", "/dashboard/", "/dashboard/sites", "/dashboard/foo/bar"):
response = await spa_routes.serve_spa(_make_request(path))
assert response.status_code == 200
assert response.media_type == "text/html"
if isinstance(response, FileResponse):
assert response.path.endswith("index.html")
else:
text = response.body.decode().lower()
assert "<html" in text or "<!doctype" in text
async def test_dashboard_spa_can_serve_404_status_with_same_shell():
from core.dashboard import spa_routes
response = await spa_routes.serve_spa(_make_request("/missing-page"), status_code=404)
assert response.status_code == 404
assert response.media_type == "text/html"
if isinstance(response, FileResponse):
assert response.path.endswith("index.html")
else:
text = response.body.decode().lower()
assert "<html" in text or "<!doctype" in text
async def test_dashboard_v2_redirects_to_dashboard():
from core.dashboard import spa_routes
cases = [
(_make_request("/dashboard-v2"), "/dashboard"),
(_make_request("/dashboard-v2/"), "/dashboard/"),
(
_make_request(
"/dashboard-v2/sites",
path_params={"path": "sites"},
),
"/dashboard/sites",
),
(
_make_request(
"/dashboard-v2/foo/bar",
query_string="x=1",
path_params={"path": "foo/bar"},
),
"/dashboard/foo/bar?x=1",
),
]
for request, expected in cases:
response = await spa_routes.redirect_dashboard_v2(request)
assert response.status_code == 308
assert response.headers["location"] == expected
async def test_site_tools_api_includes_plugin_scope_presets(monkeypatch):
from core.dashboard import routes
async def fake_require_owned_site(_request):
return {
"id": "site-1",
"plugin_type": "coolify",
"tool_scope": "admin",
}, None
class FakeAccess:
async def list_tools_for_site(self, site_id, plugin_type):
assert site_id == "site-1"
assert plugin_type == "coolify"
return []
async def fake_providers(_site_id):
return set()
monkeypatch.setattr(routes, "_require_owned_site", fake_require_owned_site)
monkeypatch.setattr("core.tool_access.get_tool_access_manager", lambda: FakeAccess())
monkeypatch.setattr("core.site_api.list_site_providers_set", fake_providers)
response = await routes.api_list_site_tools(_make_request("/api/sites/site-1/tools"))
body = json.loads(response.body)
assert response.status_code == 200
assert body["plugin_type"] == "coolify"
assert [p["value"] for p in body["scope_presets"]] == [
"read",
"read:sensitive",
"deploy",
"write",
"admin",
"custom",
]
async def test_user_key_create_all_sites_keeps_site_id_empty(monkeypatch):
from core.dashboard import routes
def fake_require_user_session(_request):
return {"user_id": "user-1", "type": "oauth_user"}, None
class FakeKeyManager:
async def create_key(self, **kwargs):
assert kwargs["user_id"] == "user-1"
assert kwargs["name"] == "all-sites"
assert kwargs["site_id"] is None
return {
"key_id": "key-1",
"key": "mhu_abcdefghijklmnopqrstuv",
"name": kwargs["name"],
"scopes": kwargs["scopes"],
"created_at": "2026-05-17T00:00:00Z",
"expires_at": None,
"site_id": kwargs["site_id"],
}
monkeypatch.setattr(routes, "_require_user_session", fake_require_user_session)
monkeypatch.setattr("core.user_keys.get_user_key_manager", lambda: FakeKeyManager())
response = await routes.api_create_key(
_make_request("/api/keys", method="POST", body={"name": "all-sites"})
)
body = json.loads(response.body)
assert response.status_code == 200
assert body["site_id"] is None
assert body["scope"] == "read editor settings install write admin"
async def test_oauth_client_create_uses_name_and_system_scopes(monkeypatch):
from core.dashboard import routes
class FakeAuth:
def get_session_from_request(self, _request):
return {"type": "master", "role": "admin"}
def get_user_session_from_request(self, _request):
return None
class FakeRegistry:
def __init__(self):
self.kwargs = None
def create_client(self, **kwargs):
self.kwargs = kwargs
return "client-1", "secret-1"
class FakeAudit:
def log_system_event(self, **_kwargs):
return None
registry = FakeRegistry()
monkeypatch.setattr(routes, "get_dashboard_auth", lambda: FakeAuth())
monkeypatch.setattr(routes, "is_admin_session", lambda _session: True)
monkeypatch.setattr("core.oauth.client_registry.get_client_registry", lambda: registry)
monkeypatch.setattr("core.audit_log.get_audit_logger", lambda: FakeAudit())
response = await routes.dashboard_oauth_clients_create(
_make_request(
"/api/dashboard/oauth-clients/create",
method="POST",
body={
"name": "Claude and ChatGPT",
"redirect_uris": [
"https://chatgpt.com/connector/oauth/jl0vrVeOwbY8",
"https://claude.ai/api/mcp/auth_callback",
],
"scope": "read",
},
)
)
body = json.loads(response.body)
assert response.status_code == 200
assert body["client_id"] == "client-1"
assert registry.kwargs["client_name"] == "Claude and ChatGPT"
assert registry.kwargs["redirect_uris"] == [
"https://chatgpt.com/connector/oauth/jl0vrVeOwbY8",
"https://claude.ai/api/mcp/auth_callback",
]
assert registry.kwargs["allowed_scopes"] == [
"read",
"read:sensitive",
"deploy",
"editor",
"settings",
"install",
"write",
"admin",
]
async def test_dashboard_settings_rejects_invalid_user_limit(monkeypatch):
from core.dashboard import routes
monkeypatch.setattr(
routes, "_require_admin_session", lambda _request: ({"role": "admin"}, None)
)
response = await routes.api_save_setting(
_make_request(
"/api/dashboard/settings",
method="POST",
headers={"content-type": "application/json"},
body={"key": "MAX_SITES_PER_USER", "value": "0"},
)
)
assert response.status_code == 400
assert "positive integer" in json.loads(response.body)["error"]
async def test_dashboard_settings_reset_clears_all_managed_settings(monkeypatch):
from core.dashboard import routes
deleted: list[str] = []
async def fake_delete_setting_value(key: str) -> bool:
deleted.append(key)
return key != "USER_RATE_LIMIT_PER_HR"
monkeypatch.setattr(
routes, "_require_admin_session", lambda _request: ({"role": "admin"}, None)
)
monkeypatch.setattr("core.settings.delete_setting_value", fake_delete_setting_value)
response = await routes.api_reset_settings(
_make_request("/api/dashboard/settings/reset", method="POST")
)
body = json.loads(response.body)
assert response.status_code == 200
assert body == {"ok": True, "deleted": 3}
assert set(deleted) == {
"ENABLED_PLUGINS",
"MAX_SITES_PER_USER",
"USER_RATE_LIMIT_PER_MIN",
"USER_RATE_LIMIT_PER_HR",
}
class _DummyDashboardAuth:
def __init__(self, *, valid: bool = True, rate_limited: bool = False):
self.valid = valid
self.rate_limited = rate_limited
self.recorded_attempts = 0
def check_rate_limit(self, _ip: str) -> bool:
return not self.rate_limited
def record_login_attempt(self, _ip: str) -> None:
self.recorded_attempts += 1
def validate_api_key(self, api_key: str):
if self.valid and api_key == "good-master-key":
return True, "master", None
return False, "", None
def create_session(self, _user_type: str, _key_id):
return "admin-session-token"
def set_session_cookie(self, response, token: str) -> None:
response.set_cookie("mcp_dashboard_session", token)
async def test_dashboard_api_login_json_success_sets_cookie(monkeypatch):
from core.dashboard.routes import dashboard_api_login
auth = _DummyDashboardAuth(valid=True)
async def _fake_ensure_master_key_user(_auth):
return "user-session-token"
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
monkeypatch.setattr(
"core.dashboard.routes._ensure_master_key_user", _fake_ensure_master_key_user
)
request = _make_request(
"/api/dashboard/login",
method="POST",
headers={"content-type": "application/json"},
body={"api_key": "good-master-key", "next": "/dashboard/health"},
)
response = await dashboard_api_login(request)
assert response.status_code == 200
assert json.loads(response.body) == {"ok": True, "next": "/dashboard/health"}
assert "mcp_dashboard_session=user-session-token" in response.headers.get("set-cookie", "")
assert auth.recorded_attempts == 1
async def test_dashboard_api_login_json_invalid_key(monkeypatch):
from core.dashboard.routes import dashboard_api_login
auth = _DummyDashboardAuth(valid=False)
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
request = _make_request(
"/api/dashboard/login",
method="POST",
headers={"content-type": "application/json"},
body={"api_key": "wrong-key"},
)
response = await dashboard_api_login(request)
assert response.status_code == 401
assert json.loads(response.body) == {"ok": False, "error": "invalid"}
async def test_dashboard_api_login_rate_limited(monkeypatch):
from core.dashboard.routes import dashboard_api_login
auth = _DummyDashboardAuth(valid=True, rate_limited=True)
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
request = _make_request(
"/api/dashboard/login",
method="POST",
headers={"content-type": "application/json"},
body={"api_key": "good-master-key"},
)
response = await dashboard_api_login(request)
assert response.status_code == 429
assert json.loads(response.body) == {"ok": False, "error": "rate_limit"}
assert auth.recorded_attempts == 0
class _LoggedInAuth(_AnonAuth):
def get_session_from_request(self, _request):
return {"type": "master", "role": "admin"}
async def test_dashboard_legacy_login_page_redirects_authenticated_users(monkeypatch):
from core.dashboard.routes import dashboard_login_page
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: _LoggedInAuth())
response = await dashboard_login_page(_make_request("/dashboard-legacy/login"))
assert response.status_code == 303
assert response.headers["location"] == "/dashboard/overview"
async def test_dashboard_legacy_login_page_posts_to_legacy_handler(monkeypatch):
from core.dashboard.routes import dashboard_login_page
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: _AnonAuth())
response = await dashboard_login_page(_make_request("/dashboard-legacy/login"))
html = response.body.decode()
assert response.status_code == 200
assert 'action="/dashboard-legacy/login"' in html
assert 'action="/dashboard/login"' not in html
async def test_dashboard_legacy_login_submit_success_sets_cookie(monkeypatch):
from core.dashboard.routes import dashboard_login_submit
auth = _DummyDashboardAuth(valid=True)
async def _fake_ensure_master_key_user(_auth):
return "user-session-token"
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
monkeypatch.setattr(
"core.dashboard.routes._ensure_master_key_user", _fake_ensure_master_key_user
)
response = await dashboard_login_submit(
_make_form_request(
"/dashboard-legacy/login",
{"api_key": "good-master-key", "next": "/dashboard/health"},
)
)
assert response.status_code == 303
assert response.headers["location"] == "/dashboard/health"
assert "mcp_dashboard_session=user-session-token" in response.headers.get("set-cookie", "")
async def test_dashboard_legacy_login_submit_invalid_redirects_back(monkeypatch):
from core.dashboard.routes import dashboard_login_submit
auth = _DummyDashboardAuth(valid=False)
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
response = await dashboard_login_submit(
_make_form_request(
"/dashboard-legacy/login",
{"api_key": "wrong-key", "next": "/dashboard/health"},
)
)
assert response.status_code == 303
assert (
response.headers["location"]
== "/dashboard-legacy/login?error=invalid&next=/dashboard/health&lang=en"
)
async def test_dashboard_legacy_login_submit_rate_limited_redirects_back(monkeypatch):
from core.dashboard.routes import dashboard_login_submit
auth = _DummyDashboardAuth(valid=True, rate_limited=True)
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
response = await dashboard_login_submit(
_make_form_request("/dashboard-legacy/login", {"api_key": "good-master-key"})
)
assert response.status_code == 303
assert response.headers["location"] == "/dashboard-legacy/login?error=rate_limit&lang=en"
assert auth.recorded_attempts == 0
def test_spa_dist_dir_exists_as_placeholder():
"""The dist dir must exist (with .gitkeep) so package_data can include it."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
dist = os.path.join(repo_root, "core", "templates", "static", "dist")
assert os.path.isdir(dist), f"SPA dist directory missing: {dist}"
class TestAnalyticsSnippet:
"""_analytics_snippet() — env-var driven analytics tag assembly."""
def test_returns_empty_when_nothing_configured(self, monkeypatch):
from core.dashboard import spa_routes
for key in ("UMAMI_WEBSITE_ID", "UMAMI_URL", "OPENPANEL_API_URL", "OPENPANEL_CLIENT_ID"):
monkeypatch.delenv(key, raising=False)
assert spa_routes._analytics_snippet() == ""
def test_umami_only(self, monkeypatch):
from core.dashboard import spa_routes
monkeypatch.setenv("UMAMI_WEBSITE_ID", "abc-123")
monkeypatch.setenv("UMAMI_URL", "https://umami.example.com/script.js")
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
snippet = spa_routes._analytics_snippet()
assert 'data-website-id="abc-123"' in snippet
assert "https://umami.example.com/script.js" in snippet
assert "window.op" not in snippet
def test_openpanel_only(self, monkeypatch):
from core.dashboard import spa_routes
monkeypatch.delenv("UMAMI_WEBSITE_ID", raising=False)
monkeypatch.delenv("UMAMI_URL", raising=False)
monkeypatch.setenv("OPENPANEL_API_URL", "https://op.example.com")
monkeypatch.setenv("OPENPANEL_CLIENT_ID", "client-xyz")
snippet = spa_routes._analytics_snippet()
assert 'clientId:"client-xyz"' in snippet
assert "data-website-id" not in snippet
def test_partial_umami_config_is_skipped(self, monkeypatch):
"""Setting only WEBSITE_ID without URL must not render half a tag."""
from core.dashboard import spa_routes
monkeypatch.setenv("UMAMI_WEBSITE_ID", "abc-123")
monkeypatch.delenv("UMAMI_URL", raising=False)
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
assert spa_routes._analytics_snippet() == ""
class TestRenderSpaIndex:
"""_render_spa_index() — inject analytics tags into the built index.html."""
def _write_index(self, tmp_path, body: str) -> str:
path = tmp_path / "index.html"
path.write_text(body, encoding="utf-8")
return str(path)
def test_returns_unmodified_when_no_snippet(self, tmp_path, monkeypatch):
from core.dashboard import spa_routes
for key in ("UMAMI_WEBSITE_ID", "UMAMI_URL", "OPENPANEL_API_URL", "OPENPANEL_CLIENT_ID"):
monkeypatch.delenv(key, raising=False)
index = self._write_index(tmp_path, "<html><head><title>x</title></head></html>")
with open(index, "rb") as fh:
assert spa_routes._render_spa_index(index) == fh.read()
def test_injects_before_head_close(self, tmp_path, monkeypatch):
from core.dashboard import spa_routes
monkeypatch.setenv("UMAMI_WEBSITE_ID", "site-abc")
monkeypatch.setenv("UMAMI_URL", "https://umami.example.com/s.js")
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
index = self._write_index(
tmp_path, "<html><head><title>x</title></head><body></body></html>"
)
rendered = spa_routes._render_spa_index(index).decode("utf-8")
assert rendered.index("data-website-id") < rendered.index("</head>")
assert rendered.count("</head>") == 1 # marker not duplicated

View File

@@ -84,11 +84,30 @@ class TestIsPluginPublic:
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("gitea") is True
def test_default_wordpress_advanced_not_public(self):
"""WordPress Advanced is not public by default."""
def test_default_wordpress_specialist_public(self):
"""WordPress Specialist is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("wordpress_advanced") is False
assert is_plugin_public("wordpress_specialist") is True
def test_default_n8n_public(self):
"""n8n is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("n8n") is True
def test_default_coolify_public(self):
"""Coolify is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("coolify") is True
def test_default_directus_and_appwrite_not_public(self):
"""Directus and Appwrite stay disabled by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("directus") is False
assert is_plugin_public("appwrite") is False
def test_case_insensitive_check(self):
"""Plugin type check is case insensitive."""
@@ -120,8 +139,11 @@ class TestSiteApiIntegration:
assert "supabase" in fields
assert "gitea" in fields
assert "openpanel" in fields
assert "wordpress_advanced" not in fields
assert "n8n" not in fields
assert "wordpress_specialist" in fields
assert "n8n" in fields
assert "coolify" in fields
assert "directus" not in fields
assert "appwrite" not in fields
def test_get_user_plugin_names_filtered(self):
"""get_user_plugin_names only returns enabled plugins."""
@@ -136,7 +158,11 @@ class TestSiteApiIntegration:
assert "supabase" in names
assert "gitea" in names
assert "openpanel" in names
assert "wordpress_advanced" not in names
assert "wordpress_specialist" in names
assert "n8n" in names
assert "coolify" in names
assert "directus" not in names
assert "appwrite" not in names
def test_custom_env_changes_fields(self):
"""Custom ENABLED_PLUGINS changes which credential fields are returned."""

View File

@@ -0,0 +1,98 @@
"""Tests for ``resolve_public_base_url`` (2026-05-01).
Behaviour matrix that the helper has to cover so the dashboard's
``mcp_url`` rendering never falls back to a host-less path again — the
mcp-test app reproduced this by setting ``PUBLIC_URL=''`` (empty string,
not unset) in its env vars, which silently bypassed the previous
``os.environ.get("PUBLIC_URL", "http://localhost:8000")`` default.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from core.dashboard.routes import resolve_public_base_url
def _request(headers: dict[str, str], scheme: str = "http", netloc: str = "localhost:8000"):
"""Build the smallest Request stub the helper needs."""
req = MagicMock()
req.headers = headers
req.url = MagicMock()
req.url.scheme = scheme
req.url.netloc = netloc
return req
def test_env_var_wins_when_set_and_non_empty(monkeypatch):
monkeypatch.setenv("PUBLIC_URL", "https://mcp.example.com")
req = _request({"host": "internal.fly.dev"}, scheme="http")
assert resolve_public_base_url(req) == "https://mcp.example.com"
def test_env_var_strips_trailing_slash(monkeypatch):
monkeypatch.setenv("PUBLIC_URL", "https://mcp.example.com/")
req = _request({})
assert resolve_public_base_url(req) == "https://mcp.example.com"
def test_empty_env_var_falls_back_to_request(monkeypatch):
"""The mcp-test bug — ``PUBLIC_URL=''`` no longer produces a host-less URL."""
monkeypatch.setenv("PUBLIC_URL", "")
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "mcp-test.example.com"})
assert resolve_public_base_url(req) == "https://mcp-test.example.com"
def test_whitespace_env_var_treated_as_unset(monkeypatch):
monkeypatch.setenv("PUBLIC_URL", " ")
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "wp.example.org"})
assert resolve_public_base_url(req) == "https://wp.example.org"
def test_unset_env_var_uses_forwarded_headers(monkeypatch):
monkeypatch.delenv("PUBLIC_URL", raising=False)
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "mcp.example.com"})
assert resolve_public_base_url(req) == "https://mcp.example.com"
def test_falls_back_to_host_header_when_no_xfh(monkeypatch):
monkeypatch.delenv("PUBLIC_URL", raising=False)
req = _request({"host": "mcp.local"}, scheme="https")
assert resolve_public_base_url(req) == "https://mcp.local"
def test_falls_back_to_request_url_when_no_headers(monkeypatch):
monkeypatch.delenv("PUBLIC_URL", raising=False)
req = _request({}, scheme="http", netloc="127.0.0.1:8001")
assert resolve_public_base_url(req) == "http://127.0.0.1:8001"
def test_handles_csv_x_forwarded_proto(monkeypatch):
"""Some upstream proxies append the original scheme: ``https, http``."""
monkeypatch.delenv("PUBLIC_URL", raising=False)
req = _request({"x-forwarded-proto": "https, http", "x-forwarded-host": "mcp.example.com"})
assert resolve_public_base_url(req) == "https://mcp.example.com"
def test_handles_csv_x_forwarded_host(monkeypatch):
monkeypatch.delenv("PUBLIC_URL", raising=False)
req = _request(
{"x-forwarded-proto": "https", "x-forwarded-host": "edge1.example.com, internal"}
)
assert resolve_public_base_url(req) == "https://edge1.example.com"
@pytest.mark.parametrize(
"url",
[
"https://mcp.example.com",
"https://mcp.example.com/",
"https://mcp.example.com//",
],
)
def test_no_trailing_slash_in_output(monkeypatch, url):
monkeypatch.setenv("PUBLIC_URL", url)
req = _request({})
assert resolve_public_base_url(req) == "https://mcp.example.com"

View File

@@ -80,17 +80,18 @@ class TestCredentialFields:
@pytest.mark.unit
def test_get_credential_fields_all_plugins(self):
"""All 9 plugin types should return non-empty credential field lists."""
"""Every registered plugin type returns non-empty credential field lists."""
expected_plugins = [
"wordpress",
"woocommerce",
"wordpress_advanced",
"wordpress_specialist",
"gitea",
"n8n",
"supabase",
"openpanel",
"appwrite",
"directus",
"coolify",
]
for plugin_type in expected_plugins:
fields = get_credential_fields(plugin_type)
@@ -143,13 +144,13 @@ class TestCredentialValidation:
@pytest.mark.unit
def test_validate_credentials_optional_field(self):
"""Optional fields (like container) should not cause errors when missing."""
"""Optional advanced fields should not cause errors when missing."""
valid, errors = validate_credentials(
"wordpress_advanced",
"wordpress",
{
"username": "admin",
"app_password": "xxxx xxxx xxxx xxxx",
# container is optional — should not cause error
# wp_app_password is optional — should not cause error
},
)
assert valid is True

View File

@@ -112,6 +112,14 @@ class TestListSiteTools:
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

View File

@@ -75,48 +75,53 @@ def client(monkeypatch, user_row, patched_db, patched_access):
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 == 301
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}.
The handler returns 302 (not 301) on purpose — see the comment in
dashboard_sites_edit — so reloads always re-hit the server instead
of browsers caching the redirect by exact URL+query.
"""
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}/edit")
assert r.status_code == 302
assert f"/dashboard-legacy/sites/{coolify_site['id']}" in r.headers["location"]
class TestUnifiedSiteManagePage:
def test_manage_page_renders_without_500(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
assert r.status_code == 200
def test_manage_page_has_connection_section(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
r = client.get(f"/dashboard-legacy/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']}")
r = client.get(f"/dashboard-legacy/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']}")
r = client.get(f"/dashboard-legacy/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']}")
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "mcp-url" in r.text
assert coolify_site["alias"] in r.text
def test_manage_page_has_config_snippets(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
r = client.get(f"/dashboard-legacy/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']}")
r = client.get(f"/dashboard-legacy/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")
r = client.get("/dashboard-legacy/sites/nonexistent-id")
assert r.status_code in (302, 303)

View File

@@ -155,6 +155,61 @@ class TestToolDefinitionDefaults:
# ---------------------------------------------------------------------------
class TestUniversalScopeTierLadder:
"""F.19.2.0: the universal tier ladder must be a strict chain.
Each higher tier is a superset of every lower tier; every tier
includes ``read`` (so any keyholder can list inventory before
acting); ``admin`` includes everything else.
"""
def test_each_tier_is_a_strict_superset(self):
from core.tool_access import UNIVERSAL_SCOPE_TIERS
ladder = ["read", "editor", "settings", "install", "write", "admin"]
for lower, higher in zip(ladder[:-1], ladder[1:], strict=True):
lower_set = UNIVERSAL_SCOPE_TIERS[lower]
higher_set = UNIVERSAL_SCOPE_TIERS[higher]
assert lower_set <= higher_set, f"{higher} must be superset of {lower}"
assert higher_set - lower_set, f"{higher} must add at least one new scope over {lower}"
def test_every_tier_includes_read(self):
from core.tool_access import UNIVERSAL_SCOPE_TIERS
for tier_name, allowed in UNIVERSAL_SCOPE_TIERS.items():
assert "read" in allowed, f"{tier_name} must include read scope"
def test_admin_is_universal(self):
from core.tool_access import UNIVERSAL_SCOPE_TIERS
# Every required-scope value used elsewhere in the ladder must be
# reachable from an admin key.
all_scopes = set().union(*UNIVERSAL_SCOPE_TIERS.values())
assert UNIVERSAL_SCOPE_TIERS["admin"] == all_scopes
def test_settings_below_install_below_write(self):
"""Confirms the F.19.2.0 ordering: settings ⊂ install ⊂ write."""
from core.tool_access import UNIVERSAL_SCOPE_TIERS
s = UNIVERSAL_SCOPE_TIERS["settings"]
i = UNIVERSAL_SCOPE_TIERS["install"]
w = UNIVERSAL_SCOPE_TIERS["write"]
assert s < i < w
# Specifically: install adds "install"; write adds "write".
assert i - s == {"install"}
assert w - i == {"write"}
def test_tier_descriptions_cover_every_tier(self):
"""Every UNIVERSAL_SCOPE_TIERS key must have an EN+FA tooltip."""
from core.tool_access import TIER_DESCRIPTIONS, UNIVERSAL_SCOPE_TIERS
for tier_name in UNIVERSAL_SCOPE_TIERS:
assert tier_name in TIER_DESCRIPTIONS, f"missing description for tier {tier_name}"
entry = TIER_DESCRIPTIONS[tier_name]
for field in ("label_en", "label_fa", "hint_en", "hint_fa"):
assert field in entry and entry[field], f"{tier_name}.{field} missing or empty"
class TestScopesToCategories:
def test_read_only(self):
assert scopes_to_categories(["read"]) == {"read"}

View File

@@ -24,6 +24,7 @@ from core.tool_access import (
class TestProviderKeyHelpers:
def test_ai_image_tool_requires_key(self):
assert _tool_requires_provider_key("wordpress_generate_and_upload_image")
assert _tool_requires_provider_key("woocommerce_generate_and_upload_image")
def test_normal_tool_does_not_require_key(self):
assert not _tool_requires_provider_key("wordpress_create_post")
@@ -36,6 +37,8 @@ class TestProviderKeyHelpers:
def test_configured_helper_gates_ai_on_providers_set(self):
assert not _tool_has_configured_provider("wordpress_generate_and_upload_image", set())
assert _tool_has_configured_provider("wordpress_generate_and_upload_image", {"openrouter"})
assert not _tool_has_configured_provider("woocommerce_generate_and_upload_image", set())
assert _tool_has_configured_provider("woocommerce_generate_and_upload_image", {"openai"})
class _FakeToolDef:

View File

@@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from starlette.requests import Request
from core.settings import get_cached_rate_per_min
from core.user_endpoints import (
USER_RATE_LIMIT_PER_MIN,
_rate_limits,
user_mcp_handler,
)
@@ -165,6 +165,12 @@ def mock_tool_registry():
registry = MagicMock()
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
# F.19.7.1: tools/call also looks up the tool by name — the universal
# scope check then reads ``required_scope`` off the returned tool_def.
# Without this wire-up ``get_by_name`` returns a fresh MagicMock and
# ``required_scope`` becomes a MagicMock, which is never a member of
# the allowed-scope set — the call is rejected with "Insufficient scope".
registry.get_by_name = MagicMock(return_value=tool_def)
with patch("core.tool_registry.get_tool_registry", return_value=registry):
yield registry
@@ -408,6 +414,216 @@ class TestMCPMethods:
assert "not supported" in body["error"]["message"]
# ── F.19.7.1 — Universal scope tier regression coverage ─────
#
# Pre-fix bug: when an mhu_ key was issued with scope ``editor`` (the
# tier introduced by F.19.5 + F.19.7), every tool call failed with
# ``Insufficient scope`` because:
#
# 1. The local ``scope_hierarchy`` map didn't know about ``editor``,
# so the editor key collapsed to level 0 and was rejected for every
# required_scope ≥ 1 (i.e. read/editor/write/admin).
# 2. The Coolify category whitelist was applied to every plugin. For
# wordpress_specialist tools whose default category is ``"read"``
# (one of Coolify's KNOWN_CATEGORIES), the whitelist rejected
# anything an editor-scope key requested unless that key happened
# to also include the ``read`` scope.
#
# These tests pin the post-fix behaviour: editor scope on a non-Coolify
# plugin admits both ``read`` and ``editor`` tools; Coolify still
# enforces its fine-grained category check.
class TestUniversalScopeTierRegression:
"""F.19.7.1 — editor scope tier must work on non-Coolify plugins."""
@pytest.mark.unit
async def test_editor_scope_admits_read_tool_on_wordpress_specialist(
self,
mock_key_mgr,
mock_db,
mock_encryption,
mock_plugin_registry,
):
"""editor scope key should be allowed to call a read-scope wp_specialist tool."""
# Key has only the editor scope.
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "editor",
}
# Site is wordpress_specialist with admin tier (so site-level
# check is permissive).
mock_db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress_specialist",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"tool_scope": "admin",
}
)
# F.19.7 read tool: required_scope=read, plugin_type=wordpress_specialist.
tool_def = MagicMock()
tool_def.name = "wordpress_specialist_wp_theme_file_list"
tool_def.plugin_type = "wordpress_specialist"
tool_def.required_scope = "read"
tool_def.category = "read"
tool_def.input_schema = {"type": "object", "properties": {}}
registry = MagicMock()
registry.get_by_name = MagicMock(return_value=tool_def)
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
# Make the mocked plugin instance expose the method so the call
# path doesn't error before the scope check completes.
plugin_instance = MagicMock()
plugin_instance.wp_theme_file_list = AsyncMock(return_value={"files": []})
mock_plugin_registry.create_instance = MagicMock(return_value=plugin_instance)
with (
patch("core.tool_registry.get_tool_registry", return_value=registry),
patch("core.plugin_visibility.is_plugin_public", return_value=True),
):
request = _make_request(
method_name="tools/call",
params={
"name": "wordpress_specialist_wp_theme_file_list",
"arguments": {"theme_slug": "palebluedot"},
},
)
response = await user_mcp_handler(request)
body = json.loads(response.body)
# Pre-fix: body["error"]["message"] starts with "Insufficient scope".
assert "error" not in body or "Insufficient scope" not in body["error"].get(
"message", ""
), f"editor scope was rejected for read-scope tool: {body}"
@pytest.mark.unit
async def test_editor_scope_admits_editor_tool_on_wordpress_specialist(
self,
mock_key_mgr,
mock_db,
mock_encryption,
mock_plugin_registry,
):
"""editor scope key should be allowed to call an editor-scope wp_specialist tool."""
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "editor",
}
mock_db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress_specialist",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"tool_scope": "editor",
}
)
tool_def = MagicMock()
tool_def.name = "wordpress_specialist_wp_theme_activate"
tool_def.plugin_type = "wordpress_specialist"
tool_def.required_scope = "editor"
tool_def.category = "read" # default — same trap that triggered the bug
tool_def.input_schema = {"type": "object", "properties": {}}
registry = MagicMock()
registry.get_by_name = MagicMock(return_value=tool_def)
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
plugin_instance = MagicMock()
plugin_instance.wp_theme_activate = AsyncMock(return_value={"activated": True})
mock_plugin_registry.create_instance = MagicMock(return_value=plugin_instance)
with (
patch("core.tool_registry.get_tool_registry", return_value=registry),
patch("core.plugin_visibility.is_plugin_public", return_value=True),
):
request = _make_request(
method_name="tools/call",
params={
"name": "wordpress_specialist_wp_theme_activate",
"arguments": {"slug": "palebluedot"},
},
)
response = await user_mcp_handler(request)
body = json.loads(response.body)
assert "error" not in body or "Insufficient scope" not in body["error"].get(
"message", ""
), f"editor scope was rejected for editor-scope tool: {body}"
@pytest.mark.unit
async def test_read_scope_still_rejects_editor_tool(
self,
mock_key_mgr,
mock_db,
mock_encryption,
mock_plugin_registry,
):
"""A pure read-scope key must NOT be able to call editor-scope tools."""
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read",
}
mock_db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
"user_id": "user-uuid-001",
"plugin_type": "wordpress_specialist",
"alias": "myblog",
"url": "https://myblog.example.com",
"credentials": b"encrypted-blob",
"status": "active",
"tool_scope": "admin",
}
)
tool_def = MagicMock()
tool_def.name = "wordpress_specialist_wp_theme_file_write"
tool_def.plugin_type = "wordpress_specialist"
tool_def.required_scope = "editor"
tool_def.category = "read"
tool_def.input_schema = {"type": "object", "properties": {}}
registry = MagicMock()
registry.get_by_name = MagicMock(return_value=tool_def)
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
with (
patch("core.tool_registry.get_tool_registry", return_value=registry),
patch("core.plugin_visibility.is_plugin_public", return_value=True),
):
request = _make_request(
method_name="tools/call",
params={
"name": "wordpress_specialist_wp_theme_file_write",
"arguments": {
"theme_slug": "palebluedot",
"path": "style.css",
"content_base64": "Lyo=",
},
},
)
response = await user_mcp_handler(request)
body = json.loads(response.body)
assert "error" in body, "read scope must not be allowed to call an editor tool"
assert "Insufficient scope" in body["error"]["message"]
# ── Rate Limiting ────────────────────────────────────────────
@@ -419,7 +635,7 @@ class TestRateLimiting:
"""Exceeding per-minute rate limit should return 429."""
# Fill the rate limit bucket
now = time.time()
_rate_limits["user-uuid-001"] = [now - i for i in range(USER_RATE_LIMIT_PER_MIN)]
_rate_limits["user-uuid-001"] = [now - i for i in range(get_cached_rate_per_min())]
request = _make_request(method_name="initialize")
response = await user_mcp_handler(request)