feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -0,0 +1,387 @@
"""Tests for F.5a.4 AI image generation + upload chain."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from core.database import Database
from plugins.ai_image.providers.base import (
GenerationRequest,
GenerationResult,
ProviderError,
)
from plugins.ai_image.providers.openai import OpenAIProvider
from plugins.ai_image.registry import get_provider, list_providers
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.ai_media import AIMediaHandler
_TEST_KEY_B64 = base64.b64encode(b"0" * 32).decode()
# 1x1 PNG
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
async def db(tmp_path):
database = Database(str(tmp_path / "ai_test.db"))
await database.initialize()
yield database
await database.close()
@pytest.fixture
async def user_row(db):
return await db.create_user(
email="alice@example.com",
name="Alice",
provider="github",
provider_id="gh-ai",
)
# --- Registry ---------------------------------------------------------------
class TestRegistry:
def test_lists_expected_providers(self):
# Registry is the source of truth now that the per-user
# user_provider_keys module has been removed (F.5a.9.x).
# F.5a.9 partial: OpenRouter added.
assert set(list_providers()) == {"openai", "stability", "replicate", "openrouter"}
def test_get_provider_known(self):
assert get_provider("openai").name == "openai"
def test_get_provider_unknown_raises(self):
with pytest.raises(ProviderError) as e:
get_provider("midjourney")
assert e.value.code == "PROVIDER_UNKNOWN"
# Per-user UserProviderKeyManager removed in F.5a.9.x. Per-site equivalents
# live in ``tests/test_site_provider_keys.py``.
# --- OpenAI provider --------------------------------------------------------
def _mock_session_post(responses: list):
"""Build a fake aiohttp.ClientSession whose .post() cycles through responses."""
iterator = iter(responses)
def _post(*_args, **_kwargs):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _post
sess.get = _post # reuse for URL fetches
return sess
def _fake_resp(*, status: int, json_data=None, text_data=None, raw=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or (json.dumps(json_data) if json_data else ""))
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
return resp
class TestOpenAIProvider:
@pytest.mark.asyncio
async def test_dalle_url_fetched_and_returned_as_bytes(self):
provider = OpenAIProvider()
generate_resp = _fake_resp(
status=200,
json_data={
"data": [
{
"url": "https://oaidalle.example.com/img/abc.png",
"revised_prompt": "revised",
}
]
},
)
fetch_resp = _fake_resp(status=200, raw=_PNG_1x1)
sess = _mock_session_post([generate_resp, fetch_resp])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
result = await provider.generate(
"sk-test",
GenerationRequest(prompt="a cube", size="1024x1024"),
)
assert result.data == _PNG_1x1
assert result.mime == "image/png"
assert result.meta.get("revised_prompt") == "revised"
assert result.cost_usd == 0.040
@pytest.mark.asyncio
async def test_missing_key_raises_auth(self):
provider = OpenAIProvider()
with pytest.raises(ProviderError) as e:
await provider.generate("", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_429_retried_then_succeeds(self):
provider = OpenAIProvider()
r429 = _fake_resp(status=429, text_data="slow down")
ok = _fake_resp(
status=200,
json_data={"data": [{"b64_json": base64.b64encode(_PNG_1x1).decode()}]},
)
sess = _mock_session_post([r429, ok])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
result = await provider.generate(
"sk-test",
GenerationRequest(prompt="x", model="gpt-image-1"),
)
assert result.data == _PNG_1x1
@pytest.mark.asyncio
async def test_persistent_5xx_becomes_unavailable(self):
provider = OpenAIProvider()
r500 = _fake_resp(status=500, text_data="boom")
sess = _mock_session_post([r500, r500, r500])
with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
with pytest.raises(ProviderError) as e:
await provider.generate("sk", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_UNAVAILABLE"
# --- AIMediaHandler ---------------------------------------------------------
class TestAIMediaHandler:
@pytest.mark.asyncio
async def test_missing_api_key_returns_typed_error(self, wp_client, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id=None)
async def _noop(*_a, **_kw):
return None
# Force the key resolver to return None without needing a real DB.
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value=None))
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "NO_PROVIDER_KEY"
@pytest.mark.asyncio
async def test_happy_path_chains_to_upload(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
fake_result = GenerationResult(
data=_PNG_1x1,
mime="image/png",
filename="ai.png",
meta={"model": "dall-e-3"},
cost_usd=0.04,
)
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(fake_result),
)
upload_mock = AsyncMock(
return_value={
"id": 42,
"title": {"rendered": "ai"},
"source_url": "https://wp.example.com/ai.png",
"mime_type": "image/png",
"media_type": "image",
}
)
monkeypatch.setattr("plugins.wordpress.handlers.ai_media.wp_raw_upload", upload_mock)
# Disable optimize (skip_optimize=True skips Pillow and keeps test hermetic).
out = await handler.generate_and_upload_image(
provider="openai", prompt="x", skip_optimize=True
)
data = json.loads(out)
assert data["id"] == 42
assert data["provider"] == "openai"
assert data["cost_usd"] == 0.04
assert upload_mock.await_count == 1
@pytest.mark.asyncio
async def test_provider_error_surfaced_with_code(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(exc=ProviderError("PROVIDER_QUOTA", "rate")),
)
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "PROVIDER_QUOTA"
@pytest.mark.asyncio
async def test_audit_entry_emitted_on_success(self, wp_client, monkeypatch):
handler = AIMediaHandler(wp_client, user_id="u1")
monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.get_provider",
lambda name: _StubProvider(
GenerationResult(
data=_PNG_1x1,
mime="image/png",
filename="ai.png",
meta={"model": "dall-e-3"},
cost_usd=0.04,
)
),
)
monkeypatch.setattr(
"plugins.wordpress.handlers.ai_media.wp_raw_upload",
AsyncMock(
return_value={
"id": 7,
"title": {"rendered": ""},
"source_url": "https://x/img.png",
"mime_type": "image/png",
"media_type": "image",
}
),
)
calls: list[dict] = []
class _FakeAudit:
def log_tool_call(self, **kwargs):
calls.append(kwargs)
monkeypatch.setattr(
"core.audit_log.get_audit_logger",
lambda: _FakeAudit(),
)
await handler.generate_and_upload_image(provider="openai", prompt="x", skip_optimize=True)
# F.5a.6.4: AI uploads emit BOTH the legacy provider-cost entry and a
# unified media.upload entry. Find each by tool_name.
ai_call = next(c for c in calls if c["tool_name"] == "wordpress_generate_and_upload_image")
assert ai_call["params"]["provider"] == "openai"
assert ai_call["params"]["cost_usd"] == 0.04
assert ai_call["user_id"] == "u1"
media_call = next(c for c in calls if c["tool_name"] == "media.upload")
assert media_call["params"]["source"] == "ai:openai"
assert media_call["params"]["cost_usd"] == 0.04
assert media_call["params"]["media_id"] == 7
class TestResolverSiteScope:
"""F.5a.9.x: ``_resolve_api_key`` reads from the per-site key store when
``site_id`` is set, and falls back to env vars only when it is None."""
@pytest.mark.asyncio
async def test_site_key_wins_over_env(self, wp_client, monkeypatch):
"""With site_id set, the per-site key is returned even if env has one."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-should-not-be-used")
async def _fake_get(site_id, provider):
assert site_id == "site-abc"
assert provider == "openai"
return "sk-site-win"
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
assert await handler._resolve_api_key("openai") == "sk-site-win"
@pytest.mark.asyncio
async def test_site_set_but_no_key_returns_none(self, wp_client, monkeypatch):
"""site_id set + no site key stored + env present → still None.
Env fallback is deliberately not applied on the per-site path."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-ignored")
async def _fake_get(site_id, provider):
return None
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
assert await handler._resolve_api_key("openai") is None
@pytest.mark.asyncio
async def test_no_site_id_falls_back_to_env(self, wp_client, monkeypatch):
"""site_id=None (admin / master-key path): env var is used."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-admin-env")
handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
assert await handler._resolve_api_key("openai") == "sk-admin-env"
@pytest.mark.asyncio
async def test_no_site_id_no_env_returns_none(self, wp_client, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
assert await handler._resolve_api_key("openai") is None
@pytest.mark.asyncio
async def test_missing_key_error_points_to_site_dashboard(self, wp_client, monkeypatch):
"""When site_id is set and no key, error message points at the site
edit page, not the deprecated /dashboard/provider-keys page."""
async def _fake_get(site_id, provider):
return None
monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-xyz")
out = await handler.generate_and_upload_image(provider="openai", prompt="x")
data = json.loads(out)
assert data["error_code"] == "NO_PROVIDER_KEY"
assert data["dashboard_url"] == "/dashboard/sites/site-xyz"
assert "/dashboard/provider-keys" not in json.dumps(data)
class _StubProvider:
"""Minimal provider stub used by AIMediaHandler tests."""
name = "stub"
def __init__(
self,
result: GenerationResult | None = None,
exc: ProviderError | None = None,
):
self._result = result
self._exc = exc
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if self._exc is not None:
raise self._exc
assert self._result is not None
return self._result

View File

@@ -0,0 +1,188 @@
"""F.X.fix #7 — AI image upload sends Idempotency-Key to the companion.
Regression: when the MCP client timed out mid-call the companion had
already created the attachment. The client retried and the companion
created a second ``-2.webp`` orphan, leaving admins to clean up by
hand. Fix: generate a stable key per logical call, send it as an
``Idempotency-Key`` header, and let the companion dedupe.
These tests cover the Python side — the key is deterministic for the
same inputs, differs when any input changes, and is actually put on
the wire when ``_companion_upload_and_attach`` fires.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import _media_core
from plugins.wordpress.handlers.ai_media import (
_content_sha,
_idempotency_key_for,
)
class TestIdempotencyKeyBuilder:
def test_matches_companion_regex(self):
key = _idempotency_key_for(
provider="openrouter",
model="google/gemini-2.5-flash-image",
prompt="a cat",
size="1024x1024",
attach_to_post=42,
set_featured=True,
site_url="https://blog.example.com",
user_id="user-1",
content_sha=_content_sha(b"bytes"),
)
# Companion PHP validates ^[A-Za-z0-9_-]{1,128}$ before use.
import re
assert re.match(r"^[A-Za-z0-9_\-]{1,128}$", key)
assert 1 <= len(key) <= 128
def test_deterministic_for_same_inputs(self):
kwargs = {
"provider": "openrouter",
"model": "google/gemini-2.5-flash-image",
"prompt": "a cat",
"size": "1024x1024",
"attach_to_post": 42,
"set_featured": True,
"site_url": "https://blog.example.com",
"user_id": "user-1",
"content_sha": "abc123",
}
assert _idempotency_key_for(**kwargs) == _idempotency_key_for(**kwargs)
def test_differs_when_prompt_changes(self):
base = {
"provider": "openrouter",
"model": "m",
"prompt": "cat",
"size": "1024x1024",
"attach_to_post": 0,
"set_featured": False,
"site_url": "https://x",
"user_id": None,
"content_sha": "a",
}
other = {**base, "prompt": "dog"}
assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
def test_differs_when_attach_to_post_changes(self):
base = {
"provider": "openrouter",
"model": "m",
"prompt": "x",
"size": "1024x1024",
"attach_to_post": None,
"set_featured": False,
"site_url": "https://x",
"user_id": None,
"content_sha": "a",
}
other = {**base, "attach_to_post": 42}
assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
class TestHeaderPropagation:
@pytest.mark.asyncio
async def test_idempotency_key_reaches_companion_route(self):
"""_companion_upload_and_attach must put the key in the header."""
client = WordPressClient("https://example.com", "u", "app_pw")
captured: dict = {}
class _FakeResp:
status = 200
async def text(self):
return "{}"
async def json(self, content_type=None):
return {"id": 99, "source_url": "https://example.com/a.png"}
def _request(url, data=None, headers=None, params=None):
captured["headers"] = headers
captured["params"] = params
return AsyncMock(
__aenter__=AsyncMock(return_value=_FakeResp()),
__aexit__=AsyncMock(return_value=False),
)
session = AsyncMock()
session.post = _request
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
):
await _media_core._companion_upload_and_attach(
client,
b"\x89PNG\r\n\x1a\n",
sniffed="image/png",
disposition='attachment; filename="a.png"',
attach_to_post=42,
set_featured=True,
title=None,
alt_text=None,
caption=None,
description=None,
idempotency_key="mcphub_ai_abc123",
)
assert captured["headers"]["Idempotency-Key"] == "mcphub_ai_abc123"
@pytest.mark.asyncio
async def test_idempotency_key_absent_header_when_none(self):
client = WordPressClient("https://example.com", "u", "app_pw")
captured: dict = {}
class _FakeResp:
status = 200
async def text(self):
return "{}"
async def json(self, content_type=None):
return {"id": 7}
def _request(url, data=None, headers=None, params=None):
captured["headers"] = headers
return AsyncMock(
__aenter__=AsyncMock(return_value=_FakeResp()),
__aexit__=AsyncMock(return_value=False),
)
session = AsyncMock()
session.post = _request
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
):
await _media_core._companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition='attachment; filename="a.png"',
attach_to_post=None,
set_featured=False,
title=None,
alt_text=None,
caption=None,
description=None,
idempotency_key=None,
)
# No Idempotency-Key header when the caller didn't supply one;
# the companion must still handle the request normally.
assert "Idempotency-Key" not in captured["headers"]

View File

@@ -0,0 +1,224 @@
"""F.18.7 — Tests for wordpress audit-hook tools."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.audit_hook import (
SUPPORTED_EVENTS,
AuditHookHandler,
_validate_configure,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return AuditHookHandler(wp_client)
class TestValidateConfigure:
def test_all_none_ok(self):
assert (
_validate_configure(endpoint_url=None, secret=None, enabled=None, events=None) is None
)
def test_https_ok(self):
assert (
_validate_configure(
endpoint_url="https://mcp.example.com/api/companion-audit",
secret=None,
enabled=None,
events=None,
)
is None
)
def test_non_url_rejected(self):
err = _validate_configure(
endpoint_url="not-a-url",
secret=None,
enabled=None,
events=None,
)
assert err is not None
assert err["error"] == "invalid_endpoint_url"
def test_short_secret_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret="short",
enabled=None,
events=None,
)
assert err is not None
assert err["error"] == "secret_too_short"
def test_empty_secret_allowed_to_clear(self):
assert _validate_configure(endpoint_url=None, secret="", enabled=None, events=None) is None
def test_unknown_event_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled=None,
events=["transition_post_status", "bogus"],
)
assert err is not None
assert err["error"] == "unknown_event"
assert "supported" in err
def test_non_list_events_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled=None,
events="not a list",
)
assert err is not None
assert err["error"] == "invalid_events"
def test_non_bool_enabled_rejected(self):
err = _validate_configure(
endpoint_url=None,
secret=None,
enabled="yes",
events=None,
)
assert err is not None
assert err["error"] == "invalid_enabled"
@pytest.mark.asyncio
async def test_status_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"enabled": True,
"endpoint_url": "https://mcp.example.com/api/companion-audit",
"secret_set": True,
"secret_last4": "abcd",
"events": list(SUPPORTED_EVENTS),
"last_push_gmt": "2026-04-15T09:00:00Z",
"failure_count": 0,
"last_error": "",
"plugin_version": "2.7.0",
}
get_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.audit_hook_status())
assert out["ok"] is True
assert out["enabled"] is True
assert out["secret_last4"] == "abcd"
assert out["failure_count"] == 0
get_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_status_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.audit_hook_status())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
@pytest.mark.asyncio
async def test_configure_happy_path(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["endpoint"] = endpoint
captured["body"] = json_data
return {
"enabled": True,
"endpoint_url": "https://mcp.example.com/api/companion-audit",
"secret_set": True,
"secret_last4": "wxyz",
"events": ["transition_post_status"],
"failure_count": 0,
}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
out = json.loads(
await handler.audit_hook_configure(
endpoint_url="https://mcp.example.com/api/companion-audit",
secret="a" * 32,
enabled=True,
events=["transition_post_status"],
)
)
assert out["ok"] is True
assert captured["endpoint"] == "airano-mcp/v1/audit-hook"
assert captured["body"]["endpoint_url"] == "https://mcp.example.com/api/companion-audit"
assert captured["body"]["secret"] == "a" * 32
assert captured["body"]["enabled"] is True
assert captured["body"]["events"] == ["transition_post_status"]
@pytest.mark.asyncio
async def test_configure_rejects_short_secret_without_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.audit_hook_configure(secret="short"))
assert out["ok"] is False
assert out["error"] == "secret_too_short"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_configure_no_fields_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.audit_hook_configure())
assert out["ok"] is False
assert out["error"] == "no_fields"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_disable_calls_delete(handler, wp_client, monkeypatch):
del_mock = AsyncMock(
return_value={
"enabled": False,
"endpoint_url": "",
"secret_set": False,
"cleared": True,
"plugin_version": "2.7.0",
}
)
monkeypatch.setattr(wp_client, "delete", del_mock)
out = json.loads(await handler.audit_hook_disable())
assert out["ok"] is True
assert out["cleared"] is True
del_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_disable_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "delete", AsyncMock(side_effect=RuntimeError("boom")))
out = json.loads(await handler.audit_hook_disable())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
def test_tool_specs_shape():
specs = get_tool_specifications()
assert len(specs) == 3
names = {s["name"] for s in specs}
assert names == {"audit_hook_status", "audit_hook_configure", "audit_hook_disable"}
scopes = {s["name"]: s["scope"] for s in specs}
assert scopes["audit_hook_status"] == "read"
assert scopes["audit_hook_configure"] == "admin"
assert scopes["audit_hook_disable"] == "admin"

View File

@@ -0,0 +1,197 @@
"""F.18.2 — Tests for wordpress_bulk_update_meta."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.bulk_meta import (
MAX_BULK_ITEMS,
BulkMetaHandler,
_validate_updates,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return BulkMetaHandler(wp_client)
# ---------------------------------------------------------------------------
# Client-side validation: should never hit the network.
# ---------------------------------------------------------------------------
def test_validate_rejects_non_list():
out = _validate_updates("not a list")
assert isinstance(out, dict)
assert out["error"] == "invalid_updates"
def test_validate_rejects_empty_list():
out = _validate_updates([])
assert isinstance(out, dict)
assert out["error"] == "empty_updates"
def test_validate_rejects_too_many_items():
too_many = [{"post_id": i + 1, "meta": {"k": "v"}} for i in range(MAX_BULK_ITEMS + 1)]
out = _validate_updates(too_many)
assert isinstance(out, dict)
assert out["error"] == "too_many_items"
def test_validate_rejects_non_object_item():
out = _validate_updates(["not a dict"])
assert isinstance(out, dict)
assert out["error"] == "invalid_item"
assert out["index"] == 0
def test_validate_rejects_non_positive_post_id():
out = _validate_updates([{"post_id": 0, "meta": {"k": "v"}}])
assert isinstance(out, dict)
assert out["error"] == "invalid_post_id"
def test_validate_rejects_non_dict_meta():
out = _validate_updates([{"post_id": 1, "meta": "not a dict"}])
assert isinstance(out, dict)
assert out["error"] == "invalid_meta"
def test_validate_passes_well_formed_input():
out = _validate_updates(
[
{"post_id": 1, "meta": {"a": 1}},
{"post_id": 2, "meta": {"b": None}},
]
)
assert isinstance(out, list)
assert out == [
{"post_id": 1, "meta": {"a": 1}},
{"post_id": 2, "meta": {"b": None}},
]
# ---------------------------------------------------------------------------
# Handler behaviour: network errors and happy paths.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_client_side_reject_does_not_hit_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out_json = await handler.bulk_update_meta(updates="not a list")
out = json.loads(out_json)
assert out["ok"] is False
assert out["error"] == "invalid_updates"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}]))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "probe_capabilities" in out["hint"]
assert out["total"] == 1
@pytest.mark.asyncio
async def test_happy_path_passes_updates_as_list(handler, wp_client, monkeypatch):
companion_response = {
"total": 2,
"updated": 2,
"failed": 0,
"skipped": 0,
"results": [
{"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
{"index": 1, "post_id": 2, "status": "ok", "updated_keys": ["b"]},
],
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
updates = [
{"post_id": 1, "meta": {"a": "1"}},
{"post_id": 2, "meta": {"b": "2"}},
]
out = json.loads(await handler.bulk_update_meta(updates=updates))
assert out["ok"] is True
assert out["total"] == 2
assert out["updated"] == 2
assert out["failed"] == 0
assert len(out["results"]) == 2
post_mock.assert_called_once_with(
"airano-mcp/v1/bulk-meta",
json_data={"updates": updates},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_mixed_success_and_skip_passed_through(handler, wp_client, monkeypatch):
companion_response = {
"total": 3,
"updated": 1,
"failed": 0,
"skipped": 2,
"results": [
{"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
{"index": 1, "post_id": 999, "status": "not_found", "error": "post_not_found"},
{"index": 2, "post_id": 2, "status": "forbidden", "error": "cannot_edit_post"},
],
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(
await handler.bulk_update_meta(
updates=[
{"post_id": 1, "meta": {"a": "1"}},
{"post_id": 999, "meta": {"b": "2"}},
{"post_id": 2, "meta": {"c": "3"}},
]
)
)
assert out["ok"] is True
assert out["updated"] == 1
assert out["skipped"] == 2
assert out["results"][1]["status"] == "not_found"
assert out["results"][2]["status"] == "forbidden"
@pytest.mark.asyncio
async def test_null_meta_value_survives_payload(handler, wp_client, monkeypatch):
"""null values delete keys in PHP — we must preserve them in the JSON payload."""
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["json_data"] = json_data
return {"total": 1, "updated": 1, "failed": 0, "skipped": 0, "results": []}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"rank_math_title": None}}])
assert captured["json_data"]["updates"][0]["meta"] == {"rank_math_title": None}
def test_tool_spec_is_write_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "bulk_update_meta"
assert specs[0]["scope"] == "write"
assert "updates" in specs[0]["schema"]["properties"]

View File

@@ -0,0 +1,128 @@
"""F.18.4 — Tests for wordpress_cache_purge."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.cache_purge import (
CachePurgeHandler,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return CachePurgeHandler(wp_client)
@pytest.mark.asyncio
async def test_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"detected": ["wp_rocket", "litespeed"],
"purged": ["wp_rocket_all", "litespeed_all", "object_cache"],
"skipped": [],
"errors": [],
"ok": True,
"plugin_version": "2.4.0",
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
assert out["detected"] == ["wp_rocket", "litespeed"]
assert "object_cache" in out["purged"]
assert out["plugin_version"] == "2.4.0"
post_mock.assert_called_once_with(
"airano-mcp/v1/cache-purge",
json_data={},
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_no_cache_plugins_detected(handler, wp_client, monkeypatch):
companion_response = {
"detected": [],
"purged": ["object_cache"],
"skipped": [],
"errors": [],
"ok": True,
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
assert out["detected"] == []
# Object cache still flushed unconditionally.
assert "object_cache" in out["purged"]
@pytest.mark.asyncio
async def test_partial_errors_propagated(handler, wp_client, monkeypatch):
companion_response = {
"detected": ["wp_rocket"],
"purged": ["object_cache"],
"skipped": [],
"errors": [{"plugin": "wp_rocket", "message": "rocket_clean_domain exploded"}],
"ok": False,
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert len(out["errors"]) == 1
assert out["errors"][0]["plugin"] == "wp_rocket"
@pytest.mark.asyncio
async def test_companion_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "manage_options" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="not a dict"))
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_ok_inferred_from_empty_errors_if_missing(handler, wp_client, monkeypatch):
"""Older companion builds might not emit an `ok` flag; derive from errors list."""
companion_response = {
"detected": ["w3_total_cache"],
"purged": ["w3_total_cache_all", "object_cache"],
"errors": [],
"plugin_version": "2.4.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
out = json.loads(await handler.cache_purge())
assert out["ok"] is True
def test_tool_spec_is_admin_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "cache_purge"
assert specs[0]["scope"] == "admin"

View File

@@ -0,0 +1,239 @@
"""F.18.1 — Tests for wordpress_probe_capabilities."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.capabilities import (
_EXPECTED_CAPS,
_EXPECTED_ROUTES,
CapabilitiesHandler,
_CapabilitiesCache,
get_cached_capabilities,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def cache():
return _CapabilitiesCache(ttl=24 * 3600)
@pytest.fixture
def companion_payload():
return {
"plugin_version": "2.1.0",
"companion": True,
"user": {
"id": 1,
"login": "mcphub",
"roles": ["administrator"],
"capabilities": {
"upload_files": True,
"edit_posts": True,
"publish_posts": True,
"edit_others_posts": True,
"delete_posts": True,
"edit_pages": True,
"publish_pages": True,
"manage_categories": True,
"moderate_comments": True,
"manage_options": True,
"edit_users": True,
"list_users": True,
"manage_woocommerce": False,
"edit_shop_orders": False,
"edit_products": False,
},
},
"features": {
"rank_math": True,
"yoast": False,
"woocommerce": False,
"multisite": False,
},
"routes": {
"seo_meta": True,
"upload_limits": True,
"upload_chunk": True,
"capabilities": True,
"bulk_meta": False,
"export": False,
"cache_purge": False,
"transient_flush": False,
"site_health": False,
"audit_hook": False,
},
"wordpress": {
"version": "6.5.3",
"php_version": "8.1.27",
"rest_enabled": True,
},
}
@pytest.mark.asyncio
async def test_companion_endpoint_parses_and_caches(
wp_client, monkeypatch, cache, companion_payload
):
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out_json = await handler.probe_capabilities()
out = json.loads(out_json)
assert out["companion_available"] is True
assert out["plugin_version"] == "2.1.0"
assert out["cached"] is False
assert out["user"]["login"] == "mcphub"
assert out["user"]["capabilities"]["upload_files"] is True
assert out["user"]["capabilities"]["manage_woocommerce"] is False
assert out["routes"]["upload_chunk"] is True
assert out["routes"]["bulk_meta"] is False
assert out["features"]["rank_math"] is True
assert out["wordpress"]["version"] == "6.5.3"
get_mock.assert_called_once_with("airano-mcp/v1/capabilities", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache, companion_payload):
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_capabilities()
out2 = json.loads(await handler.probe_capabilities())
assert out2["cached"] is True
assert get_mock.call_count == 1
@pytest.mark.asyncio
async def test_cache_expires_after_ttl(wp_client, monkeypatch, companion_payload):
cache = _CapabilitiesCache(ttl=0.0)
handler = CapabilitiesHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_capabilities()
await handler.probe_capabilities()
assert get_mock.call_count == 2
@pytest.mark.asyncio
async def test_companion_missing_returns_false_shape(wp_client, monkeypatch, cache):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is False
assert out["plugin_version"] is None
assert out["user"] is None
assert out["features"] is None
# All routes default to False so downstream code can still index safely.
assert all(v is False for v in out["routes"].values())
assert set(out["routes"].keys()) == set(_EXPECTED_ROUTES)
assert "companion_unreachable" in out["reason"]
@pytest.mark.asyncio
async def test_non_dict_payload_treated_as_missing(wp_client, monkeypatch, cache):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is False
assert out["reason"] == "companion_returned_non_dict"
@pytest.mark.asyncio
async def test_missing_caps_default_to_false(wp_client, monkeypatch, cache):
"""If the plugin drops a cap key (e.g. older plugin build), we fill with False."""
handler = CapabilitiesHandler(wp_client, cache=cache)
partial = {
"plugin_version": "2.1.0",
"user": {
"id": 1,
"login": "x",
"roles": ["editor"],
"capabilities": {"upload_files": True, "edit_posts": True},
},
"routes": {"upload_limits": True, "upload_chunk": True},
"features": {"rank_math": False},
"wordpress": {"version": "6.4"},
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=partial))
out = json.loads(await handler.probe_capabilities())
assert out["companion_available"] is True
caps = out["user"]["capabilities"]
assert caps["upload_files"] is True
assert caps["manage_options"] is False
assert set(caps.keys()) == set(_EXPECTED_CAPS)
routes = out["routes"]
assert routes["upload_limits"] is True
assert routes["bulk_meta"] is False
assert set(routes.keys()) == set(_EXPECTED_ROUTES)
@pytest.mark.asyncio
async def test_extra_caps_preserved_under_extra_key(wp_client, monkeypatch, cache):
"""Caps the plugin sends that aren't in our known list end up under extra_capabilities."""
handler = CapabilitiesHandler(wp_client, cache=cache)
payload = {
"plugin_version": "2.9.0", # future plugin with more caps
"user": {
"id": 1,
"login": "x",
"roles": ["administrator"],
"capabilities": {
"upload_files": True,
"some_future_cap": True,
},
},
"routes": {},
"features": {},
"wordpress": {},
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
out = json.loads(await handler.probe_capabilities())
assert out["user"]["capabilities"]["upload_files"] is True
assert out["user"]["extra_capabilities"] == {"some_future_cap": True}
@pytest.mark.asyncio
async def test_get_cached_capabilities_returns_none_before_probe(wp_client):
cache = _CapabilitiesCache(ttl=24 * 3600)
assert await get_cached_capabilities(wp_client, cache=cache) is None
@pytest.mark.asyncio
async def test_get_cached_capabilities_after_probe(
wp_client, monkeypatch, cache, companion_payload
):
handler = CapabilitiesHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=companion_payload))
await handler.probe_capabilities()
got = await get_cached_capabilities(wp_client, cache=cache)
assert got is not None
assert got["companion_available"] is True
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "probe_capabilities"
assert specs[0]["scope"] == "read"

View File

@@ -0,0 +1,173 @@
"""F.X.fix #3 — WordPressClient fast-fails on unreachable sites.
Regression: tools hung 35-85s on DNS-dead / TCP-refused hosts because
the client did not set a connect timeout. Fix adds connect=5 and maps
every TCP/DNS/SSL failure to SiteUnreachableError with a structured
install_hint. Budget: total wall-clock < 10s per request.
"""
from __future__ import annotations
import socket
import time
from unittest.mock import AsyncMock, patch
import aiohttp
import pytest
from plugins.wordpress.client import (
_CONNECT_TIMEOUT,
_REQUEST_TIMEOUT,
SiteUnreachableError,
WordPressClient,
)
@pytest.fixture
def client() -> WordPressClient:
return WordPressClient("https://dead.example.invalid", "user", "app_pw")
def _patch_session_raising(exc: Exception):
"""Patch aiohttp.ClientSession so ``session.request(...)`` raises ``exc``.
The production code uses ``async with session.request(...) as resp``,
so we mock ``request`` as a plain callable returning an
``AsyncMock`` whose ``__aenter__`` raises — matching the real
``aiohttp.ClientConnector*`` error path.
"""
session = AsyncMock()
def _request(*_args, **_kwargs):
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=exc)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
session.request = _request
return patch(
"plugins.wordpress.client.aiohttp.ClientSession",
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=session),
__aexit__=AsyncMock(return_value=False),
),
)
def _conn_key(site_url: str) -> aiohttp.client_reqrep.ConnectionKey:
# Minimal ConnectionKey required by aiohttp error constructors.
return aiohttp.client_reqrep.ConnectionKey(
host=site_url.split("://")[-1],
port=443,
is_ssl=site_url.startswith("https"),
ssl=None,
proxy=None,
proxy_auth=None,
proxy_headers_hash=None,
)
class TestConnectTimeoutConfigured:
def test_connect_timeout_is_five_seconds(self):
# Sanity: the module-level constant is what the roadmap asked for.
assert _CONNECT_TIMEOUT == 5
assert _REQUEST_TIMEOUT == 30
class TestSiteUnreachableMapping:
@pytest.mark.asyncio
async def test_dns_error_maps_to_site_unreachable(self, client):
dns_exc = aiohttp.ClientConnectorDNSError(
_conn_key(client.site_url), OSError("Name or service not known")
)
with _patch_session_raising(dns_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.error_code == "SITE_UNREACHABLE"
assert e.value.reason == "site_dns_error"
assert e.value.install_hint is not None
assert e.value.install_hint["companion_min_version"]
assert e.value.install_hint["install_url"].startswith("http")
@pytest.mark.asyncio
async def test_gaierror_via_connector_also_maps_to_dns_error(self, client):
gai = socket.gaierror("Name or service not known")
conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), gai)
with _patch_session_raising(conn_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_dns_error"
@pytest.mark.asyncio
async def test_connection_refused_maps_to_site_unreachable(self, client):
os_err = OSError(111, "Connection refused")
conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), os_err)
with _patch_session_raising(conn_exc):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_connection_refused"
assert e.value.install_hint is not None
@pytest.mark.asyncio
async def test_ssl_error_maps_to_site_unreachable(self, client):
import ssl
ssl_exc = aiohttp.ClientConnectorCertificateError(
_conn_key(client.site_url),
ssl.SSLCertVerificationError("bad cert"),
)
with _patch_session_raising(ssl_exc):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_ssl_error"
@pytest.mark.asyncio
async def test_invalid_url_maps_to_site_unreachable(self):
client = WordPressClient("not-a-url", "u", "p")
invalid = aiohttp.InvalidURL("not-a-url")
with _patch_session_raising(invalid):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_invalid_url"
@pytest.mark.asyncio
async def test_timeout_after_retries_maps_to_site_unreachable(self, client):
with _patch_session_raising(TimeoutError("connect")):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
with pytest.raises(SiteUnreachableError) as e:
await client.request("GET", "posts")
assert e.value.reason == "site_timeout"
assert e.value.install_hint is not None
class TestFastFailBudget:
"""Guards the 10s assertion promised in the F.X.retest matrix."""
@pytest.mark.asyncio
async def test_dns_error_fails_under_ten_seconds(self, client):
dns_exc = aiohttp.ClientConnectorDNSError(_conn_key(client.site_url), OSError("dead"))
with _patch_session_raising(dns_exc):
start = time.monotonic()
with pytest.raises(SiteUnreachableError):
await client.request("GET", "posts")
elapsed = time.monotonic() - start
# DNS errors are non-retryable, so this is essentially instant;
# give a loose 10s bound to match the F.X.retest assertion shape.
assert elapsed < 10.0
@pytest.mark.asyncio
async def test_timeout_with_retries_fails_under_ten_seconds(self, client):
# Two retries × _CONNECT_TIMEOUT + retry backoff. With mocked
# sleep this should return immediately; real-world budget is
# 3 × 5s = 15s worst-case, but only on the first request; follow-
# ups hit the cache.
with _patch_session_raising(TimeoutError()):
with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
start = time.monotonic()
with pytest.raises(SiteUnreachableError):
await client.request("GET", "posts")
elapsed = time.monotonic() - start
assert elapsed < 10.0

