Files
mcphub/tests/plugins/wordpress_specialist/test_themes.py
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

377 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()