- README: the stack belongs to Cayce, an autonomous entity operating under the Próspera ZEDE agentic-companies jurisdiction pilot; the experiment is managed by Cayce itself - tests: approver actor renamed to the generic label operator - zero occurrences of personal names in the tree (verified with grep)
264 lines
9.2 KiB
Python
264 lines
9.2 KiB
Python
"""publisher tests — calendar, human gate (signed), nostr publish, gates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from cayceops.components.publisher import adapters, nostr, publisher
|
|
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
|
|
|
# invented test keys (never real — allowlisted in .gitleaks.toml)
|
|
TEST_KEY = "11" * 32 # nostr signing key
|
|
OPERATOR_SECRET = bytes.fromhex("22" * 32) # approval signing key
|
|
|
|
|
|
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
|
|
from coincurve import PrivateKey
|
|
|
|
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
|
|
|
|
|
def _sign_content(item: ContentItem, deployment: str = "itest") -> str:
|
|
from cayceops.signing import approval_message, content_content_hash
|
|
|
|
chash = content_content_hash(item.channel, item.caption, item.link)
|
|
return _sign(approval_message("content", deployment, item.id, chash))
|
|
|
|
|
|
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
|
|
return ContentItem(
|
|
theme=theme,
|
|
channel=channel,
|
|
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
|
|
link="bitcoinhotels.org/guides/atm",
|
|
status=status,
|
|
scheduled_for=scheduled,
|
|
)
|
|
|
|
|
|
def _approved_item(cfg, sess, **kw) -> ContentItem:
|
|
"""A content item properly approved with a signature (env set first)."""
|
|
cfg() # sets the signing env before any approval
|
|
item = _item(status=ContentStatus.DRAFT, **kw)
|
|
sess.add(item)
|
|
sess.flush()
|
|
assert publisher._approve(item.id, "operator", sess, signature=_sign_content(item)) == 0
|
|
return item
|
|
|
|
|
|
# --- nostr client -------------------------------------------------------------
|
|
|
|
|
|
def test_event_build_verify_roundtrip():
|
|
event = nostr.build_event(
|
|
bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000
|
|
)
|
|
assert nostr.verify_event(event)
|
|
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
|
|
|
tampered = {**event, "content": "gm (edited)"}
|
|
assert not nostr.verify_event(tampered)
|
|
|
|
|
|
def test_event_is_deterministic():
|
|
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
|
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
|
assert a == b
|
|
|
|
|
|
def test_key_validation():
|
|
with pytest.raises(ValueError):
|
|
nostr.normalize_key("not-a-key")
|
|
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
|
|
|
|
|
|
# --- calendar ------------------------------------------------------------------
|
|
|
|
|
|
def test_plan_creates_idempotent_drafts(cfg, sess):
|
|
config = cfg()
|
|
first = publisher._plan(config, sess)
|
|
second = publisher._plan(config, sess)
|
|
assert first["created"] == 4
|
|
assert second["created"] == 0 # same themes+channels already queued
|
|
|
|
|
|
def test_approve_gate(cfg, sess):
|
|
item = _item(status=ContentStatus.DRAFT)
|
|
sess.add(item)
|
|
sess.flush()
|
|
cfg() # signing env first
|
|
|
|
# unsigned approval refused (mandatory signature, no degraded mode)
|
|
assert publisher._approve(item.id, "operator", sess, signature=None) == 2
|
|
# signed approval accepted, FULL signature stored
|
|
sig = _sign_content(item)
|
|
assert publisher._approve(item.id, "operator", sess, signature=sig) == 0
|
|
assert item.status is ContentStatus.APPROVED
|
|
assert item.operator_signature == sig and len(sig) == 128
|
|
assert publisher._approve(item.id, "operator", sess, signature=sig) == 1 # not draft anymore
|
|
|
|
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
|
assert actor == "operator"
|
|
|
|
|
|
# --- publishing ------------------------------------------------------------------
|
|
|
|
|
|
def test_publish_due_nostr_happy_path(cfg, sess):
|
|
item = _approved_item(cfg, sess)
|
|
|
|
relays_seen = []
|
|
|
|
def fake_sender(relays, event, timeout=10.0):
|
|
relays_seen.append(relays)
|
|
assert nostr.verify_event(event)
|
|
return [{"relay": r, "ok": "true"} for r in relays]
|
|
|
|
config = cfg(
|
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
|
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
|
|
)
|
|
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
|
|
|
|
assert stats["published"] == 1
|
|
assert item.status is ContentStatus.PUBLISHED
|
|
assert item.post_ref and len(item.post_ref) == 64
|
|
assert item.published_at is not None
|
|
assert "wss://relay.example.org" in relays_seen[0]
|
|
|
|
|
|
def test_future_scheduled_item_not_published(cfg, sess):
|
|
item = _approved_item(
|
|
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
|
|
)
|
|
config = cfg(
|
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
|
)
|
|
stats = publisher._publish_due(
|
|
config, sess, nostr_sender=lambda r, e, timeout=10: []
|
|
)
|
|
assert stats["published"] == 0
|
|
assert item.status is ContentStatus.APPROVED
|
|
|
|
|
|
def test_unsigned_sql_approved_item_not_published(cfg, sess):
|
|
"""Round-2 required test: raw-SQL approved row (no signature) not published."""
|
|
config = cfg(
|
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
|
)
|
|
item = _item() # status=APPROVED, no signature
|
|
sess.add(item)
|
|
sess.flush()
|
|
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
|
|
assert stats["published"] == 0 and stats["tampered"] == 1
|
|
assert item.status is ContentStatus.DRAFT # reverted
|
|
refused = sess.query(AuditEntry).filter_by(action="publish_refused").one()
|
|
assert refused.result == "error"
|
|
|
|
|
|
def test_tampered_caption_refused_by_signature(cfg, sess):
|
|
"""Round-2 required test: signature over other content refused at publish."""
|
|
item = _approved_item(cfg, sess)
|
|
|
|
item.caption = item.caption + " EDITED" # content no longer matches sig
|
|
nostr_config = cfg(
|
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
|
)
|
|
stats = publisher._publish_due(
|
|
nostr_config, sess, nostr_sender=lambda r, e, timeout=10: []
|
|
)
|
|
assert stats["published"] == 0 and stats["tampered"] == 1
|
|
assert item.status is ContentStatus.DRAFT
|
|
|
|
|
|
def test_foreign_deployment_signature_refused(cfg, sess, signer):
|
|
"""Round-2 required test: a valid signature for another deployment_id is
|
|
refused at approval time."""
|
|
item = _item(status=ContentStatus.DRAFT)
|
|
sess.add(item)
|
|
sess.flush()
|
|
other_dep = signer.raw(
|
|
signer.msg("content", "other-deploy", item.id, signer.content_hash(item))
|
|
)
|
|
assert publisher._approve(item.id, "operator", sess, signature=other_dep) == 2
|
|
assert item.status is ContentStatus.DRAFT
|
|
|
|
|
|
def test_nostr_without_config_fails_clean(cfg, sess):
|
|
"""Signed item but no nostr credentials -> clean failure, item stays."""
|
|
item = _approved_item(cfg, sess)
|
|
config = cfg() # no key, no relays
|
|
stats = publisher._publish_due(config, sess)
|
|
assert stats["failed"] == 1
|
|
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
|
|
assert item.error and "nostr" in item.error
|
|
|
|
|
|
def test_non_nostr_channels_skipped_terminal(cfg, sess):
|
|
item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
|
|
stats = publisher._publish_due(cfg(), sess)
|
|
assert stats["skipped"] == 1
|
|
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
|
|
|
|
stats2 = publisher._publish_due(cfg(), sess)
|
|
assert stats2["skipped"] == 0 # never revisited
|
|
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
|
|
assert skips == 1
|
|
|
|
|
|
def test_meta_adapters_disabled_by_default(cfg):
|
|
config = cfg()
|
|
with pytest.raises(PermissionError):
|
|
adapters.post_facebook(config, "hello")
|
|
with pytest.raises(PermissionError):
|
|
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
|
|
|
|
|
|
# --- images ------------------------------------------------------------------
|
|
|
|
|
|
def test_brand_image_render(tmp_path, cfg):
|
|
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
|
|
out = publisher._render_image(_item(), config)
|
|
assert os.path.exists(out)
|
|
from PIL import Image
|
|
|
|
with Image.open(out) as img:
|
|
assert img.size == (1080, 1350)
|
|
|
|
|
|
def test_unknown_brand_rejected():
|
|
from cayceops.components.publisher import images
|
|
|
|
with pytest.raises(ValueError):
|
|
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
|
|
|
|
|
# --- round-2: attempts budget --------------------------------------------------
|
|
|
|
|
|
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
|
|
"""Medium finding: nostr failures retried without limit."""
|
|
from cayceops.components.publisher import nostr as nostr_mod
|
|
|
|
config = cfg(
|
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
|
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
|
CAYCE_OPS_MAX_ATTEMPTS="1",
|
|
)
|
|
item = _approved_item(cfg, sess)
|
|
|
|
def always_fail(relays, event, timeout=10.0):
|
|
raise RuntimeError("relay down")
|
|
|
|
monkeypatch.setattr(nostr_mod, "publish", always_fail)
|
|
publisher._publish_due(config, sess, nostr_sender=always_fail)
|
|
assert item.attempts == 1
|
|
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
|
|
|
|
|
|
import os # noqa: E402 (used by image test)
|