Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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
|
||||
|
||||
|
||||
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": "http://safety:8080",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||
"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)
|
||||
|
||||
|
||||
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])
|
||||
@@ -0,0 +1,226 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.main import (
|
||||
EXPECTED_API_DB_REVISION,
|
||||
app,
|
||||
otp_settings,
|
||||
refresh_jwks_cache,
|
||||
websocket_token,
|
||||
)
|
||||
from app.services import SettingsSnapshot
|
||||
|
||||
EXPECTED_PATHS = {
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/api/v1/public/app-config",
|
||||
"/api/v1/public/content",
|
||||
"/api/v1/public/notifications",
|
||||
"/api/v1/public/notification-types",
|
||||
"/api/v1/auth/bootstrap",
|
||||
"/api/v1/consents",
|
||||
"/api/v1/analytics/session-start",
|
||||
"/api/v1/me",
|
||||
"/api/v1/me/documents",
|
||||
"/api/v1/notifications",
|
||||
"/api/v1/notifications/counter",
|
||||
"/api/v1/notifications/{notification_id}",
|
||||
"/api/v1/notifications/{notification_id}/read",
|
||||
"/api/v1/notifications/{notification_id}/hide",
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
|
||||
"/api/v1/notifications/{notification_id}/cta",
|
||||
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
|
||||
"/api/v1/uploads/init",
|
||||
"/api/v1/uploads/{draft_id}/complete",
|
||||
"/api/v1/uploads",
|
||||
"/api/v1/uploads/{draft_id}",
|
||||
"/api/v1/documents/{document_id}",
|
||||
"/api/v1/documents/{document_id}/download-url",
|
||||
"/api/v1/dialogs",
|
||||
"/api/v1/dialogs/{dialog_id}",
|
||||
"/api/v1/dialogs/{dialog_id}/messages",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/init",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
||||
"/internal/openlines/v1/inbox",
|
||||
"/internal/notifications/v1/notifications",
|
||||
"/internal/notifications/v1/notifications/cancel",
|
||||
"/internal/settings/v1/otp",
|
||||
}
|
||||
|
||||
|
||||
def test_openapi_31_contains_all_http_contracts() -> None:
|
||||
schema = app.openapi()
|
||||
assert schema["openapi"].startswith("3.1.")
|
||||
assert EXPECTED_PATHS <= schema["paths"].keys()
|
||||
assert all(not path.startswith("/internal/safety") for path in schema["paths"])
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["openapi"] == "3.1.0"
|
||||
assert committed["paths"].keys() == schema["paths"].keys()
|
||||
|
||||
|
||||
def test_readiness_expected_revision_matches_alembic_head() -> None:
|
||||
scripts = ScriptDirectory.from_config(Config("alembic.ini"))
|
||||
assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None:
|
||||
class FakeJWKS:
|
||||
has_keys = False
|
||||
refresh_calls = 0
|
||||
|
||||
async def refresh(self) -> None:
|
||||
self.refresh_calls += 1
|
||||
self.has_keys = True
|
||||
|
||||
jwks = FakeJWKS()
|
||||
test_app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
jwks=jwks,
|
||||
settings=SimpleNamespace(jwks_cache_ttl_seconds=300),
|
||||
)
|
||||
)
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(delay: int) -> None:
|
||||
delays.append(delay)
|
||||
if len(delays) > 1:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await refresh_jwks_cache(test_app)
|
||||
|
||||
assert jwks.refresh_calls == 1
|
||||
assert delays == [5, 300]
|
||||
|
||||
|
||||
def test_websocket_route_is_registered() -> None:
|
||||
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||
|
||||
|
||||
def test_notification_http_methods_match_contract() -> None:
|
||||
paths = app.openapi()["paths"]
|
||||
expected = {
|
||||
"/api/v1/public/notifications": {"get"},
|
||||
"/api/v1/public/notification-types": {"get"},
|
||||
"/api/v1/notifications": {"get"},
|
||||
"/api/v1/notifications/counter": {"get"},
|
||||
"/api/v1/notifications/{notification_id}": {"get"},
|
||||
"/api/v1/notifications/{notification_id}/read": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/hide": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/cta": {"post"},
|
||||
(
|
||||
"/api/v1/notifications/{notification_id}/documents/"
|
||||
"{document_id}/download-url"
|
||||
): {"get"},
|
||||
"/api/v1/uploads/init": {"post"},
|
||||
"/api/v1/uploads/{draft_id}/complete": {"post"},
|
||||
"/api/v1/uploads": {"get"},
|
||||
"/api/v1/uploads/{draft_id}": {"delete"},
|
||||
"/internal/notifications/v1/notifications": {"post"},
|
||||
"/internal/notifications/v1/notifications/cancel": {"post"},
|
||||
}
|
||||
for path, methods in expected.items():
|
||||
assert methods <= paths[path].keys()
|
||||
|
||||
|
||||
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
|
||||
jwt = "header.payload.signature"
|
||||
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
|
||||
websocket = SimpleNamespace(
|
||||
headers={"sec-websocket-protocol": f"han-chat-v1, han.jwt.{encoded}"},
|
||||
query_params={},
|
||||
)
|
||||
assert websocket_token(websocket) == (jwt, f"han.jwt.{encoded}")
|
||||
|
||||
|
||||
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["servers"] == [{"url": "/"}]
|
||||
|
||||
|
||||
def test_public_config_contract_exposes_message_length() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
||||
|
||||
assert messages["required"] == ["max_text_length"]
|
||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
||||
|
||||
|
||||
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
||||
generated = app.openapi()
|
||||
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
|
||||
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||
|
||||
assert set(schema["required"]) == {
|
||||
"max_send_attempts_per_24h",
|
||||
"min_seconds_between_attempts",
|
||||
"max_verify_attempts",
|
||||
"code_length",
|
||||
"ttl_seconds",
|
||||
"sms_order_timeout_ms",
|
||||
"version",
|
||||
"cache_ttl_seconds",
|
||||
}
|
||||
assert schema["additionalProperties"] is False
|
||||
assert schema["properties"]["code_length"] == {
|
||||
"type": "integer",
|
||||
"maximum": 10.0,
|
||||
"minimum": 4.0,
|
||||
"title": "Code Length",
|
||||
}
|
||||
assert schema["properties"]["ttl_seconds"]["multipleOf"] == 60
|
||||
|
||||
|
||||
async def test_otp_settings_returns_runtime_values_and_supports_etag() -> None:
|
||||
request = SimpleNamespace(
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
keycloak_settings_bridge_token=SecretStr("bridge-token")
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
settings = SettingsSnapshot(
|
||||
{
|
||||
"otp.phone.max_send_attempts_per_24h": "3",
|
||||
"otp.phone.min_seconds_between_attempts": "30",
|
||||
"otp.phone.max_verify_attempts": "5",
|
||||
"otp.phone.code_length": "6",
|
||||
"otp.phone.ttl_seconds": "60",
|
||||
"otp.phone.sms_order_timeout_ms": "3000",
|
||||
},
|
||||
"settings-version",
|
||||
)
|
||||
|
||||
response = await otp_settings(request, settings)
|
||||
assert json.loads(response.body) == {
|
||||
"max_send_attempts_per_24h": 3,
|
||||
"min_seconds_between_attempts": 30,
|
||||
"max_verify_attempts": 5,
|
||||
"code_length": 6,
|
||||
"ttl_seconds": 60,
|
||||
"sms_order_timeout_ms": 3000,
|
||||
"version": "settings-version",
|
||||
"cache_ttl_seconds": 60,
|
||||
}
|
||||
|
||||
cached = await otp_settings(request, settings, response.headers["etag"])
|
||||
assert cached.status_code == 304
|
||||
Reference in New Issue
Block a user