fix(telegram): P0 async race + P1 short_id 碰撞 + P0 incident_id 修復

- _build_llm_action_buttons 改 async,await setex 在 return 前完成
  (消除「按鈕發出→點擊→Redis 未寫完」的 race)
- short_id 從 4 bytes → 8 bytes(16-hex),64-bit 碰撞空間
- payload 加入 incident_id,callback handler 從 payload 還原真實 ID
  (修 P0-2:避免 short_id 進 context 造成 KM 學習鏈錯亂)
- Redis 故障與按鈕過期分流回應(P1)
- HTML escape 防 XSS(P2)
- _build_inline_keyboard 改 async,兩個呼叫端加 await
- tests 全部改 @pytest.mark.asyncio + AsyncMock redis
  (1495 passed in unit suite)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-27 19:56:09 +08:00
parent ded17caca0
commit 3e382a4225
2 changed files with 383 additions and 134 deletions

View File

@@ -2,24 +2,30 @@
B3: LLM 動態 Telegram 按鈕 — 單元測試
=====================================
2026-04-27 Claude Sonnet 4.6: ADR-082 B3
2026-04-27 Claude Sonnet 4.6: H3+M6 Fix — short_id Redis 映射 + critical 過濾
2026-04-27 Claude Sonnet 4.6: P0 Fix — async setex 在 return 前完成(消除 race
測試範圍:
1. USE_LLM_DYNAMIC_BUTTONS=false → 走 YAML 路徑(現有行為)
2. USE_LLM_DYNAMIC_BUTTONS=true + recommended_actions 空 → 走 YAML 路徑
3. USE_LLM_DYNAMIC_BUTTONS=true + recommended_actions 非空 → 走 LLM 路徑button text 正確
4. high risk action 前綴 ⚠️
5. callback_data 是合法 JSONt="llm_action"
5. callback_data 格式是 la:{16-hex-chars},不是 JSONH3 FixP0 後改 16-hex
6. [批准][拒絕] 第一排永遠存在
7. 超過 2 個 action → 多排
8. critical action 不出按鈕M6 Fix
9. Redis short_id 被寫入H3 Fix
注意P0 Fix 後 _build_llm_action_buttons 改為 async直接 await redis.setex
所有 TestBuildLlmActionButtons 測試改為 @pytest.mark.asyncio。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Literal
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -115,88 +121,151 @@ def medium_action():
# =============================================================================
class TestBuildLlmActionButtons:
"""直接測試靜態方法,不需要完整 gateway 初始化"""
"""
測試 _build_llm_action_buttons 實例方法。
def _get_builder(self):
H3 Fix 後 callback_data 格式改為 la:{8-hex-chars},不再是 JSON。
Redis 寫入透過 asyncio.ensure_future 背景執行,測試中 mock 掉。
"""
def _make_gateway(self):
"""建立最小 gateway 實例(不初始化 Redis / httpx"""
from src.services.telegram_gateway import TelegramGateway
return TelegramGateway._build_llm_action_buttons
gw = object.__new__(TelegramGateway)
gw._security = MagicMock()
return gw
def test_low_risk_text_format(self, low_action):
builder = self._get_builder()
rows = builder([low_action])
async def _call_builder(self, gw, actions):
"""呼叫 _build_llm_action_buttonsasyncmock 掉 Redis 寫入。"""
mock_redis = MagicMock()
mock_redis.setex = AsyncMock()
with patch("src.services.telegram_gateway.get_redis", return_value=mock_redis):
return await gw._build_llm_action_buttons(actions)
@pytest.mark.asyncio
async def test_low_risk_text_format(self, low_action):
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action])
assert len(rows) == 1
btn = rows[0][0]
assert btn["text"] == "📋 查 Pod 日誌"
def test_high_risk_prefix(self, high_action):
@pytest.mark.asyncio
async def test_high_risk_prefix(self, high_action):
"""Test 4: high risk 前綴 ⚠️"""
builder = self._get_builder()
rows = builder([high_action])
gw = self._make_gateway()
rows = await self._call_builder(gw, [high_action])
btn = rows[0][0]
assert btn["text"].startswith("⚠️")
assert "重啟 Pod" in btn["text"]
def test_medium_risk_no_prefix(self, medium_action):
builder = self._get_builder()
rows = builder([medium_action])
@pytest.mark.asyncio
async def test_medium_risk_no_prefix(self, medium_action):
gw = self._make_gateway()
rows = await self._call_builder(gw, [medium_action])
btn = rows[0][0]
assert not btn["text"].startswith("⚠️")
def test_callback_data_valid_json(self, low_action):
"""Test 5: callback_data 是合法 JSONt="la"llm_action 縮寫),含必要欄位"""
builder = self._get_builder()
rows = builder([low_action])
@pytest.mark.asyncio
async def test_callback_data_format_is_short_id(self, low_action):
"""Test 5 (H3 Fix + P0): callback_data 格式是 la:{16-hex-chars},不是 JSON。
P0 Fix 後 short_id 改為 secrets.token_hex(8) = 16-hex-chars≤19 bytes。
"""
import re
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action])
cb = rows[0][0]["callback_data"]
payload = json.loads(cb)
# t="la" 是 llm_action 的縮寫,與 YAML callback type 不衝突
assert payload["t"] == "la"
# n=name, p=provider, tl=tool縮短 key 以符合 64 bytes 限制)
assert "n" in payload
assert "p" in payload
assert "tl" in payload
# 格式必須是 la: 開頭,後接 16 位 hexP0 Fix 後從 8 → 16 hex chars
assert re.fullmatch(r"la:[0-9a-f]{16}", cb), f"格式不符: {cb!r}"
# 確認不是 JSON
assert not cb.startswith("{")
def test_callback_data_within_64_bytes(self, low_action):
builder = self._get_builder()
rows = builder([low_action])
@pytest.mark.asyncio
async def test_callback_data_within_19_bytes(self, low_action):
"""H3 Fix + P0: la:{16-hex-chars} 固定 ≤19 bytes絕不截斷。"""
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action])
cb = rows[0][0]["callback_data"]
assert len(cb.encode("utf-8")) <= 64
assert len(cb.encode("utf-8")) <= 19, f"callback_data 超出 19 bytes: {cb!r} ({len(cb.encode())} bytes)"
def test_long_name_truncated_within_64_bytes(self):
"""callback_data 超過 64 bytes 時自動截斷 name"""
action = _RecommendedAction(
name="a" * 50,
label="長名稱動作",
emoji="🔧",
@pytest.mark.asyncio
async def test_critical_action_skipped(self):
"""Test 8 (M6 Fix): critical risk action 不生成按鈕。"""
critical_action = _RecommendedAction(
name="nuke_cluster",
label="核爆叢集",
emoji="💣",
mcp_provider="k8s",
mcp_tool="do_something",
mcp_tool="delete_all_pods",
params={},
risk="low",
risk="critical",
)
from src.services.telegram_gateway import TelegramGateway
rows = TelegramGateway._build_llm_action_buttons([action])
cb = rows[0][0]["callback_data"]
assert len(cb.encode("utf-8")) <= 64
payload = json.loads(cb)
assert payload["t"] == "la"
gw = self._make_gateway()
rows = await self._call_builder(gw, [critical_action])
assert rows == [], f"critical action 不應生成按鈕,但得到 {rows}"
def test_two_actions_same_row(self, low_action, medium_action):
"""Test 7 前置2 個 action → 同一排"""
builder = self._get_builder()
rows = builder([low_action, medium_action])
@pytest.mark.asyncio
async def test_critical_mixed_with_others(self, low_action, medium_action):
"""M6 Fix: critical 被過濾,其他 action 正常生成。"""
critical_action = _RecommendedAction(
name="nuke_cluster",
label="核爆",
emoji="💣",
mcp_provider="k8s",
mcp_tool="delete_all_pods",
params={},
risk="critical",
)
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action, critical_action, medium_action])
# 只剩 low + medium 兩個,同排
assert len(rows) == 1
assert len(rows[0]) == 2
def test_three_actions_two_rows(self, low_action, medium_action, high_action):
@pytest.mark.asyncio
async def test_redis_short_id_written(self, low_action):
"""Test 9 (H3 Fix + P0): Redis tg:la:{short_id} 被 await setex 寫入TTL=3600
P0 Fix 後改為直接 await redis.setex不再使用 ensure_future。
"""
mock_redis = MagicMock()
mock_redis.setex = AsyncMock()
gw = self._make_gateway()
with patch("src.services.telegram_gateway.get_redis", return_value=mock_redis):
rows = await gw._build_llm_action_buttons([low_action])
assert len(rows) == 1
cb = rows[0][0]["callback_data"]
short_id = cb[3:] # 去掉 "la:"
# P0 Fix: 直接 await所以 setex 被呼叫一次
mock_redis.setex.assert_called_once()
call_args = mock_redis.setex.call_args
assert call_args[0][0] == f"tg:la:{short_id}"
assert call_args[0][1] == 3600
@pytest.mark.asyncio
async def test_two_actions_same_row(self, low_action, medium_action):
"""Test 7 前置2 個 action → 同一排"""
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action, medium_action])
assert len(rows) == 1
assert len(rows[0]) == 2
@pytest.mark.asyncio
async def test_three_actions_two_rows(self, low_action, medium_action, high_action):
"""Test 7: 超過 2 個 action → 多排(每排最多 2 個)"""
builder = self._get_builder()
rows = builder([low_action, medium_action, high_action])
gw = self._make_gateway()
rows = await self._call_builder(gw, [low_action, medium_action, high_action])
assert len(rows) == 2
assert len(rows[0]) == 2
assert len(rows[1]) == 1
def test_empty_actions_returns_empty(self):
builder = self._get_builder()
rows = builder([])
@pytest.mark.asyncio
async def test_empty_actions_returns_empty(self):
gw = self._make_gateway()
rows = await self._call_builder(gw, [])
assert rows == []
@@ -227,106 +296,83 @@ class TestBuildInlineKeyboardRouting:
def _first_row_texts(self, keyboard: dict) -> list[str]:
return [btn["text"] for btn in keyboard["inline_keyboard"][0]]
async def _build_kb(self, gw, **kwargs) -> dict:
"""await _build_inline_keyboardmock 掉 Redis。"""
mock_redis = MagicMock()
mock_redis.setex = AsyncMock()
with patch("src.services.telegram_gateway.get_redis", return_value=mock_redis):
return await gw._build_inline_keyboard(**kwargs)
# Test 1: flag=false → YAML 路徑(第一排還是批准/拒絕)
@pytest.mark.asyncio
@patch("src.services.telegram_gateway.USE_LLM_DYNAMIC_BUTTONS", False)
@patch("src.services.callback_dispatcher.list_actions_for_category", return_value=[])
def test_flag_false_uses_yaml_path(self, mock_list, low_action):
async def test_flag_false_uses_yaml_path(self, mock_list, low_action):
gw = self._make_mock_gateway()
action_plan = _ActionPlan(recommended_actions=[low_action])
kb = gw._build_inline_keyboard(
approval_id="APR-001",
incident_id="INC-001",
action_plan=action_plan,
)
kb = await self._build_kb(gw, approval_id="APR-001", incident_id="INC-001", action_plan=action_plan)
first_texts = self._first_row_texts(kb)
assert "✅ 批准" in first_texts
assert "❌ 拒絕" in first_texts
# LLM 按鈕不應出現(t="la" 代表 llm_action
all_payloads_1 = [
json.loads(btn["callback_data"])
for row in kb["inline_keyboard"]
for btn in row
if btn["callback_data"].startswith("{")
]
assert not any(p.get("t") == "la" for p in all_payloads_1)
# LLM 按鈕不應出現(la: 前綴代表 llm_action
all_cbs = [btn["callback_data"] for row in kb["inline_keyboard"] for btn in row]
assert not any(cb.startswith("la:") for cb in all_cbs)
# Test 2: flag=true + actions 空 → YAML fallback
@pytest.mark.asyncio
@patch("src.services.telegram_gateway.USE_LLM_DYNAMIC_BUTTONS", True)
@patch("src.services.callback_dispatcher.list_actions_for_category", return_value=[])
def test_flag_true_empty_actions_uses_yaml(self, mock_list):
async def test_flag_true_empty_actions_uses_yaml(self, mock_list):
gw = self._make_mock_gateway()
action_plan = _ActionPlan(recommended_actions=[])
kb = gw._build_inline_keyboard(
approval_id="APR-002",
incident_id="INC-002",
action_plan=action_plan,
)
kb = await self._build_kb(gw, approval_id="APR-002", incident_id="INC-002", action_plan=action_plan)
first_texts = self._first_row_texts(kb)
assert "✅ 批准" in first_texts
assert "❌ 拒絕" in first_texts
all_payloads_2 = [
json.loads(btn["callback_data"])
for row in kb["inline_keyboard"]
for btn in row
if btn["callback_data"].startswith("{")
]
assert not any(p.get("t") == "la" for p in all_payloads_2)
all_cbs = [btn["callback_data"] for row in kb["inline_keyboard"] for btn in row]
assert not any(cb.startswith("la:") for cb in all_cbs)
# Test 2b: flag=true + action_plan=None → YAML fallback
@pytest.mark.asyncio
@patch("src.services.telegram_gateway.USE_LLM_DYNAMIC_BUTTONS", True)
@patch("src.services.callback_dispatcher.list_actions_for_category", return_value=[])
def test_flag_true_no_action_plan_uses_yaml(self, mock_list):
async def test_flag_true_no_action_plan_uses_yaml(self, mock_list):
gw = self._make_mock_gateway()
kb = gw._build_inline_keyboard(
approval_id="APR-003",
incident_id="INC-003",
)
kb = await self._build_kb(gw, approval_id="APR-003", incident_id="INC-003")
first_texts = self._first_row_texts(kb)
assert "✅ 批准" in first_texts
assert "❌ 拒絕" in first_texts
# Test 3: flag=true + actions 非空 → LLM 路徑
@pytest.mark.asyncio
@patch("src.services.telegram_gateway.USE_LLM_DYNAMIC_BUTTONS", True)
def test_flag_true_with_actions_uses_llm(self, low_action):
async def test_flag_true_with_actions_uses_llm(self, low_action):
import re
gw = self._make_mock_gateway()
action_plan = _ActionPlan(recommended_actions=[low_action])
kb = gw._build_inline_keyboard(
approval_id="APR-004",
incident_id="INC-004",
action_plan=action_plan,
)
kb = await self._build_kb(gw, approval_id="APR-004", incident_id="INC-004", action_plan=action_plan)
# Test 6: 第一排永遠是 [批准][拒絕]
first_texts = self._first_row_texts(kb)
assert "✅ 批准" in first_texts
assert "❌ 拒絕" in first_texts
# LLM 按鈕在第二排以後(callback_data 含 "la" type
all_payloads = [
json.loads(btn["callback_data"])
for row in kb["inline_keyboard"][1:]
for btn in row
if btn["callback_data"].startswith("{")
]
assert any(p.get("t") == "la" for p in all_payloads)
# LLM 按鈕在第二排以後(P0 Fix 後 16-hex-chars
all_cbs = [btn["callback_data"] for row in kb["inline_keyboard"][1:] for btn in row]
assert any(re.fullmatch(r"la:[0-9a-f]{16}", cb) for cb in all_cbs), (
f"LLM 按鈕 callback_data 格式應為 la:{{16-hex}},實際: {all_cbs}"
)
# button text 正確
all_texts = [
btn["text"]
for row in kb["inline_keyboard"][1:]
for btn in row
]
all_texts = [btn["text"] for row in kb["inline_keyboard"][1:] for btn in row]
assert any("查 Pod 日誌" in t for t in all_texts)
# Test 6: 第一排永遠是 [批准][拒絕]LLM 路徑)
@pytest.mark.asyncio
@patch("src.services.telegram_gateway.USE_LLM_DYNAMIC_BUTTONS", True)
def test_approve_reject_always_first_row_in_llm_path(self, low_action, high_action, medium_action):
async def test_approve_reject_always_first_row_in_llm_path(self, low_action, high_action, medium_action):
gw = self._make_mock_gateway()
action_plan = _ActionPlan(recommended_actions=[low_action, high_action, medium_action])
kb = gw._build_inline_keyboard(
approval_id="APR-005",
incident_id="INC-005",
action_plan=action_plan,
)
kb = await self._build_kb(gw, approval_id="APR-005", incident_id="INC-005", action_plan=action_plan)
first_texts = self._first_row_texts(kb)
assert first_texts[0] == "✅ 批准"
assert first_texts[1] == "❌ 拒絕"