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.
113 lines
3.0 KiB
Python
113 lines
3.0 KiB
Python
"""Schema-level tests: models persist, enums hold, audit helper writes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import select
|
|
|
|
from cayceops.models import (
|
|
AuditEntry,
|
|
ContentItem,
|
|
DaemonRun,
|
|
MailMessage,
|
|
Proposal,
|
|
ProposalSource,
|
|
ProposalStatus,
|
|
audit,
|
|
)
|
|
|
|
|
|
def test_proposal_defaults(sess):
|
|
p = Proposal(kind="affiliate", title="Test merchant program")
|
|
sess.add(p)
|
|
sess.flush()
|
|
|
|
assert p.id is not None
|
|
assert p.status == ProposalStatus.PROPOSED
|
|
assert p.source == ProposalSource.SCANNER
|
|
assert p.attempts == 0
|
|
assert p.created_at.tzinfo is not None
|
|
|
|
|
|
def test_proposal_status_transitions(sess):
|
|
p = Proposal(kind="content_gap", title="Guide gap: Rio")
|
|
sess.add(p)
|
|
sess.flush()
|
|
|
|
p.status = ProposalStatus.APPROVED
|
|
p.approved_by = "gustavo"
|
|
p.approved_at = datetime.now(UTC)
|
|
p.status = ProposalStatus.RUNNING
|
|
p.status = ProposalStatus.DONE
|
|
sess.flush()
|
|
|
|
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
|
assert loaded.status == ProposalStatus.DONE
|
|
assert loaded.approved_by == "gustavo"
|
|
|
|
|
|
def test_dedupe_key_unique(sess):
|
|
sess.add(Proposal(kind="affiliate", title="A", dedupe_key="k-1"))
|
|
sess.flush()
|
|
sess.add(Proposal(kind="affiliate", title="B", dedupe_key="k-1"))
|
|
try:
|
|
sess.flush()
|
|
raised = False
|
|
except sa.exc.IntegrityError:
|
|
raised = True
|
|
sess.rollback()
|
|
assert raised, "duplicate dedupe_key must be rejected"
|
|
|
|
|
|
def test_audit_helper_writes(sess):
|
|
p = Proposal(kind="other", title="x")
|
|
sess.add(p)
|
|
sess.flush()
|
|
audit(sess, actor="gustavo", action="approve", proposal_id=p.id, detail={"note": "ok"})
|
|
sess.flush()
|
|
|
|
rows = list(sess.scalars(select(AuditEntry)).all())
|
|
assert len(rows) == 1
|
|
assert rows[0].actor == "gustavo"
|
|
assert rows[0].proposal_id == p.id
|
|
|
|
|
|
def test_mail_and_content_and_runs(sess):
|
|
m = MailMessage(
|
|
direction="in",
|
|
subject="Welcome to the partner program",
|
|
classification="approval",
|
|
confidence=0.9,
|
|
draft_status="drafted",
|
|
)
|
|
c = ContentItem(theme="lightning-abroad", channel="nostr", caption="gm")
|
|
r = DaemonRun(daemon="scanner", status="ok", detail={"found": 3})
|
|
sess.add_all([m, c, r])
|
|
sess.flush()
|
|
|
|
assert m.id and c.id and r.id
|
|
assert m.draft_status == "drafted"
|
|
assert c.status == "draft"
|
|
|
|
|
|
def test_audit_chain_and_verification(sess):
|
|
from cayceops.models import verify_audit_chain
|
|
|
|
p = Proposal(kind="other", title="chain")
|
|
sess.add(p)
|
|
sess.flush()
|
|
for i in range(3):
|
|
audit(sess, actor=f"actor-{i}", action="step", proposal_id=p.id, detail={"i": i})
|
|
sess.flush()
|
|
|
|
ok, broken = verify_audit_chain(sess)
|
|
assert ok and broken is None
|
|
|
|
# tamper with one entry -> chain breaks at that id
|
|
first = sess.query(AuditEntry).order_by(AuditEntry.id).first()
|
|
first.actor = "tampered"
|
|
ok, broken = verify_audit_chain(sess)
|
|
assert not ok and broken == first.id
|