import uuid from urllib.parse import parse_qs, urlsplit import httpx import pytest from app.integrations import ( DependencyFailure, OpenLinesClient, S3Client, SafetyClient, fresh_openlines_payload, ) from app.realtime import CHANNEL_PREFIX from app.settings import Settings from app.workers import worker_http_clients def settings() -> Settings: common = { "APP_ENV": "test", "DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db", "REDIS_URL": "redis://localhost/0", "REDIS_REALTIME_URL": "redis://localhost/1", "KEYCLOAK_PUBLIC_URL": "https://auth.example", "KEYCLOAK_INTERNAL_URL": "http://keycloak:8080", "KEYCLOAK_REALM": "han", "KEYCLOAK_AUDIENCE": "api", "MESSAGE_SAFETY_URL": "https://processing.internal:8443", "MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token", "MESSAGE_SAFETY_CA_FILE": "/run/config/message-safety-internal-ca.pem", "BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080", "BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token", "BITRIX_API_INBOX_TOKEN": "inbox-token", "KEYCLOAK_SETTINGS_BRIDGE_TOKEN": "settings-token", "SELECTEL_S3_ENDPOINT_URL": "https://s3.example", "SELECTEL_S3_BUCKET_DOCUMENTS": "documents", "SELECTEL_S3_BUCKET_ATTACHMENTS": "attachments", "SELECTEL_S3_BUCKET_QUARANTINE": "quarantine", "SELECTEL_S3_ACCESS_KEY": "access", "SELECTEL_S3_SECRET_KEY": "secret", "CURSOR_HMAC_SECRET": "x" * 32, } return Settings.model_validate(common) @pytest.mark.asyncio async def test_worker_uses_isolated_tls_client_for_remote_safety(monkeypatch) -> None: created = [] class FakeAsyncClient: def __init__(self, **kwargs): self.kwargs = kwargs self.closed = False created.append(self) async def __aenter__(self): return self async def __aexit__(self, *_): self.closed = True monkeypatch.setattr("app.workers.httpx.AsyncClient", FakeAsyncClient) async with worker_http_clients(settings()) as (openlines_http, safety_http): assert openlines_http.kwargs == {} assert safety_http.kwargs == { "verify": "/run/config/message-safety-internal-ca.pem" } assert openlines_http is not safety_http assert all(client.closed for client in created) @pytest.mark.asyncio async def test_safety_ready_uses_private_status_alias_and_accepts_redis_degradation() -> None: async def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/internal/safety/status" return httpx.Response( 200, json={ "status": "degraded", "processing_mode": "standard", "config_version": 1, "capabilities": { "text": "ready", "links": "ready", "files": "ready", "worker": "ready", }, }, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: assert await SafetyClient(settings(), http).ready() is True @pytest.mark.asyncio @pytest.mark.parametrize( ("processing_mode", "files"), (("mock", "ready"), ("standard", "unavailable")), ) async def test_safety_ready_rejects_unsafe_mode_or_unavailable_capability( processing_mode: str, files: str ) -> None: async def handler(_: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "status": "degraded", "processing_mode": processing_mode, "config_version": 1, "capabilities": { "text": "ready", "links": "ready", "files": files, "worker": "ready", }, }, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: assert await SafetyClient(settings(), http).ready() is False def test_realtime_channel_matches_redis_acl_namespace() -> None: assert CHANNEL_PREFIX == "han:rt:dialog:" @pytest.mark.asyncio async def test_s3_presigned_urls_use_virtual_hosted_addressing() -> None: s3 = S3Client(settings()) put_url = await s3.presign_put("quarantine/users/u/file.pdf", "application/pdf", 600) get_url = await s3.presign_get("attachments", "dialogs/d/file.pdf") assert urlsplit(put_url).netloc == "quarantine.s3.example" assert urlsplit(put_url).path == "/quarantine/users/u/file.pdf" assert urlsplit(get_url).netloc == "attachments.s3.example" assert urlsplit(get_url).path == "/dialogs/d/file.pdf" assert parse_qs(urlsplit(put_url).query)["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"] assert parse_qs(urlsplit(get_url).query)["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"] @pytest.mark.asyncio async def test_safety_contract_status_and_service_token() -> None: task_id = str(uuid.uuid4()) async def handler(request: httpx.Request) -> httpx.Response: assert request.headers["X-Service-Token"] == "safety-token" assert request.url.path == "/internal/safety/v2/messages/check" return httpx.Response( 202, headers={ "Location": f"/internal/safety/v2/messages/tasks/{task_id}", "Retry-After": "2", }, json={ "verdict": "pending", "processing_mode": "standard", "config_version": 1, "rules_version": "2026-01-01", "task_id": task_id, "poll_after_ms": 2000, "expires_at": "2026-08-06T12:00:00Z", }, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: result = await SafetyClient(settings(), http).check( {"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"}, "request-1", ) assert result["_status"] == 202 assert result["_location"] == f"/internal/safety/v2/messages/tasks/{task_id}" @pytest.mark.asyncio async def test_safety_file_body_uses_exact_attachment_schema() -> None: attachment_id = uuid.uuid4() async def handler(request: httpx.Request) -> httpx.Response: body = __import__("json").loads(request.content) assert body["attachment"] == { "attachment_id": str(attachment_id), "quarantine_object_key": "quarantine/users/u/file", "quarantine_version_id": "version-1", "quarantine_etag": '"etag-1"', "mime_type": "application/pdf", "size_bytes": 42, "checksum": "sha256:" + "a" * 64, } assert "file" not in body return httpx.Response( 200, json={ "verdict": "allow", "processing_mode": "standard", "config_version": 1, "rules_version": "2026-01-01", "rule_id": "safety.all_checks_passed", }, ) payload = { "message_id": str(uuid.uuid4()), "content_kind": "file", "text": "", "attachment": { "attachment_id": str(attachment_id), "quarantine_object_key": "quarantine/users/u/file", "quarantine_version_id": "version-1", "quarantine_etag": '"etag-1"', "mime_type": "application/pdf", "size_bytes": 42, "checksum": "sha256:" + "a" * 64, }, } async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: await SafetyClient(settings(), http).check(payload, "request-1") @pytest.mark.asyncio async def test_safety_auth_failure_is_dependency_failure() -> None: async def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"error": {"code": "service_unauthorized"}}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: with pytest.raises(DependencyFailure): await SafetyClient(settings(), http).check( {"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"}, "request-1", ) @pytest.mark.asyncio async def test_safety_poll_uses_location_and_rejects_untrusted_location() -> None: task_id = str(uuid.uuid4()) async def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == f"/internal/safety/v2/messages/tasks/{task_id}" return httpx.Response( 403, json={ "verdict": "deny", "processing_mode": "standard", "config_version": 2, "rules_version": "2026-08-06", "rule_id": "file.malware_detected", "reason_code": "message_blocked", }, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: client = SafetyClient(settings(), http) result = await client.poll( f"/internal/safety/v2/messages/tasks/{task_id}", "request-1" ) assert result["_status"] == 403 with pytest.raises(DependencyFailure, match="invalid_safety_location"): await client.poll("https://attacker.example/task-1", "request-1") @pytest.mark.asyncio async def test_safety_fails_closed_on_malformed_success() -> None: async def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"verdict": "allow"}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: with pytest.raises(DependencyFailure, match="invalid_safety_response"): await SafetyClient(settings(), http).check( {"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"}, "request-1", ) @pytest.mark.asyncio async def test_openlines_contract_uses_bearer_and_idempotency() -> None: message_id = uuid.uuid4() async def handler(request: httpx.Request) -> httpx.Response: assert request.headers["Authorization"] == "Bearer bitrix-token" assert request.headers["Idempotency-Key"] == str(message_id) assert request.url.path == "/internal/openlines/v1/messages" return httpx.Response(201, json={"status": "accepted"}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: result = await OpenLinesClient(settings(), http).send( message_id, {"message_id": str(message_id)}, "request-1" ) assert result["status"] == "accepted" @pytest.mark.asyncio async def test_openlines_payload_gets_fresh_download_url_without_storage_fields() -> None: class FakeS3: calls = 0 async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str: self.calls += 1 return f"https://download.example/{bucket}/{key}?generation={self.calls}" payload = { "message_id": str(uuid.uuid4()), "external_chat_id": str(uuid.uuid4()), "occurred_at": "2026-07-10T12:00:00+00:00", "user": { "id": str(uuid.uuid4()), "display_name": "Новый клиент HAN", "phone": "+79990000000", }, "message": { "content_kind": "file", "text": "", "files": [{ "attachment_id": str(uuid.uuid4()), "name": "file.pdf", "mime_type": "application/pdf", "size_bytes": 42, "_storage_bucket": "attachments", "_object_key": "dialogs/d/file", }], }, } s3 = FakeS3() first = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type] second = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type] assert first["occurred_at"] and first["user"]["id"] assert first["message"]["content_kind"] == "file" assert ( first["message"]["files"][0]["download_url"] != second["message"]["files"][0]["download_url"] ) assert all(not key.startswith("_") for key in first["message"]["files"][0])