Files
awoooi/apps/api/tests/test_agent99_telegram_lifecycle_api.py

437 lines
15 KiB
Python
Raw 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.
from __future__ import annotations
# ruff: noqa: E402
import base64
import hashlib
import os
import struct
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from src.api.v1 import agents as agents_api
from src.core.config import settings
from src.models.agent99_completion import Agent99TelegramLifecycleRequest
from src.services import agent99_telegram_lifecycle as lifecycle_service
from src.services import telegram_gateway as gateway_service
def payload() -> dict:
return {
"schema_version": "agent99_telegram_lifecycle_v1",
"delivery_id": "agent99-lifecycle-0123456789abcdef0123456789abcdef01234567",
"project_id": "awoooi",
"incident_id": "INC-20260715-AG9901",
"fingerprint": "0123456789abcdef0123456789abcdef",
"event_type": "performance_warning",
"severity": "warning",
"lifecycle": "investigating",
"state_key": "investigating|warning",
"run_id": "run-agent99-20260715-01",
"title": "Host 110 主機效能異常",
"target": "192.168.0.110",
"impact": "資源壓力可能降低服務回應速度。",
"action": "Agent99 正在執行 allowlisted 診斷與降載。",
"verification": "等待 CPU、記憶體與磁碟 post-verifier。",
"duration_text": "12.4 秒",
"occurrences": 2,
"next_update": "狀態改變時更新,相同狀態不重複推播。",
"occurred_at": "2026-07-15T04:20:00+08:00",
}
def visual_payload() -> dict:
raw = (
b"\x89PNG\r\n\x1a\n"
+ struct.pack(">I", 13)
+ b"IHDR"
+ struct.pack(">II", 1200, 760)
+ b"\x08\x02\x00\x00\x00"
)
return {
"schema_version": "agent99_telegram_visual_v1",
"media_type": "image/png",
"generated_by": "agent99_system_drawing_v1",
"width": 1200,
"height": 760,
"sha256": hashlib.sha256(raw).hexdigest(),
"content_base64": base64.b64encode(raw).decode(),
}
def app_client() -> TestClient:
app = FastAPI()
app.include_router(agents_api.router, prefix="/api/v1")
return TestClient(app)
def test_lifecycle_payload_rejects_unbounded_content() -> None:
with pytest.raises(ValidationError):
Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "raw_log": "must-not-enter-lifecycle-ingress"}
)
with pytest.raises(ValidationError):
Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "incident_id": r"C:\Wooo\Agent99\evidence\raw.json"}
)
with pytest.raises(ValidationError):
Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "project_id": "unregistered-product"}
)
with pytest.raises(ValidationError):
Agent99TelegramLifecycleRequest.model_validate(
{
**payload(),
"visual": {**visual_payload(), "sha256": "0" * 64},
}
)
def test_visual_caption_stays_within_telegram_limit_without_broken_html() -> None:
request = Agent99TelegramLifecycleRequest.model_validate(
{
**payload(),
"title": "&" * 120,
"target": "&" * 120,
"action": "&" * 360,
"verification": "&" * 300,
"duration_text": "&" * 80,
}
)
caption = lifecycle_service._format_lifecycle_caption(request)
assert len(caption) <= 1024
assert caption.count("<b>") == caption.count("</b>") == 2
assert caption.count("<code>") == caption.count("</code>") == 1
assert "&amp" not in caption.replace("&amp;", "")
def test_lifecycle_endpoint_rejects_wrong_token(monkeypatch) -> None:
monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
response = app_client().post(
"/api/v1/agents/agent99/telegram-lifecycle",
headers={"X-Agent99-Lifecycle-Token": "wrong"},
json=payload(),
)
assert response.status_code == 401
assert response.json()["detail"] == "agent99_lifecycle_auth_failed"
def test_lifecycle_endpoint_requires_durable_gateway_ack(monkeypatch) -> None:
monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
async def failed_delivery(request):
return {
"ok": False,
"delivery_id": request.delivery_id,
"delivery_status": "pending_unknown",
}
monkeypatch.setattr(
agents_api,
"deliver_agent99_telegram_lifecycle",
failed_delivery,
)
response = app_client().post(
"/api/v1/agents/agent99/telegram-lifecycle",
headers={"X-Agent99-Lifecycle-Token": "expected"},
json=payload(),
)
assert response.status_code == 503
assert "pending_unknown" in response.json()["detail"]
def test_lifecycle_endpoint_returns_same_delivery_receipt(monkeypatch) -> None:
monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
async def successful_delivery(request):
return {
"schema_version": "agent99_telegram_lifecycle_receipt_v1",
"ok": True,
"delivery_id": request.delivery_id,
"incident_id": request.incident_id,
"delivery_status": "sent",
"provider_message_id": "8123",
"durable_outbound_acknowledged": True,
}
monkeypatch.setattr(
agents_api,
"deliver_agent99_telegram_lifecycle",
successful_delivery,
)
response = app_client().post(
"/api/v1/agents/agent99/telegram-lifecycle",
headers={"X-Agent99-Lifecycle-Token": "expected"},
json=payload(),
)
assert response.status_code == 200
assert response.json()["delivery_id"] == payload()["delivery_id"]
assert response.json()["provider_message_id"] == "8123"
def test_lifecycle_outbox_identity_is_stable_and_separate() -> None:
base = {
"callback_reply": {
"project_id": "awoooi",
"automation_run_id": "agent99-lifecycle-0123456789",
"incident_id": "INC-20260715-AG9901",
"apply_op_id": "investigating|warning",
}
}
lifecycle_source = {
"callback_reply": {
**base["callback_reply"],
"action": "agent99_lifecycle",
}
}
controlled_source = {
"callback_reply": {
**base["callback_reply"],
"action": "controlled_apply_result",
}
}
lifecycle_identity = gateway_service._controlled_apply_result_delivery_identity(
lifecycle_source
)
controlled_identity = gateway_service._controlled_apply_result_delivery_identity(
controlled_source
)
assert lifecycle_identity is not None
assert controlled_identity is not None
assert lifecycle_identity["delivery_kind"] == "agent99_lifecycle"
assert controlled_identity["delivery_kind"] == "controlled_apply_result"
assert gateway_service._controlled_apply_result_delivery_run_id(
lifecycle_identity
) == gateway_service._controlled_apply_result_delivery_run_id(lifecycle_identity)
assert gateway_service._controlled_apply_result_delivery_run_id(
lifecycle_identity
) != gateway_service._controlled_apply_result_delivery_run_id(controlled_identity)
@pytest.mark.asyncio
async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> None:
calls: list[dict] = []
class Gateway:
async def send_agent99_lifecycle_receipt(self, **kwargs):
calls.append(kwargs)
return {
"ok": True,
"result": {"message_id": 9123},
"_awooop_outbound_mirror_acknowledged": True,
"_awooop_delivery_status": "sent",
"_awooop_provider_send_performed": True,
"_awooop_delivery_context": {
"destination_binding_verified": True,
},
}
monkeypatch.setattr(
lifecycle_service,
"get_telegram_gateway",
lambda: Gateway(),
)
request = Agent99TelegramLifecycleRequest.model_validate(payload())
receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request)
assert receipt["ok"] is True
assert receipt["durable_outbound_acknowledged"] is True
assert receipt["destination_binding_verified"] is True
assert receipt["provider_message_id"] == "9123"
assert calls[0]["priority"] == "P1"
assert "<b>Agent99 事件" in calls[0]["text"]
assert "AI 處置" in calls[0]["text"]
assert "raw_log" not in calls[0]["text"]
@pytest.mark.asyncio
async def test_visual_lifecycle_uses_one_durable_send_photo_receipt(monkeypatch) -> None:
calls: list[dict] = []
class Gateway:
async def send_agent99_lifecycle_receipt(self, **kwargs):
calls.append(kwargs)
return {
"ok": True,
"result": {"message_id": 9124},
"_awooop_outbound_mirror_acknowledged": True,
"_awooop_delivery_status": "sent",
"_awooop_provider_send_performed": True,
"_awooop_visual_delivery_verified": True,
"_awooop_delivery_context": {
"destination_binding_verified": True,
},
}
monkeypatch.setattr(
lifecycle_service,
"get_telegram_gateway",
lambda: Gateway(),
)
request = Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "visual": visual_payload()}
)
receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request)
assert receipt["ok"] is True
assert receipt["visual_requested"] is True
assert receipt["visual_sent"] is True
assert receipt["raw_visual_stored"] is False
assert calls[0]["visual_png"].startswith(b"\x89PNG")
assert calls[0]["visual_sha256"] == visual_payload()["sha256"]
assert len(calls[0]["text"]) <= 1024
assert "Agent99" in calls[0]["text"]
@pytest.mark.asyncio
async def test_reused_legacy_text_receipt_does_not_claim_visual_delivery(
monkeypatch,
) -> None:
class Gateway:
async def send_agent99_lifecycle_receipt(self, **kwargs):
return {
"ok": True,
"result": {"message_id": 7001},
"_awooop_outbound_mirror_acknowledged": True,
"_awooop_delivery_status": "sent_reused",
"_awooop_provider_send_performed": False,
}
monkeypatch.setattr(
lifecycle_service,
"get_telegram_gateway",
lambda: Gateway(),
)
request = Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "visual": visual_payload()}
)
receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request)
assert receipt["ok"] is True
assert receipt["visual_requested"] is True
assert receipt["visual_sent"] is False
assert receipt["visual_delivery_status"] == "sent_reused"
@pytest.mark.asyncio
async def test_gateway_builds_primary_photo_with_same_send_once_identity() -> None:
calls: list[tuple[str, dict]] = []
gateway = object.__new__(gateway_service.TelegramGateway)
async def capture(method: str, outgoing: dict) -> dict:
calls.append((method, outgoing))
return {"ok": True}
gateway._send_request = capture
image = Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "visual": visual_payload()}
).visual
assert image is not None
await gateway.send_agent99_lifecycle_receipt(
delivery_id=payload()["delivery_id"],
incident_id=payload()["incident_id"],
state_key=payload()["state_key"],
text="short caption",
priority="P1",
visual_png=image.png_bytes(),
visual_sha256=image.sha256,
)
method, outgoing = calls[0]
assert method == "sendPhoto"
assert outgoing["photo"] == "attach://photo"
assert outgoing["caption"] == "short caption"
assert outgoing[gateway_service._BINARY_UPLOAD_KEY]["content"].startswith(
b"\x89PNG"
)
source = outgoing[gateway_service._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY]
assert source["callback_reply"]["automation_run_id"] == payload()["delivery_id"]
assert source["visual_delivery"]["raw_visual_stored"] is False
@pytest.mark.asyncio
async def test_gateway_posts_visual_as_multipart_without_binary_json(monkeypatch) -> None:
requests: list[tuple[str, dict]] = []
class Response:
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {
"ok": True,
"result": {"message_id": 9125, "chat": {"id": "-10099"}},
}
class Client:
async def post(self, url: str, **kwargs):
requests.append((url, kwargs))
return Response()
monkeypatch.setattr(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token")
gateway = object.__new__(gateway_service.TelegramGateway)
gateway._initialized = True
gateway._http_client = Client()
binding = gateway_service._telegram_destination_binding("-10099")
gateway._authorize_canonical_send_message = lambda **_: (
"-10099",
{
"schema_version": "telegram_canonical_egress_receipt_v1",
"decision": "allow",
"classifier": "canonical_route",
"product_id": "awoooi",
"signal_family": "incident_lifecycle",
"severity": "P1",
"destination_alias": "telegram_chat_alias:awoooi_sre_war_room",
"destination_binding": binding,
"sender_bot_alias": "tsenyang_bot",
"provider_send_performed": False,
},
)
image = Agent99TelegramLifecycleRequest.model_validate(
{**payload(), "visual": visual_payload()}
).visual
assert image is not None
receipt = await gateway._send_request(
"sendPhoto",
{
"photo": "attach://photo",
"caption": "Agent99 visual test",
gateway_service._CANONICAL_ROUTE_CONTEXT_KEY: {
"product_id": "awoooi",
"signal_family": "incident_lifecycle",
"severity": "P1",
},
gateway_service._BINARY_UPLOAD_KEY: {
"field_name": "photo",
"filename": "agent99-card.png",
"content_type": "image/png",
"content": image.png_bytes(),
},
},
)
assert receipt["_awooop_delivery_status"] == "sent"
_, request_kwargs = requests[0]
assert "json" not in request_kwargs
assert request_kwargs["data"]["photo"] == "attach://photo"
assert request_kwargs["files"]["photo"][0] == "agent99-card.png"
assert request_kwargs["files"]["photo"][1].startswith(b"\x89PNG")