View File

@@ -0,0 +1,158 @@
"""Tests for the shared ``companion_install_hint`` helper + its use in
companion-backed handlers' ``companion_unreachable`` error payloads.
The helper's job is to give the caller enough information to actually
install / configure the companion plugin rather than just saying
"companion unreachable". Every handler that routes through the
companion now emits this dict alongside the existing human-readable
``hint`` string.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._companion_hint import (
COMPANION_DOWNLOAD_URL,
companion_install_hint,
)
@pytest.fixture
def wp_client():
c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
c.post = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
c.get = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
return c
# ---------------------------------------------------------------------------
# Helper-level tests
# ---------------------------------------------------------------------------
class TestCompanionInstallHintHelper:
@pytest.mark.unit
def test_download_url_points_at_github_raw(self):
assert COMPANION_DOWNLOAD_URL.startswith("https://github.com/airano-ir/mcphub/raw/main/")
assert COMPANION_DOWNLOAD_URL.endswith("airano-mcp-bridge.zip")
@pytest.mark.unit
def test_returns_required_keys(self):
hint = companion_install_hint(min_version="2.4.0")
assert set(hint.keys()) == {
"install_url",
"install_instructions",
"required_capability",
"companion_min_version",
}
assert hint["companion_min_version"] == "2.4.0"
assert hint["required_capability"] == "manage_options"
@pytest.mark.unit
def test_capability_override(self):
hint = companion_install_hint(min_version="2.3.0", required_capability="edit_posts")
assert hint["required_capability"] == "edit_posts"
assert "edit_posts" in hint["install_instructions"]
@pytest.mark.unit
def test_route_hint_appended_when_provided(self):
hint = companion_install_hint(min_version="2.8.0", route="airano-mcp/v1/foo")
assert hint["route"] == "airano-mcp/v1/foo"
@pytest.mark.unit
def test_install_url_is_stable(self):
hint_a = companion_install_hint(min_version="2.0.0")
hint_b = companion_install_hint(min_version="3.0.0")
# The download URL itself is version-agnostic — it always points
# at the latest zip on main.
assert hint_a["install_url"] == hint_b["install_url"] == COMPANION_DOWNLOAD_URL
# ---------------------------------------------------------------------------
# End-to-end: a handler emits install_hint when the companion is missing.
# ---------------------------------------------------------------------------
class TestHandlerEmitsInstallHint:
@pytest.mark.asyncio
async def test_cache_purge_emits_install_hint_on_failure(self, wp_client):
from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
handler = CachePurgeHandler(wp_client)
out = json.loads(await handler.cache_purge())
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "install_hint" in out
assert out["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL
assert out["install_hint"]["companion_min_version"] == "2.4.0"
assert out["install_hint"]["required_capability"] == "manage_options"
assert out["install_hint"]["route"] == "airano-mcp/v1/cache-purge"
@pytest.mark.asyncio
async def test_regenerate_thumbnails_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.regenerate_thumbnails import (
RegenerateThumbnailsHandler,
)
handler = RegenerateThumbnailsHandler(wp_client)
out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.8.0"
# Writes need upload_files, not manage_options.
assert out["install_hint"]["required_capability"] == "upload_files"
@pytest.mark.asyncio
async def test_bulk_meta_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
handler = BulkMetaHandler(wp_client)
out = json.loads(
await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}])
)
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.2.0"
@pytest.mark.asyncio
async def test_export_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.export import ExportHandler
handler = ExportHandler(wp_client)
out = json.loads(await handler.export_content())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.3.0"
# Export uses edit_posts, not manage_options.
assert out["install_hint"]["required_capability"] == "edit_posts"
@pytest.mark.asyncio
async def test_site_health_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.site_health import SiteHealthHandler
handler = SiteHealthHandler(wp_client)
out = json.loads(await handler.site_health())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.6.0"
@pytest.mark.asyncio
async def test_transient_flush_emits_install_hint(self, wp_client):
from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
handler = TransientFlushHandler(wp_client)
out = json.loads(await handler.transient_flush())
assert out["error"] == "companion_unreachable"
assert out["install_hint"]["companion_min_version"] == "2.5.0"
@pytest.mark.asyncio
async def test_capabilities_probe_embeds_install_hint_when_empty(self, wp_client):
"""``_empty_capabilities_payload`` is the fallback shape every
companion probe returns on failure; the install hint must be in
there too so the UI can link-to-install without a second lookup."""
from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
payload = _empty_capabilities_payload("https://wp.example.com", reason="test")
assert "install_hint" in payload
assert payload["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL

View File

@@ -0,0 +1,263 @@
"""F.18.3 — Tests for wordpress_export_content."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.export import (
EXPORT_DEFAULT_LIMIT,
EXPORT_MAX_LIMIT,
ExportHandler,
_build_query_params,
_normalise_bool,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return ExportHandler(wp_client)
# ---------------------------------------------------------------------------
# Parameter normalisation.
# ---------------------------------------------------------------------------
class TestNormaliseBool:
def test_none_defaults(self):
assert _normalise_bool(None, True) is True
assert _normalise_bool(None, False) is False
def test_bool_passthrough(self):
assert _normalise_bool(True, False) is True
assert _normalise_bool(False, True) is False
def test_numeric(self):
assert _normalise_bool(1, False) is True
assert _normalise_bool(0, True) is False
def test_strings(self):
assert _normalise_bool("true", False) is True
assert _normalise_bool("False", True) is False
assert _normalise_bool("yes", False) is True
assert _normalise_bool("no", True) is False
assert _normalise_bool("garbage", True) is True # fallback
class TestBuildQueryParams:
def test_defaults(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=None,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["post_type"] == "post"
assert out["status"] == "publish"
assert out["limit"] == EXPORT_DEFAULT_LIMIT
assert out["offset"] == 0
assert out["include_media"] == "true"
assert out["include_terms"] == "true"
assert out["include_meta"] == "true"
assert "since" not in out
def test_limit_clamped_to_max(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=EXPORT_MAX_LIMIT * 10,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["limit"] == EXPORT_MAX_LIMIT
def test_negative_offset_floored_to_zero(self):
out = _build_query_params(
post_type=None,
status=None,
since=None,
limit=None,
offset=-5,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["offset"] == 0
def test_since_passed_through(self):
out = _build_query_params(
post_type="post",
status="publish",
since="2026-04-01T00:00:00Z",
limit=10,
offset=0,
include_media=False,
include_terms=True,
include_meta=False,
)
assert out["since"] == "2026-04-01T00:00:00Z"
assert out["include_media"] == "false"
assert out["include_meta"] == "false"
assert out["limit"] == 10
def test_comma_separated_types(self):
out = _build_query_params(
post_type="post,page,product",
status="publish,draft",
since=None,
limit=None,
offset=None,
include_media=True,
include_terms=True,
include_meta=True,
)
assert out["post_type"] == "post,page,product"
assert out["status"] == "publish,draft"
# ---------------------------------------------------------------------------
# Handler behaviour.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_happy_path_forwards_to_companion(handler, wp_client, monkeypatch):
companion_response = {
"post_types": ["post"],
"status": ["publish"],
"since": None,
"limit": 100,
"offset": 0,
"returned": 2,
"total_matching": 5,
"has_more": True,
"next_offset": 2,
"include_media": True,
"include_terms": True,
"include_meta": True,
"posts": [
{"id": 1, "title": "First", "post_type": "post"},
{"id": 2, "title": "Second", "post_type": "post"},
],
"media": [],
"exported_at_gmt": "2026-04-15T09:00:00Z",
"plugin_version": "2.3.0",
}
get_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.export_content())
assert out["ok"] is True
assert out["returned"] == 2
assert out["has_more"] is True
assert out["next_offset"] == 2
assert len(out["posts"]) == 2
assert out["plugin_version"] == "2.3.0"
# Verify the endpoint was called with default params.
call_args = get_mock.call_args
assert call_args.args[0] == "airano-mcp/v1/export"
assert call_args.kwargs["use_custom_namespace"] is True
params = call_args.kwargs["params"]
assert params["post_type"] == "post"
assert params["status"] == "publish"
assert params["limit"] == EXPORT_DEFAULT_LIMIT
@pytest.mark.asyncio
async def test_all_params_forwarded(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_get(endpoint, params=None, **kwargs):
captured["endpoint"] = endpoint
captured["params"] = params
return {"posts": [], "media": [], "returned": 0, "total_matching": 0}
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
await handler.export_content(
post_type="post,page",
status="publish,draft",
since="2026-01-01T00:00:00Z",
limit=50,
offset=10,
include_media=False,
include_terms=False,
include_meta=True,
)
assert captured["params"]["post_type"] == "post,page"
assert captured["params"]["status"] == "publish,draft"
assert captured["params"]["since"] == "2026-01-01T00:00:00Z"
assert captured["params"]["limit"] == 50
assert captured["params"]["offset"] == 10
assert captured["params"]["include_media"] == "false"
assert captured["params"]["include_terms"] == "false"
assert captured["params"]["include_meta"] == "true"
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404")))
out = json.loads(await handler.export_content(post_type="post"))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "probe_capabilities" in out["hint"]
assert "params" in out
@pytest.mark.asyncio
async def test_non_dict_companion_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.export_content())
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_limit_clamping_before_network(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_get(endpoint, params=None, **kwargs):
captured["params"] = params
return {"posts": [], "media": []}
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
await handler.export_content(limit=10_000)
assert captured["params"]["limit"] == EXPORT_MAX_LIMIT
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "export_content"
assert specs[0]["scope"] == "read"
props = specs[0]["schema"]["properties"]
for key in (
"post_type",
"status",
"since",
"limit",
"offset",
"include_media",
"include_terms",
"include_meta",
):
assert key in props

View File

@@ -0,0 +1,153 @@
"""F.X.fix #4 — get_post default projection + strict ``fields=`` allow-list.
Regression: ``get_post`` dropped ``featured_media``, ``slug``, and
``featured_media_url`` from its response even when callers asked for
them explicitly via ``fields=``. Fix restores the defaults and makes
the ``fields`` parameter a strict allow-list rather than a subset of a
hard-coded projection.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.posts import PostsHandler
SAMPLE_POST = {
"id": 42,
"slug": "mcp-hub-launch",
"featured_media": 77,
"title": {"rendered": "MCP Hub Launch"},
"content": {"rendered": "<p>Hello world</p>"},
"excerpt": {"rendered": "<p>short</p>"},
"status": "publish",
"date": "2026-04-17T10:00:00",
"modified": "2026-04-17T10:05:00",
"categories": [3],
"tags": [9],
"link": "https://example.com/mcp-hub-launch",
"_embedded": {
"author": [{"name": "Ali"}],
"wp:featuredmedia": [
{
"id": 77,
"source_url": "https://example.com/wp-content/uploads/hero.webp",
}
],
},
}
@pytest.fixture
def handler_with(post_payload):
client = AsyncMock()
client.get = AsyncMock(return_value=post_payload)
return PostsHandler(client), client
class TestDefaultProjection:
@pytest.mark.asyncio
async def test_default_response_includes_featured_media_slug_and_url(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["id"] == 42
assert data["slug"] == "mcp-hub-launch"
assert data["featured_media"] == 77
assert data["featured_media_url"] == "https://example.com/wp-content/uploads/hero.webp"
@pytest.mark.asyncio
async def test_featured_media_url_empty_when_no_embedded_media(self):
post = {**SAMPLE_POST, "featured_media": 0, "_embedded": {"author": [{"name": "x"}]}}
client = AsyncMock()
client.get = AsyncMock(return_value=post)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["featured_media"] == 0
assert data["featured_media_url"] == ""
@pytest.mark.asyncio
async def test_default_still_embeds_metadata(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42)
# ``_embed=true`` must remain in query so featured_media_url can
# be derived from _embedded.wp:featuredmedia.
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
assert params.get("_embed") == "true"
class TestStrictFieldsAllowList:
@pytest.mark.asyncio
async def test_fields_limits_to_requested_plus_id(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,featured_media")
data = json.loads(raw)
# id is always preserved for identification.
assert set(data.keys()) == {"id", "slug", "featured_media"}
@pytest.mark.asyncio
async def test_fields_title_only_excludes_slug_by_design(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="title")
data = json.loads(raw)
# Previous behaviour implicitly included slug even when NOT
# requested — strict allow-list must drop it.
assert "slug" not in data
assert "featured_media" not in data
assert set(data.keys()) == {"id", "title"}
@pytest.mark.asyncio
async def test_fields_featured_media_url_honoured(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="featured_media_url")
data = json.loads(raw)
assert set(data.keys()) == {"id", "featured_media_url"}
assert data["featured_media_url"].startswith("https://")
@pytest.mark.asyncio
async def test_unknown_field_name_is_ignored(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,does_not_exist")
data = json.loads(raw)
# Unknown name silently dropped; requested known names plus id.
assert set(data.keys()) == {"id", "slug"}
@pytest.mark.asyncio
async def test_fields_passes_wp_fields_query_param(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42, fields="featured_media,slug")
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
wp_fields = set((params.get("_fields") or "").split(","))
assert "featured_media" in wp_fields
assert "slug" in wp_fields
assert "id" in wp_fields

View File

@@ -0,0 +1,217 @@
"""Tests for F.5a.3 WooCommerce attachment + featured-image tool."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_attach import (
MediaAttachHandler,
_merge_product_images,
)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
# --- Pure merge logic -------------------------------------------------------
class TestMergeImages:
def test_append_gallery_preserves_existing(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [3, 4], role="gallery", mode="append")
assert [i["id"] for i in out] == [1, 2, 3, 4]
def test_append_gallery_dedupes(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [2, 3], role="gallery", mode="append")
assert [i["id"] for i in out] == [1, 2, 3]
def test_replace_gallery_keeps_main(self):
out = _merge_product_images(
[{"id": 1}, {"id": 2}, {"id": 3}], [9], role="gallery", mode="replace"
)
assert [i["id"] for i in out] == [1, 9]
def test_replace_main_wipes_all(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="replace")
assert [i["id"] for i in out] == [9]
def test_append_main_promotes_new_to_index0(self):
out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="append")
assert out[0]["id"] == 9
# Existing images demoted but preserved
assert {i["id"] for i in out} == {9, 1, 2}
# --- Handler integration ----------------------------------------------------
class TestMediaAttachHandler:
@pytest.mark.asyncio
async def test_attach_happy_path(self):
handler = MediaAttachHandler(_client())
with (
patch.object(handler.client, "get", new=AsyncMock()) as mock_get,
patch.object(handler.client, "put", new=AsyncMock()) as mock_put,
):
# media validation GETs + product GET
mock_get.side_effect = [
{"id": 10}, # media/10 exists
{"id": 11}, # media/11 exists
{"id": 50, "images": [{"id": 1}]}, # products/50
]
mock_put.return_value = {
"id": 50,
"images": [{"id": 1, "src": "a"}, {"id": 10, "src": "b"}, {"id": 11, "src": "c"}],
}
out = await handler.attach_media_to_product(50, [10, 11], role="gallery", mode="append")
parsed = json.loads(out)
assert parsed["product_id"] == 50
assert [i["id"] for i in parsed["images"]] == [1, 10, 11]
# Verify PUT body
put_call = mock_put.await_args
assert put_call.kwargs["json_data"]["images"] == [{"id": 1}, {"id": 10}, {"id": 11}]
assert put_call.kwargs["use_woocommerce"] is True
@pytest.mark.asyncio
async def test_attach_rejects_missing_media(self):
handler = MediaAttachHandler(_client())
with patch.object(handler.client, "get", new=AsyncMock(side_effect=Exception("404"))):
out = await handler.attach_media_to_product(50, [999])
assert json.loads(out)["error_code"] == "MEDIA_NOT_FOUND"
@pytest.mark.asyncio
async def test_attach_rejects_bad_role(self):
handler = MediaAttachHandler(_client())
out = await handler.attach_media_to_product(50, [1], role="invalid")
assert json.loads(out)["error_code"] == "BAD_ROLE"
@pytest.mark.asyncio
async def test_attach_rejects_empty_media_ids(self):
handler = MediaAttachHandler(_client())
out = await handler.attach_media_to_product(50, [])
assert json.loads(out)["error_code"] == "MISSING_FIELD"
@pytest.mark.asyncio
async def test_upload_and_attach_base64(self):
handler = MediaAttachHandler(_client())
wp_media = {
"id": 77,
"title": {"rendered": "x"},
"source_url": "https://wp.example.com/x.png",
"mime_type": "image/png",
"media_type": "image",
}
with (
patch(
"plugins.wordpress.handlers.media_attach.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media_attach.wp_update_media_metadata",
new=AsyncMock(),
),
patch.object(
handler.client, "get", new=AsyncMock(return_value={"id": 77, "images": []})
),
patch.object(
handler.client,
"put",
new=AsyncMock(return_value={"id": 50, "images": [{"id": 77, "src": "s"}]}),
),
):
out = await handler.upload_and_attach_to_product(
product_id=50,
source="base64",
filename="x.png",
data=base64.b64encode(_PNG_1x1).decode(),
role="main",
)
parsed = json.loads(out)
assert parsed["media_id"] == 77
assert parsed["product_id"] == 50
@pytest.mark.asyncio
async def test_set_featured_image_post(self):
"""F.X.fix-pass5 — auto-detects "this is not a WC product",
falls through to /wp/v2/posts/{id} for regular posts/pages."""
handler = MediaAttachHandler(_client())
async def _fake_get(path, **kwargs):
if kwargs.get("use_woocommerce"):
raise Exception("404 — not a product")
return {"id": 9} # media exists
with (
patch.object(handler.client, "get", side_effect=_fake_get),
patch.object(
handler.client,
"post",
new=AsyncMock(return_value={"id": 100, "featured_media": 9}),
) as mock_post,
):
out = await handler.set_featured_image(post_id=100, media_id=9)
parsed = json.loads(out)
assert parsed["post_id"] == 100
assert parsed["featured_media"] == 9
assert parsed["context"] == "post"
mock_post.assert_awaited_once_with("posts/100", json_data={"featured_media": 9})
@pytest.mark.asyncio
async def test_set_featured_image_wc_product(self):
"""F.X.fix-pass5 — when post_id is a WC product, route through
WC API (PUT /wc/v3/products/{id} with images[]) instead of
/wp/v2/posts/{id} which 404s for products."""
handler = MediaAttachHandler(_client())
async def _fake_get(path, **kwargs):
if kwargs.get("use_woocommerce") and path == "products/77":
return {"id": 77, "images": [{"id": 5}, {"id": 6}]}
return {"id": 9} # media exists
with (
patch.object(handler.client, "get", side_effect=_fake_get),
patch.object(
handler.client,
"put",
new=AsyncMock(return_value={"id": 77, "images": [{"id": 9}, {"id": 5}, {"id": 6}]}),
) as mock_put,
patch.object(handler.client, "post", new=AsyncMock()) as mock_post,
):
out = await handler.set_featured_image(post_id=77, media_id=9)
parsed = json.loads(out)
assert parsed["post_id"] == 77
assert parsed["featured_media"] == 9
assert parsed["context"] == "product"
# WC PUT was used, NOT /wp/v2/posts.
mock_put.assert_awaited_once()
assert mock_put.await_args.kwargs["use_woocommerce"] is True
assert mock_post.await_count == 0
@pytest.mark.asyncio
async def test_upload_and_attach_missing_data(self):
handler = MediaAttachHandler(_client())
out = await handler.upload_and_attach_to_product(
product_id=1, source="base64", filename="x.png"
)
assert json.loads(out)["error_code"] == "MISSING_FIELD"
@pytest.mark.asyncio
async def test_upload_and_attach_ssrf_blocked(self):
handler = MediaAttachHandler(_client())
out = await handler.upload_and_attach_to_product(
product_id=1,
source="url",
filename="x.png",
url="http://127.0.0.1/x.png",
)
assert json.loads(out)["error_code"] == "SSRF"

View File

@@ -0,0 +1,161 @@
"""F.5a.6.4 — media.upload audit-log emission tests."""
from __future__ import annotations
import base64
import json
from unittest.mock import MagicMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media import MediaHandler
# 1x1 PNG — passes magic-byte sniff and size validation.
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def fake_audit(monkeypatch):
"""Replace the singleton audit logger with a MagicMock."""
fake = MagicMock()
fake.log_tool_call = MagicMock()
monkeypatch.setattr("core.audit_log.get_audit_logger", lambda: fake)
return fake
def _patch_wp_upload(monkeypatch, *, media_id=42, mime="image/png"):
"""Stub wp_raw_upload at its import site inside media.py."""
async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
return {
"id": media_id,
"mime_type": mime,
"media_type": "image",
"source_url": f"https://wp.example.com/wp-content/uploads/{filename}",
"title": {"rendered": filename},
"media_details": {"filesize": len(data)},
}
monkeypatch.setattr("plugins.wordpress.handlers.media.wp_raw_upload", fake_upload)
@pytest.mark.asyncio
async def test_base64_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
_patch_wp_upload(monkeypatch)
handler = MediaHandler(wp_client, user_id="alice")
out = json.loads(
await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="x.png",
skip_optimize=True,
)
)
assert out["id"] == 42
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["tool_name"] == "media.upload"
assert call.kwargs["user_id"] == "alice"
p = call.kwargs["params"]
assert p["source"] == "base64"
assert p["mime"] == "image/png"
assert p["media_id"] == 42
assert p["size_bytes"] == len(_PNG_1x1)
assert p["site"] == "https://wp.example.com"
assert "cost_usd" not in p
@pytest.mark.asyncio
async def test_base64_failure_writes_no_audit_entry(wp_client, fake_audit, monkeypatch):
"""Decode failure short-circuits before upload — no audit entry."""
handler = MediaHandler(wp_client, user_id="alice")
out = json.loads(
await handler.upload_media_from_base64(
data="!!! not valid base64 !!!",
filename="x.png",
)
)
assert out["error_code"] == "BAD_BASE64"
assert fake_audit.log_tool_call.call_count == 0
@pytest.mark.asyncio
async def test_url_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
_patch_wp_upload(monkeypatch)
handler = MediaHandler(wp_client, user_id=None) # admin/env
# Bypass SSRF + network: stub fetch_url_bytes.
async def fake_fetch(url, **kw):
return _PNG_1x1, "image/png", "remote.png"
monkeypatch.setattr("plugins.wordpress.handlers.media.fetch_url_bytes", fake_fetch)
monkeypatch.setattr(
"plugins.wordpress.handlers.media.ssrf_check",
lambda url: type("S", (), {"allowed": True, "reason": None})(),
)
out = json.loads(
await handler.upload_media_from_url(url="https://cdn.example.com/x.png", skip_optimize=True)
)
assert out["id"] == 42
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["params"]["source"] == "url"
assert call.kwargs["user_id"] is None # admin
@pytest.mark.asyncio
async def test_chunked_finish_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
"""Chunked finish should emit a media.upload entry with source='chunked'."""
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
_patch_wp_upload(monkeypatch)
# Stub the chunked store's finalize() to return assembled bytes.
class _FakeSession:
filename = "big.png"
mime = "image/png"
class _FakeStore:
async def finalize(self, sid):
return _FakeSession(), _PNG_1x1
monkeypatch.setattr(
"plugins.wordpress.handlers.media_chunked.wp_raw_upload",
lambda *a, **k: _patch_wp_upload, # not used; media.wp_raw_upload is patched
)
# The chunked handler calls wp_raw_upload imported into media_chunked.py
async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
return {
"id": 99,
"mime_type": "image/png",
"media_type": "image",
"source_url": "https://wp.example.com/wp-content/uploads/big.png",
"title": {"rendered": "big.png"},
"media_details": {"filesize": len(data)},
}
monkeypatch.setattr("plugins.wordpress.handlers.media_chunked.wp_raw_upload", fake_upload)
handler = MediaChunkedHandler(wp_client, user_id="bob", store=_FakeStore())
out = json.loads(
await handler.upload_media_chunked_finish(session_id="sid", skip_optimize=True)
)
assert out["id"] == 99
assert fake_audit.log_tool_call.call_count == 1
call = fake_audit.log_tool_call.call_args
assert call.kwargs["params"]["source"] == "chunked"
assert call.kwargs["params"]["media_id"] == 99
assert call.kwargs["user_id"] == "bob"

