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:
312
tests/plugins/wordpress_specialist/test_bulk.py
Normal file
312
tests/plugins/wordpress_specialist/test_bulk.py
Normal 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
|
||||
250
tests/plugins/wordpress_specialist/test_db_inspection.py
Normal file
250
tests/plugins/wordpress_specialist/test_db_inspection.py
Normal 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}
|
||||
188
tests/plugins/wordpress_specialist/test_management.py
Normal file
188
tests/plugins/wordpress_specialist/test_management.py
Normal 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"
|
||||
414
tests/plugins/wordpress_specialist/test_pages.py
Normal file
414
tests/plugins/wordpress_specialist/test_pages.py
Normal 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": [],
|
||||
}
|
||||
250
tests/plugins/wordpress_specialist/test_plugins.py
Normal file
250
tests/plugins/wordpress_specialist/test_plugins.py
Normal 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()
|
||||
257
tests/plugins/wordpress_specialist/test_site_config.py
Normal file
257
tests/plugins/wordpress_specialist/test_site_config.py
Normal 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()
|
||||
370
tests/plugins/wordpress_specialist/test_site_layout.py
Normal file
370
tests/plugins/wordpress_specialist/test_site_layout.py
Normal 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}
|
||||
376
tests/plugins/wordpress_specialist/test_themes.py
Normal file
376
tests/plugins/wordpress_specialist/test_themes.py
Normal 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()
|
||||
Reference in New Issue
Block a user