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:
@@ -360,3 +360,80 @@ class TestMetadataHelpers:
|
||||
def test_allowed_mimes_includes_common_types():
|
||||
for m in ("image/jpeg", "image/png", "image/webp", "application/pdf"):
|
||||
assert m in ALLOWED_MIMES
|
||||
|
||||
|
||||
# --- set_featured fallback (issue #90 gap 5) -------------------------------
|
||||
|
||||
|
||||
class TestSetFeaturedFallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_pages_when_post_id_invalid(self):
|
||||
from plugins.wordpress.handlers.media import _set_featured_with_fallback
|
||||
|
||||
client = _client()
|
||||
|
||||
async def fake_set_featured(_c, _post_id, _media_id):
|
||||
raise RuntimeError("Invalid post ID")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"plugins.wordpress.handlers.media.wp_set_featured_media",
|
||||
new=AsyncMock(side_effect=fake_set_featured),
|
||||
),
|
||||
patch.object(client, "post", new=AsyncMock(return_value={"id": 87})) as mock_post,
|
||||
):
|
||||
ctx = await _set_featured_with_fallback(client, 87, 94)
|
||||
assert ctx == "page"
|
||||
mock_post.assert_awaited_once_with("pages/87", json_data={"featured_media": 94})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_thumbnail_supporting_cpt(self):
|
||||
from plugins.wordpress.handlers.media import _set_featured_with_fallback
|
||||
|
||||
client = _client()
|
||||
|
||||
async def fail_post(path, **_):
|
||||
if path.startswith(("posts/", "pages/")):
|
||||
raise RuntimeError("Invalid post ID")
|
||||
return {"id": 200}
|
||||
|
||||
async def fake_get(path, **_):
|
||||
assert path == "types"
|
||||
return {
|
||||
"post": {"rest_base": "posts", "supports": {"thumbnail": True}},
|
||||
"page": {"rest_base": "pages", "supports": {"thumbnail": True}},
|
||||
"portfolio": {"rest_base": "portfolio", "supports": {"thumbnail": True}},
|
||||
"no_thumb_cpt": {"rest_base": "no_thumb_cpt", "supports": {}},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"plugins.wordpress.handlers.media.wp_set_featured_media",
|
||||
new=AsyncMock(side_effect=RuntimeError("Invalid post ID")),
|
||||
),
|
||||
patch.object(client, "post", new=AsyncMock(side_effect=fail_post)) as mock_post,
|
||||
patch.object(client, "get", new=AsyncMock(side_effect=fake_get)),
|
||||
):
|
||||
ctx = await _set_featured_with_fallback(client, 200, 9)
|
||||
assert ctx == "portfolio"
|
||||
# Should have tried pages/200 then portfolio/200 (no_thumb_cpt skipped).
|
||||
called_paths = [c.args[0] for c in mock_post.await_args_list]
|
||||
assert "pages/200" in called_paths
|
||||
assert "portfolio/200" in called_paths
|
||||
assert "no_thumb_cpt/200" not in called_paths
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_nothing_accepts(self):
|
||||
from plugins.wordpress.handlers.media import _set_featured_with_fallback
|
||||
|
||||
client = _client()
|
||||
with (
|
||||
patch(
|
||||
"plugins.wordpress.handlers.media.wp_set_featured_media",
|
||||
new=AsyncMock(side_effect=RuntimeError("Invalid post ID")),
|
||||
),
|
||||
patch.object(client, "post", new=AsyncMock(side_effect=RuntimeError("nope"))),
|
||||
patch.object(client, "get", new=AsyncMock(return_value={})),
|
||||
):
|
||||
ctx = await _set_featured_with_fallback(client, 9999, 1)
|
||||
assert ctx is None
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
|
||||
from plugins.ai_image.providers.base import GenerationRequest
|
||||
from plugins.ai_image.providers.openrouter import (
|
||||
_MODEL_PRICING,
|
||||
OpenRouterProvider,
|
||||
@@ -145,13 +145,35 @@ class TestGenerateAttributesCost:
|
||||
|
||||
class TestDeprecatedModelGuard:
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_model_raises_model_deprecated(self):
|
||||
async def test_preview_model_silently_substitutes_to_ga(self, caplog):
|
||||
"""Issue #90 — deprecated model id is auto-rewritten to the GA alias."""
|
||||
provider = OpenRouterProvider()
|
||||
with pytest.raises(ProviderError) as e:
|
||||
await provider.generate(
|
||||
"k",
|
||||
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
|
||||
b64 = base64.b64encode(_PNG_1x1).decode()
|
||||
ok = _fake_resp(
|
||||
status=200,
|
||||
json_data={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
sess = _mock_session([ok])
|
||||
with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
|
||||
cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=sess),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
assert e.value.code == "PROVIDER_MODEL_DEPRECATED"
|
||||
assert "google/gemini-2.5-flash-image" in str(e.value.message)
|
||||
assert e.value.details.get("replacement_model") == "google/gemini-2.5-flash-image"
|
||||
with caplog.at_level("WARNING", logger="mcphub.ai_image.openrouter"):
|
||||
result = await provider.generate(
|
||||
"k",
|
||||
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
|
||||
)
|
||||
assert result.meta["model"] == "google/gemini-2.5-flash-image"
|
||||
assert any(
|
||||
"is deprecated" in rec.message and "google/gemini-2.5-flash-image" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -4,11 +4,13 @@ Tests covering dashboard authentication, session management, rate limiting,
|
||||
language detection, translations, and utility functions.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.dashboard.auth import DashboardAuth, DashboardSession
|
||||
from core.dashboard.routes import (
|
||||
@@ -19,6 +21,29 @@ from core.dashboard.routes import (
|
||||
get_translations,
|
||||
)
|
||||
|
||||
|
||||
def _make_request(path: str, *, query_string: str = "", path_params: dict[str, str] | None = None):
|
||||
"""Construct a minimal GET Request for direct route-handler tests."""
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"scheme": "http",
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": query_string.encode(),
|
||||
"headers": [],
|
||||
"path_params": path_params or {},
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
# --- Plugin Display Names ---
|
||||
|
||||
|
||||
@@ -36,26 +61,27 @@ class TestPluginDisplayNames:
|
||||
assert get_plugin_display_name("appwrite") == "Appwrite"
|
||||
assert get_plugin_display_name("directus") == "Directus"
|
||||
|
||||
def test_wordpress_advanced(self):
|
||||
def test_wordpress_specialist(self):
|
||||
"""Should handle underscore-separated names."""
|
||||
assert get_plugin_display_name("wordpress_advanced") == "WordPress Advanced"
|
||||
assert get_plugin_display_name("wordpress_specialist") == "WordPress Specialist"
|
||||
|
||||
def test_unknown_plugin_titlecased(self):
|
||||
"""Should title-case unknown plugin types."""
|
||||
assert get_plugin_display_name("my_custom_plugin") == "My Custom Plugin"
|
||||
|
||||
def test_all_nine_plugins_mapped(self):
|
||||
"""All 9 plugin types should be in the display names map."""
|
||||
def test_all_plugins_mapped(self):
|
||||
"""Every registered plugin type must be in the display names map."""
|
||||
expected = {
|
||||
"wordpress",
|
||||
"woocommerce",
|
||||
"wordpress_advanced",
|
||||
"wordpress_specialist",
|
||||
"gitea",
|
||||
"n8n",
|
||||
"supabase",
|
||||
"openpanel",
|
||||
"appwrite",
|
||||
"directus",
|
||||
"coolify",
|
||||
}
|
||||
assert expected == set(PLUGIN_DISPLAY_NAMES.keys())
|
||||
|
||||
@@ -373,16 +399,46 @@ class TestDashboardCookieManagement:
|
||||
assert "mcp_dashboard_session=" in cookie_header
|
||||
|
||||
|
||||
def test_dashboard_connect_page(monkeypatch):
|
||||
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
|
||||
from server import create_multi_endpoint_app
|
||||
from starlette.testclient import TestClient
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_connect_page(monkeypatch):
|
||||
"""Legacy /dashboard-legacy/connect should redirect to legacy keys."""
|
||||
from core.dashboard.routes import register_dashboard_routes
|
||||
|
||||
app = create_multi_endpoint_app()
|
||||
client = TestClient(app, follow_redirects=False)
|
||||
class FakeMCP:
|
||||
def __init__(self):
|
||||
self.routes = {}
|
||||
|
||||
resp = client.get("/dashboard/connect")
|
||||
def custom_route(self, path, methods=None):
|
||||
def decorator(func):
|
||||
self.routes[(path, tuple(methods or []))] = func
|
||||
return func
|
||||
|
||||
# /dashboard/connect now redirects 301 to /dashboard/keys
|
||||
assert resp.status_code == 301
|
||||
assert "/dashboard/keys" in resp.headers["location"]
|
||||
return decorator
|
||||
|
||||
mcp = FakeMCP()
|
||||
register_dashboard_routes(mcp)
|
||||
handler = mcp.routes[("/dashboard-legacy/connect", ("GET",))]
|
||||
|
||||
response = handler(_make_request("/dashboard-legacy/connect"))
|
||||
if inspect.isawaitable(response):
|
||||
response = await response
|
||||
|
||||
assert response.status_code == 301
|
||||
assert response.headers["location"] == "/dashboard-legacy/keys"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_v2_redirects_to_dashboard(monkeypatch):
|
||||
"""Old SPA URLs should 308 redirect to the cutover /dashboard prefix."""
|
||||
from core.dashboard.spa_routes import redirect_dashboard_v2
|
||||
|
||||
response = await redirect_dashboard_v2(
|
||||
_make_request(
|
||||
"/dashboard-v2/overview",
|
||||
query_string="lang=fa",
|
||||
path_params={"path": "overview"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 308
|
||||
assert response.headers["location"] == "/dashboard/overview?lang=fa"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
|
||||
"""Tests for the unified legacy /dashboard-legacy/keys page (F.7b session 2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,27 +55,27 @@ def user_client(monkeypatch, user_row, patched_db):
|
||||
|
||||
class TestUnifiedKeysUserView:
|
||||
def test_get_keys_page_returns_200(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
r = user_client.get("/dashboard-legacy/keys")
|
||||
assert r.status_code == 200
|
||||
assert "API Key" in r.text or "کلید" in r.text
|
||||
|
||||
def test_old_connect_redirects_301(self, user_client):
|
||||
r = user_client.get("/dashboard/connect")
|
||||
r = user_client.get("/dashboard-legacy/connect")
|
||||
assert r.status_code == 301
|
||||
assert "/dashboard/keys" in r.headers["location"]
|
||||
assert "/dashboard-legacy/keys" in r.headers["location"]
|
||||
|
||||
def test_old_api_keys_redirects_301(self, user_client):
|
||||
r = user_client.get("/dashboard/api-keys")
|
||||
r = user_client.get("/dashboard-legacy/api-keys")
|
||||
assert r.status_code == 301
|
||||
assert "/dashboard/keys" in r.headers["location"]
|
||||
assert "/dashboard-legacy/keys" in r.headers["location"]
|
||||
|
||||
def test_user_view_has_full_access_badge(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
r = user_client.get("/dashboard-legacy/keys")
|
||||
assert r.status_code == 200
|
||||
# F.7c: No scope selector — shows "Full Access" badge instead
|
||||
assert "Full Access" in r.text or "دسترسی کامل" in r.text
|
||||
|
||||
def test_user_view_shows_create_button(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
r = user_client.get("/dashboard-legacy/keys")
|
||||
assert r.status_code == 200
|
||||
assert "Create" in r.text or "ایجاد" in r.text
|
||||
|
||||
685
tests/test_dashboard_v2.py
Normal file
685
tests/test_dashboard_v2.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
Track G.12 — backend tests for SPA support routes.
|
||||
|
||||
Covers direct handler contracts for:
|
||||
* /api/me
|
||||
* /api/i18n/{lang}
|
||||
* /dashboard/* SPA serving
|
||||
* /dashboard-v2/* -> /dashboard/* redirects
|
||||
* /api/dashboard/login
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
pytestmark = pytest.mark.frontend
|
||||
|
||||
|
||||
def _make_request(
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
headers: dict[str, str] | None = None,
|
||||
query_string: str = "",
|
||||
path_params: dict[str, str] | None = None,
|
||||
body: bytes | dict | None = None,
|
||||
) -> Request:
|
||||
"""Construct a minimal Starlette Request for direct handler tests."""
|
||||
body_bytes = body if isinstance(body, bytes) else json.dumps(body).encode() if body else b""
|
||||
header_pairs = [
|
||||
(key.lower().encode("latin-1"), value.encode("latin-1"))
|
||||
for key, value in (headers or {}).items()
|
||||
]
|
||||
sent = False
|
||||
|
||||
async def receive():
|
||||
nonlocal sent
|
||||
if sent:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
sent = True
|
||||
return {"type": "http.request", "body": body_bytes, "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"scheme": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": query_string.encode(),
|
||||
"headers": header_pairs,
|
||||
"path_params": path_params or {},
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
def _make_form_request(path: str, body: dict[str, str]) -> Request:
|
||||
"""Construct a form-encoded POST Request."""
|
||||
return _make_request(
|
||||
path,
|
||||
method="POST",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
body=urlencode(body).encode(),
|
||||
)
|
||||
|
||||
|
||||
class _AnonAuth:
|
||||
def get_session_from_request(self, _request):
|
||||
return None
|
||||
|
||||
def get_user_session_from_request(self, _request):
|
||||
return None
|
||||
|
||||
|
||||
async def test_api_me_unauthenticated_returns_anonymous(monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
|
||||
|
||||
request = _make_request("/api/me", headers={"accept-language": "en-US,en;q=0.9"})
|
||||
response = await spa_routes.api_me(request)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body["authenticated"] is False
|
||||
assert body["is_admin"] is False
|
||||
assert body["lang"] in ("en", "fa")
|
||||
assert "csrf_token" in body
|
||||
assert "master_key_login_enabled" in body
|
||||
|
||||
|
||||
async def test_api_me_master_key_enabled_by_default(monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.delenv("DISABLE_MASTER_KEY_LOGIN", raising=False)
|
||||
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
|
||||
|
||||
response = await spa_routes.api_me(_make_request("/api/me"))
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert body["master_key_login_enabled"] is True
|
||||
|
||||
|
||||
async def test_api_me_master_key_disabled_when_env_true(monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
|
||||
monkeypatch.setenv("GITHUB_CLIENT_ID", "github-client")
|
||||
monkeypatch.setenv("GITHUB_CLIENT_SECRET", "github-secret")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "google-secret")
|
||||
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
|
||||
|
||||
response = await spa_routes.api_me(_make_request("/api/me"))
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert body["master_key_login_enabled"] is False
|
||||
|
||||
|
||||
def test_dashboard_auth_rejects_master_key_when_disabled(monkeypatch):
|
||||
from core.dashboard.auth import DashboardAuth
|
||||
|
||||
class FakeApiKeyManager:
|
||||
def validate_key(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
|
||||
monkeypatch.setattr("core.api_keys.get_api_key_manager", lambda: FakeApiKeyManager())
|
||||
|
||||
auth = DashboardAuth(secret_key="test-secret", master_api_key="master-secret")
|
||||
|
||||
assert auth.validate_api_key("master-secret") == (False, "", None)
|
||||
|
||||
|
||||
async def test_api_me_csrf_token_surfaces_when_middleware_sets_it(monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setattr(spa_routes, "get_dashboard_auth", lambda: _AnonAuth())
|
||||
|
||||
request = _make_request("/api/me")
|
||||
request.state.csrf_token = "csrf-fixture-token-abc"
|
||||
response = await spa_routes.api_me(request)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert body["csrf_token"] == "csrf-fixture-token-abc"
|
||||
|
||||
|
||||
async def test_api_i18n_returns_known_keys_for_en():
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
response = await spa_routes.api_i18n(_make_request("/api/i18n/en", path_params={"lang": "en"}))
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert isinstance(body, dict)
|
||||
assert len(body) > 0
|
||||
|
||||
|
||||
async def test_api_i18n_falls_back_to_en_for_unknown_lang():
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
en_response = await spa_routes.api_i18n(
|
||||
_make_request("/api/i18n/en", path_params={"lang": "en"})
|
||||
)
|
||||
zz_response = await spa_routes.api_i18n(
|
||||
_make_request("/api/i18n/zz", path_params={"lang": "zz"})
|
||||
)
|
||||
|
||||
assert zz_response.status_code == 200
|
||||
assert json.loads(zz_response.body) == json.loads(en_response.body)
|
||||
|
||||
|
||||
async def test_dashboard_serves_index_or_fallback():
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
for path in ("/dashboard", "/dashboard/", "/dashboard/sites", "/dashboard/foo/bar"):
|
||||
response = await spa_routes.serve_spa(_make_request(path))
|
||||
assert response.status_code == 200
|
||||
assert response.media_type == "text/html"
|
||||
if isinstance(response, FileResponse):
|
||||
assert response.path.endswith("index.html")
|
||||
else:
|
||||
text = response.body.decode().lower()
|
||||
assert "<html" in text or "<!doctype" in text
|
||||
|
||||
|
||||
async def test_dashboard_spa_can_serve_404_status_with_same_shell():
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
response = await spa_routes.serve_spa(_make_request("/missing-page"), status_code=404)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.media_type == "text/html"
|
||||
if isinstance(response, FileResponse):
|
||||
assert response.path.endswith("index.html")
|
||||
else:
|
||||
text = response.body.decode().lower()
|
||||
assert "<html" in text or "<!doctype" in text
|
||||
|
||||
|
||||
async def test_dashboard_v2_redirects_to_dashboard():
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
cases = [
|
||||
(_make_request("/dashboard-v2"), "/dashboard"),
|
||||
(_make_request("/dashboard-v2/"), "/dashboard/"),
|
||||
(
|
||||
_make_request(
|
||||
"/dashboard-v2/sites",
|
||||
path_params={"path": "sites"},
|
||||
),
|
||||
"/dashboard/sites",
|
||||
),
|
||||
(
|
||||
_make_request(
|
||||
"/dashboard-v2/foo/bar",
|
||||
query_string="x=1",
|
||||
path_params={"path": "foo/bar"},
|
||||
),
|
||||
"/dashboard/foo/bar?x=1",
|
||||
),
|
||||
]
|
||||
|
||||
for request, expected in cases:
|
||||
response = await spa_routes.redirect_dashboard_v2(request)
|
||||
assert response.status_code == 308
|
||||
assert response.headers["location"] == expected
|
||||
|
||||
|
||||
async def test_site_tools_api_includes_plugin_scope_presets(monkeypatch):
|
||||
from core.dashboard import routes
|
||||
|
||||
async def fake_require_owned_site(_request):
|
||||
return {
|
||||
"id": "site-1",
|
||||
"plugin_type": "coolify",
|
||||
"tool_scope": "admin",
|
||||
}, None
|
||||
|
||||
class FakeAccess:
|
||||
async def list_tools_for_site(self, site_id, plugin_type):
|
||||
assert site_id == "site-1"
|
||||
assert plugin_type == "coolify"
|
||||
return []
|
||||
|
||||
async def fake_providers(_site_id):
|
||||
return set()
|
||||
|
||||
monkeypatch.setattr(routes, "_require_owned_site", fake_require_owned_site)
|
||||
monkeypatch.setattr("core.tool_access.get_tool_access_manager", lambda: FakeAccess())
|
||||
monkeypatch.setattr("core.site_api.list_site_providers_set", fake_providers)
|
||||
|
||||
response = await routes.api_list_site_tools(_make_request("/api/sites/site-1/tools"))
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body["plugin_type"] == "coolify"
|
||||
assert [p["value"] for p in body["scope_presets"]] == [
|
||||
"read",
|
||||
"read:sensitive",
|
||||
"deploy",
|
||||
"write",
|
||||
"admin",
|
||||
"custom",
|
||||
]
|
||||
|
||||
|
||||
async def test_user_key_create_all_sites_keeps_site_id_empty(monkeypatch):
|
||||
from core.dashboard import routes
|
||||
|
||||
def fake_require_user_session(_request):
|
||||
return {"user_id": "user-1", "type": "oauth_user"}, None
|
||||
|
||||
class FakeKeyManager:
|
||||
async def create_key(self, **kwargs):
|
||||
assert kwargs["user_id"] == "user-1"
|
||||
assert kwargs["name"] == "all-sites"
|
||||
assert kwargs["site_id"] is None
|
||||
return {
|
||||
"key_id": "key-1",
|
||||
"key": "mhu_abcdefghijklmnopqrstuv",
|
||||
"name": kwargs["name"],
|
||||
"scopes": kwargs["scopes"],
|
||||
"created_at": "2026-05-17T00:00:00Z",
|
||||
"expires_at": None,
|
||||
"site_id": kwargs["site_id"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "_require_user_session", fake_require_user_session)
|
||||
monkeypatch.setattr("core.user_keys.get_user_key_manager", lambda: FakeKeyManager())
|
||||
|
||||
response = await routes.api_create_key(
|
||||
_make_request("/api/keys", method="POST", body={"name": "all-sites"})
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body["site_id"] is None
|
||||
assert body["scope"] == "read editor settings install write admin"
|
||||
|
||||
|
||||
async def test_oauth_client_create_uses_name_and_system_scopes(monkeypatch):
|
||||
from core.dashboard import routes
|
||||
|
||||
class FakeAuth:
|
||||
def get_session_from_request(self, _request):
|
||||
return {"type": "master", "role": "admin"}
|
||||
|
||||
def get_user_session_from_request(self, _request):
|
||||
return None
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self):
|
||||
self.kwargs = None
|
||||
|
||||
def create_client(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return "client-1", "secret-1"
|
||||
|
||||
class FakeAudit:
|
||||
def log_system_event(self, **_kwargs):
|
||||
return None
|
||||
|
||||
registry = FakeRegistry()
|
||||
monkeypatch.setattr(routes, "get_dashboard_auth", lambda: FakeAuth())
|
||||
monkeypatch.setattr(routes, "is_admin_session", lambda _session: True)
|
||||
monkeypatch.setattr("core.oauth.client_registry.get_client_registry", lambda: registry)
|
||||
monkeypatch.setattr("core.audit_log.get_audit_logger", lambda: FakeAudit())
|
||||
|
||||
response = await routes.dashboard_oauth_clients_create(
|
||||
_make_request(
|
||||
"/api/dashboard/oauth-clients/create",
|
||||
method="POST",
|
||||
body={
|
||||
"name": "Claude and ChatGPT",
|
||||
"redirect_uris": [
|
||||
"https://chatgpt.com/connector/oauth/jl0vrVeOwbY8",
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
],
|
||||
"scope": "read",
|
||||
},
|
||||
)
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body["client_id"] == "client-1"
|
||||
assert registry.kwargs["client_name"] == "Claude and ChatGPT"
|
||||
assert registry.kwargs["redirect_uris"] == [
|
||||
"https://chatgpt.com/connector/oauth/jl0vrVeOwbY8",
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
]
|
||||
assert registry.kwargs["allowed_scopes"] == [
|
||||
"read",
|
||||
"read:sensitive",
|
||||
"deploy",
|
||||
"editor",
|
||||
"settings",
|
||||
"install",
|
||||
"write",
|
||||
"admin",
|
||||
]
|
||||
|
||||
|
||||
async def test_dashboard_settings_rejects_invalid_user_limit(monkeypatch):
|
||||
from core.dashboard import routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes, "_require_admin_session", lambda _request: ({"role": "admin"}, None)
|
||||
)
|
||||
|
||||
response = await routes.api_save_setting(
|
||||
_make_request(
|
||||
"/api/dashboard/settings",
|
||||
method="POST",
|
||||
headers={"content-type": "application/json"},
|
||||
body={"key": "MAX_SITES_PER_USER", "value": "0"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "positive integer" in json.loads(response.body)["error"]
|
||||
|
||||
|
||||
async def test_dashboard_settings_reset_clears_all_managed_settings(monkeypatch):
|
||||
from core.dashboard import routes
|
||||
|
||||
deleted: list[str] = []
|
||||
|
||||
async def fake_delete_setting_value(key: str) -> bool:
|
||||
deleted.append(key)
|
||||
return key != "USER_RATE_LIMIT_PER_HR"
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes, "_require_admin_session", lambda _request: ({"role": "admin"}, None)
|
||||
)
|
||||
monkeypatch.setattr("core.settings.delete_setting_value", fake_delete_setting_value)
|
||||
|
||||
response = await routes.api_reset_settings(
|
||||
_make_request("/api/dashboard/settings/reset", method="POST")
|
||||
)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body == {"ok": True, "deleted": 3}
|
||||
assert set(deleted) == {
|
||||
"ENABLED_PLUGINS",
|
||||
"MAX_SITES_PER_USER",
|
||||
"USER_RATE_LIMIT_PER_MIN",
|
||||
"USER_RATE_LIMIT_PER_HR",
|
||||
}
|
||||
|
||||
|
||||
class _DummyDashboardAuth:
|
||||
def __init__(self, *, valid: bool = True, rate_limited: bool = False):
|
||||
self.valid = valid
|
||||
self.rate_limited = rate_limited
|
||||
self.recorded_attempts = 0
|
||||
|
||||
def check_rate_limit(self, _ip: str) -> bool:
|
||||
return not self.rate_limited
|
||||
|
||||
def record_login_attempt(self, _ip: str) -> None:
|
||||
self.recorded_attempts += 1
|
||||
|
||||
def validate_api_key(self, api_key: str):
|
||||
if self.valid and api_key == "good-master-key":
|
||||
return True, "master", None
|
||||
return False, "", None
|
||||
|
||||
def create_session(self, _user_type: str, _key_id):
|
||||
return "admin-session-token"
|
||||
|
||||
def set_session_cookie(self, response, token: str) -> None:
|
||||
response.set_cookie("mcp_dashboard_session", token)
|
||||
|
||||
|
||||
async def test_dashboard_api_login_json_success_sets_cookie(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_api_login
|
||||
|
||||
auth = _DummyDashboardAuth(valid=True)
|
||||
|
||||
async def _fake_ensure_master_key_user(_auth):
|
||||
return "user-session-token"
|
||||
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
monkeypatch.setattr(
|
||||
"core.dashboard.routes._ensure_master_key_user", _fake_ensure_master_key_user
|
||||
)
|
||||
|
||||
request = _make_request(
|
||||
"/api/dashboard/login",
|
||||
method="POST",
|
||||
headers={"content-type": "application/json"},
|
||||
body={"api_key": "good-master-key", "next": "/dashboard/health"},
|
||||
)
|
||||
response = await dashboard_api_login(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {"ok": True, "next": "/dashboard/health"}
|
||||
assert "mcp_dashboard_session=user-session-token" in response.headers.get("set-cookie", "")
|
||||
assert auth.recorded_attempts == 1
|
||||
|
||||
|
||||
async def test_dashboard_api_login_json_invalid_key(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_api_login
|
||||
|
||||
auth = _DummyDashboardAuth(valid=False)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
|
||||
request = _make_request(
|
||||
"/api/dashboard/login",
|
||||
method="POST",
|
||||
headers={"content-type": "application/json"},
|
||||
body={"api_key": "wrong-key"},
|
||||
)
|
||||
response = await dashboard_api_login(request)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert json.loads(response.body) == {"ok": False, "error": "invalid"}
|
||||
|
||||
|
||||
async def test_dashboard_api_login_rate_limited(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_api_login
|
||||
|
||||
auth = _DummyDashboardAuth(valid=True, rate_limited=True)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
|
||||
request = _make_request(
|
||||
"/api/dashboard/login",
|
||||
method="POST",
|
||||
headers={"content-type": "application/json"},
|
||||
body={"api_key": "good-master-key"},
|
||||
)
|
||||
response = await dashboard_api_login(request)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert json.loads(response.body) == {"ok": False, "error": "rate_limit"}
|
||||
assert auth.recorded_attempts == 0
|
||||
|
||||
|
||||
class _LoggedInAuth(_AnonAuth):
|
||||
def get_session_from_request(self, _request):
|
||||
return {"type": "master", "role": "admin"}
|
||||
|
||||
|
||||
async def test_dashboard_legacy_login_page_redirects_authenticated_users(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_login_page
|
||||
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: _LoggedInAuth())
|
||||
|
||||
response = await dashboard_login_page(_make_request("/dashboard-legacy/login"))
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/dashboard/overview"
|
||||
|
||||
|
||||
async def test_dashboard_legacy_login_page_posts_to_legacy_handler(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_login_page
|
||||
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: _AnonAuth())
|
||||
|
||||
response = await dashboard_login_page(_make_request("/dashboard-legacy/login"))
|
||||
html = response.body.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'action="/dashboard-legacy/login"' in html
|
||||
assert 'action="/dashboard/login"' not in html
|
||||
|
||||
|
||||
async def test_dashboard_legacy_login_submit_success_sets_cookie(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_login_submit
|
||||
|
||||
auth = _DummyDashboardAuth(valid=True)
|
||||
|
||||
async def _fake_ensure_master_key_user(_auth):
|
||||
return "user-session-token"
|
||||
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
monkeypatch.setattr(
|
||||
"core.dashboard.routes._ensure_master_key_user", _fake_ensure_master_key_user
|
||||
)
|
||||
|
||||
response = await dashboard_login_submit(
|
||||
_make_form_request(
|
||||
"/dashboard-legacy/login",
|
||||
{"api_key": "good-master-key", "next": "/dashboard/health"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/dashboard/health"
|
||||
assert "mcp_dashboard_session=user-session-token" in response.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
async def test_dashboard_legacy_login_submit_invalid_redirects_back(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_login_submit
|
||||
|
||||
auth = _DummyDashboardAuth(valid=False)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
|
||||
response = await dashboard_login_submit(
|
||||
_make_form_request(
|
||||
"/dashboard-legacy/login",
|
||||
{"api_key": "wrong-key", "next": "/dashboard/health"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "/dashboard-legacy/login?error=invalid&next=/dashboard/health&lang=en"
|
||||
)
|
||||
|
||||
|
||||
async def test_dashboard_legacy_login_submit_rate_limited_redirects_back(monkeypatch):
|
||||
from core.dashboard.routes import dashboard_login_submit
|
||||
|
||||
auth = _DummyDashboardAuth(valid=True, rate_limited=True)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_dashboard_auth", lambda: auth)
|
||||
monkeypatch.setattr("core.dashboard.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
|
||||
response = await dashboard_login_submit(
|
||||
_make_form_request("/dashboard-legacy/login", {"api_key": "good-master-key"})
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/dashboard-legacy/login?error=rate_limit&lang=en"
|
||||
assert auth.recorded_attempts == 0
|
||||
|
||||
|
||||
def test_spa_dist_dir_exists_as_placeholder():
|
||||
"""The dist dir must exist (with .gitkeep) so package_data can include it."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
dist = os.path.join(repo_root, "core", "templates", "static", "dist")
|
||||
assert os.path.isdir(dist), f"SPA dist directory missing: {dist}"
|
||||
|
||||
|
||||
class TestAnalyticsSnippet:
|
||||
"""_analytics_snippet() — env-var driven analytics tag assembly."""
|
||||
|
||||
def test_returns_empty_when_nothing_configured(self, monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
for key in ("UMAMI_WEBSITE_ID", "UMAMI_URL", "OPENPANEL_API_URL", "OPENPANEL_CLIENT_ID"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
assert spa_routes._analytics_snippet() == ""
|
||||
|
||||
def test_umami_only(self, monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setenv("UMAMI_WEBSITE_ID", "abc-123")
|
||||
monkeypatch.setenv("UMAMI_URL", "https://umami.example.com/script.js")
|
||||
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
|
||||
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
|
||||
snippet = spa_routes._analytics_snippet()
|
||||
assert 'data-website-id="abc-123"' in snippet
|
||||
assert "https://umami.example.com/script.js" in snippet
|
||||
assert "window.op" not in snippet
|
||||
|
||||
def test_openpanel_only(self, monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.delenv("UMAMI_WEBSITE_ID", raising=False)
|
||||
monkeypatch.delenv("UMAMI_URL", raising=False)
|
||||
monkeypatch.setenv("OPENPANEL_API_URL", "https://op.example.com")
|
||||
monkeypatch.setenv("OPENPANEL_CLIENT_ID", "client-xyz")
|
||||
snippet = spa_routes._analytics_snippet()
|
||||
assert 'clientId:"client-xyz"' in snippet
|
||||
assert "data-website-id" not in snippet
|
||||
|
||||
def test_partial_umami_config_is_skipped(self, monkeypatch):
|
||||
"""Setting only WEBSITE_ID without URL must not render half a tag."""
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setenv("UMAMI_WEBSITE_ID", "abc-123")
|
||||
monkeypatch.delenv("UMAMI_URL", raising=False)
|
||||
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
|
||||
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
|
||||
assert spa_routes._analytics_snippet() == ""
|
||||
|
||||
|
||||
class TestRenderSpaIndex:
|
||||
"""_render_spa_index() — inject analytics tags into the built index.html."""
|
||||
|
||||
def _write_index(self, tmp_path, body: str) -> str:
|
||||
path = tmp_path / "index.html"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def test_returns_unmodified_when_no_snippet(self, tmp_path, monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
for key in ("UMAMI_WEBSITE_ID", "UMAMI_URL", "OPENPANEL_API_URL", "OPENPANEL_CLIENT_ID"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
index = self._write_index(tmp_path, "<html><head><title>x</title></head></html>")
|
||||
with open(index, "rb") as fh:
|
||||
assert spa_routes._render_spa_index(index) == fh.read()
|
||||
|
||||
def test_injects_before_head_close(self, tmp_path, monkeypatch):
|
||||
from core.dashboard import spa_routes
|
||||
|
||||
monkeypatch.setenv("UMAMI_WEBSITE_ID", "site-abc")
|
||||
monkeypatch.setenv("UMAMI_URL", "https://umami.example.com/s.js")
|
||||
monkeypatch.delenv("OPENPANEL_API_URL", raising=False)
|
||||
monkeypatch.delenv("OPENPANEL_CLIENT_ID", raising=False)
|
||||
index = self._write_index(
|
||||
tmp_path, "<html><head><title>x</title></head><body></body></html>"
|
||||
)
|
||||
rendered = spa_routes._render_spa_index(index).decode("utf-8")
|
||||
assert rendered.index("data-website-id") < rendered.index("</head>")
|
||||
assert rendered.count("</head>") == 1 # marker not duplicated
|
||||
@@ -84,11 +84,30 @@ class TestIsPluginPublic:
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("gitea") is True
|
||||
|
||||
def test_default_wordpress_advanced_not_public(self):
|
||||
"""WordPress Advanced is not public by default."""
|
||||
def test_default_wordpress_specialist_public(self):
|
||||
"""WordPress Specialist is public by default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("wordpress_advanced") is False
|
||||
assert is_plugin_public("wordpress_specialist") is True
|
||||
|
||||
def test_default_n8n_public(self):
|
||||
"""n8n is public by default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("n8n") is True
|
||||
|
||||
def test_default_coolify_public(self):
|
||||
"""Coolify is public by default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("coolify") is True
|
||||
|
||||
def test_default_directus_and_appwrite_not_public(self):
|
||||
"""Directus and Appwrite stay disabled by default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("directus") is False
|
||||
assert is_plugin_public("appwrite") is False
|
||||
|
||||
def test_case_insensitive_check(self):
|
||||
"""Plugin type check is case insensitive."""
|
||||
@@ -120,8 +139,11 @@ class TestSiteApiIntegration:
|
||||
assert "supabase" in fields
|
||||
assert "gitea" in fields
|
||||
assert "openpanel" in fields
|
||||
assert "wordpress_advanced" not in fields
|
||||
assert "n8n" not in fields
|
||||
assert "wordpress_specialist" in fields
|
||||
assert "n8n" in fields
|
||||
assert "coolify" in fields
|
||||
assert "directus" not in fields
|
||||
assert "appwrite" not in fields
|
||||
|
||||
def test_get_user_plugin_names_filtered(self):
|
||||
"""get_user_plugin_names only returns enabled plugins."""
|
||||
@@ -136,7 +158,11 @@ class TestSiteApiIntegration:
|
||||
assert "supabase" in names
|
||||
assert "gitea" in names
|
||||
assert "openpanel" in names
|
||||
assert "wordpress_advanced" not in names
|
||||
assert "wordpress_specialist" in names
|
||||
assert "n8n" in names
|
||||
assert "coolify" in names
|
||||
assert "directus" not in names
|
||||
assert "appwrite" not in names
|
||||
|
||||
def test_custom_env_changes_fields(self):
|
||||
"""Custom ENABLED_PLUGINS changes which credential fields are returned."""
|
||||
|
||||
98
tests/test_public_base_url.py
Normal file
98
tests/test_public_base_url.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for ``resolve_public_base_url`` (2026-05-01).
|
||||
|
||||
Behaviour matrix that the helper has to cover so the dashboard's
|
||||
``mcp_url`` rendering never falls back to a host-less path again — the
|
||||
mcp-test app reproduced this by setting ``PUBLIC_URL=''`` (empty string,
|
||||
not unset) in its env vars, which silently bypassed the previous
|
||||
``os.environ.get("PUBLIC_URL", "http://localhost:8000")`` default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.dashboard.routes import resolve_public_base_url
|
||||
|
||||
|
||||
def _request(headers: dict[str, str], scheme: str = "http", netloc: str = "localhost:8000"):
|
||||
"""Build the smallest Request stub the helper needs."""
|
||||
req = MagicMock()
|
||||
req.headers = headers
|
||||
req.url = MagicMock()
|
||||
req.url.scheme = scheme
|
||||
req.url.netloc = netloc
|
||||
return req
|
||||
|
||||
|
||||
def test_env_var_wins_when_set_and_non_empty(monkeypatch):
|
||||
monkeypatch.setenv("PUBLIC_URL", "https://mcp.example.com")
|
||||
req = _request({"host": "internal.fly.dev"}, scheme="http")
|
||||
assert resolve_public_base_url(req) == "https://mcp.example.com"
|
||||
|
||||
|
||||
def test_env_var_strips_trailing_slash(monkeypatch):
|
||||
monkeypatch.setenv("PUBLIC_URL", "https://mcp.example.com/")
|
||||
req = _request({})
|
||||
assert resolve_public_base_url(req) == "https://mcp.example.com"
|
||||
|
||||
|
||||
def test_empty_env_var_falls_back_to_request(monkeypatch):
|
||||
"""The mcp-test bug — ``PUBLIC_URL=''`` no longer produces a host-less URL."""
|
||||
monkeypatch.setenv("PUBLIC_URL", "")
|
||||
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "mcp-test.example.com"})
|
||||
assert resolve_public_base_url(req) == "https://mcp-test.example.com"
|
||||
|
||||
|
||||
def test_whitespace_env_var_treated_as_unset(monkeypatch):
|
||||
monkeypatch.setenv("PUBLIC_URL", " ")
|
||||
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "wp.example.org"})
|
||||
assert resolve_public_base_url(req) == "https://wp.example.org"
|
||||
|
||||
|
||||
def test_unset_env_var_uses_forwarded_headers(monkeypatch):
|
||||
monkeypatch.delenv("PUBLIC_URL", raising=False)
|
||||
req = _request({"x-forwarded-proto": "https", "x-forwarded-host": "mcp.example.com"})
|
||||
assert resolve_public_base_url(req) == "https://mcp.example.com"
|
||||
|
||||
|
||||
def test_falls_back_to_host_header_when_no_xfh(monkeypatch):
|
||||
monkeypatch.delenv("PUBLIC_URL", raising=False)
|
||||
req = _request({"host": "mcp.local"}, scheme="https")
|
||||
assert resolve_public_base_url(req) == "https://mcp.local"
|
||||
|
||||
|
||||
def test_falls_back_to_request_url_when_no_headers(monkeypatch):
|
||||
monkeypatch.delenv("PUBLIC_URL", raising=False)
|
||||
req = _request({}, scheme="http", netloc="127.0.0.1:8001")
|
||||
assert resolve_public_base_url(req) == "http://127.0.0.1:8001"
|
||||
|
||||
|
||||
def test_handles_csv_x_forwarded_proto(monkeypatch):
|
||||
"""Some upstream proxies append the original scheme: ``https, http``."""
|
||||
monkeypatch.delenv("PUBLIC_URL", raising=False)
|
||||
req = _request({"x-forwarded-proto": "https, http", "x-forwarded-host": "mcp.example.com"})
|
||||
assert resolve_public_base_url(req) == "https://mcp.example.com"
|
||||
|
||||
|
||||
def test_handles_csv_x_forwarded_host(monkeypatch):
|
||||
monkeypatch.delenv("PUBLIC_URL", raising=False)
|
||||
req = _request(
|
||||
{"x-forwarded-proto": "https", "x-forwarded-host": "edge1.example.com, internal"}
|
||||
)
|
||||
assert resolve_public_base_url(req) == "https://edge1.example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://mcp.example.com",
|
||||
"https://mcp.example.com/",
|
||||
"https://mcp.example.com//",
|
||||
],
|
||||
)
|
||||
def test_no_trailing_slash_in_output(monkeypatch, url):
|
||||
monkeypatch.setenv("PUBLIC_URL", url)
|
||||
req = _request({})
|
||||
assert resolve_public_base_url(req) == "https://mcp.example.com"
|
||||
@@ -80,17 +80,18 @@ class TestCredentialFields:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_credential_fields_all_plugins(self):
|
||||
"""All 9 plugin types should return non-empty credential field lists."""
|
||||
"""Every registered plugin type returns non-empty credential field lists."""
|
||||
expected_plugins = [
|
||||
"wordpress",
|
||||
"woocommerce",
|
||||
"wordpress_advanced",
|
||||
"wordpress_specialist",
|
||||
"gitea",
|
||||
"n8n",
|
||||
"supabase",
|
||||
"openpanel",
|
||||
"appwrite",
|
||||
"directus",
|
||||
"coolify",
|
||||
]
|
||||
for plugin_type in expected_plugins:
|
||||
fields = get_credential_fields(plugin_type)
|
||||
@@ -143,13 +144,13 @@ class TestCredentialValidation:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_credentials_optional_field(self):
|
||||
"""Optional fields (like container) should not cause errors when missing."""
|
||||
"""Optional advanced fields should not cause errors when missing."""
|
||||
valid, errors = validate_credentials(
|
||||
"wordpress_advanced",
|
||||
"wordpress",
|
||||
{
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
# container is optional — should not cause error
|
||||
# wp_app_password is optional — should not cause error
|
||||
},
|
||||
)
|
||||
assert valid is True
|
||||
|
||||
@@ -112,6 +112,14 @@ class TestListSiteTools:
|
||||
assert body["site_id"] == coolify_site["id"]
|
||||
assert body["plugin_type"] == "coolify"
|
||||
assert body["tool_scope"] == "admin"
|
||||
assert [p["value"] for p in body["scope_presets"]] == [
|
||||
"read",
|
||||
"read:sensitive",
|
||||
"deploy",
|
||||
"write",
|
||||
"admin",
|
||||
"custom",
|
||||
]
|
||||
tools = body["tools"]
|
||||
by_name = {t["name"]: t for t in tools}
|
||||
assert "coolify_list_applications" in by_name
|
||||
|
||||
@@ -75,48 +75,53 @@ def client(monkeypatch, user_row, patched_db, patched_access):
|
||||
|
||||
class TestSitesEditRedirect:
|
||||
def test_edit_page_redirects_to_manage(self, client, coolify_site):
|
||||
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}."""
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
|
||||
assert r.status_code == 301
|
||||
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
|
||||
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}.
|
||||
|
||||
The handler returns 302 (not 301) on purpose — see the comment in
|
||||
dashboard_sites_edit — so reloads always re-hit the server instead
|
||||
of browsers caching the redirect by exact URL+query.
|
||||
"""
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}/edit")
|
||||
assert r.status_code == 302
|
||||
assert f"/dashboard-legacy/sites/{coolify_site['id']}" in r.headers["location"]
|
||||
|
||||
|
||||
class TestUnifiedSiteManagePage:
|
||||
def test_manage_page_renders_without_500(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_manage_page_has_connection_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "connection-section" in r.text
|
||||
|
||||
def test_manage_page_has_tool_access_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "tool-access-content" in r.text or "Tool Access" in r.text
|
||||
|
||||
def test_manage_page_has_scope_tiers(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "scope-tiers" in r.text
|
||||
|
||||
def test_manage_page_shows_mcp_url(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "mcp-url" in r.text
|
||||
assert coolify_site["alias"] in r.text
|
||||
|
||||
def test_manage_page_has_config_snippets(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "config-client" in r.text
|
||||
|
||||
def test_manage_page_has_quick_key_create(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
r = client.get(f"/dashboard-legacy/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "quick-key-btn" in r.text
|
||||
|
||||
def test_nonexistent_site_redirects(self, client):
|
||||
r = client.get("/dashboard/sites/nonexistent-id")
|
||||
r = client.get("/dashboard-legacy/sites/nonexistent-id")
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
@@ -155,6 +155,61 @@ class TestToolDefinitionDefaults:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUniversalScopeTierLadder:
|
||||
"""F.19.2.0: the universal tier ladder must be a strict chain.
|
||||
|
||||
Each higher tier is a superset of every lower tier; every tier
|
||||
includes ``read`` (so any keyholder can list inventory before
|
||||
acting); ``admin`` includes everything else.
|
||||
"""
|
||||
|
||||
def test_each_tier_is_a_strict_superset(self):
|
||||
from core.tool_access import UNIVERSAL_SCOPE_TIERS
|
||||
|
||||
ladder = ["read", "editor", "settings", "install", "write", "admin"]
|
||||
for lower, higher in zip(ladder[:-1], ladder[1:], strict=True):
|
||||
lower_set = UNIVERSAL_SCOPE_TIERS[lower]
|
||||
higher_set = UNIVERSAL_SCOPE_TIERS[higher]
|
||||
assert lower_set <= higher_set, f"{higher} must be superset of {lower}"
|
||||
assert higher_set - lower_set, f"{higher} must add at least one new scope over {lower}"
|
||||
|
||||
def test_every_tier_includes_read(self):
|
||||
from core.tool_access import UNIVERSAL_SCOPE_TIERS
|
||||
|
||||
for tier_name, allowed in UNIVERSAL_SCOPE_TIERS.items():
|
||||
assert "read" in allowed, f"{tier_name} must include read scope"
|
||||
|
||||
def test_admin_is_universal(self):
|
||||
from core.tool_access import UNIVERSAL_SCOPE_TIERS
|
||||
|
||||
# Every required-scope value used elsewhere in the ladder must be
|
||||
# reachable from an admin key.
|
||||
all_scopes = set().union(*UNIVERSAL_SCOPE_TIERS.values())
|
||||
assert UNIVERSAL_SCOPE_TIERS["admin"] == all_scopes
|
||||
|
||||
def test_settings_below_install_below_write(self):
|
||||
"""Confirms the F.19.2.0 ordering: settings ⊂ install ⊂ write."""
|
||||
from core.tool_access import UNIVERSAL_SCOPE_TIERS
|
||||
|
||||
s = UNIVERSAL_SCOPE_TIERS["settings"]
|
||||
i = UNIVERSAL_SCOPE_TIERS["install"]
|
||||
w = UNIVERSAL_SCOPE_TIERS["write"]
|
||||
assert s < i < w
|
||||
# Specifically: install adds "install"; write adds "write".
|
||||
assert i - s == {"install"}
|
||||
assert w - i == {"write"}
|
||||
|
||||
def test_tier_descriptions_cover_every_tier(self):
|
||||
"""Every UNIVERSAL_SCOPE_TIERS key must have an EN+FA tooltip."""
|
||||
from core.tool_access import TIER_DESCRIPTIONS, UNIVERSAL_SCOPE_TIERS
|
||||
|
||||
for tier_name in UNIVERSAL_SCOPE_TIERS:
|
||||
assert tier_name in TIER_DESCRIPTIONS, f"missing description for tier {tier_name}"
|
||||
entry = TIER_DESCRIPTIONS[tier_name]
|
||||
for field in ("label_en", "label_fa", "hint_en", "hint_fa"):
|
||||
assert field in entry and entry[field], f"{tier_name}.{field} missing or empty"
|
||||
|
||||
|
||||
class TestScopesToCategories:
|
||||
def test_read_only(self):
|
||||
assert scopes_to_categories(["read"]) == {"read"}
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.tool_access import (
|
||||
class TestProviderKeyHelpers:
|
||||
def test_ai_image_tool_requires_key(self):
|
||||
assert _tool_requires_provider_key("wordpress_generate_and_upload_image")
|
||||
assert _tool_requires_provider_key("woocommerce_generate_and_upload_image")
|
||||
|
||||
def test_normal_tool_does_not_require_key(self):
|
||||
assert not _tool_requires_provider_key("wordpress_create_post")
|
||||
@@ -36,6 +37,8 @@ class TestProviderKeyHelpers:
|
||||
def test_configured_helper_gates_ai_on_providers_set(self):
|
||||
assert not _tool_has_configured_provider("wordpress_generate_and_upload_image", set())
|
||||
assert _tool_has_configured_provider("wordpress_generate_and_upload_image", {"openrouter"})
|
||||
assert not _tool_has_configured_provider("woocommerce_generate_and_upload_image", set())
|
||||
assert _tool_has_configured_provider("woocommerce_generate_and_upload_image", {"openai"})
|
||||
|
||||
|
||||
class _FakeToolDef:
|
||||
|
||||
@@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.settings import get_cached_rate_per_min
|
||||
from core.user_endpoints import (
|
||||
USER_RATE_LIMIT_PER_MIN,
|
||||
_rate_limits,
|
||||
user_mcp_handler,
|
||||
)
|
||||
@@ -165,6 +165,12 @@ def mock_tool_registry():
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||
# F.19.7.1: tools/call also looks up the tool by name — the universal
|
||||
# scope check then reads ``required_scope`` off the returned tool_def.
|
||||
# Without this wire-up ``get_by_name`` returns a fresh MagicMock and
|
||||
# ``required_scope`` becomes a MagicMock, which is never a member of
|
||||
# the allowed-scope set — the call is rejected with "Insufficient scope".
|
||||
registry.get_by_name = MagicMock(return_value=tool_def)
|
||||
|
||||
with patch("core.tool_registry.get_tool_registry", return_value=registry):
|
||||
yield registry
|
||||
@@ -408,6 +414,216 @@ class TestMCPMethods:
|
||||
assert "not supported" in body["error"]["message"]
|
||||
|
||||
|
||||
# ── F.19.7.1 — Universal scope tier regression coverage ─────
|
||||
#
|
||||
# Pre-fix bug: when an mhu_ key was issued with scope ``editor`` (the
|
||||
# tier introduced by F.19.5 + F.19.7), every tool call failed with
|
||||
# ``Insufficient scope`` because:
|
||||
#
|
||||
# 1. The local ``scope_hierarchy`` map didn't know about ``editor``,
|
||||
# so the editor key collapsed to level 0 and was rejected for every
|
||||
# required_scope ≥ 1 (i.e. read/editor/write/admin).
|
||||
# 2. The Coolify category whitelist was applied to every plugin. For
|
||||
# wordpress_specialist tools whose default category is ``"read"``
|
||||
# (one of Coolify's KNOWN_CATEGORIES), the whitelist rejected
|
||||
# anything an editor-scope key requested unless that key happened
|
||||
# to also include the ``read`` scope.
|
||||
#
|
||||
# These tests pin the post-fix behaviour: editor scope on a non-Coolify
|
||||
# plugin admits both ``read`` and ``editor`` tools; Coolify still
|
||||
# enforces its fine-grained category check.
|
||||
|
||||
|
||||
class TestUniversalScopeTierRegression:
|
||||
"""F.19.7.1 — editor scope tier must work on non-Coolify plugins."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_editor_scope_admits_read_tool_on_wordpress_specialist(
|
||||
self,
|
||||
mock_key_mgr,
|
||||
mock_db,
|
||||
mock_encryption,
|
||||
mock_plugin_registry,
|
||||
):
|
||||
"""editor scope key should be allowed to call a read-scope wp_specialist tool."""
|
||||
# Key has only the editor scope.
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "editor",
|
||||
}
|
||||
# Site is wordpress_specialist with admin tier (so site-level
|
||||
# check is permissive).
|
||||
mock_db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress_specialist",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"tool_scope": "admin",
|
||||
}
|
||||
)
|
||||
|
||||
# F.19.7 read tool: required_scope=read, plugin_type=wordpress_specialist.
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_specialist_wp_theme_file_list"
|
||||
tool_def.plugin_type = "wordpress_specialist"
|
||||
tool_def.required_scope = "read"
|
||||
tool_def.category = "read"
|
||||
tool_def.input_schema = {"type": "object", "properties": {}}
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get_by_name = MagicMock(return_value=tool_def)
|
||||
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||
|
||||
# Make the mocked plugin instance expose the method so the call
|
||||
# path doesn't error before the scope check completes.
|
||||
plugin_instance = MagicMock()
|
||||
plugin_instance.wp_theme_file_list = AsyncMock(return_value={"files": []})
|
||||
mock_plugin_registry.create_instance = MagicMock(return_value=plugin_instance)
|
||||
|
||||
with (
|
||||
patch("core.tool_registry.get_tool_registry", return_value=registry),
|
||||
patch("core.plugin_visibility.is_plugin_public", return_value=True),
|
||||
):
|
||||
request = _make_request(
|
||||
method_name="tools/call",
|
||||
params={
|
||||
"name": "wordpress_specialist_wp_theme_file_list",
|
||||
"arguments": {"theme_slug": "palebluedot"},
|
||||
},
|
||||
)
|
||||
response = await user_mcp_handler(request)
|
||||
body = json.loads(response.body)
|
||||
|
||||
# Pre-fix: body["error"]["message"] starts with "Insufficient scope".
|
||||
assert "error" not in body or "Insufficient scope" not in body["error"].get(
|
||||
"message", ""
|
||||
), f"editor scope was rejected for read-scope tool: {body}"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_editor_scope_admits_editor_tool_on_wordpress_specialist(
|
||||
self,
|
||||
mock_key_mgr,
|
||||
mock_db,
|
||||
mock_encryption,
|
||||
mock_plugin_registry,
|
||||
):
|
||||
"""editor scope key should be allowed to call an editor-scope wp_specialist tool."""
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "editor",
|
||||
}
|
||||
mock_db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress_specialist",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"tool_scope": "editor",
|
||||
}
|
||||
)
|
||||
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_specialist_wp_theme_activate"
|
||||
tool_def.plugin_type = "wordpress_specialist"
|
||||
tool_def.required_scope = "editor"
|
||||
tool_def.category = "read" # default — same trap that triggered the bug
|
||||
tool_def.input_schema = {"type": "object", "properties": {}}
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get_by_name = MagicMock(return_value=tool_def)
|
||||
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||
|
||||
plugin_instance = MagicMock()
|
||||
plugin_instance.wp_theme_activate = AsyncMock(return_value={"activated": True})
|
||||
mock_plugin_registry.create_instance = MagicMock(return_value=plugin_instance)
|
||||
|
||||
with (
|
||||
patch("core.tool_registry.get_tool_registry", return_value=registry),
|
||||
patch("core.plugin_visibility.is_plugin_public", return_value=True),
|
||||
):
|
||||
request = _make_request(
|
||||
method_name="tools/call",
|
||||
params={
|
||||
"name": "wordpress_specialist_wp_theme_activate",
|
||||
"arguments": {"slug": "palebluedot"},
|
||||
},
|
||||
)
|
||||
response = await user_mcp_handler(request)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert "error" not in body or "Insufficient scope" not in body["error"].get(
|
||||
"message", ""
|
||||
), f"editor scope was rejected for editor-scope tool: {body}"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_read_scope_still_rejects_editor_tool(
|
||||
self,
|
||||
mock_key_mgr,
|
||||
mock_db,
|
||||
mock_encryption,
|
||||
mock_plugin_registry,
|
||||
):
|
||||
"""A pure read-scope key must NOT be able to call editor-scope tools."""
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read",
|
||||
}
|
||||
mock_db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress_specialist",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"tool_scope": "admin",
|
||||
}
|
||||
)
|
||||
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_specialist_wp_theme_file_write"
|
||||
tool_def.plugin_type = "wordpress_specialist"
|
||||
tool_def.required_scope = "editor"
|
||||
tool_def.category = "read"
|
||||
tool_def.input_schema = {"type": "object", "properties": {}}
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get_by_name = MagicMock(return_value=tool_def)
|
||||
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||
|
||||
with (
|
||||
patch("core.tool_registry.get_tool_registry", return_value=registry),
|
||||
patch("core.plugin_visibility.is_plugin_public", return_value=True),
|
||||
):
|
||||
request = _make_request(
|
||||
method_name="tools/call",
|
||||
params={
|
||||
"name": "wordpress_specialist_wp_theme_file_write",
|
||||
"arguments": {
|
||||
"theme_slug": "palebluedot",
|
||||
"path": "style.css",
|
||||
"content_base64": "Lyo=",
|
||||
},
|
||||
},
|
||||
)
|
||||
response = await user_mcp_handler(request)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert "error" in body, "read scope must not be allowed to call an editor tool"
|
||||
assert "Insufficient scope" in body["error"]["message"]
|
||||
|
||||
|
||||
# ── Rate Limiting ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -419,7 +635,7 @@ class TestRateLimiting:
|
||||
"""Exceeding per-minute rate limit should return 429."""
|
||||
# Fill the rate limit bucket
|
||||
now = time.time()
|
||||
_rate_limits["user-uuid-001"] = [now - i for i in range(USER_RATE_LIMIT_PER_MIN)]
|
||||
_rate_limits["user-uuid-001"] = [now - i for i in range(get_cached_rate_per_min())]
|
||||
|
||||
request = _make_request(method_name="initialize")
|
||||
response = await user_mcp_handler(request)
|
||||
|
||||
Reference in New Issue
Block a user