View File

@@ -0,0 +1,216 @@
"""F.5a.8.3 — Tests for bulk_delete_media + bulk_reassign_media."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_bulk import (
MediaBulkHandler,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return MediaBulkHandler(wp_client)
# ---------------------------------------------------------------------------
# Spec
# ---------------------------------------------------------------------------
def test_specs_expose_both_tools():
specs = get_tool_specifications()
names = {s["name"] for s in specs}
assert names == {"bulk_delete_media", "bulk_reassign_media"}
delete_spec = next(s for s in specs if s["name"] == "bulk_delete_media")
assert delete_spec["scope"] == "admin"
assert delete_spec["schema"]["properties"]["media_ids"]["maxItems"] == 100
reassign_spec = next(s for s in specs if s["name"] == "bulk_reassign_media")
assert reassign_spec["scope"] == "write"
assert "target_post" in reassign_spec["schema"]["required"]
# ---------------------------------------------------------------------------
# Input normalization
# ---------------------------------------------------------------------------
def test_normalize_ids_dedupes_and_caps(handler):
huge = [5, 5, 5] + list(range(1, 200))
out = handler._normalize_ids(huge)
assert len(out) == 100
assert len(out) == len(set(out))
def test_normalize_ids_drops_non_positive_and_garbage(handler):
out = handler._normalize_ids([1, 0, -5, "abc", None, 2, 3])
assert out == [1, 2, 3]
# ---------------------------------------------------------------------------
# bulk_delete_media
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_rejects_empty_list(handler, wp_client, monkeypatch):
delete_mock = AsyncMock()
monkeypatch.setattr(wp_client, "delete", delete_mock)
out = json.loads(await handler.bulk_delete_media([]))
assert out["ok"] is False
assert out["error"] == "invalid_request"
delete_mock.assert_not_called()
@pytest.mark.asyncio
async def test_delete_happy_path(handler, wp_client, monkeypatch):
calls: list[tuple[str, dict]] = []
async def _delete(path, params=None):
calls.append((path, params or {}))
return {"deleted": True}
monkeypatch.setattr(wp_client, "delete", _delete)
out = json.loads(await handler.bulk_delete_media([10, 20, 30], force=True))
assert out["ok"] is True
assert out["total"] == 3
assert out["processed"] == 3
assert out["errors"] == []
assert out["force"] is True
assert len(calls) == 3
# force flag propagated as string
assert all(c[1].get("force") == "true" for c in calls)
paths = sorted(c[0] for c in calls)
assert paths == ["media/10", "media/20", "media/30"]
@pytest.mark.asyncio
async def test_delete_partial_failure(handler, wp_client, monkeypatch):
async def _delete(path, params=None):
if path == "media/20":
raise RuntimeError("HTTP 404 not found")
return {"deleted": True}
monkeypatch.setattr(wp_client, "delete", _delete)
out = json.loads(await handler.bulk_delete_media([10, 20, 30]))
assert out["ok"] is False
assert out["total"] == 3
assert out["processed"] == 2
assert len(out["errors"]) == 1
assert out["errors"][0]["id"] == 20
assert "404" in out["errors"][0]["error"]
@pytest.mark.asyncio
async def test_delete_force_false_moves_to_trash(handler, wp_client, monkeypatch):
captured: list[dict] = []
async def _delete(path, params=None):
captured.append(params or {})
return {}
monkeypatch.setattr(wp_client, "delete", _delete)
await handler.bulk_delete_media([1], force=False)
assert captured[0]["force"] == "false"
# ---------------------------------------------------------------------------
# bulk_reassign_media
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reassign_rejects_empty_ids(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([], target_post=5))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_rejects_negative_target(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([1], target_post=-1))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_rejects_non_integer_target(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.bulk_reassign_media([1], target_post="abc")) # type: ignore[arg-type]
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_reassign_happy_path(handler, wp_client, monkeypatch):
calls: list[tuple[str, dict]] = []
async def _post(path, json_data=None, **kw):
calls.append((path, json_data or {}))
return {"id": 1, "post": 42}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([1, 2, 3], target_post=42))
assert out["ok"] is True
assert out["processed"] == 3
assert out["total"] == 3
assert out["target_post"] == 42
assert len(calls) == 3
assert all(c[1] == {"post": 42} for c in calls)
@pytest.mark.asyncio
async def test_reassign_detach_target_zero(handler, wp_client, monkeypatch):
calls: list[dict] = []
async def _post(path, json_data=None, **kw):
calls.append(json_data or {})
return {}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([1, 2], target_post=0))
assert out["ok"] is True
assert all(c == {"post": 0} for c in calls)
@pytest.mark.asyncio
async def test_reassign_partial_failure_surfaces_errors(handler, wp_client, monkeypatch):
async def _post(path, json_data=None, **kw):
if path == "media/7":
raise RuntimeError("permission denied")
return {}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.bulk_reassign_media([6, 7, 8], target_post=100))
assert out["ok"] is False
assert out["processed"] == 2
assert len(out["errors"]) == 1
assert out["errors"][0]["id"] == 7

View File

@@ -0,0 +1,264 @@
"""Tests for F.5a.5 chunked media upload (session store + WP handler)."""
from __future__ import annotations
import asyncio
import base64 as _b64
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from core.database import Database
from core.upload_sessions import (
CleanupTask,
UploadSessionError,
UploadSessionStore,
make_session_id,
set_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
_PNG_1x1 = _b64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client() -> WordPressClient:
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
async def db(tmp_path: Path):
d = Database(str(tmp_path / "test.db"))
await d.initialize()
try:
yield d
finally:
await d.close()
@pytest.fixture
async def store(tmp_path: Path, db: Database) -> UploadSessionStore:
s = UploadSessionStore(db=db, spill_dir=tmp_path / "spill")
set_upload_session_store(s)
try:
yield s
finally:
set_upload_session_store(None)
@pytest.fixture
def handler(store: UploadSessionStore) -> MediaChunkedHandler:
return MediaChunkedHandler(_client(), user_id="alice", store=store)
def _wp_response_mock(wp_response: dict):
mock_resp = AsyncMock()
mock_resp.status = 201
mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
mock_resp.json = AsyncMock(return_value=wp_response)
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
cls_mock = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
return cls_mock
class TestSessionStoreBasics:
@pytest.mark.asyncio
async def test_deterministic_session_id(self):
a = make_session_id("u1", "a.bin", 100, None, None)
b = make_session_id("u1", "a.bin", 100, None, None)
c = make_session_id("u2", "a.bin", 100, None, None)
assert a == b
assert a != c
@pytest.mark.asyncio
async def test_start_creates_row_and_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert sess.status == "open"
assert sess.spill_path.exists()
assert sess.received_bytes == 0
@pytest.mark.asyncio
async def test_start_is_idempotent_for_same_tuple(self, store: UploadSessionStore):
s1 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
s2 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert s1.id == s2.id
@pytest.mark.asyncio
async def test_hard_cap_rejects(self, store: UploadSessionStore):
store.max_session_bytes = 1024
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="alice", filename="big.bin", total_bytes=2048)
assert e.value.code == "SESSION_TOO_LARGE"
class TestAppendAndFinalize:
@pytest.mark.asyncio
async def test_round_trip_two_chunks(self, store: UploadSessionStore):
payload = b"abcdefghij" # 10 bytes
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, payload[:6])
await store.append_chunk(sess.id, 1, payload[6:])
sess2, data = await store.finalize(sess.id)
assert data == payload
assert not sess2.spill_path.exists()
@pytest.mark.asyncio
async def test_chunk_out_of_order(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, b"abcde")
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 2, b"fgh")
assert e.value.code == "CHUNK_ORDER"
@pytest.mark.asyncio
async def test_chunk_overflow(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=5)
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 0, b"toolongdata")
assert e.value.code == "CHUNK_OVERFLOW"
@pytest.mark.asyncio
async def test_finalize_sha_mismatch_keeps_session(self, store: UploadSessionStore):
payload = b"hello world"
wrong = hashlib.sha256(b"other").hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=wrong,
)
await store.append_chunk(sess.id, 0, payload)
with pytest.raises(UploadSessionError) as e:
await store.finalize(sess.id)
assert e.value.code == "CHECKSUM_MISMATCH"
# Session still present — spill file still on disk
again = await store.get(sess.id)
assert again is not None
assert again.spill_path.exists()
@pytest.mark.asyncio
async def test_finalize_sha_match_passes(self, store: UploadSessionStore):
payload = b"hello world"
digest = hashlib.sha256(payload).hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=digest,
)
await store.append_chunk(sess.id, 0, payload)
_, data = await store.finalize(sess.id)
assert data == payload
@pytest.mark.asyncio
async def test_abort_removes_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
assert spill.exists()
assert await store.abort(sess.id) is True
assert not spill.exists()
assert await store.get(sess.id) is None
class TestQuotaAndCleanup:
@pytest.mark.asyncio
async def test_quota_rejects_11th_session(self, store: UploadSessionStore):
store.max_concurrent_per_user = 10
ids = set()
for i in range(10):
s = await store.start(user_id="bob", filename=f"f{i}.bin", total_bytes=10)
ids.add(s.id)
assert len(ids) == 10
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="bob", filename="f10.bin", total_bytes=10)
assert e.value.code == "QUOTA_EXCEEDED"
@pytest.mark.asyncio
async def test_cleanup_reaps_expired(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
# Force-expire via DB update
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
reaped = await store.cleanup_expired()
assert reaped == 1
assert not spill.exists()
assert await store.get(sess.id) is None
@pytest.mark.asyncio
async def test_cleanup_task_runs(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
task = CleanupTask(store=store, interval_seconds=60)
await task.start()
# Give the loop a tick to run its first iteration.
for _ in range(20):
if await store.get(sess.id) is None:
break
await asyncio.sleep(0.05)
await task.stop()
assert await store.get(sess.id) is None
class TestHandlerIntegration:
@pytest.mark.asyncio
async def test_handler_round_trip_calls_wp_once(self, handler: MediaChunkedHandler):
payload = _PNG_1x1
# Start
start_out = json.loads(
await handler.upload_media_chunked_start(filename="photo.png", total_bytes=len(payload))
)
sid = start_out["session_id"]
half = len(payload) // 2
c0 = _b64.b64encode(payload[:half]).decode()
c1 = _b64.b64encode(payload[half:]).decode()
await handler.upload_media_chunked_chunk(sid, 0, c0)
await handler.upload_media_chunked_chunk(sid, 1, c1)
wp_response = {
"id": 999,
"title": {"rendered": "photo"},
"source_url": "https://wp.example.com/photo.png",
"mime_type": "image/png",
"media_type": "image",
}
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = _wp_response_mock(wp_response)
out = json.loads(await handler.upload_media_chunked_finish(session_id=sid))
# No metadata/attach args → only the raw-upload ClientSession() call
assert mock_cls.call_count == 1
assert out["id"] == 999
assert out["source"] == "chunked"
@pytest.mark.asyncio
async def test_handler_abort(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
out = json.loads(await handler.upload_media_chunked_abort(sid))
assert out["aborted"] is True
@pytest.mark.asyncio
async def test_handler_chunk_order_returns_error_json(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
chunk_b64 = _b64.b64encode(b"abc").decode()
out = json.loads(await handler.upload_media_chunked_chunk(sid, 5, chunk_b64))
assert out["error_code"] == "CHUNK_ORDER"

View File

@@ -0,0 +1,129 @@
"""F.5a.8.4 — resumable chunked upload status tool.
Verifies the new ``upload_media_chunked_status`` tool returns the
expected ``received_bytes`` / ``next_chunk`` for active sessions and
yields a ``NOT_FOUND`` error for unknown / finalised / aborted ones.
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from core.database import Database
from core.upload_sessions import (
UploadSessionStore,
set_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_chunked import (
MediaChunkedHandler,
get_tool_specifications,
)
@pytest.fixture
async def _db(tmp_path: Path):
db = Database(str(tmp_path / "status.db"))
await db.initialize()
yield db
await db.close()
@pytest.fixture
async def _store(tmp_path: Path, _db: Database):
s = UploadSessionStore(db=_db, spill_dir=tmp_path / "spill")
set_upload_session_store(s)
try:
yield s
finally:
set_upload_session_store(None)
@pytest.fixture
def _handler(_store: UploadSessionStore) -> MediaChunkedHandler:
client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
return MediaChunkedHandler(client, user_id="alice", store=_store)
class TestStatusToolSpec:
@pytest.mark.unit
def test_status_tool_is_registered(self):
specs = get_tool_specifications()
names = [s["name"] for s in specs]
assert "upload_media_chunked_status" in names
@pytest.mark.unit
def test_status_tool_is_read_scope(self):
specs = get_tool_specifications()
status = next(s for s in specs if s["name"] == "upload_media_chunked_status")
assert status["scope"] == "read"
assert status["schema"]["required"] == ["session_id"]
class TestStatusLookup:
@pytest.mark.asyncio
async def test_unknown_session_returns_no_session(self, _handler):
out = json.loads(await _handler.upload_media_chunked_status(session_id="does-not-exist"))
# NO_SESSION is the documented taxonomy code used by the rest of
# the chunked-session surface for exactly this case.
assert out["error_code"] == "NO_SESSION"
assert out["session_id"] == "does-not-exist"
@pytest.mark.asyncio
async def test_fresh_session_reports_zero_received(self, _handler):
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=1_000_000)
)
sid = started["session_id"]
out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
assert out["session_id"] == sid
assert out["received_bytes"] == 0
assert out["next_chunk"] == 0
assert out["total_bytes"] == 1_000_000
@pytest.mark.asyncio
async def test_partial_session_reports_progress(self, _handler):
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=100)
)
sid = started["session_id"]
chunk = base64.b64encode(b"A" * 40).decode()
await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=chunk)
out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
assert out["received_bytes"] == 40
assert out["next_chunk"] == 1
# Still open for more chunks.
assert out["status"] == "open"
@pytest.mark.asyncio
async def test_resume_flow_via_status_then_chunk(self, _handler):
"""End-to-end resume: start → chunk → (simulate disconnect) →
status → chunk from next_chunk → progress should advance."""
started = json.loads(
await _handler.upload_media_chunked_start(filename="big.bin", total_bytes=60)
)
sid = started["session_id"]
first = base64.b64encode(b"x" * 20).decode()
await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=first)
# Simulate client coming back from a disconnect.
status = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
resume_idx = status["next_chunk"]
assert resume_idx == 1
second = base64.b64encode(b"y" * 25).decode()
progressed = json.loads(
await _handler.upload_media_chunked_chunk(
session_id=sid, index=resume_idx, data_b64=second
)
)
assert progressed["received_bytes"] == 45
assert progressed["next_chunk"] == 2

View File

@@ -0,0 +1,235 @@
"""F.5a.7 — Companion upload route selection tests.
Validates `_media_core.wp_raw_upload` behaviour when the
`airano-mcp/v1/upload-limits` probe advertises the companion plugin:
- helper-present + size > advertised limit → POST to companion upload-chunk
- helper-present + size < advertised limit → POST to standard /wp/v2/media
- helper-absent → always POST to /wp/v2/media (no probe = no companion)
- companion 4xx → fall back to /wp/v2/media (never regress default path)
"""
from __future__ import annotations
import base64
import json
from contextlib import asynccontextmanager
from unittest.mock import patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import _media_core as media_core
from plugins.wordpress.handlers._media_core import wp_raw_upload
from plugins.wordpress.handlers.media_probe import _ProbeCache
# A minimal valid 1x1 PNG.
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client() -> WordPressClient:
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
class _FakeResponse:
def __init__(self, status: int, payload: dict | None, *, text: str | None = None) -> None:
self.status = status
self._payload = payload
self._text = text if text is not None else json.dumps(payload or {})
async def text(self) -> str:
return self._text
async def json(self, content_type=None): # noqa: D401, ANN001
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, *exc): # noqa: ANN002
return False
class _RecordingSession:
"""aiohttp.ClientSession stand-in that records every POST target."""
def __init__(self, responses_by_url: dict[str, _FakeResponse]) -> None:
self._responses_by_url = responses_by_url
self.posts: list[str] = []
def post(self, url, data=None, headers=None): # noqa: ANN001
self.posts.append(url)
resp = self._responses_by_url.get(url)
if resp is None:
raise AssertionError(f"unexpected POST to {url!r}")
return resp
async def __aenter__(self):
return self
async def __aexit__(self, *exc): # noqa: ANN002
return False
@asynccontextmanager
async def _patched_session(session: _RecordingSession):
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as cls:
cls.return_value = session
yield cls
# WP attachment JSON shape (identical across /wp/v2/media and companion route).
_WP_MEDIA_JSON = {
"id": 101,
"title": {"rendered": "photo"},
"mime_type": "image/png",
"media_type": "image",
"source_url": "https://wp.example.com/wp-content/uploads/photo.png",
}
def _rest_url() -> str:
return "https://wp.example.com/wp-json/wp/v2/media"
def _companion_url() -> str:
return "https://wp.example.com/wp-json/airano-mcp/v1/upload-chunk"
async def _seed_probe_cache(
client: WordPressClient,
*,
companion: bool,
upload_max_filesize_bytes: int | None = None,
) -> _ProbeCache:
"""Inject a cached probe result so wp_raw_upload sees it synchronously."""
cache = _ProbeCache(ttl=3600)
limits = {
"upload_max_filesize": None,
"post_max_size": None,
"memory_limit": None,
"max_input_time": None,
"wp_max_upload_size": None,
}
if upload_max_filesize_bytes is not None:
limits["upload_max_filesize"] = upload_max_filesize_bytes # already bytes
payload = {
"site_url": client.site_url,
"source": "companion" if companion else "rest_index",
"companion_available": companion,
"limits": limits,
"limits_bytes": {
"upload_max_filesize": upload_max_filesize_bytes,
"post_max_size": None,
"wp_max_upload_size": None,
"effective_ceiling": upload_max_filesize_bytes,
},
}
await cache.set((client.site_url, client.username), payload)
return cache
@pytest.mark.asyncio
async def test_companion_preferred_when_size_exceeds_limit(monkeypatch):
client = _client()
cache = await _seed_probe_cache(
client, companion=True, upload_max_filesize_bytes=4 # 4 bytes → our 1x1 PNG is larger
)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_companion_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_companion_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "companion"
@pytest.mark.asyncio
async def test_rest_used_when_size_under_limit(monkeypatch):
client = _client()
# Advertise a huge limit so the PNG slips under it.
cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=10 * 1024**2)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "rest"
@pytest.mark.asyncio
async def test_rest_used_when_companion_absent(monkeypatch):
client = _client()
cache = await _seed_probe_cache(client, companion=False)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
@pytest.mark.asyncio
async def test_rest_used_when_cache_empty(monkeypatch):
"""Cold cache = no companion hint available = take the standard route."""
client = _client()
empty_cache = _ProbeCache(ttl=3600)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", empty_cache)
session = _RecordingSession(
{_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
)
async with _patched_session(session):
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert session.posts == [_rest_url()]
@pytest.mark.asyncio
async def test_companion_4xx_falls_back_to_rest(monkeypatch):
client = _client()
cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=4)
monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
session = _RecordingSession(
{
_companion_url(): _FakeResponse(500, None, text='{"code":"sideload_failed"}'),
_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON),
}
)
async with _patched_session(session):
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
# Companion tried first, then the REST fallback.
assert session.posts == [_companion_url(), _rest_url()]
assert result["id"] == 101
assert result.get("_upload_route") == "rest"
@pytest.mark.asyncio
async def test_should_use_companion_is_defensive(monkeypatch):
"""If the probe lookup raises, we must not crash the upload path."""
client = _client()
async def boom(_client):
raise RuntimeError("probe blew up")
monkeypatch.setattr(
"plugins.wordpress.handlers.media_probe.get_cached_limits",
boom,
)
assert await media_core._should_use_companion(client, 10 * 1024**2) is False

View File

@@ -0,0 +1,120 @@
"""F.5a.6.2 — Error-code taxonomy stability test.
Walks every raise-site in the media stack, extracts the literal error
code, and asserts each one is in the documented
:data:`core.media_error_codes.MEDIA_ERROR_CODES` set (or matches the
dynamic ``WP_<status>`` pattern).
If this test fails, you're either:
- Introducing a new code — add it to ``MEDIA_ERROR_CODES`` *and*
``docs/media-error-codes.md``, or
- Removing a documented code — make sure it's actually unused and drop
it from both the set and the docs.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from core.media_error_codes import MEDIA_ERROR_CODES, is_valid_code
REPO_ROOT = Path(__file__).resolve().parents[3]
# Files to scan for error-code literals.
SCANNED_FILES = [
"plugins/wordpress/handlers/_media_security.py",
"plugins/wordpress/handlers/_media_core.py",
"plugins/wordpress/handlers/media.py",
"plugins/wordpress/handlers/media_attach.py",
"plugins/wordpress/handlers/media_chunked.py",
"plugins/wordpress/handlers/ai_media.py",
"core/upload_sessions.py",
"core/tool_rate_limiter.py",
"plugins/ai_image/providers/base.py",
"plugins/ai_image/providers/openai.py",
"plugins/ai_image/providers/stability.py",
"plugins/ai_image/providers/replicate.py",
"plugins/ai_image/registry.py",
]
# Literal raise-site patterns. Multiline to span wrapped calls.
_RAISE_RE = re.compile(
r'raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*"([A-Z][A-Z0-9_]+)"',
re.MULTILINE,
)
# Formatted (f-string) codes like f"WP_{response.status}" — we assert the
# pattern prefix is a valid dynamic code.
_FSTRING_RE = re.compile(
r"raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*" r'f"([A-Z_]+)\{',
re.MULTILINE,
)
# Hard-coded error_code dict literals like {"error_code": "INTERNAL", ...}
_ERROR_CODE_RE = re.compile(r'"error_code"\s*:\s*"([A-Z][A-Z0-9_]+)"')
def _collect_codes() -> tuple[set[str], set[str]]:
literal_codes: set[str] = set()
fstring_prefixes: set[str] = set()
for rel in SCANNED_FILES:
path = REPO_ROOT / rel
text = path.read_text(encoding="utf-8")
literal_codes.update(_RAISE_RE.findall(text))
literal_codes.update(_ERROR_CODE_RE.findall(text))
fstring_prefixes.update(_FSTRING_RE.findall(text))
return literal_codes, fstring_prefixes
def test_every_raise_site_uses_documented_code():
literal_codes, fstring_prefixes = _collect_codes()
assert literal_codes, "taxonomy test found no raise sites — scanner broken?"
undocumented = {c for c in literal_codes if not is_valid_code(c)}
assert not undocumented, (
f"Undocumented error codes in raise sites: {sorted(undocumented)}. "
f"Add them to core.media_error_codes.MEDIA_ERROR_CODES and "
f"docs/media-error-codes.md, or remove the raise site."
)
# f-string codes must have a documented prefix (e.g. "WP_" for WP_{status}
# or "COMPANION_" for COMPANION_{status} from the F.5a.7 upload-chunk route).
allowed_prefixes = {"WP_", "COMPANION_"}
for prefix in fstring_prefixes:
assert prefix in allowed_prefixes, (
f"Unknown dynamic error-code prefix '{prefix}'. "
f"Allowed prefixes: {sorted(allowed_prefixes)} (see is_valid_code)."
)
def test_documented_codes_are_all_used():
"""Stability guard: every documented code has at least one raise/use site.
Prevents the documented set from drifting with dead entries. A small
allow-list exists for codes that are reserved for upcoming work.
"""
literal_codes, _ = _collect_codes()
reserved: set[str] = set() # None currently reserved.
unused = MEDIA_ERROR_CODES - literal_codes - reserved
# WP_<status> is dynamic; its fixed siblings (WP_413, WP_AUTH, WP_BAD_RESPONSE)
# must be explicitly raised.
assert not unused, (
f"Documented codes with no raise site: {sorted(unused)}. "
"Either wire them up, move to the 'reserved' set, or remove them."
)
@pytest.mark.parametrize("code", sorted(MEDIA_ERROR_CODES))
def test_is_valid_code_accepts_documented(code):
assert is_valid_code(code)
@pytest.mark.parametrize("code", ["WP_400", "WP_500", "WP_418"])
def test_is_valid_code_accepts_dynamic_wp_status(code):
assert is_valid_code(code)
@pytest.mark.parametrize("code", ["UNKNOWN", "wp_413", "WP_", "WP_X", "WP_41"])
def test_is_valid_code_rejects_others(code):
assert not is_valid_code(code)

View File

@@ -0,0 +1,180 @@
"""Tests for F.5a.2 image optimization pipeline."""
from __future__ import annotations
import io
import pytest
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
from plugins.wordpress.handlers._media_optimize import optimize # noqa: E402
def _make_png(w: int, h: int, mode: str = "RGB") -> bytes:
import os as _os
img = Image.new(mode, (w, h))
# Fill with noise so solid-color PNG compression doesn't undercut JPEG
img.frombytes(_os.urandom(w * h * len(mode)))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _make_jpeg(w: int, h: int, quality: int = 95) -> bytes:
img = Image.new("RGB", (w, h), color=(200, 100, 50))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def test_large_jpeg_is_resized():
data = _make_jpeg(4000, 3000)
new_data, new_mime = optimize(data, "image/jpeg", max_edge=1024)
assert new_mime == "image/jpeg"
img = Image.open(io.BytesIO(new_data))
assert max(img.size) == 1024
assert len(new_data) < len(data)
def test_opaque_png_converted_to_jpeg():
data = _make_png(800, 600, mode="RGB")
new_data, new_mime = optimize(data, "image/png", max_edge=2560)
assert new_mime == "image/jpeg"
assert len(new_data) < len(data)
def test_transparent_png_stays_png():
data = _make_png(800, 600, mode="RGBA")
new_data, new_mime = optimize(data, "image/png", max_edge=2560)
assert new_mime == "image/png"
def test_small_image_returns_original_bytes():
# Tiny image that cannot be made smaller
data = _make_jpeg(8, 8, quality=50)
new_data, new_mime = optimize(data, "image/jpeg", max_edge=2560)
# Either unchanged or still JPEG
assert new_mime in (None, "image/jpeg")
assert len(new_data) <= len(data) + 1024
def test_pdf_passthrough():
data = b"%PDF-1.4\n%garbage\n%%EOF"
new_data, new_mime = optimize(data, "application/pdf")
assert new_data == data
assert new_mime == "application/pdf"
def test_video_passthrough():
data = b"\x00" * 32
new_data, new_mime = optimize(data, "video/mp4")
assert new_data == data
assert new_mime == "video/mp4"
def test_unknown_mime_tries_anyway_but_fails_gracefully():
# Garbage bytes with no PIL-recognizable format → passthrough
data = b"not an image at all"
new_data, new_mime = optimize(data, None)
assert new_data == data
# ---------------------------------------------------------------------------
# F.5a.8.1 — convert_to override (WebP / AVIF)
# ---------------------------------------------------------------------------
def _peek_format(data: bytes) -> str:
return (Image.open(io.BytesIO(data)).format or "").upper()
def test_convert_png_to_webp():
data = _make_png(64, 64, mode="RGB")
new_data, new_mime = optimize(data, "image/png", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_jpeg_to_webp():
data = _make_jpeg(64, 64)
new_data, new_mime = optimize(data, "image/jpeg", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_preserves_alpha_on_webp():
data = _make_png(64, 64, mode="RGBA")
new_data, new_mime = optimize(data, "image/png", convert_to="webp")
assert new_mime == "image/webp"
img = Image.open(io.BytesIO(new_data))
# Either RGBA or palette-with-alpha is acceptable
assert "A" in img.mode or "A" in "".join(img.getbands())
def test_convert_avif_falls_back_to_webp_when_unsupported():
from plugins.wordpress.handlers._media_optimize import _avif_supported
data = _make_png(32, 32, mode="RGB")
new_data, new_mime = optimize(data, "image/png", convert_to="avif")
if _avif_supported():
assert new_mime == "image/avif"
assert _peek_format(new_data) == "AVIF"
else:
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_to_env_default(monkeypatch):
import plugins.wordpress.handlers._media_optimize as opt_mod
monkeypatch.setattr(opt_mod, "_DEFAULT_CONVERT_TO", "webp")
data = _make_png(48, 48, mode="RGB")
new_data, new_mime = optimize(data, "image/png")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_wins_over_size_guard():
# 1×1 images re-encode to formats that carry larger container overhead.
# The explicit convert_to request MUST still win — the size guard only
# applies to the implicit recompression path.
tiny = _make_png(1, 1, mode="RGB")
new_data, new_mime = optimize(tiny, "image/png", convert_to="webp")
assert new_mime == "image/webp"
assert _peek_format(new_data) == "WEBP"
def test_convert_unknown_value_falls_through_to_heuristic():
# "jpg" isn't in the convert_to map → optimizer uses source-format heuristic.
data = _make_jpeg(64, 64)
_, new_mime = optimize(data, "image/jpeg", convert_to="jpg")
assert new_mime == "image/jpeg"
def test_convert_to_does_not_touch_pdf():
data = b"%PDF-1.4\n%fake\n"
new_data, new_mime = optimize(data, "application/pdf", convert_to="webp")
assert new_data == data
assert new_mime == "application/pdf"
def test_maybe_optimize_forwards_convert_to():
from plugins.wordpress.handlers.media import _maybe_optimize
out, mime = _maybe_optimize(
_make_png(48, 48, mode="RGB"), "image/png", skip=False, convert_to="webp"
)
assert mime == "image/webp"
assert _peek_format(out) == "WEBP"
def test_maybe_optimize_skip_overrides_convert_to():
from plugins.wordpress.handlers.media import _maybe_optimize
src = _make_png(48, 48, mode="RGB")
out, mime = _maybe_optimize(src, "image/png", skip=True, convert_to="webp")
assert out == src
assert mime == "image/png"

View File

@@ -0,0 +1,96 @@
"""F.X.fix #6 — get_media / list_media expose post_parent.
Regression: neither endpoint returned the parent post, so MCP clients
had no way to verify "is media X attached to post Y" without a second
round trip. Fix adds ``post_parent`` (int, 0 when unattached).
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.media import MediaHandler
def _make_media(media_id: int, *, post: int = 0) -> dict:
return {
"id": media_id,
"title": {"rendered": f"media-{media_id}"},
"mime_type": "image/webp",
"media_type": "image",
"source_url": f"https://example.com/uploads/{media_id}.webp",
"date": "2026-04-17T10:00:00",
"alt_text": "",
"link": f"https://example.com/?attachment_id={media_id}",
"post": post,
"caption": {"rendered": ""},
"description": {"rendered": ""},
"media_details": {},
}
@pytest.fixture
def handler():
client = AsyncMock()
return MediaHandler(client), client
class TestGetMediaPostParent:
@pytest.mark.asyncio
async def test_get_media_returns_attached_parent(self, handler):
h, client = handler
client.get = AsyncMock(return_value=_make_media(77, post=42))
raw = await h.get_media(media_id=77)
data = json.loads(raw)
assert data["post_parent"] == 42
@pytest.mark.asyncio
async def test_get_media_returns_zero_when_unattached(self, handler):
h, client = handler
client.get = AsyncMock(return_value=_make_media(78, post=0))
raw = await h.get_media(media_id=78)
data = json.loads(raw)
# WP's own REST returns 0 for unattached; we preserve that.
assert data["post_parent"] == 0
@pytest.mark.asyncio
async def test_get_media_handles_missing_post_key(self, handler):
h, client = handler
payload = _make_media(79, post=0)
del payload["post"] # older WP installs or partial fields=
client.get = AsyncMock(return_value=payload)
raw = await h.get_media(media_id=79)
data = json.loads(raw)
assert data["post_parent"] == 0
class TestListMediaPostParent:
@pytest.mark.asyncio
async def test_list_media_shape_includes_post_parent(self, handler):
h, client = handler
client.get = AsyncMock(
return_value=[
_make_media(1, post=42),
_make_media(2, post=0),
_make_media(3, post=99),
]
)
raw = await h.list_media()
data = json.loads(raw)
parents = [m["post_parent"] for m in data["media"]]
assert parents == [42, 0, 99]
@pytest.mark.asyncio
async def test_list_media_unattached_reports_zero_not_null(self, handler):
h, client = handler
item = _make_media(5, post=0)
item["post"] = None # Some older WP payloads send null
client.get = AsyncMock(return_value=[item])
raw = await h.list_media()
data = json.loads(raw)
# MCP consumers expect an int they can compare against; None
# makes type-strict callers crash. Normalise to 0.
assert data["media"][0]["post_parent"] == 0

