Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,25 @@
from __future__ import annotations
import pytest
from app.config import Settings
@pytest.fixture
def full_settings() -> Settings:
return Settings(
enabled=True,
mode="full",
database_url="postgresql+asyncpg://user:pass@db/han",
crm_rest_webhook_url="https://portal.example/rest/1/credential/",
contact_receiver_token="contact-token-value-32-characters",
alert_receiver_token="alert-token-value-32-characters---",
service_token="service-token-value-32-characters-",
portal_host="portal.example",
portal_member_id="member_12345678",
public_base_url="https://sync.example",
contact_user_id_field="UF_CRM_100",
contact_registered_field="UF_CRM_101",
contact_citizenship_field="UF_CRM_102",
webhook_allowed_cidrs="203.0.113.0/24",
)
@@ -0,0 +1,77 @@
from __future__ import annotations
import ast
import re
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_disabled_mode_needs_no_secrets() -> None:
settings = Settings(enabled=False, mode="disabled")
assert settings.enabled is False
def test_full_mode_rejects_portal_host_mismatch() -> None:
with pytest.raises(ValidationError, match="approved portal host"):
Settings(
enabled=True,
mode="full",
database_url="postgresql+asyncpg://u:p@db/han",
crm_rest_webhook_url="https://evil.example/rest/1/token/",
contact_receiver_token="contact",
alert_receiver_token="alert",
service_token="service",
portal_host="portal.example",
portal_member_id="member_12345678",
public_base_url="https://sync.example",
contact_user_id_field="UF_CRM_1",
contact_registered_field="UF_CRM_2",
contact_citizenship_field="UF_CRM_3",
webhook_allowed_cidrs="203.0.113.0/24",
)
def test_rest_field_conversion_is_deterministic() -> None:
assert Settings.rest_field_name("UF_CRM_1778692456") == "ufCrm_1778692456"
with pytest.raises(ValueError):
Settings.rest_field_name("uf_crm_1")
def test_alembic_chain_preserves_legacy_baseline() -> None:
versions = Path(__file__).parents[1] / "alembic" / "versions"
revisions: dict[str, str | None] = {}
for migration in versions.glob("*.py"):
assignments = {
node.target.id: node.value.value
for node in ast.parse(migration.read_text(encoding="utf-8")).body
if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
and node.target.id in {"revision", "down_revision"}
and isinstance(node.value, ast.Constant)
}
revisions[assignments["revision"]] = assignments["down_revision"]
assert revisions == {
"0001_sync_baseline": None,
"0001_bitrix_sync_full": "0001_sync_baseline",
"0002_app_queue_contract": "0001_bitrix_sync_full",
}
def test_app_queue_migration_does_not_revoke_foreign_schema_privileges() -> None:
migration = (
Path(__file__).parents[1]
/ "alembic"
/ "versions"
/ "0002_app_queue_contract.py"
)
source = migration.read_text(encoding="utf-8")
assert "FROM han_app.entity_external_mapping" in source
assert "REVOKE" not in source
assert re.search(r'"[^"]+"\s*:', source) is None
@@ -0,0 +1,48 @@
from __future__ import annotations
import httpx
import pytest
from app.crm import CrmClient, CrmOutcome
def make_client(handler) -> CrmClient:
client = CrmClient.__new__(CrmClient)
client._base_url = "https://portal.example/rest/1/token/"
client._host = "portal.example"
client._client = httpx.AsyncClient(
transport=httpx.MockTransport(handler), follow_redirects=False
)
return client
@pytest.mark.asyncio
async def test_crm_success_and_no_redirect() -> None:
client = make_client(
lambda request: httpx.Response(200, json={"result": {"ID": "42"}}, request=request)
)
result = await client.call("crm.contact.get", {"id": "42"}, mutating=False)
assert result.outcome == CrmOutcome.SUCCEEDED
assert result.result["ID"] == "42"
await client.close()
redirecting = make_client(
lambda request: httpx.Response(
302, headers={"Location": "https://evil.example/"}, request=request
)
)
result = await redirecting.call("crm.contact.get", {"id": "42"}, mutating=False)
assert result.outcome == CrmOutcome.PERMANENT
assert result.error_code == "crm_redirect_rejected"
await redirecting.close()
@pytest.mark.asyncio
async def test_mutating_timeout_is_uncertain() -> None:
def timeout(request):
raise httpx.ReadTimeout("timed out", request=request)
client = make_client(timeout)
result = await client.call("crm.contact.add", {"fields": {}}, mutating=True)
assert result.outcome == CrmOutcome.UNCERTAIN
await client.close()
@@ -0,0 +1,57 @@
from __future__ import annotations
import random
from datetime import UTC, datetime, timedelta
import pytest
from app.domain import (
ContactCandidate,
WorkflowState,
assert_transition,
choose_newest,
full_jitter_delay,
select_email,
validate_phone,
)
from app.mapping import CitizenshipDictionary, UnknownCitizenship
def test_contact_choice_has_numeric_id_tie_breaker() -> None:
created = datetime(2026, 8, 6, tzinfo=UTC)
selected = choose_newest(
[
ContactCandidate("9", created, None),
ContactCandidate("10", created, None),
]
)
assert selected and selected.b24_id == "10"
def test_phone_email_and_citizenship_mapping() -> None:
assert validate_phone("+79001234567") == "+79001234567"
with pytest.raises(ValueError):
validate_phone("8 900 123-45-67")
assert (
select_email(
[
{"VALUE": "home@example.test", "VALUE_TYPE": "HOME"},
{"VALUE": "work@example.test", "VALUE_TYPE": "WORK"},
]
)
== "work@example.test"
)
dictionary = CitizenshipDictionary(ttl_seconds=60)
now = datetime(2026, 8, 6, tzinfo=UTC)
dictionary.load([{"ID": "7", "VALUE": "Казахстан"}], now)
assert dictionary.resolve("7", now + timedelta(seconds=30)) == "Казахстан"
with pytest.raises(UnknownCitizenship):
dictionary.resolve("8", now)
def test_state_machine_and_retry_bounds() -> None:
assert_transition(WorkflowState.CREATED, WorkflowState.RUNNING)
with pytest.raises(ValueError):
assert_transition(WorkflowState.SUCCEEDED, WorkflowState.RUNNING)
delay = full_jitter_delay(4, 1, 5, rng=random.Random(1))
assert 0 <= delay <= 5
@@ -0,0 +1,72 @@
from __future__ import annotations
import uuid
from contextlib import asynccontextmanager
import pytest
from app.crm import CrmOutcome, CrmResult
from app.engine import WorkflowEngine
from app.repository import Profile
class Result:
rowcount = 1
class Connection:
async def execute(self, statement, params=None):
return Result()
class FakeRepository:
def __init__(self) -> None:
self.mapping = None
self.statements: list[str] = []
@asynccontextmanager
async def transaction(self):
yield Connection()
async def active_mapping(self, user_id):
return self.mapping
async def reserve_limiter_token(self, refill_per_second, burst):
return 0
class FakeCrm:
def __init__(self, user_id: uuid.UUID) -> None:
self.user_id = user_id
self.calls: list[tuple[str, dict]] = []
async def call(self, method, params, *, mutating):
self.calls.append((method, params))
if method == "crm.duplicate.findbycomm":
return CrmResult(CrmOutcome.SUCCEEDED, {"CONTACT": ["9", "10"]})
if method == "crm.contact.get":
contact_id = str(params["id"])
return CrmResult(
CrmOutcome.SUCCEEDED,
{
"ID": contact_id,
"CREATED_TIME": "2026-08-06T10:00:00Z",
"UF_CRM_100": None,
},
)
return CrmResult(CrmOutcome.SUCCEEDED, True)
@pytest.mark.asyncio
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
user_id = uuid.uuid4()
repository = FakeRepository()
crm = FakeCrm(user_id)
engine = WorkflowEngine(repository, crm, full_settings)
await engine._map_or_create(
uuid.uuid4(),
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
)
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
assert updates[0]["id"] == "10"
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
@@ -0,0 +1,62 @@
from __future__ import annotations
import pytest
from app.security import (
WebhookValidationError,
parse_bounded_form,
redact,
token_matches,
validate_webhook,
)
def test_contact_webhook_contract(full_settings) -> None:
event = validate_webhook(
"contact",
{"token": "contact-token-value-32-characters", "ID": "42"},
{
"document_id[0]": "crm",
"document_id[1]": "CCrmDocumentContact",
"document_id[2]": "CONTACT_42",
"auth[domain]": "portal.example",
"auth[member_id]": "member_12345678",
"auth[client_endpoint]": "https://attacker.invalid/rest/",
},
"203.0.113.10",
full_settings,
alert_entity_type_id=None,
)
assert event.entity_id == "42"
def test_webhook_rejects_document_query_mismatch(full_settings) -> None:
with pytest.raises(WebhookValidationError, match="mismatch"):
validate_webhook(
"contact",
{"token": "contact-token-value-32-characters", "ID": "41"},
{
"document_id[0]": "crm",
"document_id[1]": "CCrmDocumentContact",
"document_id[2]": "CONTACT_42",
"auth[domain]": "portal.example",
"auth[member_id]": "member_12345678",
},
"203.0.113.10",
full_settings,
alert_entity_type_id=None,
)
def test_bounded_form_and_constant_time_token_helpers() -> None:
assert parse_bounded_form(b"a=1&b=2", max_fields=2) == {"a": "1", "b": "2"}
with pytest.raises(WebhookValidationError):
parse_bounded_form(b"a=1&b=2&c=3", max_fields=2)
assert token_matches("old", "new", "old")
assert not token_matches("other", "new", "old")
def test_redaction_removes_pii_and_secrets() -> None:
assert "secret-value" not in redact("token=secret-value")
assert redact("user@example.test") == "[PII_REDACTED]"
assert redact("+79001234567") == "[PII_REDACTED]"