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:
376
tests/plugins/wordpress_specialist/test_themes.py
Normal file
376
tests/plugins/wordpress_specialist/test_themes.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""F.19.7 — Tests for the WordPress Specialist theme dev handler.
|
||||
|
||||
Mocks ``WordPressClient.get`` / ``post`` / ``put`` / ``delete`` and
|
||||
asserts:
|
||||
|
||||
* tool spec contract: 7 tools (3 management + 4 file CRUD), reads at
|
||||
``scope=read``, writes at ``scope=editor``
|
||||
* each handler method targets the correct ``airano-mcp/v1/admin/*``
|
||||
route with ``use_custom_namespace=True``
|
||||
* client-side guards reject malformed slugs (S-15), traversal-shaped
|
||||
paths (S-16), oversized payloads (S-18), and bad expected_sha256
|
||||
(S-19)
|
||||
* mutually-exclusive zip_url / zip_base64 contract is enforced
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress_specialist.handlers.themes import (
|
||||
ThemesHandler,
|
||||
_validate_theme_file_path,
|
||||
_validate_theme_slug,
|
||||
get_tool_specifications,
|
||||
)
|
||||
|
||||
# ───── Tool spec contract ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tool_specs_count_and_names_match_f197():
|
||||
specs = get_tool_specifications()
|
||||
assert len(specs) == 7, "F.19.7 advertises 3 management + 4 file CRUD"
|
||||
names = {s["name"] for s in specs}
|
||||
assert names == {
|
||||
"wp_theme_install_from_zip",
|
||||
"wp_theme_activate",
|
||||
"wp_theme_delete",
|
||||
"wp_theme_file_list",
|
||||
"wp_theme_file_read",
|
||||
"wp_theme_file_write",
|
||||
"wp_theme_file_delete",
|
||||
}
|
||||
|
||||
|
||||
def test_reads_are_read_scope_writes_are_editor_scope():
|
||||
specs_by_name = {s["name"]: s for s in get_tool_specifications()}
|
||||
expected_scope = {
|
||||
"wp_theme_install_from_zip": "editor",
|
||||
"wp_theme_activate": "editor",
|
||||
"wp_theme_delete": "editor",
|
||||
"wp_theme_file_list": "read",
|
||||
"wp_theme_file_read": "read",
|
||||
"wp_theme_file_write": "editor",
|
||||
"wp_theme_file_delete": "editor",
|
||||
}
|
||||
for name, scope in expected_scope.items():
|
||||
assert specs_by_name[name]["scope"] == scope, f"{name} should be scope={scope}"
|
||||
|
||||
|
||||
def test_every_spec_has_the_full_contract():
|
||||
"""Each F.19.7 spec must carry name, method_name, description, schema."""
|
||||
for spec in get_tool_specifications():
|
||||
assert spec["name"] == spec["method_name"]
|
||||
assert spec["description"]
|
||||
assert isinstance(spec["schema"], dict)
|
||||
assert spec["schema"].get("type") == "object"
|
||||
|
||||
|
||||
def test_file_list_schema_caps_max_files_at_1000():
|
||||
spec = next(s for s in get_tool_specifications() if s["name"] == "wp_theme_file_list")
|
||||
assert spec["schema"]["properties"]["max_files"]["maximum"] == 1000
|
||||
|
||||
|
||||
# ───── Slug + path validation (S-15 + S-16 client-side) ──────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"good",
|
||||
[
|
||||
"twentytwentyfive",
|
||||
"palebluedot",
|
||||
"my-child-theme",
|
||||
"theme_v2",
|
||||
"T1",
|
||||
],
|
||||
)
|
||||
def test_validate_theme_slug_accepts_well_formed(good):
|
||||
assert _validate_theme_slug(good) == good
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"",
|
||||
"../etc",
|
||||
"slug/with/slash",
|
||||
"slug.with.dot",
|
||||
"-leading-dash",
|
||||
"_leading-underscore-fine?", # actually valid? No: starts with _, our regex requires alnum start
|
||||
"slug with spaces",
|
||||
"x" * 65,
|
||||
None,
|
||||
42,
|
||||
],
|
||||
)
|
||||
def test_validate_theme_slug_rejects_malformed(bad):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_theme_slug(bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"good",
|
||||
[
|
||||
"style.css",
|
||||
"parts/header.html",
|
||||
"templates/page-home.html",
|
||||
"assets/img/hero.jpg",
|
||||
"..foo/bar", # `..foo` is a real filename, not traversal
|
||||
],
|
||||
)
|
||||
def test_validate_theme_file_path_accepts_clean_paths(good):
|
||||
out = _validate_theme_file_path(good)
|
||||
assert "/" in out or out == good
|
||||
assert ".." not in out.split("/")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"",
|
||||
"../etc/passwd",
|
||||
"/absolute/path",
|
||||
"parts/../../escape",
|
||||
"parts/\x00null",
|
||||
"parts\\windows.html",
|
||||
"parts/..", # `..` segment
|
||||
],
|
||||
)
|
||||
def test_validate_theme_file_path_rejects_traversal(bad):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_theme_file_path(bad)
|
||||
|
||||
|
||||
# ───── Fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
|
||||
c.get = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
|
||||
c.post = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
|
||||
c.put = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
|
||||
c.delete = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(client):
|
||||
return ThemesHandler(client)
|
||||
|
||||
|
||||
# ───── Theme management routing ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_zip_url_calls_install_route(handler, client):
|
||||
await handler.wp_theme_install_from_zip(
|
||||
zip_url="https://example.com/theme.zip", activate=True, overwrite=False
|
||||
)
|
||||
client.post.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/install",
|
||||
json_data={
|
||||
"zip_url": "https://example.com/theme.zip",
|
||||
"activate": True,
|
||||
"overwrite": False,
|
||||
},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_zip_base64_calls_install_route(handler, client):
|
||||
payload = base64.b64encode(b"PK\x03\x04 fake zip").decode()
|
||||
await handler.wp_theme_install_from_zip(zip_base64=payload)
|
||||
args, kwargs = client.post.call_args
|
||||
assert args[0] == "airano-mcp/v1/admin/themes/install"
|
||||
assert kwargs["json_data"]["zip_base64"] == payload
|
||||
assert kwargs["json_data"]["activate"] is False
|
||||
assert "zip_url" not in kwargs["json_data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_requires_one_of_url_or_base64(handler, client):
|
||||
with pytest.raises(ValueError, match="zip_url or zip_base64"):
|
||||
await handler.wp_theme_install_from_zip()
|
||||
client.post.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_rejects_both_url_and_base64(handler, client):
|
||||
with pytest.raises(ValueError, match="not both"):
|
||||
await handler.wp_theme_install_from_zip(
|
||||
zip_url="https://example.com/x.zip", zip_base64="aGVsbG8="
|
||||
)
|
||||
client.post.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_rejects_oversized_zip_base64(handler, client):
|
||||
# 60 MB worth of base64 chars (~45 MB decoded? no — 60M chars × 3/4 = 45M).
|
||||
# We need the upper bound > 50 MB, so 70M chars should do it.
|
||||
huge = "A" * (70 * 1024 * 1024)
|
||||
with pytest.raises(ValueError, match=r"exceeds .* byte cap \(S-18\)"):
|
||||
await handler.wp_theme_install_from_zip(zip_base64=huge)
|
||||
client.post.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_calls_activate_route(handler, client):
|
||||
await handler.wp_theme_activate(slug="palebluedot")
|
||||
client.post.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/palebluedot/activate",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_rejects_bad_slug(handler, client):
|
||||
with pytest.raises(ValueError):
|
||||
await handler.wp_theme_activate(slug="../etc")
|
||||
client.post.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_calls_delete_route(handler, client):
|
||||
await handler.wp_theme_delete(slug="oldtheme")
|
||||
client.delete.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/oldtheme",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
# ───── Theme file CRUD routing ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_list_calls_list_route_with_glob_and_max(handler, client):
|
||||
await handler.wp_theme_file_list(theme_slug="palebluedot", glob="**/*.php", max_files=500)
|
||||
client.get.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/files/palebluedot",
|
||||
params={"glob": "**/*.php", "max_files": 500},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_list_uses_default_glob_and_max(handler, client):
|
||||
await handler.wp_theme_file_list(theme_slug="palebluedot")
|
||||
args, kwargs = client.get.call_args
|
||||
assert kwargs["params"]["glob"] == "**/*"
|
||||
assert kwargs["params"]["max_files"] == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_list_rejects_oversized_max_files(handler, client):
|
||||
with pytest.raises(ValueError, match="exceeds the 1000"):
|
||||
await handler.wp_theme_file_list(theme_slug="palebluedot", max_files=2000)
|
||||
client.get.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_read_calls_read_route_with_quoted_path(handler, client):
|
||||
await handler.wp_theme_file_read(theme_slug="palebluedot", path="parts/header.html")
|
||||
client.get.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/files/palebluedot/parts/header.html",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_read_quotes_special_characters_in_path(handler, client):
|
||||
"""Spaces and other URL-unsafe chars must be percent-encoded; / stays literal."""
|
||||
await handler.wp_theme_file_read(theme_slug="palebluedot", path="parts/hero image.html")
|
||||
args, kwargs = client.get.call_args
|
||||
# Forward slashes preserved; space encoded.
|
||||
assert "parts/hero%20image.html" in args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_read_rejects_traversal(handler, client):
|
||||
with pytest.raises(ValueError):
|
||||
await handler.wp_theme_file_read(theme_slug="palebluedot", path="../wp-config.php")
|
||||
client.get.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_write_calls_put_route_with_body(handler, client):
|
||||
payload = base64.b64encode(b"body { color: red; }").decode()
|
||||
await handler.wp_theme_file_write(
|
||||
theme_slug="palebluedot", path="style.css", content_base64=payload
|
||||
)
|
||||
client.put.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/files/palebluedot/style.css",
|
||||
json_data={"content_base64": payload, "create_dirs": True},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_write_passes_expected_sha256_when_supplied(handler, client):
|
||||
sha = "a" * 64
|
||||
payload = base64.b64encode(b"hi").decode()
|
||||
await handler.wp_theme_file_write(
|
||||
theme_slug="palebluedot",
|
||||
path="style.css",
|
||||
content_base64=payload,
|
||||
expected_sha256=sha,
|
||||
)
|
||||
args, kwargs = client.put.call_args
|
||||
assert kwargs["json_data"]["expected_sha256"] == sha
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_write_rejects_invalid_expected_sha256(handler, client):
|
||||
with pytest.raises(ValueError, match="64-char hex"):
|
||||
await handler.wp_theme_file_write(
|
||||
theme_slug="palebluedot",
|
||||
path="style.css",
|
||||
content_base64=base64.b64encode(b"hi").decode(),
|
||||
expected_sha256="not-a-hash",
|
||||
)
|
||||
client.put.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_write_rejects_oversized_content(handler, client):
|
||||
huge = "A" * (8 * 1024 * 1024) # ~8 MB base64 → ~6 MB decoded > 5 MB cap
|
||||
with pytest.raises(ValueError, match=r"exceeds .* byte cap \(S-18\)"):
|
||||
await handler.wp_theme_file_write(
|
||||
theme_slug="palebluedot", path="style.css", content_base64=huge
|
||||
)
|
||||
client.put.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_write_passes_create_dirs_false(handler, client):
|
||||
payload = base64.b64encode(b"x").decode()
|
||||
await handler.wp_theme_file_write(
|
||||
theme_slug="palebluedot",
|
||||
path="parts/new.html",
|
||||
content_base64=payload,
|
||||
create_dirs=False,
|
||||
)
|
||||
args, kwargs = client.put.call_args
|
||||
assert kwargs["json_data"]["create_dirs"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_delete_calls_delete_route(handler, client):
|
||||
await handler.wp_theme_file_delete(theme_slug="palebluedot", path="style.css")
|
||||
client.delete.assert_awaited_once_with(
|
||||
"airano-mcp/v1/admin/themes/files/palebluedot/style.css",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_delete_rejects_traversal(handler, client):
|
||||
with pytest.raises(ValueError):
|
||||
await handler.wp_theme_file_delete(theme_slug="palebluedot", path="../config.php")
|
||||
client.delete.assert_not_awaited()
|
||||
Reference in New Issue
Block a user