View File

@@ -0,0 +1,177 @@
"""F.5a.6.3 — Tests for wordpress_probe_upload_limits."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_probe import ProbeHandler, _ProbeCache
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def cache():
return _ProbeCache(ttl=24 * 3600)
@pytest.mark.asyncio
async def test_companion_endpoint_parses_and_caches(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
payload = {
"upload_max_filesize": "64M",
"post_max_size": "128M",
"memory_limit": "256M",
"max_input_time": "300",
"wp_max_upload_size": 67108864,
}
get_mock = AsyncMock(return_value=payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out_json = await handler.probe_upload_limits()
out = json.loads(out_json)
assert out["source"] == "companion"
assert out["limits"] == payload
assert out["cached"] is False
# F.5a.7: companion payload exposes byte-parsed limits + companion flag.
assert out["companion_available"] is True
assert out["limits_bytes"]["upload_max_filesize"] == 64 * 1024**2
assert out["limits_bytes"]["post_max_size"] == 128 * 1024**2
assert out["limits_bytes"]["wp_max_upload_size"] == 67108864
# The effective ceiling is the smallest of the byte-valued keys.
assert out["limits_bytes"]["effective_ceiling"] == 64 * 1024**2
# First call hit the network exactly once.
assert get_mock.call_count == 1
get_mock.assert_called_with("airano-mcp/v1/upload-limits", use_custom_namespace=True)
@pytest.mark.asyncio
async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
payload = {"upload_max_filesize": "64M"}
get_mock = AsyncMock(return_value=payload)
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_upload_limits()
out2 = json.loads(await handler.probe_upload_limits())
assert out2["cached"] is True
# Network only hit once across two probe calls.
assert get_mock.call_count == 1
@pytest.mark.asyncio
async def test_cache_expires_after_ttl(wp_client, monkeypatch):
cache = _ProbeCache(ttl=0.0) # immediate expiry
handler = ProbeHandler(wp_client, cache=cache)
get_mock = AsyncMock(return_value={"upload_max_filesize": "64M"})
monkeypatch.setattr(wp_client, "get", get_mock)
await handler.probe_upload_limits()
await handler.probe_upload_limits()
# ttl=0 → both calls re-fetch.
assert get_mock.call_count == 2
@pytest.mark.asyncio
async def test_companion_failure_falls_back_to_rest_index(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
async def fake_get(endpoint, **kwargs):
if endpoint == "airano-mcp/v1/upload-limits":
raise RuntimeError("404")
if endpoint == "":
return {"wp_max_upload_size": 4 * 1024 * 1024}
raise AssertionError(f"unexpected endpoint: {endpoint}")
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
out = json.loads(await handler.probe_upload_limits())
assert out["source"] == "rest_index"
assert out["limits"]["wp_max_upload_size"] == 4 * 1024 * 1024
@pytest.mark.asyncio
async def test_total_failure_returns_empty_limits(wp_client, monkeypatch, cache):
handler = ProbeHandler(wp_client, cache=cache)
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("totally offline")))
out = json.loads(await handler.probe_upload_limits())
assert out["source"] == "unknown"
assert all(v is None for v in out["limits"].values())
def test_tool_spec_is_read_scope():
from plugins.wordpress.handlers.media_probe import get_tool_specifications
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "probe_upload_limits"
assert specs[0]["scope"] == "read"
# F.5a.7: byte parser helpers ------------------------------------------------
class TestParsePhpSize:
@staticmethod
def _parse(v):
from plugins.wordpress.handlers.media_probe import parse_php_size
return parse_php_size(v)
def test_none(self):
assert self._parse(None) is None
def test_empty_string(self):
assert self._parse("") is None
def test_bare_integer_string(self):
assert self._parse("1024") == 1024
def test_megabytes_upper(self):
assert self._parse("64M") == 64 * 1024**2
def test_megabytes_lower(self):
assert self._parse("8m") == 8 * 1024**2
def test_gigabytes(self):
assert self._parse("2G") == 2 * 1024**3
def test_numeric_int(self):
assert self._parse(4096) == 4096
def test_unlimited_minus_one(self):
# PHP "-1" means no limit; treat as unknown.
assert self._parse("-1") is None
assert self._parse(-1) is None
def test_invalid(self):
assert self._parse("garbage") is None
class TestEffectiveCeiling:
def test_returns_min_of_populated(self):
from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
limits = {
"upload_max_filesize": "8M",
"post_max_size": "64M",
"memory_limit": "256M",
"max_input_time": "60",
"wp_max_upload_size": 8388608,
}
# min(8MB, 64MB, 8388608 bytes) = 8388608 bytes
assert effective_upload_ceiling(limits) == 8 * 1024**2
def test_none_when_empty(self):
from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
assert effective_upload_ceiling({}) is None
assert effective_upload_ceiling(None) is None

View File

@@ -0,0 +1,362 @@
"""Tests for F.5a.1 media upload primitives and handler."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import (
wp_raw_upload,
wp_set_featured_media,
wp_update_media_metadata,
)
from plugins.wordpress.handlers._media_security import (
ALLOWED_MIMES,
UploadError,
content_disposition,
safe_filename,
sniff_mime,
ssrf_check,
validate_mime,
validate_size,
)
from plugins.wordpress.handlers.media import MediaHandler
# A minimal valid PNG (1x1, transparent)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
# --- Security primitives ----------------------------------------------------
class TestSecurity:
def test_sniff_mime_detects_png(self):
assert sniff_mime(_PNG_1x1) == "image/png"
def test_sniff_mime_falls_back_to_hint(self):
# Random ASCII with no image/video/audio magic signature. Different
# libmagic builds disagree on whether short human-readable bytes are
# "application/octet-stream" (conservative) or "text/plain" (aggressive).
# Either is acceptable; what matters is that the function doesn't crash
# and returns *something* usable when the hint can't be trusted.
result = sniff_mime(b"not a real file", hint="image/png")
assert result in {
"image/png",
"application/octet-stream",
"text/plain",
}
def test_validate_size_empty(self):
with pytest.raises(UploadError) as e:
validate_size(b"")
assert e.value.code == "EMPTY_FILE"
def test_validate_size_too_large(self):
with pytest.raises(UploadError) as e:
validate_size(b"x" * 10, max_bytes=5)
assert e.value.code == "TOO_LARGE"
def test_validate_mime_rejects_unknown(self):
with pytest.raises(UploadError) as e:
validate_mime("application/x-msdownload")
assert e.value.code == "MIME_REJECTED"
def test_validate_mime_allows_jpeg(self):
validate_mime("image/jpeg") # no raise
def test_safe_filename_ascii_preserved(self):
name, encoded = safe_filename("photo.jpg", mime="image/jpeg")
assert name == "photo.jpg"
assert encoded is None
def test_safe_filename_sanitizes_path(self):
name, _ = safe_filename("../../etc/passwd.jpg", mime="image/jpeg")
assert "/" not in name and ".." not in name
assert name.endswith(".jpg")
def test_safe_filename_unicode(self):
name, encoded = safe_filename("عکس.jpg", mime="image/jpeg")
assert encoded is not None
assert encoded.startswith("UTF-8''")
# ASCII filename still has extension
assert name.endswith(".jpg")
def test_safe_filename_adds_extension_from_mime(self):
name, _ = safe_filename("noext", mime="image/png")
assert name.endswith(".png")
def test_content_disposition_ascii_only(self):
h = content_disposition("a.jpg", None)
assert h == 'attachment; filename="a.jpg"'
def test_content_disposition_rfc5987(self):
h = content_disposition("a.jpg", "UTF-8''%D8%B9.jpg")
assert "filename*=UTF-8''" in h
class TestSSRF:
def test_blocks_localhost(self):
r = ssrf_check("http://127.0.0.1/x", allow_http=True)
assert not r.allowed
def test_blocks_metadata_host(self):
r = ssrf_check("http://169.254.169.254/latest/meta-data", allow_http=True)
assert not r.allowed
def test_blocks_http_by_default(self):
r = ssrf_check("http://example.com/x")
assert not r.allowed
def test_blocks_bad_scheme(self):
r = ssrf_check("file:///etc/passwd")
assert not r.allowed
def test_blocks_metadata_hostname(self):
r = ssrf_check("https://metadata.google.internal/")
assert not r.allowed
def test_blocks_private_range(self):
# 10.x resolves without DNS via literal IP
r = ssrf_check("https://10.0.0.1/x")
assert not r.allowed
# --- wp_raw_upload ----------------------------------------------------------
class TestRawUpload:
@pytest.mark.asyncio
async def test_happy_path_png(self):
client = _client()
wp_response = {
"id": 123,
"title": {"rendered": "photo"},
"source_url": "https://wp.example.com/wp-content/uploads/photo.png",
"mime_type": "image/png",
"media_type": "image",
}
mock_resp = AsyncMock()
mock_resp.status = 201
mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
mock_resp.json = AsyncMock(return_value=wp_response)
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert result["id"] == 123
@pytest.mark.asyncio
async def test_413_raises_typed_error(self):
client = _client()
mock_resp = AsyncMock()
mock_resp.status = 413
mock_resp.text = AsyncMock(return_value="Payload Too Large")
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(UploadError) as e:
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert e.value.code == "WP_413"
@pytest.mark.asyncio
async def test_401_raises_auth_error(self):
client = _client()
mock_resp = AsyncMock()
mock_resp.status = 401
mock_resp.text = AsyncMock(return_value='{"code":"invalid_auth"}')
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
with pytest.raises(UploadError) as e:
await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
assert e.value.code == "WP_AUTH"
@pytest.mark.asyncio
async def test_rejects_disallowed_mime(self):
client = _client()
# Binary that will be sniffed as something unusual; enforce via hint list
with pytest.raises(UploadError) as e:
await wp_raw_upload(
client,
b"MZ\x90\x00" + b"\x00" * 100,
filename="evil.exe",
mime_hint="application/x-msdownload",
allowed_mimes={"image/png"},
)
assert e.value.code == "MIME_REJECTED"
# --- MediaHandler integration ----------------------------------------------
class TestMediaHandler:
@pytest.mark.asyncio
async def test_upload_from_base64_happy_path(self):
handler = MediaHandler(_client())
wp_media = {
"id": 42,
"title": {"rendered": "hi"},
"source_url": "https://wp.example.com/wp-content/uploads/hi.png",
"mime_type": "image/png",
"media_type": "image",
}
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
):
out = await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="hi.png",
alt_text="hi",
)
parsed = json.loads(out)
assert parsed["id"] == 42
assert parsed["mime_type"] == "image/png"
@pytest.mark.asyncio
async def test_upload_from_base64_rejects_bad_payload(self):
handler = MediaHandler(_client())
out = await handler.upload_media_from_base64(data="!!!!not-base64", filename="x.png")
parsed = json.loads(out)
assert parsed["error_code"] == "BAD_BASE64"
@pytest.mark.asyncio
async def test_upload_from_base64_strips_data_url_prefix(self):
handler = MediaHandler(_client())
wp_media = {
"id": 9,
"title": {"rendered": "x"},
"source_url": "https://wp.example.com/x.png",
"mime_type": "image/png",
"media_type": "image",
}
prefixed = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
):
out = await handler.upload_media_from_base64(data=prefixed, filename="x.png")
assert json.loads(out)["id"] == 9
@pytest.mark.asyncio
async def test_upload_from_url_ssrf_blocked(self):
handler = MediaHandler(_client())
out = await handler.upload_media_from_url(url="http://127.0.0.1/image.png")
parsed = json.loads(out)
assert parsed["error_code"] == "SSRF"
@pytest.mark.asyncio
async def test_upload_sets_featured_when_attached(self):
handler = MediaHandler(_client())
wp_media = {
"id": 7,
"title": {"rendered": "hi"},
"source_url": "https://wp.example.com/hi.png",
"mime_type": "image/png",
"media_type": "image",
}
featured_mock = AsyncMock(return_value={"id": 100, "featured_media": 7})
with (
patch(
"plugins.wordpress.handlers.media.wp_raw_upload",
new=AsyncMock(return_value=wp_media),
),
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
new=AsyncMock(return_value={}),
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
new=featured_mock,
),
):
await handler.upload_media_from_base64(
data=base64.b64encode(_PNG_1x1).decode(),
filename="hi.png",
attach_to_post=100,
set_featured=True,
)
featured_mock.assert_awaited_once()
args = featured_mock.await_args
assert args.args[1] == 100 # post_id
assert args.args[2] == 7 # media_id
# --- metadata helpers -------------------------------------------------------
class TestMetadataHelpers:
@pytest.mark.asyncio
async def test_update_media_metadata_skips_when_nothing_to_update(self):
client = _client()
with patch.object(client, "post", new=AsyncMock()) as mock_post:
result = await wp_update_media_metadata(client, 1)
assert result == {}
mock_post.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_media_metadata_sends_only_set_fields(self):
client = _client()
with patch.object(client, "post", new=AsyncMock(return_value={"id": 1})) as mock_post:
await wp_update_media_metadata(client, 1, alt_text="hi")
mock_post.assert_awaited_once()
call = mock_post.await_args
assert call.kwargs["json_data"] == {"alt_text": "hi"}
@pytest.mark.asyncio
async def test_set_featured_media(self):
client = _client()
with patch.object(client, "post", new=AsyncMock(return_value={"id": 5})) as mock_post:
await wp_set_featured_media(client, 5, 9)
mock_post.assert_awaited_once_with("posts/5", json_data={"featured_media": 9})
def test_allowed_mimes_includes_common_types():
for m in ("image/jpeg", "image/png", "image/webp", "application/pdf"):
assert m in ALLOWED_MIMES

View File

@@ -0,0 +1,192 @@
"""F.X.fix #12 — OpenRouter /v1/models discovery + 1h cache."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers import openrouter as or_mod
from plugins.ai_image.providers.openrouter import OpenRouterProvider
def _fake_resp(*, status, json_data=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.json = AsyncMock(return_value=json_data or {})
resp.headers = headers or {}
return resp
def _mock_session(responses):
iterator = iter(responses)
def _request(*_a, **_kw):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.get = _request
sess.post = _request
return sess
def _reset_cache():
or_mod._models_cache["fetched_at"] = 0.0
or_mod._models_cache["payload"] = None
@pytest.fixture(autouse=True)
def reset_cache():
_reset_cache()
yield
_reset_cache()
CATALOG_PAYLOAD = {
"data": [
{
"id": "google/gemini-2.5-flash-image",
"name": "Gemini 2.5 Flash Image",
"description": "Google's GA image model",
"context_length": 1000000,
"architecture": {
"input_modalities": ["text", "image"],
"output_modalities": ["image", "text"],
"modality": "text+image->image+text",
},
},
{
"id": "google/gemini-2.5-flash-image-preview",
"name": "Gemini 2.5 Flash Image (Preview)",
"architecture": {
"output_modalities": ["image"],
},
},
{
"id": "openai/gpt-4o",
"name": "GPT-4o",
"architecture": {
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
},
},
{
"id": "openai/dall-e-3",
"name": "DALL-E 3",
"architecture": {"output_modalities": ["image"]},
},
{
"id": "meta/llama-3",
"name": "Llama 3",
"description": "chat only",
"architecture": {"output_modalities": ["text"]},
},
]
}
class TestListImageModels:
@pytest.mark.asyncio
async def test_filters_to_image_output_only(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp])
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),
)
models = await provider.list_image_models()
ids = {m["id"] for m in models}
# Kept — image output
assert "google/gemini-2.5-flash-image" in ids
assert "openai/dall-e-3" in ids
# Dropped — text-only
assert "openai/gpt-4o" not in ids
assert "meta/llama-3" not in ids
# Dropped — deprecated alias (we refuse to surface it)
assert "google/gemini-2.5-flash-image-preview" not in ids
@pytest.mark.asyncio
async def test_shape_includes_pricing_and_modalities(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp])
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),
)
models = await provider.list_image_models()
gemini = next(m for m in models if m["id"] == "google/gemini-2.5-flash-image")
assert gemini["name"]
assert "image" in gemini["output_modalities"]
# F.X.fix #11 integration — catalog exposes price per image for
# UI display alongside the model list.
assert gemini["price_per_image_usd"] is not None
@pytest.mark.asyncio
async def test_cache_hits_second_call(self):
provider = OpenRouterProvider()
resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
sess = _mock_session([resp]) # only ONE response
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),
)
first = await provider.list_image_models()
second = await provider.list_image_models() # cache hit
assert first == second
# Second call should not have tried a second HTTP request (mock
# session would raise StopIteration if it did).
@pytest.mark.asyncio
async def test_force_bypasses_cache(self):
provider = OpenRouterProvider()
resp1 = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
resp2 = _fake_resp(status=200, json_data={"data": []})
sess = _mock_session([resp1, resp2])
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),
)
first = await provider.list_image_models()
second = await provider.list_image_models(force=True)
assert len(first) > 0
assert second == []
@pytest.mark.asyncio
async def test_upstream_error_returns_previous_cache(self):
provider = OpenRouterProvider()
ok = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
err = _fake_resp(status=500, json_data={"error": "boom"})
sess = _mock_session([ok, err])
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),
)
first = await provider.list_image_models()
second = await provider.list_image_models(force=True)
# First populated cache, second upstream failed — we serve the
# cached snapshot rather than an empty list.
assert second == first
@pytest.mark.asyncio
async def test_upstream_error_with_empty_cache_returns_empty(self):
provider = OpenRouterProvider()
err = _fake_resp(status=500, json_data={})
sess = _mock_session([err])
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),
)
models = await provider.list_image_models()
assert models == []

View File

@@ -0,0 +1,157 @@
"""F.X.fix #11 — OpenRouter pricing table for cost_usd attribution."""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.providers.openrouter import (
_MODEL_PRICING,
OpenRouterProvider,
_cost_for,
_pricing_table,
)
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _fake_resp(*, status, json_data=None, text_data="", raw=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or "")
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
resp.headers = headers or {}
return resp
def _mock_session(responses):
iterator = iter(responses)
def _request(*_a, **_kw):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _request
sess.get = _request
return sess
class TestPricingTable:
def test_known_model_maps_to_non_null_cost(self):
assert _cost_for("google/gemini-2.5-flash-image") is not None
assert _cost_for("google/gemini-2.5-flash-image") > 0
def test_unknown_model_returns_none(self, caplog):
assert _cost_for("nonexistent/model-xyz") is None
def test_gemini_preview_still_priced(self):
# Preview alias is deprecated but still has a price entry so if
# an admin call bypasses the generate-time guard the cost column
# is still usable for audit.
assert _cost_for("google/gemini-2.5-flash-image-preview") is not None
def test_pricing_table_contains_expected_providers(self):
ids = set(_MODEL_PRICING.keys())
assert any(m.startswith("google/gemini-") for m in ids)
assert any(m.startswith("openai/dall-e") for m in ids)
assert any(m.startswith("black-forest-labs/flux") for m in ids)
def test_env_override_merges_and_wins(self, monkeypatch):
monkeypatch.setenv(
"OPENROUTER_PRICING_OVERRIDE",
json.dumps({"google/gemini-2.5-flash-image": 0.001, "custom/model": 0.5}),
)
table = _pricing_table()
assert table["google/gemini-2.5-flash-image"] == 0.001
assert table["custom/model"] == 0.5
# Untouched entries stay from the hard-coded table.
assert "openai/dall-e-3" in table
def test_env_override_bad_json_is_ignored(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_PRICING_OVERRIDE", "not-json{")
# Falls back to the default table, no exception.
assert (
_pricing_table()["google/gemini-2.5-flash-image"]
== _MODEL_PRICING["google/gemini-2.5-flash-image"]
)
class TestGenerateAttributesCost:
@pytest.mark.asyncio
async def test_cost_usd_populated_for_known_model(self):
provider = OpenRouterProvider()
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),
)
result = await provider.generate("k", GenerationRequest(prompt="x"))
assert result.cost_usd == _MODEL_PRICING["google/gemini-2.5-flash-image"]
@pytest.mark.asyncio
async def test_cost_usd_null_for_unknown_model(self):
provider = OpenRouterProvider()
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),
)
result = await provider.generate(
"k", GenerationRequest(prompt="x", model="weird/unreleased-model")
)
assert result.cost_usd is None
class TestDeprecatedModelGuard:
@pytest.mark.asyncio
async def test_preview_model_raises_model_deprecated(self):
provider = OpenRouterProvider()
with pytest.raises(ProviderError) as e:
await provider.generate(
"k",
GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
)
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"

View File

@@ -0,0 +1,314 @@
"""F.5a.9 partial — Tests for the OpenRouter image-generation provider."""
from __future__ import annotations
import base64
from unittest.mock import AsyncMock, patch
import pytest
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.providers.openrouter import (
OpenRouterProvider,
_extract_image_url,
_image_url_from_entry,
)
from plugins.ai_image.registry import get_provider, list_providers
_PNG_1x1 = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _mock_session_post(responses: list):
"""Build a fake aiohttp.ClientSession whose .post/.get cycle responses."""
iterator = iter(responses)
def _request(*_args, **_kwargs):
resp = next(iterator)
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _request
sess.get = _request
return sess
def _fake_resp(*, status: int, json_data=None, text_data="", raw=None, headers=None):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=text_data or "")
if json_data is not None:
resp.json = AsyncMock(return_value=json_data)
if raw is not None:
resp.read = AsyncMock(return_value=raw)
resp.headers = headers or {}
return resp
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestRegistry:
def test_openrouter_is_registered(self):
assert "openrouter" in list_providers()
def test_registry_lookup_returns_singleton(self):
p = get_provider("openrouter")
assert p.name == "openrouter"
assert p is get_provider("openrouter") # same instance
# ---------------------------------------------------------------------------
# Response-shape parsers (pure, no network)
# ---------------------------------------------------------------------------
class TestImageUrlExtraction:
def test_image_url_from_dict_url(self):
entry = {"image_url": {"url": "data:image/png;base64,AAAA"}}
assert _image_url_from_entry(entry) == "data:image/png;base64,AAAA"
def test_image_url_from_string(self):
entry = {"image_url": "https://x.example/y.png"}
assert _image_url_from_entry(entry) == "https://x.example/y.png"
def test_image_url_bare_url_field(self):
assert (
_image_url_from_entry({"url": "https://z.example/a.png"}) == "https://z.example/a.png"
)
def test_image_url_from_junk(self):
assert _image_url_from_entry(None) is None
assert _image_url_from_entry({"image_url": 42}) is None
def test_extract_gemini_shape_message_images(self):
body = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,Zm9v"}}]}}
]
}
assert _extract_image_url(body) == "data:image/png;base64,Zm9v"
def test_extract_multimodal_content_parts(self):
body = {
"choices": [
{
"message": {
"content": [
{"type": "text", "text": "here's your image"},
{"type": "image_url", "image_url": {"url": "https://x/y.png"}},
]
}
}
]
}
assert _extract_image_url(body) == "https://x/y.png"
def test_extract_no_image_returns_none(self):
body = {"choices": [{"message": {"content": "just text"}}]}
assert _extract_image_url(body) is None
def test_extract_empty_choices(self):
assert _extract_image_url({"choices": []}) is None
assert _extract_image_url({}) is None
# ---------------------------------------------------------------------------
# Provider.generate — auth / request shape
# ---------------------------------------------------------------------------
class TestOpenRouterProvider:
@pytest.mark.asyncio
async def test_missing_api_key_raises_auth(self):
provider = OpenRouterProvider()
with pytest.raises(ProviderError) as e:
await provider.generate("", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_happy_path_gemini_data_url(self):
provider = OpenRouterProvider()
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}"}}]
}
}
],
"usage": {"total_tokens": 128},
},
)
sess = _mock_session_post([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),
)
result = await provider.generate(
"or-test",
GenerationRequest(prompt="a cat"),
)
assert result.data == _PNG_1x1
assert result.mime == "image/png"
assert "gemini" in result.filename
assert result.meta.get("total_tokens") == 128
# F.X.fix #11: known model → non-null cost_usd from _MODEL_PRICING.
assert result.cost_usd is not None
assert result.cost_usd > 0
@pytest.mark.asyncio
async def test_http_url_is_fetched(self):
provider = OpenRouterProvider()
gen_resp = _fake_resp(
status=200,
json_data={
"choices": [
{"message": {"images": [{"image_url": {"url": "https://cdn.example/out.png"}}]}}
]
},
)
fetch_resp = _fake_resp(
status=200,
raw=_PNG_1x1,
headers={"Content-Type": "image/png"},
)
sess = _mock_session_post([gen_resp, fetch_resp])
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),
)
result = await provider.generate("or-test", GenerationRequest(prompt="x"))
assert result.data == _PNG_1x1
assert result.mime == "image/png"
@pytest.mark.asyncio
async def test_no_image_raises_bad_response(self):
provider = OpenRouterProvider()
empty = _fake_resp(
status=200,
json_data={"choices": [{"message": {"content": "no image for you"}}]},
)
sess = _mock_session_post([empty])
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),
)
with pytest.raises(ProviderError) as e:
await provider.generate("or-test", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_BAD_RESPONSE"
@pytest.mark.asyncio
async def test_401_maps_to_provider_auth(self):
provider = OpenRouterProvider()
r401 = _fake_resp(status=401, text_data="invalid key")
sess = _mock_session_post([r401])
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),
)
with pytest.raises(ProviderError) as e:
await provider.generate("bad-key", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_AUTH"
@pytest.mark.asyncio
async def test_400_maps_to_provider_bad_request(self):
provider = OpenRouterProvider()
r400 = _fake_resp(status=400, text_data="model not found")
sess = _mock_session_post([r400])
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),
)
with pytest.raises(ProviderError) as e:
await provider.generate(
"or-test",
GenerationRequest(prompt="x", model="nonexistent/model"),
)
assert e.value.code == "PROVIDER_BAD_REQUEST"
@pytest.mark.asyncio
async def test_429_retried_then_succeeds(self):
provider = OpenRouterProvider()
b64 = base64.b64encode(_PNG_1x1).decode()
r429 = _fake_resp(status=429, text_data="slow down")
ok = _fake_resp(
status=200,
json_data={
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
},
)
sess = _mock_session_post([r429, 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),
)
with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
result = await provider.generate("or-test", GenerationRequest(prompt="x"))
assert result.data == _PNG_1x1
@pytest.mark.asyncio
async def test_persistent_5xx_raises_unavailable(self):
provider = OpenRouterProvider()
r500 = _fake_resp(status=500, text_data="boom")
sess = _mock_session_post([r500, r500, r500])
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),
)
with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
with pytest.raises(ProviderError) as e:
await provider.generate("or-test", GenerationRequest(prompt="x"))
assert e.value.code == "PROVIDER_UNAVAILABLE"
@pytest.mark.asyncio
async def test_negative_prompt_appended_to_user_content(self, monkeypatch):
"""Negative prompt is inlined into the user message as 'Avoid: ...'."""
provider = OpenRouterProvider()
captured: dict = {}
async def _mock_post_with_retry(self, api_key, payload):
captured["payload"] = payload
b64 = base64.b64encode(_PNG_1x1).decode()
return {
"choices": [
{
"message": {
"images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
}
}
]
}
monkeypatch.setattr(
OpenRouterProvider, "_post_with_retry", _mock_post_with_retry, raising=True
)
await provider.generate(
"or-test",
GenerationRequest(prompt="a cat", negative_prompt="no dogs"),
)
user_msg = captured["payload"]["messages"][0]["content"]
assert "a cat" in user_msg
assert "Avoid: no dogs" in user_msg

