Files
cayce-ops/tests/test_models.py
T
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.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