Files
cayce-ops/tests/test_scanner.py
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.

Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.

Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.

Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).

Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.

Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.

tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
2026-10-19 23:06:29 +00:00

113 lines
3.6 KiB
Python

"""scanner tests — discovery, classification, dedupe. No network (injected fetch)."""
from __future__ import annotations
import json
from cayceops.components.scanner import scanner
from cayceops.models import Proposal, ProposalStatus
RSS = b"""<?xml version="1.0"?>
<rss><channel>
<item><title>Acme launches affiliate program for hotels</title>
<link>https://acme.example.org/affiliates</link>
<description>Earn commission on every booking.</description></item>
<item><title>Weekly crypto digest</title>
<link>https://news.example.org/digest</link>
<description>Everything unrelated.</description></item>
</channel></rss>"""
PAGE = b"""<html><head><title>Partners</title></head><body>
<a href="https://pay.example.org/partner">Become a payment partner</a>
<a href="https://pay.example.org/docs">API docs</a>
</body></html>"""
def _fake_fetch(responses):
def fetch(url, cfg, timeout=20.0):
return responses[url]
return fetch
SOURCES = [
{"type": "rss", "url": "https://feeds.example.org/news.xml"},
{"type": "page", "url": "https://pay.example.org/partners"},
]
def _cfg(cfg, **kw):
return cfg(
CAYCE_OPS_SCANNER_SOURCES=json.dumps(SOURCES),
CAYCE_OPS_SCANNER_KEYWORDS="affiliate,partner,bitcoin,commission",
**kw,
)
def test_scan_files_proposals_with_kind_and_score(cfg, sess):
config = _cfg(cfg)
stats = scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
assert stats["proposed"] == 3 # affiliate item + both page links (pages keep all)
proposals = sess.query(Proposal).all()
by_title = {p.title: p for p in proposals}
affiliate = by_title["Acme launches affiliate program for hotels"]
assert affiliate.kind == "affiliate"
assert affiliate.status == ProposalStatus.PROPOSED
assert affiliate.match_score > 0
assert affiliate.dedupe_key
partner = by_title["Become a payment partner"]
assert partner.kind == "partnership"
def test_scan_is_idempotent_via_dedupe(cfg, sess):
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
stats2 = scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
assert stats2["proposed"] == 0
assert stats2["skipped_existing"] == 3
def test_scan_filters_keywordless_feed_items(cfg, sess):
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
titles = {p.title for p in sess.query(Proposal).all()}
assert "Weekly crypto digest" not in titles # no keyword match in feed -> dropped
def test_scan_records_audit_for_each_proposal(cfg, sess):
from cayceops.models import AuditEntry
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
acts = [a.action for a in sess.query(AuditEntry).all()]
assert acts.count("propose") == 3
assert all(a.actor == "daemon:scanner" for a in sess.query(AuditEntry).all())
def test_scan_survives_source_error(cfg, sess):
def broken_fetch(url, cfg, timeout=20.0):
raise OSError("connection refused")
config = _cfg(cfg)
stats = scanner.scan(config, sess, fetch=broken_fetch)
assert stats["proposed"] == 0
assert len(stats["source_errors"]) == 2