View File

@@ -0,0 +1,222 @@
"""F.5a.8.2 — Tests for wordpress_regenerate_thumbnails."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.regenerate_thumbnails import (
RegenerateThumbnailsHandler,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return RegenerateThumbnailsHandler(wp_client)
# ---------------------------------------------------------------------------
# Spec
# ---------------------------------------------------------------------------
def test_spec_shape():
specs = get_tool_specifications()
assert len(specs) == 1
s = specs[0]
assert s["name"] == "regenerate_thumbnails"
assert s["scope"] == "write"
props = s["schema"]["properties"]
assert {"ids", "all", "offset", "limit"} <= set(props.keys())
# ---------------------------------------------------------------------------
# Validation (no round-trip)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_neither_ids_nor_all_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails())
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_both_ids_and_all_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails(ids=[1], all=True))
assert out["ok"] is False
assert "mutually exclusive" in out["message"]
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_empty_ids_list_is_rejected(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.regenerate_thumbnails(ids=["abc", 0, -1]))
assert out["ok"] is False
assert out["error"] == "invalid_request"
post_mock.assert_not_called()
# ---------------------------------------------------------------------------
# Happy path — ids mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ids_mode_dedupes_and_caps(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["route"] = route
captured["body"] = json_data
captured["ns"] = use_custom_namespace
return {
"mode": "ids",
"attempted": 3,
"processed": 3,
"skipped": [],
"errors": [],
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _post)
# 60 ids with duplicates — should be deduped and capped at 50
ids = [5, 5, 10, 10] + list(range(1, 57))
out = json.loads(await handler.regenerate_thumbnails(ids=ids))
assert out["ok"] is True
assert out["mode"] == "ids"
assert out["processed"] == 3
assert out["plugin_version"] == "2.8.0"
assert captured["route"] == "airano-mcp/v1/regenerate-thumbnails"
assert captured["ns"] is True
assert "all" not in captured["body"]
sent = captured["body"]["ids"]
assert len(sent) <= 50
assert len(sent) == len(set(sent)) # deduped
@pytest.mark.asyncio
async def test_all_mode_forwards_offset_and_limit(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {
"mode": "all",
"attempted": 10,
"processed": 10,
"skipped": [],
"errors": [],
"offset": 100,
"limit": 10,
"has_more": True,
"next_offset": 110,
"total": 200,
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _post)
out = json.loads(await handler.regenerate_thumbnails(all=True, offset=100, limit=10))
assert captured["body"] == {"all": True, "offset": 100, "limit": 10}
assert out["mode"] == "all"
assert out["has_more"] is True
assert out["next_offset"] == 110
assert out["total"] == 200
@pytest.mark.asyncio
async def test_all_mode_limit_capped_server_side(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {"mode": "all", "attempted": 0, "processed": 0}
monkeypatch.setattr(wp_client, "post", _post)
await handler.regenerate_thumbnails(all=True, limit=9999)
assert captured["body"]["limit"] == 50
@pytest.mark.asyncio
async def test_all_mode_negative_offset_clamped(handler, wp_client, monkeypatch):
captured: dict = {}
async def _post(route, json_data=None, use_custom_namespace=False):
captured["body"] = json_data
return {"mode": "all", "attempted": 0, "processed": 0}
monkeypatch.setattr(wp_client, "post", _post)
await handler.regenerate_thumbnails(all=True, offset=-5)
assert captured["body"]["offset"] == 0
# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
async def _boom(*_a, **_kw):
raise RuntimeError("404 - rest_no_route")
monkeypatch.setattr(wp_client, "post", _boom)
out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
assert "v2.8.0" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response_is_rejected(handler, wp_client, monkeypatch):
async def _weird(*_a, **_kw):
return ["not", "a", "dict"]
monkeypatch.setattr(wp_client, "post", _weird)
out = json.loads(await handler.regenerate_thumbnails(ids=[1]))
assert out["ok"] is False
assert out["error"] == "invalid_response"
@pytest.mark.asyncio
async def test_errors_from_companion_surface_through(handler, wp_client, monkeypatch):
async def _errs(*_a, **_kw):
return {
"mode": "ids",
"attempted": 2,
"processed": 0,
"skipped": [{"id": 42, "reason": "not_image"}],
"errors": [{"id": 99, "error": "file_missing"}],
"plugin_version": "2.8.0",
}
monkeypatch.setattr(wp_client, "post", _errs)
out = json.loads(await handler.regenerate_thumbnails(ids=[42, 99]))
# processed=0 with at least one error → not ok
assert out["ok"] is False
assert out["errors"] == [{"id": 99, "error": "file_missing"}]
assert out["skipped"] == [{"id": 42, "reason": "not_image"}]

View File

@@ -0,0 +1,122 @@
"""F.X.fix #2 — Rank Math SEO roundtrip: write → read → assert equality.
Protects against the regression where ``update_post_seo`` wrote
``rank_math_seo_title`` (wrong key) while ``get_post_seo`` read back
from the same wrong key, so a full write→read cycle looked successful
but the WordPress frontend showed no SEO title because Rank Math never
saw the value.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.seo import SEOHandler
@pytest.fixture
def fake_wp_state():
"""In-memory meta store shared across fake client get/post calls.
Rank Math is considered active when at least one canonical
rank_math_* key exists in the store, which mirrors the real
_check_seo_plugins detection heuristic.
"""
return {
"meta": {
# Seeded so _check_seo_plugins detects Rank Math as active.
"rank_math_focus_keyword": "",
"rank_math_title": "",
"rank_math_description": "",
}
}
@pytest.fixture
def fake_client(fake_wp_state):
client = AsyncMock()
async def _get(endpoint, params=None, use_custom_namespace=False, use_woocommerce=False):
if endpoint.startswith("posts/"):
return {
"id": 42,
"title": {"rendered": "Test Post"},
"meta": dict(fake_wp_state["meta"]),
}
if endpoint == "posts":
return [
{
"id": 42,
"title": {"rendered": "Test Post"},
"meta": dict(fake_wp_state["meta"]),
}
]
raise AssertionError(f"unexpected GET {endpoint}")
async def _post(endpoint, json_data=None, **_):
if endpoint.startswith("posts/") and json_data and "meta" in json_data:
fake_wp_state["meta"].update(json_data["meta"])
return {"id": 42, "meta": dict(fake_wp_state["meta"])}
raise AssertionError(f"unexpected POST {endpoint}")
client.get = _get
client.post = _post
return client
class TestRankMathRoundtrip:
@pytest.mark.asyncio
async def test_update_writes_canonical_rank_math_title(self, fake_client, fake_wp_state):
handler = SEOHandler(fake_client)
# Force the bridge-status fallback path to detect Rank Math via
# meta heuristic (seeded in fixture).
raw = await handler.update_post_seo(
post_id=42,
focus_keyword="mcp hub",
seo_title="MCP Hub — unified MCP server",
meta_description="Self-hosted MCP platform",
)
resp = json.loads(raw)
assert "rank_math_title" in resp["updated_fields"]
# The wrong key must NOT be written — guards against silent
# regression of the rank_math_seo_title bug.
assert "rank_math_seo_title" not in resp["updated_fields"]
assert fake_wp_state["meta"]["rank_math_title"] == "MCP Hub — unified MCP server"
assert fake_wp_state["meta"]["rank_math_focus_keyword"] == "mcp hub"
assert fake_wp_state["meta"]["rank_math_description"] == "Self-hosted MCP platform"
@pytest.mark.asyncio
async def test_get_reads_canonical_rank_math_title(self, fake_client, fake_wp_state):
fake_wp_state["meta"].update(
{
"rank_math_focus_keyword": "mcp hub",
"rank_math_title": "Readback Title",
"rank_math_description": "Readback Desc",
}
)
handler = SEOHandler(fake_client)
raw = await handler.get_post_seo(post_id=42)
data = json.loads(raw)
assert data["plugin_detected"] == "rank_math"
assert data["seo_title"] == "Readback Title"
assert data["focus_keyword"] == "mcp hub"
assert data["meta_description"] == "Readback Desc"
@pytest.mark.asyncio
async def test_roundtrip_write_then_read_returns_non_empty(self, fake_client, fake_wp_state):
handler = SEOHandler(fake_client)
await handler.update_post_seo(
post_id=42,
focus_keyword="kw",
seo_title="Title A",
meta_description="Desc A",
)
raw = await handler.get_post_seo(post_id=42)
data = json.loads(raw)
# Every field we just wrote must come back non-empty.
assert data["focus_keyword"] == "kw"
assert data["seo_title"] == "Title A"
assert data["meta_description"] == "Desc A"

View File

@@ -0,0 +1,176 @@
"""F.18.6 — Tests for wordpress_site_health."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.site_health import (
SiteHealthHandler,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return SiteHealthHandler(wp_client)
@pytest.fixture
def companion_payload():
return {
"ok": True,
"wordpress": {
"version": "6.5.3",
"multisite": False,
"home_url": "https://wp.example.com",
"site_url": "https://wp.example.com",
"language": "en_US",
"timezone": "UTC",
"rest_enabled": True,
"application_passwords_available": True,
"debug_mode": False,
"https_home": True,
},
"php": {
"version": "8.1.27",
"memory_limit": "256M",
"max_execution_time": "30",
"upload_max_filesize": "64M",
"post_max_size": "128M",
"max_input_vars": 1000,
"extensions": ["curl", "gd", "json", "mbstring", "xml"],
"has_mbstring": True,
"has_curl": True,
"has_gd": True,
"has_imagick": False,
"has_intl": True,
},
"server": {
"software": "nginx/1.25.3",
"disk_free_bytes": 123456789,
"disk_total_bytes": 999999999,
},
"database": {
"version": "10.5.15",
"server_info": "10.5.15-MariaDB",
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci",
"prefix": "wp_",
},
"plugins": {
"total_count": 20,
"active_count": 12,
"active": [
{
"file": "airano-mcp-bridge/airano-mcp-bridge.php",
"name": "Airano MCP Bridge for WordPress",
"version": "2.6.0",
}
],
"must_use_count": 2,
},
"theme": {
"active": {
"name": "Twenty Twenty-Four",
"version": "1.1",
"stylesheet": "twentytwentyfour",
},
"parent": None,
"total_count": 3,
},
"checks": {
"writable_wp_content": True,
"writable_uploads": True,
"ssl_enabled": True,
},
"companion": {
"plugin_version": "2.6.0",
"routes": {
"seo_meta": True,
"site_health": True,
"audit_hook": False,
},
"features": {
"rank_math": True,
"yoast": False,
"woocommerce": False,
},
},
"generated_at_gmt": "2026-04-15T09:00:00Z",
"plugin_version": "2.6.0",
}
@pytest.mark.asyncio
async def test_happy_path(handler, wp_client, monkeypatch, companion_payload):
get_mock = AsyncMock(return_value=companion_payload)
monkeypatch.setattr(wp_client, "get", get_mock)
out = json.loads(await handler.site_health())
assert out["ok"] is True
assert out["companion_available"] is True
assert out["wordpress"]["version"] == "6.5.3"
assert out["php"]["version"] == "8.1.27"
assert out["database"]["charset"] == "utf8mb4"
assert out["plugins"]["active_count"] == 12
assert out["theme"]["active"]["name"] == "Twenty Twenty-Four"
assert out["checks"]["writable_uploads"] is True
assert out["companion"]["plugin_version"] == "2.6.0"
get_mock.assert_called_once_with(
"airano-mcp/v1/site-health",
use_custom_namespace=True,
)
@pytest.mark.asyncio
async def test_companion_unreachable_falls_back(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.site_health())
assert out["ok"] is False
assert out["companion_available"] is False
assert out["error"] == "companion_unreachable"
assert "wordpress_get_site_health" in out["hint"]
assert "manage_options" in out["hint"]
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
out = json.loads(await handler.site_health())
assert out["ok"] is False
assert out["error"] == "invalid_response"
assert out["companion_available"] is False
@pytest.mark.asyncio
async def test_ok_inferred_when_payload_omits_it(handler, wp_client, monkeypatch):
"""Older plugin builds might not set `ok`; derive it from presence/no-error."""
payload = {
"wordpress": {"version": "6.4"},
"php": {"version": "8.0"},
"plugin_version": "2.6.0",
}
monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
out = json.loads(await handler.site_health())
assert out["ok"] is True
assert out["companion_available"] is True
assert out["wordpress"]["version"] == "6.4"
def test_tool_spec_is_read_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "site_health"
assert specs[0]["scope"] == "read"

View File

@@ -0,0 +1,98 @@
"""F.5a.6.1 — Per-tool rate limit tests."""
from __future__ import annotations
import json
import pytest
from core.tool_rate_limiter import (
PerToolRateLimiter,
ToolRateLimitError,
set_tool_rate_limiter,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.ai_media import AIMediaHandler
@pytest.fixture(autouse=True)
def _fresh_limiter():
set_tool_rate_limiter(PerToolRateLimiter())
yield
set_tool_rate_limiter(None)
def test_admin_exempt():
limiter = PerToolRateLimiter({"tool_x": 2})
for _ in range(10):
limiter.check("tool_x", None) # None = admin/env
limiter.check("tool_x", "")
def test_per_user_limit_enforced():
limiter = PerToolRateLimiter({"tool_x": 3})
for _ in range(3):
limiter.check("tool_x", "alice")
with pytest.raises(ToolRateLimitError) as exc:
limiter.check("tool_x", "alice")
assert exc.value.tool_name == "tool_x"
assert exc.value.limit_per_hour == 3
d = exc.value.to_dict()
assert d["error_code"] == "TOOL_RATE_LIMITED"
assert d["details"]["tool"] == "tool_x"
def test_different_users_isolated():
limiter = PerToolRateLimiter({"tool_x": 2})
limiter.check("tool_x", "alice")
limiter.check("tool_x", "alice")
with pytest.raises(ToolRateLimitError):
limiter.check("tool_x", "alice")
# Bob still has full quota
limiter.check("tool_x", "bob")
limiter.check("tool_x", "bob")
def test_unknown_tool_is_unlimited():
limiter = PerToolRateLimiter({})
for _ in range(100):
limiter.check("whatever", "alice")
@pytest.mark.asyncio
async def test_ai_generate_returns_typed_error_on_11th_call():
"""With cap of 10/hr/user, the 11th AI call yields TOOL_RATE_LIMITED."""
set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 10}))
client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
handler = AIMediaHandler(client, user_id="alice")
# Pre-consume 10 tokens for alice
from core.tool_rate_limiter import get_tool_rate_limiter
limiter = get_tool_rate_limiter()
for _ in range(10):
limiter.check("wordpress_generate_and_upload_image", "alice")
# 11th call via handler should short-circuit with typed error and not
# even attempt to call the provider.
result_json = await handler.generate_and_upload_image(provider="openai", prompt="a cat")
result = json.loads(result_json)
assert result["error_code"] == "TOOL_RATE_LIMITED"
assert result["details"]["tool"] == "wordpress_generate_and_upload_image"
@pytest.mark.asyncio
async def test_ai_generate_another_user_not_rate_limited():
set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 2}))
from core.tool_rate_limiter import get_tool_rate_limiter
limiter = get_tool_rate_limiter()
# Burn alice's quota
for _ in range(2):
limiter.check("wordpress_generate_and_upload_image", "alice")
with pytest.raises(ToolRateLimitError):
limiter.check("wordpress_generate_and_upload_image", "alice")
# Bob's check still passes (handler isn't invoked — we verify the
# limiter path, actual provider call is out of scope for this unit).
limiter.check("wordpress_generate_and_upload_image", "bob")

View File

@@ -0,0 +1,193 @@
"""F.18.5 — Tests for wordpress_transient_flush."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.transient_flush import (
TransientFlushHandler,
_validate,
get_tool_specifications,
)
@pytest.fixture
def wp_client():
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
def handler(wp_client):
return TransientFlushHandler(wp_client)
# ---------------------------------------------------------------------------
# Client-side validation.
# ---------------------------------------------------------------------------
def test_validate_defaults_to_expired():
assert _validate(None, None) is None
def test_validate_expired_ok():
assert _validate("expired", None) is None
def test_validate_all_ok():
assert _validate("all", None) is None
def test_validate_pattern_requires_pattern():
err = _validate("pattern", None)
assert err is not None
assert err["error"] == "pattern_required"
def test_validate_pattern_with_glob_ok():
assert _validate("pattern", "rank_math_*") is None
def test_validate_rejects_unknown_scope():
err = _validate("demolish", None)
assert err is not None
assert err["error"] == "invalid_scope"
# ---------------------------------------------------------------------------
# Handler behaviour.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invalid_scope_never_hits_network(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush(scope="demolish"))
assert out["ok"] is False
assert out["error"] == "invalid_scope"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_pattern_without_pattern_rejected_client_side(handler, wp_client, monkeypatch):
post_mock = AsyncMock()
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush(scope="pattern"))
assert out["ok"] is False
assert out["error"] == "pattern_required"
post_mock.assert_not_called()
@pytest.mark.asyncio
async def test_expired_happy_path(handler, wp_client, monkeypatch):
companion_response = {
"ok": True,
"scope": "expired",
"pattern": None,
"include_site_transients": True,
"deleted_count": 42,
"deleted_sample": ["foo", "bar"],
"plugin_version": "2.5.0",
}
post_mock = AsyncMock(return_value=companion_response)
monkeypatch.setattr(wp_client, "post", post_mock)
out = json.loads(await handler.transient_flush()) # default scope
assert out["ok"] is True
assert out["scope"] == "expired"
assert out["deleted_count"] == 42
call_args = post_mock.call_args
assert call_args.args[0] == "airano-mcp/v1/transient-flush"
body = call_args.kwargs["json_data"]
assert body["scope"] == "expired"
assert body["include_site_transients"] is True
assert "pattern" not in body
@pytest.mark.asyncio
async def test_pattern_passes_pattern_through(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["body"] = json_data
return {
"ok": True,
"scope": "pattern",
"pattern": "rank_math_*",
"include_site_transients": False,
"deleted_count": 3,
"deleted_sample": ["rank_math_a", "rank_math_b", "rank_math_c"],
"plugin_version": "2.5.0",
}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
out = json.loads(
await handler.transient_flush(
scope="pattern",
pattern="rank_math_*",
include_site_transients=False,
)
)
assert out["ok"] is True
assert out["pattern"] == "rank_math_*"
assert out["deleted_count"] == 3
assert captured["body"]["pattern"] == "rank_math_*"
assert captured["body"]["include_site_transients"] is False
@pytest.mark.asyncio
async def test_include_site_transients_bool_coercion(handler, wp_client, monkeypatch):
captured: dict = {}
async def fake_post(endpoint, json_data=None, **kwargs):
captured["body"] = json_data
return {"ok": True, "scope": "all", "deleted_count": 0}
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
# String "false" should coerce to False.
await handler.transient_flush(scope="all", include_site_transients="false")
assert captured["body"]["include_site_transients"] is False
# Integer 1 should coerce to True.
await handler.transient_flush(scope="all", include_site_transients=1)
assert captured["body"]["include_site_transients"] is True
# None / default → True.
await handler.transient_flush(scope="all", include_site_transients=None)
assert captured["body"]["include_site_transients"] is True
@pytest.mark.asyncio
async def test_companion_unreachable(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
out = json.loads(await handler.transient_flush(scope="expired"))
assert out["ok"] is False
assert out["error"] == "companion_unreachable"
@pytest.mark.asyncio
async def test_non_dict_response(handler, wp_client, monkeypatch):
monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="bad"))
out = json.loads(await handler.transient_flush(scope="expired"))
assert out["ok"] is False
assert out["error"] == "invalid_response"
def test_tool_spec_is_admin_scope():
specs = get_tool_specifications()
assert len(specs) == 1
assert specs[0]["name"] == "transient_flush"
assert specs[0]["scope"] == "admin"
assert set(specs[0]["schema"]["properties"]["scope"]["enum"]) == {
"expired",
"all",
"pattern",
}

View File

@@ -0,0 +1,437 @@
"""F.5a.8.5 — unified upload+metadata+attach+featured via companion.
Tests the routing layer in ``_media_core.wp_raw_upload``:
* When the cached capability probe advertises ``upload_and_attach`` AND
the caller supplies metadata/attach intent, we POST to
``/airano-mcp/v1/upload-and-attach`` and ``_apply_metadata_and_attach``
becomes a no-op.
* When the probe doesn't advertise the route, we fall through to the
legacy 3-step path (upload + PATCH metadata + PATCH featured).
* When the companion call errors, we fall back to the legacy path and
metadata gets reapplied via the REST calls.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
import pytest
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import (
_companion_has_upload_and_attach,
_companion_upload_and_attach,
wp_raw_upload,
)
from plugins.wordpress.handlers._media_security import UploadError
from plugins.wordpress.handlers.media import _apply_metadata_and_attach
_PNG_1x1 = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010806000000"
"1f15c4890000000a49444154789c6300010000000500010a2db4b70000"
"000049454e44ae426082"
)
@pytest.fixture
def client():
return WordPressClient(site_url="https://wp.example.com", username="admin", app_password="xxxx")
# ---------------------------------------------------------------------------
# Route advertisement check
# ---------------------------------------------------------------------------
class TestCompanionAdvertiseCheck:
@pytest.mark.asyncio
async def test_returns_true_when_route_advertised(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(
return_value={
"companion_available": True,
"routes": {"upload_and_attach": True, "upload_chunk": True},
}
),
):
assert await _companion_has_upload_and_attach(client) is True
@pytest.mark.asyncio
async def test_returns_false_when_route_absent(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(
return_value={
"companion_available": True,
"routes": {"upload_chunk": True}, # no upload_and_attach
}
),
):
assert await _companion_has_upload_and_attach(client) is False
@pytest.mark.asyncio
async def test_returns_false_when_companion_missing(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(return_value={"companion_available": False}),
):
assert await _companion_has_upload_and_attach(client) is False
@pytest.mark.asyncio
async def test_returns_false_on_cold_cache(self, client):
with patch(
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
AsyncMock(return_value=None),
):
assert await _companion_has_upload_and_attach(client) is False
# ---------------------------------------------------------------------------
# wp_raw_upload routing: unified path when advertised + metadata supplied
# ---------------------------------------------------------------------------
class TestWpRawUploadRouting:
@pytest.mark.asyncio
async def test_uses_unified_path_when_advertised_and_metadata_supplied(self, client):
captured: dict = {}
async def _fake_unified(
_client,
data,
*,
sniffed,
disposition,
attach_to_post,
set_featured,
title,
alt_text,
caption,
description,
idempotency_key=None,
):
captured["params"] = {
"attach_to_post": attach_to_post,
"set_featured": set_featured,
"title": title,
"alt_text": alt_text,
"caption": caption,
"description": description,
}
captured["called"] = True
return {
"id": 123,
"source_url": "https://wp.example.com/out.png",
"mime_type": "image/png",
"_upload_and_attach": {
"attach_to_post": attach_to_post,
"set_featured": set_featured,
},
}
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_fake_unified,
),
):
media = await wp_raw_upload(
client,
_PNG_1x1,
filename="hero.png",
mime_hint="image/png",
attach_to_post=42,
set_featured=True,
title="Hero",
alt_text="A hero image",
caption="caption here",
)
assert captured.get("called") is True
assert captured["params"]["attach_to_post"] == 42
assert captured["params"]["set_featured"] is True
assert captured["params"]["title"] == "Hero"
assert media["_upload_route"] == "companion_unified"
assert media["id"] == 123
@pytest.mark.asyncio
async def test_skips_unified_when_no_metadata_intent(self, client):
"""Upload with zero metadata → no reason to use unified route
even if advertised; fall through to legacy path."""
unified_called: dict = {"hit": False}
async def _fake_unified(*_a, **_kw):
unified_called["hit"] = True
return {}
legacy_mock = AsyncMock(return_value={"id": 1, "source_url": "x", "mime_type": "image/png"})
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_fake_unified,
),
patch(
"plugins.wordpress.handlers._media_core._should_use_companion",
AsyncMock(return_value=False),
),
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
):
# Bare /wp/v2/media session mock.
resp = AsyncMock()
resp.status = 201
resp.text = AsyncMock(return_value="{}")
resp.json = AsyncMock(
return_value={"id": 1, "source_url": "x", "mime_type": "image/png"}
)
sess = AsyncMock()
sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
_ = legacy_mock
media = await wp_raw_upload(client, _PNG_1x1, filename="x.png", mime_hint="image/png")
assert unified_called["hit"] is False
assert media["_upload_route"] == "rest"
@pytest.mark.asyncio
async def test_falls_back_when_unified_errors(self, client):
"""Unified call raising UploadError → fall through to legacy
path, and the caller's metadata gets applied via the REST calls."""
async def _boom(*_a, **_kw):
raise UploadError("COMPANION_500", "simulated")
with (
patch(
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
AsyncMock(return_value=True),
),
patch(
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
_boom,
),
patch(
"plugins.wordpress.handlers._media_core._should_use_companion",
AsyncMock(return_value=False),
),
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
):
resp = AsyncMock()
resp.status = 201
resp.text = AsyncMock(return_value="{}")
resp.json = AsyncMock(
return_value={"id": 2, "source_url": "x", "mime_type": "image/png"}
)
sess = AsyncMock()
sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess_cls.return_value = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
media = await wp_raw_upload(
client,
_PNG_1x1,
filename="x.png",
mime_hint="image/png",
attach_to_post=7,
)
# Legacy path marker — metadata will be applied via separate calls.
assert media["_upload_route"] == "rest"
assert "_upload_and_attach" not in media
# ---------------------------------------------------------------------------
# _apply_metadata_and_attach: skip when unified route marker present
# ---------------------------------------------------------------------------
class TestApplyMetadataSkip:
@pytest.mark.asyncio
async def test_unified_marker_short_circuits(self, client):
# Media response carries the unified marker → no REST calls.
media = {"id": 55, "_upload_route": "companion_unified"}
update_mock = AsyncMock()
featured_mock = AsyncMock()
with (
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
update_mock,
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
featured_mock,
),
):
await _apply_metadata_and_attach(
client,
media,
title="t",
alt_text="a",
caption="c",
attach_to_post=7,
set_featured=True,
)
update_mock.assert_not_called()
featured_mock.assert_not_called()
@pytest.mark.asyncio
async def test_legacy_marker_runs_metadata_calls(self, client):
media = {"id": 55, "_upload_route": "rest"}
update_mock = AsyncMock()
featured_mock = AsyncMock()
with (
patch(
"plugins.wordpress.handlers.media.wp_update_media_metadata",
update_mock,
),
patch(
"plugins.wordpress.handlers.media.wp_set_featured_media",
featured_mock,
),
):
await _apply_metadata_and_attach(
client,
media,
title="t",
alt_text="a",
caption="c",
attach_to_post=7,
set_featured=True,
)
update_mock.assert_awaited_once()
featured_mock.assert_awaited_once()
# ---------------------------------------------------------------------------
# _companion_upload_and_attach: query params + error mapping
# ---------------------------------------------------------------------------
def _build_fake_session(status: int, body_text: str):
resp = AsyncMock()
resp.status = status
resp.text = AsyncMock(return_value=body_text)
try:
_parsed = json.loads(body_text) if body_text else {}
except json.JSONDecodeError:
_parsed = {}
resp.json = AsyncMock(return_value=_parsed)
captured: dict = {}
def _post(url, **kwargs):
captured["url"] = url
captured["params"] = kwargs.get("params")
captured["headers"] = kwargs.get("headers")
return AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
sess = AsyncMock()
sess.post = _post
sess_cm = AsyncMock(
__aenter__=AsyncMock(return_value=sess),
__aexit__=AsyncMock(return_value=False),
)
return sess_cm, captured
class TestCompanionUploadAndAttachClient:
@pytest.mark.asyncio
async def test_query_params_encoded_from_metadata(self, client):
cm, captured = _build_fake_session(
200,
json.dumps({"id": 7, "source_url": "x", "mime_type": "image/png"}),
)
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
out = await _companion_upload_and_attach(
client,
b"irrelevant",
sniffed="image/png",
disposition='attachment; filename="x.png"',
attach_to_post=42,
set_featured=True,
title="T",
alt_text="A",
caption="C",
description="D",
)
assert out["id"] == 7
p = captured["params"]
assert p["attach_to_post"] == "42"
assert p["set_featured"] == "true"
assert p["title"] == "T"
assert p["alt_text"] == "A"
assert p["caption"] == "C"
assert p["description"] == "D"
assert "airano-mcp/v1/upload-and-attach" in captured["url"]
@pytest.mark.asyncio
async def test_set_featured_ignored_without_attach_target(self, client):
cm, captured = _build_fake_session(
200, json.dumps({"id": 1, "source_url": "x", "mime_type": "image/png"})
)
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
await _companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition="attachment",
attach_to_post=None,
set_featured=True, # ignored when no attach_to_post
title=None,
alt_text=None,
caption=None,
description=None,
)
assert "set_featured" not in captured["params"]
assert "attach_to_post" not in captured["params"]
@pytest.mark.asyncio
async def test_500_maps_to_companion_status_error(self, client):
cm, _ = _build_fake_session(500, "internal error")
with patch(
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
return_value=cm,
):
with pytest.raises(UploadError) as e:
await _companion_upload_and_attach(
client,
b"x",
sniffed="image/png",
disposition="attachment",
attach_to_post=1,
set_featured=False,
title=None,
alt_text=None,
caption=None,
description=None,
)
assert e.value.code == "COMPANION_500"

