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>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""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"}
|