Проект разделен на два репозитория
This commit is contained in:
@@ -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]"
|
||||
Reference in New Issue
Block a user