View File

@@ -0,0 +1,200 @@
"""F.8 security hardening: bcrypt + legacy SHA-256 upgrade-on-verify.
Ensures:
* Fresh keys created via ``create_key`` are stored as bcrypt hashes
(``$2`` prefix). No new SHA-256 hashes can land.
* Keys whose storage file carries legacy SHA-256 hashes still validate
(no customer lock-out), but the moment they validate once they are
re-hashed with bcrypt and persisted — so the file on disk
progressively drifts to bcrypt-only.
* Legacy-hash verification uses ``hmac.compare_digest`` (constant-time)
rather than ``==`` to avoid a timing oracle on legacy entries.
* Corrupt or truncated bcrypt hashes don't crash the verifier — they
return False so the caller emits a uniform 401.
"""
from __future__ import annotations
import hashlib
import json
import tempfile
from pathlib import Path
import pytest
from core.api_keys import APIKey, APIKeyManager
@pytest.fixture
def temp_storage():
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
storage_path = f.name
yield storage_path
Path(storage_path).unlink(missing_ok=True)
@pytest.fixture
def manager(temp_storage):
return APIKeyManager(storage_path=temp_storage)
# ---------------------------------------------------------------------------
# Fresh keys always bcrypt
# ---------------------------------------------------------------------------
class TestFreshKeysAreBcrypt:
@pytest.mark.unit
def test_created_key_stores_bcrypt_hash(self, manager):
result = manager.create_key(project_id="*", scope="read")
key = manager.keys[result["key_id"]]
assert APIKeyManager._is_bcrypt_hash(key.key_hash)
@pytest.mark.unit
def test_created_key_hash_is_not_sha256(self, manager):
plain = "cmp_test_plaintext_not_sha256"
# Grab the output of _hash_key directly and compare against what a
# SHA-256 would produce — they must differ.
bcrypt_hash = manager._hash_key(plain)
assert bcrypt_hash.startswith("$2")
assert bcrypt_hash != hashlib.sha256(plain.encode()).hexdigest()
# ---------------------------------------------------------------------------
# Legacy SHA-256 validation + upgrade
# ---------------------------------------------------------------------------
def _install_legacy_key(manager: APIKeyManager, raw_key: str, *, scope: str = "read") -> str:
"""Inject a legacy SHA-256-hashed key directly into the manager.
Simulates a keys.json file that was written before F.8 landed.
Returns the key_id.
"""
import uuid
from datetime import datetime
key_id = "legacy-" + uuid.uuid4().hex[:8]
legacy_hash = hashlib.sha256(raw_key.encode()).hexdigest()
manager.keys[key_id] = APIKey(
key_id=key_id,
key_hash=legacy_hash,
project_id="*",
scope=scope,
created_at=datetime.now().isoformat(),
)
manager._save_keys()
return key_id
class TestLegacySha256Validation:
@pytest.mark.unit
def test_legacy_sha256_hash_still_validates(self, manager):
raw = "cmp_legacy_customer_key_1234567890"
_install_legacy_key(manager, raw)
assert manager.validate_key(raw, project_id="anything", required_scope="read") is not None
@pytest.mark.unit
def test_legacy_hash_rejects_wrong_key(self, manager):
_install_legacy_key(manager, "cmp_real_one_1234")
assert manager.validate_key("cmp_wrong", project_id="x", required_scope="read") is None
@pytest.mark.unit
def test_verify_uses_constant_time_on_legacy_path(self, manager):
"""Sanity check: _verify_key returns the same type regardless of
how many leading characters of the SHA-256 hex happen to match."""
raw = "cmp_constant_time_check_key"
_install_legacy_key(manager, raw)
legacy_hash = hashlib.sha256(raw.encode()).hexdigest()
# Flip the very last byte of the hash — must still be rejected.
tampered = legacy_hash[:-1] + ("0" if legacy_hash[-1] != "0" else "1")
assert manager._verify_key(raw, tampered) is False
# Real hash still passes.
assert manager._verify_key(raw, legacy_hash) is True
class TestLegacyHashUpgradeOnVerify:
@pytest.mark.unit
def test_validate_key_upgrades_legacy_hash_to_bcrypt(self, manager):
raw = "cmp_upgrade_target_1234567890"
key_id = _install_legacy_key(manager, raw)
# Before validation: legacy SHA-256 hash.
assert not APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
# Validate → upgrade path runs.
assert manager.validate_key(raw, project_id="*", required_scope="read") == key_id
# After validation: bcrypt.
assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
@pytest.mark.unit
def test_get_key_by_token_also_upgrades(self, manager):
raw = "cmp_get_by_token_upgrade_key"
key_id = _install_legacy_key(manager, raw)
assert manager.get_key_by_token(raw) is not None
assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
@pytest.mark.unit
def test_upgrade_is_persisted_to_disk(self, manager, temp_storage):
raw = "cmp_persistent_upgrade_key"
key_id = _install_legacy_key(manager, raw)
manager.validate_key(raw, project_id="*", required_scope="read")
on_disk = json.loads(Path(temp_storage).read_text())
assert APIKeyManager._is_bcrypt_hash(on_disk[key_id]["key_hash"])
@pytest.mark.unit
def test_upgrade_keeps_key_valid_after(self, manager):
raw = "cmp_double_validate_key"
_install_legacy_key(manager, raw)
# First call upgrades.
assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
# Second call uses the bcrypt path.
assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
@pytest.mark.unit
def test_upgrade_does_not_touch_bcrypt_keys(self, manager):
"""Keys that are already bcrypt-hashed must not be re-hashed every
verify (bcrypt with fresh salt would invalidate later calls)."""
result = manager.create_key(project_id="*", scope="read")
raw = result["key"] # raw plaintext key (create_key returns ``key``)
before = manager.keys[result["key_id"]].key_hash
manager.validate_key(raw, project_id="*", required_scope="read")
after = manager.keys[result["key_id"]].key_hash
assert before == after
# ---------------------------------------------------------------------------
# Robustness: corrupt / truncated bcrypt hashes must not crash
# ---------------------------------------------------------------------------
class TestCorruptBcryptHandling:
@pytest.mark.unit
def test_truncated_bcrypt_returns_false(self, manager):
truncated = "$2b$12$truncated"
assert manager._verify_key("any", truncated) is False
@pytest.mark.unit
def test_non_bcrypt_non_matching_returns_false(self, manager):
assert manager._verify_key("any", "garbage-not-a-hash") is False
@pytest.mark.unit
def test_is_bcrypt_hash_classifier(self):
# Positive cases.
assert APIKeyManager._is_bcrypt_hash("$2b$12$abc.def")
assert APIKeyManager._is_bcrypt_hash("$2a$10$x")
assert APIKeyManager._is_bcrypt_hash("$2y$12$foo")
# Negative cases: SHA-256 hex and garbage.
assert not APIKeyManager._is_bcrypt_hash("a" * 64)
assert not APIKeyManager._is_bcrypt_hash("")
assert not APIKeyManager._is_bcrypt_hash("not-a-hash")

View File

@@ -0,0 +1,249 @@
"""F.7e — dashboard_sites_view renders capability probe into template context.
End-to-end-ish test: builds a real DB + site, stubs the plugin's probe,
and verifies that ``dashboard_sites_view`` injects a ``capability_probe``
context entry with a well-shaped ``fit`` dict. The manage.html template
consumes this entry to render the badge; this test only covers the
context wiring, not the template output (templates are hit in
integration dashboard tests).
"""
from __future__ import annotations
import base64
import os
from typing import Any
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
import core.encryption as enc_mod
monkeypatch.setattr(enc_mod, "_credential_encryption", None)
@pytest.fixture(autouse=True)
def _reset_probe_cache():
from core.capability_probe import get_probe_cache
get_probe_cache()._entries.clear()
yield
get_probe_cache()._entries.clear()
@pytest.fixture
async def _db_with_wp_site(tmp_path, monkeypatch):
import core.database as db_mod
from core.database import initialize_database
from core.encryption import get_credential_encryption
monkeypatch.setattr(db_mod, "_database", None)
db = await initialize_database(str(tmp_path / "badge.db"))
user = await db.create_user(
email="badge@example.com",
name="Badge",
provider="github",
provider_id="gh-badge",
)
enc = get_credential_encryption()
creds = enc.encrypt_credentials(
{"username": "admin", "app_password": "xxxx xxxx"}, "site-badge-1"
)
site = await db.create_site(
user_id=user["id"],
plugin_type="wordpress",
alias="bloggy",
url="https://wp.example.com",
credentials=creds,
)
await db.execute(
"UPDATE sites SET id = ?, tool_scope = ? WHERE id = ?",
("site-badge-1", "admin", site["id"]),
)
site = await db.get_site("site-badge-1", user["id"])
yield db, user, site
await db.close()
monkeypatch.setattr(db_mod, "_database", None)
class TestSitesViewInjectsCapabilityProbe:
@pytest.mark.asyncio
async def test_admin_tier_with_admin_role_renders_ok(self, _db_with_wp_site, monkeypatch):
_, user, site = _db_with_wp_site
async def _probe(self: Any) -> dict[str, Any]:
return {
"probe_available": True,
"granted": ["administrator", "manage_options", "upload_files"],
"source": "wordpress_companion",
"roles": ["administrator"],
}
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
captured: dict = {}
def _capture_template(_request, template_name, context, **kwargs):
captured["template"] = template_name
captured["context"] = context
# Return a minimal response object; the test doesn't render HTML.
class _Resp:
status_code = 200
return _Resp()
from starlette.requests import Request
from core.dashboard.routes import dashboard_sites_view
scope = {
"type": "http",
"method": "GET",
"path": f"/dashboard/sites/{site['id']}",
"path_params": {"id": site["id"]},
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b""}
request = Request(scope, receive)
with (
patch("core.dashboard.routes.templates") as mock_templates,
patch(
"core.dashboard.routes._require_user_session",
return_value=({"user_id": user["id"]}, None),
),
):
mock_templates.TemplateResponse = _capture_template
await dashboard_sites_view(request)
assert captured["template"] == "dashboard/sites/manage.html"
ctx = captured["context"]
assert "capability_probe" in ctx
probe = ctx["capability_probe"]
assert probe["probe_available"] is True
assert probe["fit"]["status"] == "ok"
assert probe["fit"]["missing"] == []
@pytest.mark.asyncio
async def test_admin_tier_with_editor_role_renders_warning(self, _db_with_wp_site, monkeypatch):
_, user, site = _db_with_wp_site
async def _probe(self: Any) -> dict[str, Any]:
return {
"probe_available": True,
"granted": ["editor", "edit_posts", "upload_files"],
"source": "wordpress_companion",
"roles": ["editor"],
}
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
captured: dict = {}
def _capture_template(_request, _name, context, **kwargs):
captured["context"] = context
class _Resp:
status_code = 200
return _Resp()
from starlette.requests import Request
from core.dashboard.routes import dashboard_sites_view
scope = {
"type": "http",
"method": "GET",
"path": f"/dashboard/sites/{site['id']}",
"path_params": {"id": site["id"]},
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b""}
request = Request(scope, receive)
with (
patch("core.dashboard.routes.templates") as mock_templates,
patch(
"core.dashboard.routes._require_user_session",
return_value=({"user_id": user["id"]}, None),
),
):
mock_templates.TemplateResponse = _capture_template
await dashboard_sites_view(request)
probe = captured["context"]["capability_probe"]
assert probe["fit"]["status"] == "warning"
assert "manage_options" in probe["fit"]["missing"]
@pytest.mark.asyncio
async def test_probe_raises_renders_probe_unavailable(self, _db_with_wp_site, monkeypatch):
_, user, site = _db_with_wp_site
async def _probe(self: Any) -> dict[str, Any]:
raise RuntimeError("no companion")
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
captured: dict = {}
def _capture_template(_request, _name, context, **kwargs):
captured["context"] = context
class _Resp:
status_code = 200
return _Resp()
from starlette.requests import Request
from core.dashboard.routes import dashboard_sites_view
scope = {
"type": "http",
"method": "GET",
"path": f"/dashboard/sites/{site['id']}",
"path_params": {"id": site["id"]},
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b""}
request = Request(scope, receive)
with (
patch("core.dashboard.routes.templates") as mock_templates,
patch(
"core.dashboard.routes._require_user_session",
return_value=({"user_id": user["id"]}, None),
),
):
mock_templates.TemplateResponse = _capture_template
await dashboard_sites_view(request)
probe = captured["context"]["capability_probe"]
assert probe["probe_available"] is False
assert probe["fit"]["status"] == "probe_unavailable"

View File

@@ -0,0 +1,86 @@
"""F.X.fix #9 — capability badge HTMX partial endpoint + template.
Regression: the Re-check button called ``window.location.reload()``,
which is jarring on slow networks and discards unrelated unsaved
state on the page. Fix: swap the badge in place via HTMX. The button
now carries ``hx-get="/api/sites/{id}/capabilities/badge"`` and the
target element has a matching id.
These tests don't spin up Starlette — they verify the two contracts
the front end depends on:
1. The partial template contains the right HTMX attrs (hx-get,
hx-target, hx-swap outerHTML) so the button actually triggers a
swap of just the badge element.
2. The new Starlette handler name is importable from
``core.capability_probe`` and wired under
/api/sites/{id}/capabilities/badge in server.py.
"""
from __future__ import annotations
from pathlib import Path
import pytest
BADGE_PARTIAL = (
Path(__file__).resolve().parent.parent / "core/templates/dashboard/sites/_capability_badge.html"
)
MANAGE_PAGE = Path(__file__).resolve().parent.parent / "core/templates/dashboard/sites/manage.html"
SERVER_PY = Path(__file__).resolve().parent.parent / "server.py"
class TestBadgePartialTemplate:
@pytest.mark.unit
def test_partial_exists(self):
assert BADGE_PARTIAL.is_file(), "_capability_badge.html partial must exist"
@pytest.mark.unit
def test_partial_has_stable_target_id(self):
text = BADGE_PARTIAL.read_text()
# The target id MUST match the hx-target value and the manage.html
# container id the old JS used.
assert 'id="capability-badge"' in text
@pytest.mark.unit
def test_partial_has_htmx_attrs(self):
text = BADGE_PARTIAL.read_text()
assert 'hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"' in text
assert 'hx-target="#capability-badge"' in text
assert 'hx-swap="outerHTML"' in text
@pytest.mark.unit
def test_partial_renders_all_four_fit_states(self):
text = BADGE_PARTIAL.read_text()
# One branch per fit.status value produced by evaluate_tier_fit.
for state in ("ok", "warning", "probe_unavailable", "unknown_tier"):
assert state in text, f"partial missing branch for status={state!r}"
class TestManagePageUsesPartial:
@pytest.mark.unit
def test_manage_page_includes_partial(self):
text = MANAGE_PAGE.read_text()
assert '"dashboard/sites/_capability_badge.html"' in text
@pytest.mark.unit
def test_legacy_reload_shim_is_gone(self):
text = MANAGE_PAGE.read_text()
# The old window.location.reload()-based recheck must not be
# the mechanism any more.
assert "__capabilityProbe.recheck()" not in text
assert "window.__capabilityProbe" not in text
class TestHandlerIsWired:
@pytest.mark.unit
def test_handler_importable(self):
from core.capability_probe import api_site_capabilities_badge
assert callable(api_site_capabilities_badge)
@pytest.mark.unit
def test_route_registered_in_server_py(self):
text = SERVER_PY.read_text()
assert "/api/sites/{id}/capabilities/badge" in text
assert "api_site_capabilities_badge" in text

View File

@@ -0,0 +1,367 @@
"""F.7e — tests for the per-site credential capability probe.
Covers:
* ``BasePlugin.probe_credential_capabilities`` default (probe unavailable).
* WordPress override: extracts granted capabilities from the companion
payload, surfaces roles + plugin_version, and degrades gracefully
when the companion isn't installed.
* ``probe_site_capabilities`` caches, decrypts credentials safely,
handles unknown sites, plugin-not-registered, and probe failures.
* ``_ProbeCache`` TTL expiry + explicit invalidation.
* Starlette handler: 401 unauth, 404 unknown site, happy-path body.
"""
from __future__ import annotations
import base64
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from core.capability_probe import (
_ProbeCache,
api_site_capabilities,
get_probe_cache,
probe_site_capabilities,
)
from plugins.base import BasePlugin
# ---------------------------------------------------------------------------
# BasePlugin default
# ---------------------------------------------------------------------------
class _DummyPlugin(BasePlugin):
def get_plugin_name(self) -> str:
return "dummy"
class TestBasePluginDefault:
@pytest.mark.asyncio
async def test_default_reports_probe_unavailable(self):
p = _DummyPlugin(config={"url": "https://x"}, project_id="dummy_t")
out = await p.probe_credential_capabilities()
assert out["probe_available"] is False
assert out["granted"] == []
assert out["source"] == "unavailable"
assert out["reason"] == "probe_not_implemented"
# ---------------------------------------------------------------------------
# WordPress override
# ---------------------------------------------------------------------------
@pytest.fixture
def wp_plugin():
"""Build a WordPress plugin instance with a stubbed client."""
from plugins.wordpress.plugin import WordPressPlugin
return WordPressPlugin(
config={
"url": "https://wp.example.com",
"username": "admin",
"app_password": "xxxx xxxx xxxx",
},
project_id="wordpress_probe_test",
)
class TestWordPressProbe:
@pytest.mark.asyncio
async def test_happy_path_extracts_granted_caps(self, wp_plugin, monkeypatch):
companion_payload = {
"companion_available": True,
"plugin_version": "2.8.0",
"user": {
"id": 1,
"login": "admin",
"roles": ["administrator"],
"capabilities": {
"edit_posts": True,
"upload_files": True,
"manage_options": True,
"read": True,
"publish_pages": False,
},
},
"features": {"rank_math": True},
"routes": {"cache_purge": True},
}
monkeypatch.setattr(
wp_plugin.capabilities,
"_fetch_capabilities",
AsyncMock(return_value=companion_payload),
)
out = await wp_plugin.probe_credential_capabilities()
assert out["probe_available"] is True
assert out["source"] == "wordpress_companion"
assert set(out["granted"]) == {"edit_posts", "upload_files", "manage_options", "read"}
assert out["roles"] == ["administrator"]
assert out["plugin_version"] == "2.8.0"
@pytest.mark.asyncio
async def test_companion_unavailable_returns_probe_false(self, wp_plugin, monkeypatch):
monkeypatch.setattr(
wp_plugin.capabilities,
"_fetch_capabilities",
AsyncMock(
return_value={
"companion_available": False,
"reason": "companion_unreachable: 404",
"site_url": "https://wp.example.com",
}
),
)
out = await wp_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert out["granted"] == []
assert "companion_unreachable" in out["reason"]
@pytest.mark.asyncio
async def test_probe_call_failure_does_not_raise(self, wp_plugin, monkeypatch):
async def _boom(*_a, **_kw):
raise RuntimeError("network collapsed")
monkeypatch.setattr(wp_plugin.capabilities, "_fetch_capabilities", _boom)
out = await wp_plugin.probe_credential_capabilities()
# _empty_capabilities_payload kicks in → probe_available stays False.
assert out["probe_available"] is False
assert "reason" in out
# ---------------------------------------------------------------------------
# Cache behaviour
# ---------------------------------------------------------------------------
class TestProbeCache:
@pytest.mark.unit
def test_get_missing_returns_none(self):
c = _ProbeCache(ttl_seconds=60)
assert c.get("no-such-id") is None
@pytest.mark.unit
def test_set_then_get_round_trip(self):
c = _ProbeCache(ttl_seconds=60)
c.set("s1", {"granted": ["read"]})
assert c.get("s1") == {"granted": ["read"]}
@pytest.mark.unit
def test_expiry_evicts(self):
c = _ProbeCache(ttl_seconds=0)
c.set("s1", {"granted": ["read"]})
# ttl=0 → immediately expired.
assert c.get("s1") is None
@pytest.mark.unit
def test_invalidate_removes(self):
c = _ProbeCache(ttl_seconds=60)
c.set("s1", {"granted": ["read"]})
assert c.invalidate("s1") is True
assert c.get("s1") is None
# Second invalidate on empty slot returns False.
assert c.invalidate("s1") is False
@pytest.mark.unit
def test_get_probe_cache_is_singleton(self):
a = get_probe_cache()
b = get_probe_cache()
assert a is b
# ---------------------------------------------------------------------------
# probe_site_capabilities (integration with DB + encryption)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_probe_cache():
cache = get_probe_cache()
cache._entries.clear()
yield
cache._entries.clear()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
import core.encryption as enc_mod
monkeypatch.setattr(enc_mod, "_credential_encryption", None)
@pytest.fixture
async def db_with_site(tmp_path, monkeypatch):
"""Initialize a real DB + fixture user and WP site."""
import core.database as db_mod
from core.database import initialize_database
from core.encryption import get_credential_encryption
monkeypatch.setattr(db_mod, "_database", None)
database = await initialize_database(str(tmp_path / "probe.db"))
user = await database.create_user(
email="probe@example.com",
name="Probe",
provider="github",
provider_id="gh-probe",
)
enc = get_credential_encryption()
creds = enc.encrypt_credentials(
{"username": "admin", "app_password": "yyyy yyyy yyyy"},
"site-probe-1", # any scope string; decrypt uses same
)
site = await database.create_site(
user_id=user["id"],
plugin_type="wordpress",
alias="blog",
url="https://wp.example.com",
credentials=creds,
)
# Force known id so encryption scope matches.
await database.execute(
"UPDATE sites SET id = ? WHERE id = ?",
("site-probe-1", site["id"]),
)
# Re-fetch.
site = await database.get_site("site-probe-1", user["id"])
yield database, user, site
await database.close()
monkeypatch.setattr(db_mod, "_database", None)
class TestProbeSiteCapabilities:
@pytest.mark.asyncio
async def test_unknown_site_returns_structured_error(self, db_with_site):
_, user, _ = db_with_site
out = await probe_site_capabilities("no-such", user["id"])
assert out["reason"] == "site_not_found"
assert out["probe_available"] is False
@pytest.mark.asyncio
async def test_happy_path_caches_result(self, db_with_site, monkeypatch):
_, user, site = db_with_site
call_count = {"n": 0}
async def _fake_probe(self: Any) -> dict[str, Any]:
call_count["n"] += 1
return {
"probe_available": True,
"granted": ["edit_posts", "upload_files"],
"source": "wordpress_companion",
"roles": ["administrator"],
"plugin_version": "2.8.0",
}
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _fake_probe)
out1 = await probe_site_capabilities(site["id"], user["id"])
assert out1["probe_available"] is True
assert out1["cached"] is False
assert set(out1["granted"]) == {"edit_posts", "upload_files"}
assert out1["roles"] == ["administrator"]
# Second call hits cache.
out2 = await probe_site_capabilities(site["id"], user["id"])
assert out2["cached"] is True
assert call_count["n"] == 1 # only one real probe
# Force bypass.
out3 = await probe_site_capabilities(site["id"], user["id"], force=True)
assert out3["cached"] is False
assert call_count["n"] == 2
@pytest.mark.asyncio
async def test_probe_exception_is_swallowed(self, db_with_site, monkeypatch):
_, user, site = db_with_site
async def _boom(self: Any) -> dict[str, Any]:
raise RuntimeError("no network")
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _boom)
out = await probe_site_capabilities(site["id"], user["id"])
assert out["probe_available"] is False
assert "probe_call_failed" in out["reason"]
# ---------------------------------------------------------------------------
# Starlette handler
# ---------------------------------------------------------------------------
def _make_probe_request(site_id: str, force: bool = False):
from starlette.requests import Request
query = b"force=1" if force else b""
scope = {
"type": "http",
"method": "GET",
"path": f"/api/sites/{site_id}/capabilities",
"path_params": {"id": site_id},
"headers": [],
"query_string": query,
}
async def receive():
return {"type": "http.request", "body": b""}
return Request(scope, receive)
class TestCapabilitiesEndpoint:
@pytest.mark.asyncio
async def test_unauthenticated_returns_401(self):
with patch("core.dashboard.routes._require_user_session") as mock_guard:
mock_guard.return_value = (None, MagicMock())
resp = await api_site_capabilities(_make_probe_request("s1"))
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_unknown_site_returns_404(self, db_with_site):
_, user, _ = db_with_site
with patch("core.dashboard.routes._require_user_session") as mock_guard:
mock_guard.return_value = ({"user_id": user["id"]}, None)
resp = await api_site_capabilities(_make_probe_request("no-such"))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_happy_path_returns_payload(self, db_with_site, monkeypatch):
_, user, site = db_with_site
async def _fake_probe(self: Any) -> dict[str, Any]:
return {
"probe_available": True,
"granted": ["edit_posts", "upload_files"],
"source": "wordpress_companion",
}
from plugins.wordpress.plugin import WordPressPlugin
monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _fake_probe)
with patch("core.dashboard.routes._require_user_session") as mock_guard:
mock_guard.return_value = ({"user_id": user["id"]}, None)
resp = await api_site_capabilities(_make_probe_request(site["id"]))
assert resp.status_code == 200
import json as _json
body = _json.loads(bytes(resp.body))
assert body["ok"] is True
assert body["probe_available"] is True
assert body["plugin_type"] == "wordpress"
assert set(body["granted"]) == {"edit_posts", "upload_files"}

View File

