263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""
|
||
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 的按鈕不應包含 nonce(TYPE-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):
|
||
"""群組卡片絕不包含 nonce(ADR-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 群組訊息絕不含 nonce(ADR-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()
|