Files
airano-ir 43fd2201a0 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>
2026-05-20 23:33:20 +02:00

251 lines
8.4 KiB
Python

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