feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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:
387
tests/plugins/wordpress/test_ai_media.py
Normal file
387
tests/plugins/wordpress/test_ai_media.py
Normal 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
|
||||
188
tests/plugins/wordpress/test_ai_media_idempotency.py
Normal file
188
tests/plugins/wordpress/test_ai_media_idempotency.py
Normal 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"]
|
||||
224
tests/plugins/wordpress/test_audit_hook.py
Normal file
224
tests/plugins/wordpress/test_audit_hook.py
Normal 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"
|
||||
197
tests/plugins/wordpress/test_bulk_meta.py
Normal file
197
tests/plugins/wordpress/test_bulk_meta.py
Normal 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"]
|
||||
128
tests/plugins/wordpress/test_cache_purge.py
Normal file
128
tests/plugins/wordpress/test_cache_purge.py
Normal 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"
|
||||
239
tests/plugins/wordpress/test_capabilities.py
Normal file
239
tests/plugins/wordpress/test_capabilities.py
Normal 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"
|
||||
173
tests/plugins/wordpress/test_client_fast_fail.py
Normal file
173
tests/plugins/wordpress/test_client_fast_fail.py
Normal 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
|
||||
158
tests/plugins/wordpress/test_companion_install_hint.py
Normal file
158
tests/plugins/wordpress/test_companion_install_hint.py
Normal 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
|
||||
263
tests/plugins/wordpress/test_export.py
Normal file
263
tests/plugins/wordpress/test_export.py
Normal 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
|
||||
153
tests/plugins/wordpress/test_get_post_fields.py
Normal file
153
tests/plugins/wordpress/test_get_post_fields.py
Normal 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
|
||||
217
tests/plugins/wordpress/test_media_attach.py
Normal file
217
tests/plugins/wordpress/test_media_attach.py
Normal 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"
|
||||
161
tests/plugins/wordpress/test_media_audit.py
Normal file
161
tests/plugins/wordpress/test_media_audit.py
Normal 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"
|
||||
216
tests/plugins/wordpress/test_media_bulk.py
Normal file
216
tests/plugins/wordpress/test_media_bulk.py
Normal 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
|
||||
264
tests/plugins/wordpress/test_media_chunked.py
Normal file
264
tests/plugins/wordpress/test_media_chunked.py
Normal 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"
|
||||
129
tests/plugins/wordpress/test_media_chunked_status.py
Normal file
129
tests/plugins/wordpress/test_media_chunked_status.py
Normal 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
|
||||
235
tests/plugins/wordpress/test_media_companion_upload.py
Normal file
235
tests/plugins/wordpress/test_media_companion_upload.py
Normal 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
|
||||
120
tests/plugins/wordpress/test_media_error_taxonomy.py
Normal file
120
tests/plugins/wordpress/test_media_error_taxonomy.py
Normal 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)
|
||||
180
tests/plugins/wordpress/test_media_optimize.py
Normal file
180
tests/plugins/wordpress/test_media_optimize.py
Normal 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"
|
||||
96
tests/plugins/wordpress/test_media_post_parent.py
Normal file
96
tests/plugins/wordpress/test_media_post_parent.py
Normal 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
|
||||
177
tests/plugins/wordpress/test_media_probe.py
Normal file
177
tests/plugins/wordpress/test_media_probe.py
Normal 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
|
||||
362
tests/plugins/wordpress/test_media_upload.py
Normal file
362
tests/plugins/wordpress/test_media_upload.py
Normal 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
|
||||
192
tests/plugins/wordpress/test_openrouter_model_discovery.py
Normal file
192
tests/plugins/wordpress/test_openrouter_model_discovery.py
Normal 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 == []
|
||||
157
tests/plugins/wordpress/test_openrouter_pricing.py
Normal file
157
tests/plugins/wordpress/test_openrouter_pricing.py
Normal 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"
|
||||
314
tests/plugins/wordpress/test_openrouter_provider.py
Normal file
314
tests/plugins/wordpress/test_openrouter_provider.py
Normal 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
|
||||
222
tests/plugins/wordpress/test_regenerate_thumbnails.py
Normal file
222
tests/plugins/wordpress/test_regenerate_thumbnails.py
Normal 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"}]
|
||||
122
tests/plugins/wordpress/test_seo_roundtrip.py
Normal file
122
tests/plugins/wordpress/test_seo_roundtrip.py
Normal 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"
|
||||
176
tests/plugins/wordpress/test_site_health.py
Normal file
176
tests/plugins/wordpress/test_site_health.py
Normal 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"
|
||||
98
tests/plugins/wordpress/test_tool_rate_limit.py
Normal file
98
tests/plugins/wordpress/test_tool_rate_limit.py
Normal 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")
|
||||
193
tests/plugins/wordpress/test_transient_flush.py
Normal file
193
tests/plugins/wordpress/test_transient_flush.py
Normal 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",
|
||||
}
|
||||
437
tests/plugins/wordpress/test_upload_and_attach.py
Normal file
437
tests/plugins/wordpress/test_upload_and_attach.py
Normal 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"
|
||||
200
tests/test_api_keys_bcrypt_upgrade.py
Normal file
200
tests/test_api_keys_bcrypt_upgrade.py
Normal 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")
|
||||
249
tests/test_capability_badge_integration.py
Normal file
249
tests/test_capability_badge_integration.py
Normal 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"
|
||||
86
tests/test_capability_badge_partial.py
Normal file
86
tests/test_capability_badge_partial.py
Normal 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
|
||||
367
tests/test_capability_probe.py
Normal file
367
tests/test_capability_probe.py
Normal 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"}
|
||||
235
tests/test_capability_probe_adapters.py
Normal file
235
tests/test_capability_probe_adapters.py
Normal 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"]
|
||||
123
tests/test_capability_probe_read_tier.py
Normal file
123
tests/test_capability_probe_read_tier.py
Normal 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"]
|
||||
232
tests/test_capability_tier_fit.py
Normal file
232
tests/test_capability_tier_fit.py
Normal 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"]
|
||||
404
tests/test_companion_audit.py
Normal file
404
tests/test_companion_audit.py
Normal 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
|
||||
104
tests/test_dashboard_ux_hints.py
Normal file
104
tests/test_dashboard_ux_hints.py
Normal 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")
|
||||
@@ -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."""
|
||||
|
||||
|
||||
307
tests/test_gitea_f17_ergonomics.py
Normal file
307
tests/test_gitea_f17_ergonomics.py
Normal 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"
|
||||
@@ -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."""
|
||||
|
||||
249
tests/test_site_provider_keys.py
Normal file
249
tests/test_site_provider_keys.py
Normal 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"
|
||||
117
tests/test_tool_access_visibility.py
Normal file
117
tests/test_tool_access_visibility.py
Normal 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
|
||||
108
tests/test_user_endpoints_tool_filter.py
Normal file
108
tests/test_user_endpoints_tool_filter.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user