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,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