Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
@@ -0,0 +1,155 @@
import uuid
import httpx
import pytest
from app.integrations import (
DependencyFailure,
OpenLinesClient,
SafetyClient,
fresh_openlines_payload,
)
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)
@pytest.mark.asyncio
async def test_safety_contract_status_and_service_token() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["X-Service-Token"] == "safety-token"
assert request.url.path == "/internal/safety/v1/messages/check"
return httpx.Response(203, json={"verdict": "pending", "task_id": "task-1"})
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 == {"verdict": "pending", "task_id": "task-1", "_status": 203}
@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",
"mime_type": "application/pdf",
"size_bytes": 42,
"checksum": "sha256:" + "a" * 64,
}
assert "file" not in body
return httpx.Response(200, json={"verdict": "allow"})
payload = {
"message_id": str(uuid.uuid4()),
"content_kind": "file",
"text": "",
"attachment": {
"attachment_id": str(attachment_id),
"quarantine_object_key": "quarantine/users/u/file",
"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_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,58 @@
import base64
from pathlib import Path
from types import SimpleNamespace
import yaml
from app.main import app, websocket_token
EXPECTED_PATHS = {
"/health/live",
"/health/ready",
"/api/v1/public/app-config",
"/api/v1/public/content",
"/api/v1/auth/bootstrap",
"/api/v1/consents",
"/api/v1/analytics/session-start",
"/api/v1/me",
"/api/v1/me/documents",
"/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/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_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
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": "/"}]
@@ -0,0 +1,26 @@
from pathlib import Path
import pytest
from app.cli.seed_settings import load_seed
from app.services import REQUIRED_SETTINGS
def test_production_like_seed_contains_all_mandatory_settings() -> None:
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
rows = load_seed(path)
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows)
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" bad.integer: {type: integer, value: nope, public: false}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="integer value expected"):
load_seed(path)
@@ -0,0 +1,80 @@
import uuid
import pytest
from pydantic import TypeAdapter, ValidationError
from app.auth import canonical_phone
from app.integrations import CircuitBreaker, RateLimiter
from app.schemas import (
FileMessageRequest,
MessageRequest,
TextMessageRequest,
canonical_fingerprint,
decode_cursor,
encode_cursor,
)
def test_phone_claim_priority_and_e164_validation() -> None:
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
assert canonical_phone(claims) == "+74999591007"
assert canonical_phone({"phone_number": "89999999999"}) is None
def test_message_discriminated_union() -> None:
adapter = TypeAdapter(MessageRequest)
assert isinstance(
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
TextMessageRequest,
)
assert isinstance(
adapter.validate_python(
{
"content_kind": "file",
"attachment_id": str(uuid.uuid4()),
"checksum": "sha256:" + "a" * 64,
}
),
FileMessageRequest,
)
with pytest.raises(ValidationError):
adapter.validate_python(
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
)
def test_fingerprint_is_canonical_and_user_scoped() -> None:
user = uuid.uuid4()
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
assert first == second
assert first != canonical_fingerprint(
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
)
def test_cursor_roundtrip_and_tamper_rejection() -> None:
secret = b"test-secret" * 4
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
with pytest.raises(ValueError, match="invalid cursor"):
decode_cursor(cursor[:-2] + "aa", secret)
def test_rate_limit_keys_do_not_expose_identity() -> None:
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
assert "203.0.113.7" not in key
assert key.startswith("han:api:rl:ip:")
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
clock = [10.0]
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
breaker = CircuitBreaker(2, 30)
breaker.failure()
breaker.failure()
assert not breaker.allow()
clock[0] = 41.0
assert breaker.allow()
breaker.success()
assert breaker.allow()