331 lines
12 KiB
Python
331 lines
12 KiB
Python
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": "+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])
|