437 lines
15 KiB
Python
437 lines
15 KiB
Python
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 "&" not in caption.replace("&", "")
|
||
|
||
|
||
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")
|