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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,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"}