@@ -0,0 +1,235 @@
"""F.7e — WooCommerce + Gitea ``probe_credential_capabilities`` adapters.
Each plugin's override is exercised with a stubbed client / network layer
so the test is purely logic.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# WooCommerce
# ---------------------------------------------------------------------------
@pytest.fixture
def wc_plugin():
from plugins.woocommerce.plugin import WooCommercePlugin
return WooCommercePlugin(
config={
"url": "https://shop.example.com",
"consumer_key": "ck_1234567890abcdefghijklmnopqrstuvwxyzABC",
"consumer_secret": "cs_1234567890abcdefghijklmnopqrstuvwxyzABC",
},
project_id="wc_probe_test",
)
class TestWooCommerceProbe:
@pytest.mark.asyncio
async def test_read_permission_grants_read_only(self, wc_plugin, monkeypatch):
payload = {
"security": {
"rest_api_keys": [
{
"truncated_key": "wxyzABC", # last 7 chars of consumer_key
"permissions": "read",
}
]
}
}
monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
out = await wc_plugin.probe_credential_capabilities()
assert out["probe_available"] is True
assert out["source"] == "woocommerce_system_status"
assert out["permissions"] == "read"
assert set(out["granted"]) == {"read_products", "read_orders"}
@pytest.mark.asyncio
async def test_read_write_permission_grants_everything(self, wc_plugin, monkeypatch):
payload = {
"security": {
"rest_api_keys": [{"truncated_key": "wxyzABC", "permissions": "read_write"}]
}
}
monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
out = await wc_plugin.probe_credential_capabilities()
assert set(out["granted"]) == {
"read_products",
"read_orders",
"write_products",
"write_orders",
}
@pytest.mark.asyncio
async def test_key_not_listed_falls_back_to_inferred(self, wc_plugin, monkeypatch):
"""F.X.fix-pass3 — when the consumer key isn't in
system_status (truncated_key mismatch is common across WC
builds), fall back to ``probe_inferred`` instead of returning
a misleading probe_unavailable that triggers the badge's
"install companion plugin" hint (WC has no companion).
F.X.fix-pass5 — STAY CONSERVATIVE: report read-only on the
inferred path. The previous pass probed ``settings`` and
upgraded to write+admin on 200, but ``/wc/v3/settings`` checks
the WP user's manage_woocommerce capability (not the API key's
WC permission), so an admin user with a read-only key was
being over-granted and tier-fit "Read + Write" stayed green.
Now the badge correctly warns when ck/cs is read-only and
the user picks Read+Write tier; tools that actually have
write permission still execute fine.
"""
payload = {
"security": {
"rest_api_keys": [
{"truncated_key": "xxxxxxx", "permissions": "read"},
{"truncated_key": "yyyyyyy", "permissions": "write"},
]
}
}
async def _fake_get(path, **kwargs):
if path == "system_status":
return payload
raise RuntimeError(f"unexpected path {path}")
monkeypatch.setattr(wc_plugin.client, "get", _fake_get)
out = await wc_plugin.probe_credential_capabilities()
assert out["probe_available"] is True
assert out.get("probe_inferred") is True
# Conservative: only read perms reported, write withheld.
assert "read_products" in out["granted"]
assert "read_orders" in out["granted"]
assert "write_products" not in out["granted"]
assert "write_orders" not in out["granted"]
assert out["source"] == "woocommerce_system_status_inferred"
@pytest.mark.asyncio
async def test_single_key_fallback_when_truncation_missing(self, wc_plugin, monkeypatch):
"""Some WC versions omit truncated_key; if there's exactly one
key listed, fall back to it."""
payload = {
"security": {"rest_api_keys": [{"permissions": "read_write"}]} # no truncated_key
}
monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
out = await wc_plugin.probe_credential_capabilities()
assert out["probe_available"] is True
assert "write_products" in out["granted"]
@pytest.mark.asyncio
async def test_network_failure_returns_probe_false(self, wc_plugin, monkeypatch):
async def _boom(*_a, **_kw):
raise RuntimeError("502 bad gateway")
monkeypatch.setattr(wc_plugin.client, "get", _boom)
out = await wc_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert "system_status_unreachable" in out["reason"]
@pytest.mark.asyncio
async def test_non_dict_response_returns_probe_false(self, wc_plugin, monkeypatch):
monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=["not a dict"]))
out = await wc_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert out["reason"] == "non_dict_response"
# ---------------------------------------------------------------------------
# Gitea
# ---------------------------------------------------------------------------
@pytest.fixture
def gitea_plugin():
from plugins.gitea.plugin import GiteaPlugin
return GiteaPlugin(
config={"url": "https://git.example.com", "token": "gta_test"},
project_id="gitea_probe_test",
)
def _fake_aiohttp_get(status: int, scopes_header: str, text_body: str = ""):
"""Build an aiohttp.ClientSession mock whose .get() returns a response
with ``status`` and ``X-OAuth-Scopes`` header."""
resp = AsyncMock()
resp.status = status
resp.headers = {"X-OAuth-Scopes": scopes_header}
resp.text = AsyncMock(return_value=text_body)
sess = MagicMock()
sess.get = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=resp),
__aexit__=AsyncMock(return_value=False),
)
)
sess.__aenter__ = AsyncMock(return_value=sess)
sess.__aexit__ = AsyncMock(return_value=False)
return sess
class TestGiteaProbe:
@pytest.mark.asyncio
async def test_happy_path_returns_header_scopes(self, gitea_plugin):
sess = _fake_aiohttp_get(200, "read:repository, write:repository, admin:repo_hook")
with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
out = await gitea_plugin.probe_credential_capabilities()
assert out["probe_available"] is True
assert out["source"] == "gitea_oauth_scopes"
assert out["granted"] == [
"admin:repo_hook",
"read:repository",
"write:repository",
]
@pytest.mark.asyncio
async def test_empty_header_returns_probe_false(self, gitea_plugin):
sess = _fake_aiohttp_get(200, "")
with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
out = await gitea_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert out["reason"] == "scopes_header_absent_or_empty"
@pytest.mark.asyncio
async def test_http_error_returns_probe_false(self, gitea_plugin):
sess = _fake_aiohttp_get(401, "", text_body="bad token")
with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
out = await gitea_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert "user_endpoint_http_401" in out["reason"]
assert "bad token" in out["reason"]
@pytest.mark.asyncio
async def test_network_failure_returns_probe_false(self, gitea_plugin):
with patch(
"plugins.gitea.plugin.aiohttp.ClientSession",
side_effect=RuntimeError("dns failure"),
):
out = await gitea_plugin.probe_credential_capabilities()
assert out["probe_available"] is False
assert "probe_failed" in out["reason"]
@pytest.mark.asyncio
async def test_single_scope_parsed(self, gitea_plugin):
sess = _fake_aiohttp_get(200, "read:user")
with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
out = await gitea_plugin.probe_credential_capabilities()
assert out["granted"] == ["read:user"]
@pytest.mark.asyncio
async def test_whitespace_stripped_from_scopes(self, gitea_plugin):
sess = _fake_aiohttp_get(200, " read:user , write:repository ,")
with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
out = await gitea_plugin.probe_credential_capabilities()
assert out["granted"] == ["read:user", "write:repository"]

View File

@@ -0,0 +1,123 @@
"""F.X.fix #5 — tier-fit for the WP ``read`` tier recognises role names.
Regression: every admin probe showed ``status=warning, missing=['read']``
because the companion capability payload places WP role strings under
``roles`` (e.g. ``administrator``) while the bare ``read`` capability
is only implied by the role and never written out as a standalone cap.
The ``_cap_matches`` alias resolver understood the mapping but was
only fed ``granted`` — not ``granted roles``.
"""
from __future__ import annotations
import pytest
from core.capability_probe import evaluate_tier_fit
# Realistic admin probe payload captured from Phase A of F.X.test on
# blog.example.com. Only the fields that ``evaluate_tier_fit``
# actually reads are kept.
ADMIN_PROBE_PAYLOAD = {
"probe_available": True,
"granted": [
"delete_others_pages",
"delete_others_posts",
"delete_pages",
"delete_posts",
"delete_private_pages",
"delete_private_posts",
"delete_published_pages",
"delete_published_posts",
"edit_others_pages",
"edit_others_posts",
"edit_pages",
"edit_posts",
"manage_options",
"moderate_comments",
"upload_files",
],
"roles": ["administrator"],
"plugin_version": "2.9.0",
}
@pytest.mark.unit
class TestReadTierForAdminRoles:
def test_read_tier_ok_when_only_role_is_administrator(self):
# Admin user: ``read`` is implied by the role, not present in
# granted. Before the fix this returned status=warning.
fit = evaluate_tier_fit(
plugin_type="wordpress",
tier="read",
probe_payload=ADMIN_PROBE_PAYLOAD,
)
assert fit["status"] == "ok"
assert fit["missing"] == []
def test_read_tier_ok_for_subscriber_role_only(self):
# Subscriber: no caps in granted, but ``subscriber`` role
# implies ``read``. This was also broken before the fix.
payload = {
"probe_available": True,
"granted": [],
"roles": ["subscriber"],
}
fit = evaluate_tier_fit(
plugin_type="wordpress",
tier="read",
probe_payload=payload,
)
assert fit["status"] == "ok"
def test_write_tier_ok_when_editor_role_present(self):
# ``edit_posts`` aliases cover ``editor`` and ``administrator``
# roles — this was the alias resolver's job all along; with
# the fix it now also works when the cap only arrives via
# ``roles`` (edge case but possible for leaner probes).
payload = {
"probe_available": True,
"granted": ["upload_files"], # one required cap missing from granted
"roles": ["editor"],
}
fit = evaluate_tier_fit(
plugin_type="wordpress",
tier="write",
probe_payload=payload,
)
assert fit["status"] == "ok"
assert fit["missing"] == []
@pytest.mark.unit
class TestWarningStillFiresForUnderPrivileged:
def test_read_tier_warning_when_no_role_and_no_cap(self):
# Sanity: the union doesn't silence ALL missing-cap warnings,
# only the "role implies cap" bucket.
payload = {
"probe_available": True,
"granted": [],
"roles": [],
}
fit = evaluate_tier_fit(
plugin_type="wordpress",
tier="read",
probe_payload=payload,
)
assert fit["status"] == "warning"
assert fit["missing"] == ["read"]
def test_admin_tier_still_requires_manage_options(self):
# An editor cannot satisfy the admin tier — roles are in the
# union but manage_options still isn't granted.
payload = {
"probe_available": True,
"granted": ["edit_posts", "upload_files"],
"roles": ["editor"],
}
fit = evaluate_tier_fit(
plugin_type="wordpress",
tier="admin",
probe_payload=payload,
)
assert fit["status"] == "warning"
assert fit["missing"] == ["manage_options"]

View File

@@ -0,0 +1,232 @@
"""F.7e — ``evaluate_tier_fit`` + ``TIER_REQUIREMENTS`` coverage."""
from __future__ import annotations
import pytest
from core.capability_probe import (
TIER_REQUIREMENTS,
_cap_matches,
evaluate_tier_fit,
)
# ---------------------------------------------------------------------------
# TIER_REQUIREMENTS sanity
# ---------------------------------------------------------------------------
class TestTierRequirementsTable:
@pytest.mark.unit
def test_all_three_tiers_for_every_mapped_plugin(self):
for plugin_type, tiers in TIER_REQUIREMENTS.items():
assert {"read", "write", "admin"} <= set(tiers.keys()), f"{plugin_type} missing a tier"
@pytest.mark.unit
def test_requirements_are_non_empty_sets(self):
for plugin_type, tiers in TIER_REQUIREMENTS.items():
for tier, reqs in tiers.items():
assert isinstance(reqs, set), f"{plugin_type}/{tier} is not a set"
assert reqs, f"{plugin_type}/{tier} has no required caps"
# ---------------------------------------------------------------------------
# _cap_matches: canonical + alias resolution
# ---------------------------------------------------------------------------
class TestCapMatches:
@pytest.mark.unit
def test_exact_match(self):
assert _cap_matches("read", {"read"})
assert _cap_matches("manage_options", {"manage_options"})
@pytest.mark.unit
def test_role_satisfies_capability_alias(self):
# WP role -> implied capability
assert _cap_matches("manage_options", {"administrator"})
assert _cap_matches("edit_posts", {"editor"})
assert _cap_matches("read", {"subscriber"})
@pytest.mark.unit
def test_read_write_satisfies_read_and_write(self):
# WC "read_write" permission satisfies both read_* and write_*.
assert _cap_matches("read_products", {"read_write"})
assert _cap_matches("write_products", {"read_write"})
@pytest.mark.unit
def test_missing_capability(self):
assert not _cap_matches("manage_options", {"edit_posts"})
assert not _cap_matches("write_products", {"read"})
# ---------------------------------------------------------------------------
# evaluate_tier_fit: probe-unavailable path
# ---------------------------------------------------------------------------
class TestEvaluateTierFit:
@pytest.mark.unit
def test_probe_unavailable_short_circuits(self):
fit = evaluate_tier_fit(
"wordpress",
"admin",
{"probe_available": False, "reason": "companion_not_installed"},
)
assert fit["status"] == "probe_unavailable"
assert fit["reason"] == "companion_not_installed"
assert fit["missing"] == []
@pytest.mark.unit
def test_custom_tier_is_always_ok(self):
# "custom" means the caller cherry-picked tools — no tier-level
# contract to validate.
fit = evaluate_tier_fit(
"wordpress",
"custom",
{"probe_available": True, "granted": ["read"]},
)
assert fit["status"] == "ok"
assert fit["required"] == []
assert fit["missing"] == []
@pytest.mark.unit
def test_none_tier_is_always_ok(self):
fit = evaluate_tier_fit(
"wordpress",
None,
{"probe_available": True, "granted": ["read"]},
)
assert fit["status"] == "ok"
@pytest.mark.unit
def test_unknown_tier_for_plugin_returns_unknown(self):
fit = evaluate_tier_fit(
"wordpress",
"deploy", # not in WP's table
{"probe_available": True, "granted": ["read"]},
)
assert fit["status"] == "unknown_tier"
@pytest.mark.unit
def test_unknown_plugin_returns_unknown(self):
fit = evaluate_tier_fit(
"n8n", # no tier table
"read",
{"probe_available": True, "granted": ["read"]},
)
assert fit["status"] == "unknown_tier"
class TestWordPressFit:
@pytest.mark.unit
def test_admin_tier_with_administrator_role_is_ok(self):
fit = evaluate_tier_fit(
"wordpress",
"admin",
{
"probe_available": True,
"granted": ["administrator", "manage_options", "upload_files"],
},
)
assert fit["status"] == "ok"
assert fit["missing"] == []
@pytest.mark.unit
def test_admin_tier_with_editor_role_warns_missing_manage_options(self):
fit = evaluate_tier_fit(
"wordpress",
"admin",
{"probe_available": True, "granted": ["editor", "edit_posts", "upload_files"]},
)
assert fit["status"] == "warning"
assert "manage_options" in fit["missing"]
@pytest.mark.unit
def test_write_tier_with_editor_role_is_ok(self):
fit = evaluate_tier_fit(
"wordpress",
"write",
{"probe_available": True, "granted": ["editor"]},
)
# editor alias covers edit_posts + upload_files.
assert fit["status"] == "ok"
@pytest.mark.unit
def test_read_tier_with_subscriber_is_ok(self):
fit = evaluate_tier_fit(
"wordpress",
"read",
{"probe_available": True, "granted": ["subscriber"]},
)
assert fit["status"] == "ok"
class TestWooCommerceFit:
@pytest.mark.unit
def test_read_key_satisfies_read_tier(self):
fit = evaluate_tier_fit(
"woocommerce",
"read",
{"probe_available": True, "granted": ["read_products", "read_orders"]},
)
assert fit["status"] == "ok"
@pytest.mark.unit
def test_read_key_fails_write_tier(self):
fit = evaluate_tier_fit(
"woocommerce",
"write",
{"probe_available": True, "granted": ["read_products", "read_orders"]},
)
assert fit["status"] == "warning"
assert "write_products" in fit["missing"]
@pytest.mark.unit
def test_read_write_key_satisfies_both(self):
# Using the raw permission alias "read_write" directly.
fit_read = evaluate_tier_fit(
"woocommerce",
"read",
{"probe_available": True, "granted": ["read_write"]},
)
fit_write = evaluate_tier_fit(
"woocommerce",
"write",
{"probe_available": True, "granted": ["read_write"]},
)
assert fit_read["status"] == "ok"
assert fit_write["status"] == "ok"
class TestGiteaFit:
@pytest.mark.unit
def test_admin_tier_needs_admin_repo_hook(self):
fit = evaluate_tier_fit(
"gitea",
"admin",
{
"probe_available": True,
"granted": ["read:repository", "write:repository"],
},
)
assert fit["status"] == "warning"
assert "admin:repo_hook" in fit["missing"]
@pytest.mark.unit
def test_write_tier_with_write_scope_ok(self):
fit = evaluate_tier_fit(
"gitea",
"write",
{"probe_available": True, "granted": ["write:repository"]},
)
assert fit["status"] == "ok"
@pytest.mark.unit
def test_read_tier_with_only_user_scope_warns(self):
fit = evaluate_tier_fit(
"gitea",
"read",
{"probe_available": True, "granted": ["read:user"]},
)
assert fit["status"] == "warning"
assert "read:repository" in fit["missing"]

View File

@@ -0,0 +1,404 @@
"""F.18.7 — Tests for the companion-audit receiver + secret store."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from core.companion_audit import (
CompanionAuditSecretStore,
_parse_timestamp,
handle_companion_audit_request,
verify_companion_signature,
)
SITE = "https://wp.example.com"
SECRET = "a" * 32
def _now_iso() -> str:
"""Return an ISO 8601 UTC timestamp matching the companion plugin's format."""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _build_request(body_bytes: bytes, headers: dict[str, str]) -> Request:
"""Construct a minimal Starlette Request from a body + headers."""
scope = {
"type": "http",
"method": "POST",
"path": "/api/companion-audit",
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": body_bytes, "more_body": False}
return Request(scope, receive)
# ---------------------------------------------------------------------------
# Signature verification.
# ---------------------------------------------------------------------------
def test_verify_signature_accepts_sha256_prefix():
body = b'{"hi":1}'
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
assert verify_companion_signature(body, f"sha256={sig}", SECRET) is True
def test_verify_signature_accepts_bare_hex():
body = b'{"hi":1}'
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
assert verify_companion_signature(body, sig, SECRET) is True
def test_verify_signature_rejects_wrong_secret():
body = b'{"hi":1}'
sig = hmac.new(b"other_secret_1234", body, hashlib.sha256).hexdigest()
assert verify_companion_signature(body, f"sha256={sig}", SECRET) is False
def test_verify_signature_rejects_missing_header():
assert verify_companion_signature(b"x", None, SECRET) is False
assert verify_companion_signature(b"x", "", SECRET) is False
def test_verify_signature_rejects_non_hex():
assert verify_companion_signature(b"x", "sha256=NOTHEX!", SECRET) is False
# ---------------------------------------------------------------------------
# Secret store.
# ---------------------------------------------------------------------------
def test_store_set_and_get(tmp_path: Path):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
assert store.get(SITE) == SECRET
# Normalisation: trailing slash + case should not matter.
assert store.get(SITE + "/") == SECRET
assert store.get(SITE.upper()) == SECRET
def test_store_rejects_short_secret(tmp_path: Path):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
with pytest.raises(ValueError):
store.set(SITE, "short")
def test_store_delete(tmp_path: Path):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
assert store.delete(SITE) is True
assert store.get(SITE) is None
assert store.delete(SITE) is False
def test_store_list_sites_masks_secret(tmp_path: Path):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
sites = store.list_sites()
assert len(sites) == 1
assert sites[0]["site_url"].lower().startswith("https://wp.example.com")
assert sites[0]["secret_set"] is True
assert sites[0]["secret_last4"] == SECRET[-4:]
assert "secret" not in sites[0] # never leaks plaintext
def test_store_survives_restart(tmp_path: Path):
path = tmp_path / "secrets.json"
store1 = CompanionAuditSecretStore(path)
store1.set(SITE, SECRET)
store2 = CompanionAuditSecretStore(path)
assert store2.get(SITE) == SECRET
def test_store_corrupt_file_treated_as_empty(tmp_path: Path):
path = tmp_path / "secrets.json"
path.write_text("not json")
store = CompanionAuditSecretStore(path)
assert store.get(SITE) is None
# ---------------------------------------------------------------------------
# Receiver Starlette handler.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_receiver_rejects_empty_body():
req = _build_request(b"", {})
resp = await handle_companion_audit_request(req)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_receiver_rejects_invalid_json():
req = _build_request(b"not json", {"X-Airano-MCP-Site": SITE})
resp = await handle_companion_audit_request(req)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_receiver_rejects_missing_site(tmp_path: Path):
body = json.dumps({"event": "x"}).encode()
req = _build_request(body, {})
resp = await handle_companion_audit_request(req)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_receiver_unauthorized_when_unknown_site(tmp_path: Path, monkeypatch):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
body = json.dumps({"event": "x", "site_url": "https://unknown.example"}).encode()
req = _build_request(body, {"X-Airano-MCP-Signature": "sha256=deadbeef"})
resp = await handle_companion_audit_request(req)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_receiver_unauthorized_on_bad_signature(tmp_path: Path, monkeypatch):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
body = json.dumps({"event": "x", "site_url": SITE}).encode()
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": "sha256=" + "0" * 64,
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_receiver_happy_path(tmp_path: Path, monkeypatch):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
envelope = {
"event": "transition_post_status",
"site_url": SITE,
"timestamp": _now_iso(),
"user_id": 1,
"data": {"post_id": 42, "new_status": "publish", "old_status": "draft"},
"plugin_version": "2.7.0",
}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
fake_logger = MagicMock()
with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
"Content-Type": "application/json",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 200
fake_logger.log_system_event.assert_called_once()
call_args = fake_logger.log_system_event.call_args
assert call_args.kwargs["event"] == "companion_audit:transition_post_status"
details = call_args.kwargs["details"]
assert details["site_url"] == SITE
assert details["known_event"] is True
assert details["data"]["post_id"] == 42
@pytest.mark.asyncio
async def test_receiver_tags_unknown_events(tmp_path: Path, monkeypatch):
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
envelope = {"event": "made_up_event", "site_url": SITE, "timestamp": _now_iso()}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
fake_logger = MagicMock()
with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 200
details = fake_logger.log_system_event.call_args.kwargs["details"]
assert details["known_event"] is False
# ---------------------------------------------------------------------------
# Pre-F.20 security sweep: body-size cap + replay window + timestamp parsing.
# ---------------------------------------------------------------------------
def test_parse_timestamp_iso_8601_z_suffix():
iso = "2026-04-15T09:00:00Z"
ts = _parse_timestamp(iso)
assert ts is not None
# Round-trip: same moment in epoch seconds.
assert abs(ts - datetime(2026, 4, 15, 9, 0, 0, tzinfo=UTC).timestamp()) < 1
def test_parse_timestamp_numeric_epoch():
assert _parse_timestamp(1712345678) == 1712345678.0
assert _parse_timestamp(1712345678.5) == 1712345678.5
def test_parse_timestamp_numeric_string():
assert _parse_timestamp("1712345678") == 1712345678.0
def test_parse_timestamp_rejects_garbage():
assert _parse_timestamp("not a date") is None
assert _parse_timestamp(None) is None
assert _parse_timestamp("") is None
assert _parse_timestamp({"not": "a timestamp"}) is None
@pytest.mark.asyncio
async def test_receiver_rejects_oversized_body_via_content_length():
# Content-Length > cap → 413 before we even read the body.
req = _build_request(
b'{"event":"x"}',
{
"X-Airano-MCP-Site": SITE,
"Content-Length": str(1_000_000),
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 413
body = json.loads(bytes(resp.body))
assert body["error"] == "body_too_large"
@pytest.mark.asyncio
async def test_receiver_rejects_oversized_body_via_actual_length(monkeypatch):
# If Content-Length is absent but the body itself exceeds the cap,
# we still return 413 after the read.
monkeypatch.setattr("core.companion_audit._MAX_BODY_BYTES", 32)
big = b"x" * 64
req = _build_request(big, {"X-Airano-MCP-Site": SITE})
resp = await handle_companion_audit_request(req)
assert resp.status_code == 413
@pytest.mark.asyncio
async def test_receiver_rejects_missing_timestamp(tmp_path: Path, monkeypatch):
"""Signed but timestamp-less envelope: rejected as if sig were bad."""
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
envelope = {"event": "transition_post_status", "site_url": SITE}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_receiver_rejects_stale_timestamp(tmp_path: Path, monkeypatch):
"""Valid sig but timestamp outside the replay window → 401."""
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
stale = datetime.fromtimestamp(time.time() - 3600, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": stale}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_receiver_accepts_future_timestamp_within_skew(tmp_path: Path, monkeypatch):
"""Small positive clock skew (server drifted ahead of WP) is tolerated."""
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
# +30 s is well inside the default 300 s window.
skewed = datetime.fromtimestamp(time.time() + 30, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": skewed}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
fake_logger = MagicMock()
with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_receiver_replay_window_disabled(tmp_path: Path, monkeypatch):
"""Setting the window to 0 turns replay protection off."""
store = CompanionAuditSecretStore(tmp_path / "secrets.json")
store.set(SITE, SECRET)
monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
monkeypatch.setattr("core.companion_audit._REPLAY_WINDOW_SECONDS", 0)
ancient = "2020-01-01T00:00:00Z"
envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": ancient}
body = json.dumps(envelope).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
fake_logger = MagicMock()
with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
req = _build_request(
body,
{
"X-Airano-MCP-Site": SITE,
"X-Airano-MCP-Signature": f"sha256={sig}",
},
)
resp = await handle_companion_audit_request(req)
assert resp.status_code == 200

View File

@@ -0,0 +1,104 @@
"""Dashboard UX hints (F.20 prep): companion-plugin download + credential hints.
Verifies:
* WP / WC service pages and site-manage page surface the companion
download URL.
* Other plugin types (Gitea, n8n, Supabase, OpenPanel) do NOT get the
URL, so the banner / hint is hidden.
* Credential hints for WP ``app_password`` and WC
``consumer_key``/``consumer_secret`` explicitly tell the user the
field IS the API auth (no separate API key needed) — the user-
feedback item the phase was added for.
"""
from __future__ import annotations
import pytest
from core.site_api import PLUGIN_CREDENTIAL_FIELDS, get_credential_fields
# ---------------------------------------------------------------------------
# Credential hint copy (static, no DB).
# ---------------------------------------------------------------------------
class TestCredentialHintCopy:
@pytest.mark.unit
def test_wp_app_password_hint_flags_it_as_the_api_credential(self):
fields = PLUGIN_CREDENTIAL_FIELDS["wordpress"]
app_pw = next(f for f in fields if f["name"] == "app_password")
assert "API credential" in app_pw["hint"] or "api credential" in app_pw["hint"].lower()
# No separate API key is needed.
assert "no separate" in app_pw["hint"].lower() or "no extra" in app_pw["hint"].lower()
@pytest.mark.unit
def test_wc_consumer_key_hint_flags_pair_as_api_auth(self):
fields = PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
ck = next(f for f in fields if f["name"] == "consumer_key")
assert "API" in ck["hint"]
assert "no extra" in ck["hint"].lower() or "no separate" in ck["hint"].lower()
@pytest.mark.unit
def test_wc_consumer_secret_hint_mentions_shown_once(self):
fields = PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
cs = next(f for f in fields if f["name"] == "consumer_secret")
assert "Shown once" in cs["hint"] or "shown once" in cs["hint"].lower()
# Starts-with-cs_ tip helps users confirm they grabbed the right one.
assert "cs_" in cs["hint"]
@pytest.mark.unit
def test_get_credential_fields_wraps_the_dict(self):
# Sanity: the public getter returns the same structure.
assert get_credential_fields("wordpress") == PLUGIN_CREDENTIAL_FIELDS["wordpress"]
assert get_credential_fields("woocommerce") == PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
@pytest.mark.unit
def test_get_credential_fields_unknown_raises(self):
with pytest.raises(ValueError, match="Unknown plugin type"):
get_credential_fields("does_not_exist")
# ---------------------------------------------------------------------------
# Companion download URL gating on service / sites views.
# ---------------------------------------------------------------------------
# The exact URL we advertise until F.20 swaps it to wp.org.
EXPECTED_COMPANION_URL = (
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
)
@pytest.mark.parametrize("plugin_type", ["wordpress", "woocommerce"])
def test_companion_url_is_set_for_wp_and_wc(plugin_type):
"""Exercising the exact branch the two views take inline."""
companion_download_url = None
if plugin_type in {"wordpress", "woocommerce"}:
companion_download_url = EXPECTED_COMPANION_URL
assert companion_download_url == EXPECTED_COMPANION_URL
@pytest.mark.parametrize(
"plugin_type",
["gitea", "n8n", "supabase", "openpanel", "appwrite", "directus", "coolify"],
)
def test_companion_url_is_none_for_other_plugins(plugin_type):
companion_download_url = None
if plugin_type in {"wordpress", "woocommerce"}:
companion_download_url = EXPECTED_COMPANION_URL
assert companion_download_url is None
@pytest.mark.unit
def test_companion_url_is_the_github_raw_path():
"""Guard against accidentally pointing at a 404 or a 3rd-party host.
F.20 will swap this to ``wordpress.org/plugins/airano-mcp-bridge/``
once the wp.org listing goes live. Until then, the GitHub raw path
in the main branch is the canonical distribution channel.
"""
assert EXPECTED_COMPANION_URL.startswith("https://github.com/airano-ir/mcphub/raw/main/")
assert EXPECTED_COMPANION_URL.endswith("airano-mcp-bridge.zip")

View File

@@ -604,6 +604,103 @@ class TestSiteToolToggles:
assert await db.get_site_tool_scope("does-not-exist") == "admin"
class TestSiteProviderKeys:
"""F.5a.9.x: per-site AI provider key CRUD + cascade on site delete."""
@pytest.mark.unit
async def test_empty_list_by_default(self, db, site_row):
assert await db.list_site_provider_keys(site_row["id"]) == []
@pytest.mark.unit
async def test_upsert_and_get(self, db, site_row):
row = await db.upsert_site_provider_key(site_row["id"], "openai", b"ciphertext-bytes")
assert row["provider"] == "openai"
assert row["site_id"] == site_row["id"]
fetched = await db.get_site_provider_key(site_row["id"], "openai")
assert fetched is not None
assert fetched["key_ciphertext"] == b"ciphertext-bytes"
@pytest.mark.unit
async def test_upsert_replaces_existing(self, db, site_row):
await db.upsert_site_provider_key(site_row["id"], "openai", b"first")
await db.upsert_site_provider_key(site_row["id"], "openai", b"second")
fetched = await db.get_site_provider_key(site_row["id"], "openai")
assert fetched is not None
assert fetched["key_ciphertext"] == b"second"
@pytest.mark.unit
async def test_list_orders_by_provider(self, db, site_row):
await db.upsert_site_provider_key(site_row["id"], "stability", b"s")
await db.upsert_site_provider_key(site_row["id"], "openai", b"o")
await db.upsert_site_provider_key(site_row["id"], "replicate", b"r")
rows = await db.list_site_provider_keys(site_row["id"])
providers = [r["provider"] for r in rows]
assert providers == ["openai", "replicate", "stability"]
# list_* excludes ciphertext
assert all("key_ciphertext" not in r for r in rows)
@pytest.mark.unit
async def test_delete(self, db, site_row):
await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
deleted = await db.delete_site_provider_key(site_row["id"], "openai")
assert deleted is True
assert await db.get_site_provider_key(site_row["id"], "openai") is None
@pytest.mark.unit
async def test_delete_missing_returns_false(self, db, site_row):
assert await db.delete_site_provider_key(site_row["id"], "openai") is False
@pytest.mark.unit
async def test_touch_updates_last_used(self, db, site_row):
await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
fetched_before = await db.get_site_provider_key(site_row["id"], "openai")
assert fetched_before is not None
assert fetched_before["last_used"] is None
await db.touch_site_provider_key(site_row["id"], "openai")
fetched_after = await db.get_site_provider_key(site_row["id"], "openai")
assert fetched_after is not None
assert fetched_after["last_used"] is not None
@pytest.mark.unit
async def test_cascade_delete_on_site(self, db, user_row, site_row):
await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
await db.delete_site(site_row["id"], user_row["id"])
rows = await db.fetchall(
"SELECT * FROM site_provider_keys WHERE site_id = ?",
(site_row["id"],),
)
assert rows == []
@pytest.mark.unit
async def test_two_sites_keys_are_isolated(self, db, user_row):
s1 = await db.create_site(
user_id=user_row["id"],
plugin_type="wordpress",
alias="a",
url="https://a.example.com",
credentials=b"c1",
)
s2 = await db.create_site(
user_id=user_row["id"],
plugin_type="woocommerce",
alias="b",
url="https://b.example.com",
credentials=b"c2",
)
await db.upsert_site_provider_key(s1["id"], "openai", b"A")
await db.upsert_site_provider_key(s2["id"], "openai", b"B")
got_a = await db.get_site_provider_key(s1["id"], "openai")
got_b = await db.get_site_provider_key(s2["id"], "openai")
assert got_a is not None and got_a["key_ciphertext"] == b"A"
assert got_b is not None and got_b["key_ciphertext"] == b"B"
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""

View File

@@ -0,0 +1,307 @@
"""F.17 — Gitea ergonomics: batch files, tree, search, compare, releases, fork.
Tests cover both the client-level additions and the handler wrappers.
All network calls are mocked through ``client.request`` so the tests
exercise validation, error shaping, and payload construction without
hitting a real Gitea instance.
"""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock
import pytest
from plugins.gitea.client import GiteaClient
from plugins.gitea.handlers import repositories as repo_handlers
@pytest.fixture
def client():
"""A GiteaClient with its ``request`` method replaced by an AsyncMock."""
c = GiteaClient(site_url="https://git.example.com", token="ghs_test")
c.request = AsyncMock() # type: ignore[assignment]
return c
# ---------------------------------------------------------------------------
# _normalise_file_content: error messages + round-trip
# ---------------------------------------------------------------------------
class TestNormaliseFileContent:
@pytest.mark.unit
def test_plaintext_roundtrips(self):
out = GiteaClient._normalise_file_content("hello world", False)
assert base64.b64decode(out) == b"hello world"
@pytest.mark.unit
def test_bytes_input_accepted(self):
out = GiteaClient._normalise_file_content(b"\x00\x01\x02", False)
assert base64.b64decode(out) == b"\x00\x01\x02"
@pytest.mark.unit
def test_base64_input_validates_roundtrip(self):
b64 = base64.b64encode(b"precise").decode()
out = GiteaClient._normalise_file_content(b64, True)
assert base64.b64decode(out) == b"precise"
@pytest.mark.unit
def test_invalid_base64_raises_actionable_error(self):
# F.17 feedback #10: make the error message tell the user how to
# recover rather than just echoing the decoder's byte offset.
with pytest.raises(ValueError) as exc:
GiteaClient._normalise_file_content("###not base64###", True)
msg = str(exc.value)
assert "not valid base64" in msg
assert "content_is_base64=False" in msg
@pytest.mark.unit
def test_data_url_prefix_rejected_with_hint(self):
with pytest.raises(ValueError) as exc:
GiteaClient._normalise_file_content("data:text/plain;base64,aGVsbG8=", True)
assert "data:" in str(exc.value)
assert "Strip" in str(exc.value)
@pytest.mark.unit
def test_non_string_non_bytes_rejected(self):
with pytest.raises(ValueError):
GiteaClient._normalise_file_content(12345, False) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Client-level endpoints
# ---------------------------------------------------------------------------
class TestClientEndpoints:
@pytest.mark.asyncio
async def test_change_files_forwards_to_batch_endpoint(self, client):
client.request.return_value = {"commit": {"sha": "abc"}}
payload = {"message": "m", "files": [{"operation": "create", "path": "x"}]}
await client.change_files("o", "r", payload)
client.request.assert_called_once_with("POST", "repos/o/r/contents", json_data=payload)
@pytest.mark.asyncio
async def test_get_tree_passes_recursive_flag(self, client):
client.request.return_value = {"tree": []}
await client.get_tree("o", "r", "main", recursive=True, page=2, per_page=50)
call = client.request.call_args
assert call.args == ("GET", "repos/o/r/git/trees/main")
assert call.kwargs["params"] == {"page": 2, "per_page": 50, "recursive": "true"}
@pytest.mark.asyncio
async def test_get_tree_omits_recursive_when_false(self, client):
client.request.return_value = {"tree": []}
await client.get_tree("o", "r")
params = client.request.call_args.kwargs["params"]
assert "recursive" not in params
@pytest.mark.asyncio
async def test_search_code_scoped_to_repo(self, client):
client.request.return_value = {"ok": True, "data": []}
await client.search_code(keyword="func", owner="o", repo="r")
assert client.request.call_args.args == ("GET", "repos/o/r/search/code")
@pytest.mark.asyncio
async def test_search_code_instance_wide(self, client):
client.request.return_value = {"ok": True, "data": []}
await client.search_code(keyword="func")
assert client.request.call_args.args == ("GET", "repos/search/code")
@pytest.mark.asyncio
async def test_compare_uses_triple_dot_separator(self, client):
client.request.return_value = {"commits": []}
await client.compare("o", "r", "main", "feature-x")
assert client.request.call_args.args == ("GET", "repos/o/r/compare/main...feature-x")
@pytest.mark.asyncio
async def test_create_release_forwards_payload(self, client):
client.request.return_value = {"id": 1}
await client.create_release("o", "r", {"tag_name": "v1.0", "draft": False})
assert client.request.call_args.args == ("POST", "repos/o/r/releases")
@pytest.mark.asyncio
async def test_fork_payload_omits_none_fields(self, client):
client.request.return_value = {"id": 1}
await client.fork_repository("o", "r")
# Empty payload — no organization, no name.
assert client.request.call_args.kwargs["json_data"] == {}
client.request.reset_mock()
await client.fork_repository("o", "r", organization="new-org", name="new-name")
assert client.request.call_args.kwargs["json_data"] == {
"organization": "new-org",
"name": "new-name",
}
# ---------------------------------------------------------------------------
# Handlers: validation + JSON shape
# ---------------------------------------------------------------------------
class TestCreateFilesHandler:
@pytest.mark.asyncio
async def test_happy_path_batches_operations(self, client):
client.request.return_value = {"commit": {"sha": "abc"}}
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[
{"operation": "create", "path": "a.txt", "content": "hi"},
{
"operation": "update",
"path": "b.txt",
"content": "bye",
"sha": "deadbeef",
},
{"operation": "delete", "path": "c.txt", "sha": "cafebabe"},
],
message="batch commit",
branch="main",
)
)
assert out["success"] is True
assert "Batched 3 file" in out["message"]
# Client got a single batch call.
body = client.request.call_args.kwargs["json_data"]
assert body["branch"] == "main"
assert body["message"] == "batch commit"
assert len(body["files"]) == 3
# content is base64-encoded on the way out.
for f in body["files"][:2]:
assert base64.b64decode(f["content"])
@pytest.mark.asyncio
async def test_rejects_invalid_operation(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "rename", "path": "a.txt", "content": "x"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"].startswith("invalid_operation")
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_update_requires_sha(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "update", "path": "a.txt", "content": "x"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"] == "missing_sha_for_update"
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_requires_sha(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "delete", "path": "a.txt"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"] == "missing_sha_for_delete"
@pytest.mark.asyncio
async def test_bad_base64_surfaces_actionable_error(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[
{
"operation": "create",
"path": "a.txt",
"content": "###garbage###",
"content_is_base64": True,
}
],
message="m",
)
)
assert out["success"] is False
assert "content_is_base64=False" in out["errors"][0]["error"]
class TestTreeAndSearchHandlers:
@pytest.mark.asyncio
async def test_get_tree_passthrough(self, client):
client.request.return_value = {"tree": [{"path": "x"}]}
out = json.loads(await repo_handlers.get_tree(client, "o", "r", sha="main", recursive=True))
assert out["success"] is True
assert out["tree"]["tree"][0]["path"] == "x"
@pytest.mark.asyncio
async def test_search_code_passthrough(self, client):
client.request.return_value = {"ok": True, "data": [{"path": "hit"}]}
out = json.loads(
await repo_handlers.search_code(client, keyword="foo", owner="o", repo="r")
)
assert out["success"] is True
assert out["result"]["data"][0]["path"] == "hit"
@pytest.mark.asyncio
async def test_compare_passthrough(self, client):
client.request.return_value = {"commits": [{"sha": "c1"}]}
out = json.loads(await repo_handlers.compare(client, "o", "r", "main", "x"))
assert out["success"] is True
assert out["compare"]["commits"][0]["sha"] == "c1"
class TestReleaseAndForkHandlers:
@pytest.mark.asyncio
async def test_list_releases_passthrough(self, client):
client.request.return_value = [{"tag_name": "v1"}]
out = json.loads(await repo_handlers.list_releases(client, "o", "r"))
assert out["success"] is True
assert out["releases"][0]["tag_name"] == "v1"
@pytest.mark.asyncio
async def test_create_release_forwards_optional_fields(self, client):
client.request.return_value = {"id": 42}
await repo_handlers.create_release(
client, "o", "r", tag_name="v1.0", name="1.0", body="hi", prerelease=True
)
body = client.request.call_args.kwargs["json_data"]
assert body["tag_name"] == "v1.0"
assert body["name"] == "1.0"
assert body["body"] == "hi"
assert body["prerelease"] is True
@pytest.mark.asyncio
async def test_create_release_without_optional_fields(self, client):
client.request.return_value = {"id": 42}
await repo_handlers.create_release(client, "o", "r", tag_name="v1.0")
body = client.request.call_args.kwargs["json_data"]
assert body["tag_name"] == "v1.0"
assert "name" not in body
assert "body" not in body
@pytest.mark.asyncio
async def test_fork_repository_with_org(self, client):
client.request.return_value = {"full_name": "neworg/r"}
out = json.loads(
await repo_handlers.fork_repository(client, "o", "r", organization="neworg")
)
assert out["success"] is True
assert out["fork"]["full_name"] == "neworg/r"

View File

@@ -125,8 +125,13 @@ class TestGiteaToolSpecifications:
return GiteaPlugin.get_tool_specifications()
def test_total_tool_count(self, specs):
"""Should have 58 tools total (16 repo + 13 issue + 15 PR + 8 user + 6 webhook)."""
assert len(specs) == 58
"""Gitea tool count.
Base v3.6.0 count was 58. F.17 ergonomics (2026-04-16) added 7
tools: create_files, get_tree, search_code, compare, list_releases,
create_release, fork_repository.
"""
assert len(specs) == 65
def test_all_specs_have_required_keys(self, specs):
"""Every spec must have name, method_name, description, schema, scope."""

View File

@@ -0,0 +1,249 @@
"""Tests for per-site AI provider keys (F.5a.9.x).
Uses a real SQLite DB + real AES-256-GCM encryption to exercise the full
round-trip: site_api encrypts/decrypts via the per-site scope, DB stores
ciphertext, tenant isolation is enforced, and cascade delete cleans up.
"""
import base64
import os
import pytest
from core.database import Database, initialize_database
from core.site_api import (
PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES,
SITE_PROVIDERS,
delete_site_provider_key,
get_site_provider_key,
list_site_providers_set,
set_site_provider_key,
site_provider_scope,
site_supports_provider_keys,
)
@pytest.fixture(autouse=True)
def _set_encryption_key(monkeypatch):
"""Ensure ENCRYPTION_KEY is set and encryption singleton is fresh."""
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
import core.encryption as enc_mod
monkeypatch.setattr(enc_mod, "_credential_encryption", None)
@pytest.fixture
async def db(tmp_path, monkeypatch):
"""Initialize DB singleton so site_api.get_database() works."""
import core.database as db_mod
monkeypatch.setattr(db_mod, "_database", None)
database = await initialize_database(str(tmp_path / "test.db"))
yield database
await database.close()
monkeypatch.setattr(db_mod, "_database", None)
@pytest.fixture
async def user_row(db: Database):
return await db.create_user(
email="owner@example.com",
name="Owner",
provider="github",
provider_id="gh-999",
)
@pytest.fixture
async def second_user(db: Database):
return await db.create_user(
email="stranger@example.com",
name="Stranger",
provider="google",
provider_id="gg-888",
)
async def _make_site(db: Database, user_id: str, *, plugin_type="wordpress", alias="myblog"):
return await db.create_site(
user_id=user_id,
plugin_type=plugin_type,
alias=alias,
url=f"https://{alias}.example.com",
credentials=b"fake-encrypted-creds",
)
# ---------------------------------------------------------------------------
# Pure helpers (no DB)
# ---------------------------------------------------------------------------
class TestHelpers:
@pytest.mark.unit
def test_scope_format(self):
assert site_provider_scope("abc-123", "openai") == "site_provider:abc-123:openai"
@pytest.mark.unit
def test_scope_differs_per_provider(self):
s1 = site_provider_scope("site-1", "openai")
s2 = site_provider_scope("site-1", "stability")
assert s1 != s2
@pytest.mark.unit
def test_supports_provider_keys_wp_wc(self):
assert site_supports_provider_keys("wordpress") is True
assert site_supports_provider_keys("woocommerce") is True
@pytest.mark.unit
def test_supports_provider_keys_rejects_others(self):
for plugin_type in ("gitea", "n8n", "supabase", "openpanel", "appwrite"):
assert site_supports_provider_keys(plugin_type) is False
@pytest.mark.unit
def test_allowed_plugin_types_and_providers(self):
# Guard against accidental scope creep
assert frozenset({"wordpress", "woocommerce"}) == PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
assert set(SITE_PROVIDERS) == {"openai", "stability", "replicate", "openrouter"}
# ---------------------------------------------------------------------------
# Round-trip via site_api + DB + real AES-GCM
# ---------------------------------------------------------------------------
class TestSiteProviderKeyRoundTrip:
@pytest.mark.unit
async def test_set_then_get_returns_plaintext(self, db, user_row):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", "sk-test-1234567890")
got = await get_site_provider_key(site["id"], "openai")
assert got == "sk-test-1234567890"
@pytest.mark.unit
async def test_get_missing_returns_none(self, db, user_row):
site = await _make_site(db, user_row["id"])
assert await get_site_provider_key(site["id"], "openai") is None
@pytest.mark.unit
async def test_set_trims_whitespace(self, db, user_row):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", " sk-xyz ")
assert await get_site_provider_key(site["id"], "openai") == "sk-xyz"
@pytest.mark.unit
async def test_set_overwrites_existing(self, db, user_row):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", "first")
await set_site_provider_key(site["id"], user_row["id"], "openai", "second")
assert await get_site_provider_key(site["id"], "openai") == "second"
@pytest.mark.unit
async def test_list_providers_set(self, db, user_row):
site = await _make_site(db, user_row["id"])
assert await list_site_providers_set(site["id"]) == set()
await set_site_provider_key(site["id"], user_row["id"], "openai", "a")
await set_site_provider_key(site["id"], user_row["id"], "stability", "b")
assert await list_site_providers_set(site["id"]) == {"openai", "stability"}
@pytest.mark.unit
async def test_delete(self, db, user_row):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", "a")
deleted = await delete_site_provider_key(site["id"], user_row["id"], "openai")
assert deleted is True
assert await get_site_provider_key(site["id"], "openai") is None
@pytest.mark.unit
async def test_delete_missing_returns_false(self, db, user_row):
site = await _make_site(db, user_row["id"])
assert await delete_site_provider_key(site["id"], user_row["id"], "openai") is False
# ---------------------------------------------------------------------------
# Validation / security
# ---------------------------------------------------------------------------
class TestSiteProviderKeyValidation:
@pytest.mark.unit
async def test_rejects_empty_key(self, db, user_row):
site = await _make_site(db, user_row["id"])
with pytest.raises(ValueError, match="empty"):
await set_site_provider_key(site["id"], user_row["id"], "openai", " ")
@pytest.mark.unit
async def test_rejects_unknown_provider(self, db, user_row):
site = await _make_site(db, user_row["id"])
with pytest.raises(ValueError, match="Unsupported provider"):
await set_site_provider_key(site["id"], user_row["id"], "midjourney", "abc")
@pytest.mark.unit
async def test_rejects_unknown_site(self, db, user_row):
with pytest.raises(ValueError, match="Site not found"):
await set_site_provider_key("does-not-exist", user_row["id"], "openai", "abc")
@pytest.mark.unit
async def test_rejects_non_wp_wc_site(self, db, user_row):
site = await _make_site(db, user_row["id"], plugin_type="gitea")
with pytest.raises(ValueError, match="does not support"):
await set_site_provider_key(site["id"], user_row["id"], "openai", "abc")
@pytest.mark.unit
async def test_get_unknown_provider_returns_none(self, db, user_row):
site = await _make_site(db, user_row["id"])
assert await get_site_provider_key(site["id"], "midjourney") is None
@pytest.mark.unit
async def test_foreign_user_cannot_set_key(self, db, user_row, second_user):
site = await _make_site(db, user_row["id"])
# set_site_provider_key enforces ownership via db.get_site(site_id, user_id)
with pytest.raises(ValueError, match="Site not found"):
await set_site_provider_key(site["id"], second_user["id"], "openai", "abc")
@pytest.mark.unit
async def test_foreign_user_cannot_delete_key(self, db, user_row, second_user):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", "abc")
# Delete by stranger — returns False, row still present
assert await delete_site_provider_key(site["id"], second_user["id"], "openai") is False
assert await get_site_provider_key(site["id"], "openai") == "abc"
# ---------------------------------------------------------------------------
# Encryption isolation
# ---------------------------------------------------------------------------
class TestSiteProviderKeyEncryption:
@pytest.mark.unit
async def test_ciphertext_not_plaintext_in_db(self, db, user_row):
site = await _make_site(db, user_row["id"])
await set_site_provider_key(site["id"], user_row["id"], "openai", "sk-SENSITIVE-VALUE")
row = await db.get_site_provider_key(site["id"], "openai")
assert row is not None
assert b"sk-SENSITIVE-VALUE" not in row["key_ciphertext"]
@pytest.mark.unit
async def test_keys_differ_across_sites(self, db, user_row):
"""Two sites storing the same plaintext key should produce different
ciphertexts (different HKDF scope + different random nonce)."""
s1 = await _make_site(db, user_row["id"], alias="one")
s2 = await _make_site(db, user_row["id"], alias="two", plugin_type="woocommerce")
await set_site_provider_key(s1["id"], user_row["id"], "openai", "same")
await set_site_provider_key(s2["id"], user_row["id"], "openai", "same")
r1 = await db.get_site_provider_key(s1["id"], "openai")
r2 = await db.get_site_provider_key(s2["id"], "openai")
assert r1 is not None and r2 is not None
assert r1["key_ciphertext"] != r2["key_ciphertext"]
# Both decrypt back to the same plaintext via their own scopes
assert await get_site_provider_key(s1["id"], "openai") == "same"
assert await get_site_provider_key(s2["id"], "openai") == "same"

View File

@@ -0,0 +1,117 @@
"""F.X.fix #8 — Tool Access hides AI tools until a provider key is set.
Regression: ``wordpress_generate_and_upload_image`` appeared as
available in the Tool Access list even when the site had no AI
provider key configured. User only discovered at call time via
``NO_PROVIDER_KEY``. Fix: each tool row now carries
``provider_key_required`` + ``provider_key_configured`` so the
template can gray the tool out and render a "Configure key" CTA.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from core.tool_access import (
_tool_has_configured_provider,
_tool_requires_provider_key,
get_tool_access_manager,
)
class TestProviderKeyHelpers:
def test_ai_image_tool_requires_key(self):
assert _tool_requires_provider_key("wordpress_generate_and_upload_image")
def test_normal_tool_does_not_require_key(self):
assert not _tool_requires_provider_key("wordpress_create_post")
assert not _tool_requires_provider_key("wordpress_list_posts")
def test_configured_helper_returns_true_for_non_ai_tools(self):
# Non-AI tools aren't gated; they're always "configured".
assert _tool_has_configured_provider("wordpress_create_post", set())
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"})
class _FakeToolDef:
"""Minimal ToolDefinition stand-in for the registry mock."""
def __init__(self, name: str, category: str = "content"):
self.name = name
self.description = f"desc for {name}"
self.plugin_type = "wordpress"
self.category = category
self.sensitivity = "low"
self.required_scope = "read"
class TestListToolsForSiteAnnotatesProviderKey:
@pytest.fixture
def fake_tools(self):
return [
_FakeToolDef("wordpress_list_posts"),
_FakeToolDef("wordpress_create_post", category="content"),
_FakeToolDef("wordpress_generate_and_upload_image", category="media"),
]
@pytest.mark.asyncio
async def test_ai_tool_flagged_not_configured_when_site_has_no_keys(self, fake_tools):
manager = get_tool_access_manager()
fake_db = AsyncMock()
fake_db.get_site_tool_toggles = AsyncMock(return_value={})
fake_registry = AsyncMock()
fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
with (
patch("core.database.get_database", return_value=fake_db),
patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
patch("core.site_api.list_site_providers_set", new=AsyncMock(return_value=set())),
):
rows = await manager.list_tools_for_site("site-1", "wordpress")
ai = next(r for r in rows if r["name"] == "wordpress_generate_and_upload_image")
assert ai["provider_key_required"] is True
assert ai["provider_key_configured"] is False
@pytest.mark.asyncio
async def test_ai_tool_flagged_configured_when_site_has_openrouter_key(self, fake_tools):
manager = get_tool_access_manager()
fake_db = AsyncMock()
fake_db.get_site_tool_toggles = AsyncMock(return_value={})
fake_registry = AsyncMock()
fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
with (
patch("core.database.get_database", return_value=fake_db),
patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
patch(
"core.site_api.list_site_providers_set",
new=AsyncMock(return_value={"openrouter"}),
),
):
rows = await manager.list_tools_for_site("site-1", "wordpress")
ai = next(r for r in rows if r["name"] == "wordpress_generate_and_upload_image")
assert ai["provider_key_required"] is True
assert ai["provider_key_configured"] is True
@pytest.mark.asyncio
async def test_non_ai_tool_always_shows_configured_regardless_of_keys(self, fake_tools):
manager = get_tool_access_manager()
fake_db = AsyncMock()
fake_db.get_site_tool_toggles = AsyncMock(return_value={})
fake_registry = AsyncMock()
fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
with (
patch("core.database.get_database", return_value=fake_db),
patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
patch("core.site_api.list_site_providers_set", new=AsyncMock(return_value=set())),
):
rows = await manager.list_tools_for_site("site-1", "wordpress")
non_ai = next(r for r in rows if r["name"] == "wordpress_create_post")
assert non_ai["provider_key_required"] is False
assert non_ai["provider_key_configured"] is True

View File

@@ -0,0 +1,108 @@
"""F.5a.9.x: per-site tool visibility filter for wordpress_generate_and_upload_image.
Verifies that ``_get_visible_tools_for_site`` hides the AI-image tool from
``tools/list`` when the site has no provider key configured — the user's
stated requirement that "if not defined, the image creation tool should
be disabled by default".
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from core.user_endpoints import _get_visible_tools_for_site
def _make_tool(name: str) -> MagicMock:
tool = MagicMock()
tool.name = name
tool.description = f"description for {name}"
tool.plugin_type = "wordpress"
tool.required_scope = "write"
tool.category = "write"
tool.sensitivity = "normal"
tool.input_schema = {"type": "object", "properties": {}}
return tool
@pytest.fixture
def base_tools():
return [
_make_tool("wordpress_list_posts"),
_make_tool("wordpress_create_post"),
_make_tool("wordpress_generate_and_upload_image"),
]
class TestAIImageToolVisibility:
@pytest.mark.unit
async def test_no_key_hides_ai_tool(self, base_tools):
"""Site without any provider key → ai-image tool is filtered out."""
with (
patch("core.tool_access.get_tool_access_manager") as m_access,
patch("core.site_api.list_site_providers_set", AsyncMock(return_value=set())),
):
m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
out = await _get_visible_tools_for_site(
site_id="site-1", key_scopes=["write"], plugin_type="wordpress"
)
names = [t["name"] for t in out]
assert "wordpress_generate_and_upload_image" not in names
assert "wordpress_list_posts" in names
assert "wordpress_create_post" in names
@pytest.mark.unit
async def test_any_key_exposes_ai_tool(self, base_tools):
"""Site with at least one provider key → ai-image tool is visible."""
with (
patch("core.tool_access.get_tool_access_manager") as m_access,
patch(
"core.site_api.list_site_providers_set",
AsyncMock(return_value={"openai"}),
),
):
m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
out = await _get_visible_tools_for_site(
site_id="site-1", key_scopes=["write"], plugin_type="wordpress"
)
names = [t["name"] for t in out]
assert "wordpress_generate_and_upload_image" in names
@pytest.mark.unit
async def test_non_wp_wc_plugins_are_unaffected(self):
"""Gitea etc. never carry provider keys and never register the
ai-image tool — the filter must not interfere."""
tools = [_make_tool("gitea_list_repos")]
tools[0].plugin_type = "gitea"
with patch("core.tool_access.get_tool_access_manager") as m_access:
m_access.return_value.get_visible_tools = AsyncMock(return_value=tools)
# list_site_providers_set should NOT be called on non-wp/wc plugin types
with patch(
"core.site_api.list_site_providers_set",
AsyncMock(side_effect=AssertionError("should not be called")),
):
out = await _get_visible_tools_for_site(
site_id="site-1", key_scopes=["read"], plugin_type="gitea"
)
assert [t["name"] for t in out] == ["gitea_list_repos"]
@pytest.mark.unit
async def test_woocommerce_plugin_also_gates_ai_tool(self, base_tools):
"""Symmetric behaviour on woocommerce plugin_type."""
with (
patch("core.tool_access.get_tool_access_manager") as m_access,
patch("core.site_api.list_site_providers_set", AsyncMock(return_value=set())),
):
m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
out = await _get_visible_tools_for_site(
site_id="wc-1", key_scopes=["write"], plugin_type="woocommerce"
)
names = [t["name"] for t in out]
assert "wordpress_generate_and_upload_image" not in names

View File

@@ -94,9 +94,16 @@ class TestWooCommerceToolSpecs:
assert len(specs) > 0
def test_specs_count(self):
"""Should return exactly 28 tool specs."""
"""Should return 32 tool specs.
Breakdown: 12 products + 5 orders + 4 customers + 4 coupons +
3 reports + 3 media-attach (F.5a.3) + 1 AI image (F.X.fix-pass5
re-exposed generate_and_upload_image on the WC plugin so
operators don't need a separate WP site to chain AI generation
with WC product attachment).
"""
specs = WooCommercePlugin.get_tool_specifications()
assert len(specs) == 28
assert len(specs) == 32
def test_specs_have_required_fields(self):
"""Each spec should have name, method_name, description, schema, scope."""