Files
awoooi/apps/api/tests/test_telegram_button_consistency.py
Your Name ee2cc2bfc3
Some checks failed
CD Pipeline / tests (push) Failing after 1m23s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Successful in 15s
fix(alerts): 收斂 Telegram 告警到 SRE 戰情室
2026-06-12 11:06:16 +08:00

263 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram 按鈕一致性測試
======================
驗證 send_info_notification 與 _send_approval_card_to_group 的 reply_markup 鏈路
測試策略 (遵循 feedback_no_mock_testing.md):
- 直接讀取 source code 驗證實作結構(不發送實際 Telegram 訊息)
- 鬼魂按鈕鐵律feedback_no_ghost_buttons.md:
每個按鈕必須同時具備 callback_data 格式/handler/MCP能力
建立: 2026-04-25 (台北時區)
建立者: ogt + Claude Sonnet 4.6
"""
import re
def _read_gateway() -> str:
with open("src/services/telegram_gateway.py", encoding="utf-8") as f:
return f.read()
def _read_security_interceptor() -> str:
with open("src/services/security_interceptor.py", encoding="utf-8") as f:
return f.read()
# =============================================================================
# Test: send_info_notification 按鈕結構
# =============================================================================
class TestSendInfoNotificationKeyboard:
"""驗證 TYPE-1 純資訊通知現在附帶 read-only 按鈕"""
def test_send_info_notification_has_reply_markup(self):
"""send_info_notification payload 包含 reply_markup"""
source = _read_gateway()
# 取出 send_info_notification 函式體(從定義到下一個 async def
match = re.search(
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 send_info_notification 函式"
fn_body = match.group(0)
assert '"reply_markup"' in fn_body, (
"send_info_notification 必須在 payload 中包含 reply_markup"
)
def test_send_info_notification_detail_button_correct_format(self):
"""detail 按鈕使用 2-part info 格式 detail:{incident_id}"""
source = _read_gateway()
match = re.search(
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 send_info_notification 函式"
fn_body = match.group(0)
assert 'f"detail:{incident_id}"' in fn_body, (
"detail 按鈕 callback_data 必須使用 detail:{incident_id} 格式ADR-050 2-part info"
)
def test_send_info_notification_history_button_correct_format(self):
"""history 按鈕使用 2-part info 格式 history:{incident_id}"""
source = _read_gateway()
match = re.search(
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 send_info_notification 函式"
fn_body = match.group(0)
assert 'f"history:{incident_id}"' in fn_body, (
"history 按鈕 callback_data 必須使用 history:{incident_id} 格式ADR-050 2-part info"
)
def test_send_info_notification_no_nonce_in_buttons(self):
"""send_info_notification 按鈕不含 nonce純查類無副作用"""
source = _read_gateway()
match = re.search(
r"async def send_info_notification.*?(?=\n async def |\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 send_info_notification 函式"
fn_body = match.group(0)
assert "generate_callback_nonce" not in fn_body, (
"send_info_notification 的按鈕不應包含 nonceTYPE-1 為純資訊,不應有寫類操作)"
)
# =============================================================================
# Test: _send_approval_card_to_group 按鈕結構
# =============================================================================
class TestSendApprovalCardToGroupKeyboard:
"""驗證群組卡片現在附帶 read-only 按鈕"""
def test_group_card_calls_send_to_group_with_reply_markup(self):
"""_send_approval_card_to_group 呼叫 send_to_group 時傳遞 reply_markup"""
source = _read_gateway()
match = re.search(
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 _send_approval_card_to_group 函式"
fn_body = match.group(0)
assert "reply_markup" in fn_body, (
"_send_approval_card_to_group 必須傳 reply_markup 給 send_to_group"
)
def test_group_card_detail_button_correct_format(self):
"""群組卡片 detail 按鈕:接受 inline 2-part 或 _build_inline_keyboard 通用建構器
2026-04-26 by Claude Opus 4.7 — 對齊 bb12647e 後的群組卡片升級
- bb12647e: 群組卡片改用 _build_inline_keyboard與 DM 相同六鍵佈局)
- 不再 inline 寫 f"detail:{incident_id}",但 _build_inline_keyboard 內仍會產生
- 所以接受兩種設計:直接 inline 或透過通用建構器
"""
source = _read_gateway()
match = re.search(
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 _send_approval_card_to_group 函式"
fn_body = match.group(0)
has_inline_detail = 'f"detail:{incident_id}"' in fn_body
has_keyboard_builder = "_build_inline_keyboard" in fn_body
assert has_inline_detail or has_keyboard_builder, (
"群組卡片必須有 detail 按鈕inline 2-part 格式 或透過 _build_inline_keyboard 通用建構器"
)
def test_group_card_no_nonce_buttons(self):
"""群組卡片絕不包含 nonceADR-075 斷點 C防洩漏"""
source = _read_gateway()
match = re.search(
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 _send_approval_card_to_group 函式"
fn_body = match.group(0)
assert "generate_callback_nonce" not in fn_body, (
"_send_approval_card_to_group 群組訊息絕不含 nonceADR-075 斷點 C 安全設計)"
)
def test_group_card_keyboard_conditional_on_incident_id(self):
"""群組按鍵僅在有 incident_id 時才建立(防 empty callback_data"""
source = _read_gateway()
match = re.search(
r"async def _send_approval_card_to_group.*?(?=\n # ====)",
source,
re.DOTALL,
)
assert match, "找不到 _send_approval_card_to_group 函式"
fn_body = match.group(0)
# 確認有 if incident_id 守衛
assert "if incident_id" in fn_body, (
"群組按鍵必須有 if incident_id 守衛,避免 callback_data 空字串"
)
# =============================================================================
# Test: SRE group cutover (2026-04-30)
# =============================================================================
class TestSREGroupCutover:
"""驗證告警卡片只送 SRE 戰情室,不再先送個人 DM。"""
def test_send_approval_card_uses_alert_chat_id(self):
source = _read_gateway()
match = re.search(
r"async def send_approval_card.*?(?=\n async def _send_approval_card_to_group)",
source,
re.DOTALL,
)
assert match, "找不到 send_approval_card 函式"
fn_body = match.group(0)
assert '"chat_id": target_chat_id' in fn_body
assert "target_chat_id = self.alert_chat_id" in fn_body
assert '"chat_id": self.chat_id' not in fn_body
def test_send_approval_card_does_not_double_send_group(self):
source = _read_gateway()
match = re.search(
r"async def send_approval_card.*?(?=\n async def _send_approval_card_to_group)",
source,
re.DOTALL,
)
assert match, "找不到 send_approval_card 函式"
fn_body = match.group(0)
assert "_send_approval_card_to_group(" not in fn_body
assert "asyncio.create_task" not in fn_body
def test_alert_chat_id_is_sre_only(self):
source = _read_gateway()
match = re.search(
r"def alert_chat_id\(self\).*?(?=\n def _summarize_callback_data_for_audit)",
source,
re.DOTALL,
)
assert match, "找不到 alert_chat_id property"
fn_body = match.group(0)
assert "return settings.SRE_GROUP_CHAT_ID" in fn_body
assert "or settings.OPENCLAW_TG_CHAT_ID" not in fn_body
def test_default_chat_id_is_sre_only(self):
source = _read_gateway()
match = re.search(
r"def chat_id\(self\).*?(?=\n @property\n def alert_chat_id)",
source,
re.DOTALL,
)
assert match, "找不到 chat_id property"
fn_body = match.group(0)
assert "return settings.SRE_GROUP_CHAT_ID" in fn_body
assert "return settings.OPENCLAW_TG_CHAT_ID" not in fn_body
# =============================================================================
# Test: callback handler 完整性(鬼魂按鈕鐵律)
# =============================================================================
class TestCallbackHandlerCompleteness:
"""驗證 detail/history 按鈕有對應的 handler鬼魂按鈕鐵律三條件之二"""
def test_detail_action_in_info_actions_whitelist(self):
"""detail action 在 INFO_ACTIONS 白名單中"""
source = _read_security_interceptor()
assert '"detail"' in source, "detail 必須在 INFO_ACTIONS 白名單"
# 確認是在 INFO_ACTIONS 集合定義中
assert "INFO_ACTIONS" in source
def test_history_action_in_info_actions_whitelist(self):
"""history action 在 INFO_ACTIONS 白名單中"""
source = _read_security_interceptor()
assert '"history"' in source, "history 必須在 INFO_ACTIONS 白名單"
def test_detail_handler_exists_in_handle_callback(self):
"""handle_callback 有 detail handler 分支"""
source = _read_gateway()
assert 'action == "detail"' in source, (
"handle_callback 必須有 detail handler 分支"
)
def test_history_handler_exists_in_handle_callback(self):
"""handle_callback 有 history handler 分支"""
source = _read_gateway()
assert 'action == "history"' in source, (
"handle_callback 必須有 history handler 分支"
)
def test_send_incident_detail_method_exists(self):
"""_send_incident_detail 方法存在handler 的實際執行體)"""
assert "_send_incident_detail" in _read_gateway()
def test_send_incident_history_method_exists(self):
"""_send_incident_history 方法存在handler 的實際執行體)"""
assert "_send_incident_history" in _read_gateway()