test_step6_pap_idempotency.py 5.05 KB
# Step 6 (PAP 审计) 验证:跨渠道同一意图幂等 + 审批动作对称性
# 知识来源:personal-agent-protocol-pap(learn_queue #1307/#1361)
#   - PAP 授权层 ③「账号授权 ≠ 操作授权」:同一意图重复触发不得产生第二笔外部动作
#   - PAP 跨渠道统一会话:任一入口不得绕过统一守卫(单条 send 与批量 send 守卫必须一致)
#   - 4C 对称性:batch-send 逐条返回 gate reason_code ⇒ batch-cancel 同样要逐条返回
# 安全策略:monkeypatch app._send_wechat_text,绝不真正外发微信。
import sys
sys.path.insert(0, "/data/data/com.termux/files/home/wechat-viewer")

import local_db
import app as app_module
from app import app

SENT_CALLS = []


def _fake_send(to_wxid, text):
    """记录调用,绝不真发。任何进入发送层的调用都会被断言计数。"""
    SENT_CALLS.append((to_wxid, text))
    return {"status": "sent", "to": to_wxid}


app_module._send_wechat_text = _fake_send

client = app.test_client()
TAG = "pap_step6_test"
failures = []


def check(name, cond, detail=""):
    if cond:
        print(f"[{name} PASS] {detail}")
    else:
        print(f"[{name} FAIL] {detail}")
        failures.append(name)


# ── 准备:一条 pending + 一条已 sent
rid_pending = local_db.reply_add(
    msg_id=0, talker=f"{TAG}_wxid", sender=f"{TAG}_wxid",
    sender_name=f"{TAG}_pending", kind="private", incoming="测试消息",
    draft="幂等测试草稿", action="reply", reason="pap-step6", status="pending",
)
rid_sent = local_db.reply_add(
    msg_id=0, talker=f"{TAG}_wxid", sender=f"{TAG}_wxid",
    sender_name=f"{TAG}_sent", kind="private", incoming="测试消息2",
    draft="已发送草稿", action="reply", reason="pap-step6", status="sent",
)
print(f"[setup] pending id={rid_pending} sent id={rid_sent}")

# ── T1: 单条 send 对「已发送」条目必须拒绝(幂等守卫)
#    BEFORE FIX: 无 status 检查 ⇒ 直接进入 _send_wechat_text ⇒ 重复外发
before = len(SENT_CALLS)
r = client.post(f"/api/reply/{rid_sent}/send", json={})
j = r.get_json()
check("T1", j.get("ok") is False and j.get("gate") == "REPLY_NOT_PENDING",
      f"status={r.status_code} gate={j.get('gate')} error={j.get('error')}")
check("T1b", len(SENT_CALLS) == before,
      f"未触发外发 (send 调用数 {before}->{len(SENT_CALLS)})")

# ── T2: 单条 send 重复调用同一 pending 条目:第一次成功、第二次必须被拦(幂等实测)
before = len(SENT_CALLS)
r1 = client.post(f"/api/reply/{rid_pending}/send", json={})
j1 = r1.get_json()
r2 = client.post(f"/api/reply/{rid_pending}/send", json={})
j2 = r2.get_json()
check("T2", j1.get("ok") is True, f"首次发送 ok={j1.get('ok')}")
check("T2b", j2.get("ok") is False, f"二次发送被拦 ok={j2.get('ok')} gate={j2.get('gate')}")
check("T2c", len(SENT_CALLS) == before + 1,
      f"同一意图只产生一次外发动作 (send 调用 {len(SENT_CALLS) - before} 次)")

# ── T3: batch-send 与单条 send 守卫一致(跨渠道/跨入口权限一致)
r = client.post("/api/replies/batch-send", json={"ids": [rid_sent]})
j = r.get_json()
check("T3", j["results"][0]["gate"] == "REPLY_NOT_PENDING",
      f"batch-send 同一守卫: gate={j['results'][0]['gate']}")

# ── T4: batch-cancel 逐条返回 gate reason_code(4C 对称性)
r = client.post("/api/replies/batch-cancel", json={"ids": [rid_sent, 999999]})
j = r.get_json()
results = j.get("results") or []
check("T4", len(results) == 2, f"逐条返回 results: {j}")
check("T4b", results and results[0].get("gate") == "REPLY_ALREADY_SENT",
      f"已发送条目不可静默取消: {results[0] if results else None}")
check("T4c", len(results) > 1 and results[1].get("gate") == "REPLY_NOT_FOUND",
      f"不存在条目 gate: {results[1] if len(results) > 1 else None}")

# ── T5: reply_add 按 msg_id 幂等(msg_id>0 时同一消息不重复入队)
mid = 987654321
a = local_db.reply_add(mid, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_dup",
                       "private", "dup", "draft", "reply", "pap-step6")
b = local_db.reply_add(mid, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_dup",
                       "private", "dup", "draft", "reply", "pap-step6")
check("T5", a == b, f"同一 msg_id 复用同一条目 (a={a} b={b})")

# ── T6: 回归 —— msg_id=0 是「无消息来源」哨兵,不得被去重(保 test_step6_approvals 语义)
c = local_db.reply_add(0, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_z1",
                       "private", "z", "draft", "reply", "pap-step6")
d = local_db.reply_add(0, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_z2",
                       "private", "z", "draft", "reply", "pap-step6")
check("T6", c != d, f"msg_id=0 不去重 (c={c} d={d})")

# ── 清理
try:
    conn = local_db.get_conn()
    conn.execute("DELETE FROM reply_queue WHERE sender_name LIKE ?", (f"{TAG}%",))
    conn.commit()
    conn.close()
    print("[cleanup] 测试数据已清理")
except Exception as e:
    print(f"[cleanup] 清理失败(不阻塞): {e}")

if failures:
    print(f"\nFAILED: {failures}")
    sys.exit(1)
print("\nALL_PAP_STEP6_TESTS_PASS")