"""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)