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