import base64 import os import uuid from types import SimpleNamespace from urllib.parse import parse_qs os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused") os.environ.setdefault("BITRIX_CLIENT_ID", "client") os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret") os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token") os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long") os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox") os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long") os.environ.setdefault( "BITRIX_TOKEN_ENCRYPTION_KEY", base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="), ) import httpx import pytest from app.main import ( BitrixClient, TokenCipher, canonical_fingerprint, normalize_event, resolve_inbound_file_urls, retry_delay, safely_retryable, validate_portal, ) def test_token_cipher_binds_aad(): cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1") ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access") assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret" with pytest.raises(Exception): cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access") def test_normalize_message_and_finish(): external = str(uuid.uuid4()) message = normalize_event( { "event": "ONIMCONNECTORMESSAGEADD", "data": { "MESSAGES": [ { "im": {"chat_id": 1807, "message_id": 86497}, "chat": {"id": external}, "message": {"text": "Ответ", "files": []}, } ] }, } ) assert message["event_type"] == "message.new" assert message["external_chat_id"] == external assert message["bitrix_message_id"] == "86497" closed = normalize_event( {"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}} ) assert closed["event_type"] == "dialog.closed" def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type(): external = str(uuid.uuid4()) message = normalize_event( { "event": "ONIMCONNECTORMESSAGEADD", "data": { "MESSAGES": [ { "im": {"chat_id": 1807, "message_id": 86498}, "chat": {"id": external}, "message": { "text": "", "files": [ { "name": "image.png", "type": "image", "size": 941380, "urlDownload": "https://portal.bitrix24.ru/download/file.png", } ], }, } ] }, } ) assert message["message"]["files"] == [ { "name": "image.png", "mime_type": "image/png", "size_bytes": 941380, "download_url": "https://portal.bitrix24.ru/download/file.png", } ] def test_normalize_bitrix_file_uses_open_lines_download_link_and_mime(): external = str(uuid.uuid4()) message = normalize_event( { "event": "ONIMCONNECTORMESSAGEADD", "data": { "MESSAGES": [ { "im": {"chat_id": 1807, "message_id": 86499}, "chat": {"id": external}, "message": { "text": "", "files": [ { "name": "diploma.jpg", "type": "image", "mime": "image/jpeg", "size": 236934, "downloadLink": ( "https://portal.bitrix24.ru/download/diploma.jpg" ), "link": "https://portal.bitrix24.ru/view/diploma.jpg", "width": 1200, "height": 800, } ], }, } ] }, } ) assert message["message"]["files"] == [ { "name": "diploma.jpg", "mime_type": "image/jpeg", "size_bytes": 236934, "download_url": "https://portal.bitrix24.ru/download/diploma.jpg", } ] @pytest.mark.asyncio async def test_resolve_inbound_file_url_from_bitrix_file_id(monkeypatch): class SessionContext: async def __aenter__(self): return object() async def __aexit__(self, *_args): return None class Bitrix: async def call(self, portal, method, params): assert portal == "portal" assert method == "im.v2.File.download" assert params == {"id": "5155"} return {"downloadUrl": "https://portal.bitrix24.ru/download/file.png"} async def fake_active_portal(_session): return "portal" monkeypatch.setattr("app.main.active_portal", fake_active_portal) app = SimpleNamespace( state=SimpleNamespace(sessions=lambda: SessionContext(), bitrix=Bitrix()) ) payload = { "message": { "files": [ { "name": "image.png", "mime_type": "image/png", "size_bytes": 941380, "download_url": "", "_bitrix_file_id": "5155", } ] } } await resolve_inbound_file_urls(app, payload) assert payload["message"]["files"] == [ { "name": "image.png", "mime_type": "image/png", "size_bytes": 941380, "download_url": "https://portal.bitrix24.ru/download/file.png", } ] def test_fingerprint_ignores_signed_query_and_portal_validation(): payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}} other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}} assert canonical_fingerprint(payload) == canonical_fingerprint(other) validate_portal( "han0107.bitrix24.ru", "https://han0107.bitrix24.ru/rest/", "han0107.bitrix24.ru", ) with pytest.raises(ValueError): validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru") assert 0 <= retry_delay(4, 300) <= 8 def test_network_timeouts_are_retryable(): assert safely_retryable(TimeoutError("Delivery operation timed out")) assert safely_retryable(httpx.ReadTimeout("Bitrix response timed out")) assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out")) @pytest.mark.asyncio async def test_bitrix_call_refreshes_and_retries_once_after_401(): auth_values: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: auth = parse_qs(request.content.decode())["auth"][0] auth_values.append(auth) if auth == "old-access": return httpx.Response(401, request=request) return httpx.Response(200, json={"result": {"ok": True}}, request=request) class Cipher: @staticmethod def decrypt(ciphertext, *_): return ciphertext portal = SimpleNamespace( access_ciphertext="old-access", access_nonce="nonce", member_id="member", domain="han0107.bitrix24.ru", expires_at=None, ) settings = SimpleNamespace(bitrix_http_max_concurrency=2) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: client = BitrixClient(http, settings, Cipher(), sessions=None) refresh_calls: list[bool] = [] async def ensure_fresh(_, *, force=False, stale_access_ciphertext=None): refresh_calls.append(force) if force: assert stale_access_ciphertext == "old-access" portal.access_ciphertext = "new-access" client.ensure_fresh = ensure_fresh result = await client.call(portal, "imconnector.send.messages", {"MESSAGE": "test"}) assert result == {"ok": True} assert auth_values == ["old-access", "new-access"] assert refresh_calls == [False, True]