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:
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
|
||||
Reference in New Issue
Block a user