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

@@ -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()