diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py index bbe5245..80c6052 100644 --- a/api/src/services/directory_import.py +++ b/api/src/services/directory_import.py @@ -15,6 +15,7 @@ import asyncio import datetime import hashlib +import unicodedata import uuid from collections import defaultdict from pathlib import PurePosixPath @@ -69,6 +70,8 @@ CONTENT_TYPES = { "csv": "text/csv", } MEGABYTE = 1024 * 1024 +MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uuid}/{имя}` остаётся в пределах 1024 байт +MAX_SUFFIX_LENGTH = 16 class ObjectStorage(Protocol): @@ -86,9 +89,20 @@ def check_size(size: int, limit: int) -> None: def safe_filename(filename: str | None) -> str: - """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.""" - name = PurePosixPath((filename or "").replace("\\", "/")).name.strip() - return name if name not in {"", ".", ".."} else DEFAULT_FILENAME + """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. + + Управляющие символы (в т. ч. NUL — его не принимает PostgreSQL) отбрасываются; длина ограничена + `MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта. + """ + cleaned = "".join(char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) != "Cc") + name = PurePosixPath(cleaned).name.strip() + if name in {"", ".", ".."}: + return DEFAULT_FILENAME + if len(name) > MAX_FILENAME_LENGTH: + suffix = PurePosixPath(name).suffix + suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else "" + name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix + return name class DirectoryImportService: diff --git a/api/tests/conftest.py b/api/tests/conftest.py index def0071..7109763 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -4,6 +4,12 @@ трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из `.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. + +`committed_sessionmaker` / `committed_app` / `committed_client` / `committed_storage` — для тестов, которым нужна сессия +запроса «как в проде»: отдельная временная БД с ЗАФИКСИРОВАННЫМ seed и сессия `async with session.begin()` без +SAVEPOINT-обёртки. Нужны там, где сервис делает явный `session.rollback()` (`dry_run` импорта): внутри `db_session` +такой откат снял бы корневой SAVEPOINT вместе с seed. Приложение собирается той же `_build_application`, что и `app`; +S3 — `FakeStorage` (объекты в словаре). """ import uuid @@ -11,13 +17,20 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AsyncExitStack from pathlib import Path from types import SimpleNamespace +from typing import Any import asyncpg import httpx import pytest import pytest_asyncio from fastapi import FastAPI -from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from scripts.seed import run_seed from src.api.router import build_api_router @@ -113,12 +126,10 @@ async def seeded(db_session: AsyncSession) -> AsyncSession: return db_session -@pytest.fixture -def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI: - """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) поверх `db_session`. - - Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, - данные теста сохраняются; без lifespan, `storage` отсутствует. +def _build_application( + session_dependency: Callable[[], AsyncIterator[AsyncSession]], monkeypatch: pytest.MonkeyPatch +) -> FastAPI: + """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) и переопределённой сессией БД. Предупреждение «Включён dev-эмитент…» из `build_verifier` (`src.core.auth.logger`) подавлено (T3.1). """ @@ -127,15 +138,38 @@ def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI: application = FastAPI() register_exception_handlers(application) application.include_router(build_api_router(settings)) + application.dependency_overrides[get_async_session] = session_dependency + application.state.token_verifier = build_verifier(settings) + application.state.storage = None + return application + + +@pytest.fixture +def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI: + """Приложение поверх `db_session` (см. `_build_application`); без lifespan, `storage` отсутствует. + + Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, + данные теста сохраняются. + """ async def override_session() -> AsyncIterator[AsyncSession]: async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT yield db_session - application.dependency_overrides[get_async_session] = override_session - application.state.token_verifier = build_verifier(settings) - application.state.storage = None - return application + return _build_application(override_session, monkeypatch) + + +async def _client_factory(application: FastAPI, stack: AsyncExitStack, email: str) -> httpx.AsyncClient: + """Клиент с dev-токеном пользователя `email`, `base_url=.../api/v1`; закрывается вместе со `stack`.""" + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: + response = await anonymous.post("/api/v1/dev/token", json={"email": email}) + assert response.status_code == 200, response.text # noqa: PLR2004,S101 + token = response.json()["access_token"] + client = httpx.AsyncClient( + transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"} + ) + return await stack.enter_async_context(client) @pytest_asyncio.fixture @@ -148,14 +182,66 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC async with AsyncExitStack() as stack: async def factory(email: str) -> httpx.AsyncClient: - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: - response = await anonymous.post("/api/v1/dev/token", json={"email": email}) - assert response.status_code == 200, response.text # noqa: PLR2004,S101 - token = response.json()["access_token"] - client = httpx.AsyncClient( - transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"} - ) - return await stack.enter_async_context(client) + return await _client_factory(app, stack, email) + + yield factory + + +class FakeStorage: + """Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре.""" + + def __init__(self, bucket: str = "arm-ka") -> None: + self.bucket = bucket + self.objects: dict[str, bytes] = {} + + async def put_object(self, obj_name: str, file_data: bytes) -> None: + self.objects[obj_name] = file_data + + +@pytest_asyncio.fixture +async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """Отдельная временная БД: `alembic upgrade head` и seed (dev) ЗАФИКСИРОВАНЫ; удаляется после теста.""" + name = f"armcore_import_{uuid.uuid4().hex[:12]}" + url = (await create_scratch_db(name)).render_as_string(hide_password=False) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": "dkb"}}) + try: + await alembic_async(url, "upgrade", "head") + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session, session.begin(): + await run_seed(session, dev=True) + yield maker + finally: + await engine.dispose() + await drop_scratch_db(name) + + +@pytest.fixture +def committed_app(committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch) -> FastAPI: + """Приложение с сессией запроса как в проде (`session.begin()` без SAVEPOINT); `storage` — `FakeStorage`.""" + + async def production_like_session() -> AsyncIterator[AsyncSession]: + async with committed_sessionmaker() as session, session.begin(): + yield session + + application = _build_application(production_like_session, monkeypatch) + application.state.storage = FakeStorage() + return application + + +@pytest.fixture +def committed_storage(committed_app: FastAPI) -> FakeStorage: + """`FakeStorage` приложения `committed_app`: что реально «записано в S3».""" + storage: Any = committed_app.state.storage + assert isinstance(storage, FakeStorage) # noqa: S101 + return storage + + +@pytest_asyncio.fixture +async def committed_client(committed_app: FastAPI) -> AsyncIterator[AuthClientFactory]: + """Фабрика клиентов `committed_app` (как `auth_client`, но на БД с зафиксированным seed).""" + async with AsyncExitStack() as stack: + + async def factory(email: str) -> httpx.AsyncClient: + return await _client_factory(committed_app, stack, email) yield factory diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py index a0d01a3..db59ce4 100644 --- a/api/tests/test_api_directory_import.py +++ b/api/tests/test_api_directory_import.py @@ -1,33 +1,24 @@ """API импорта справочников из выгрузки ЕЦП (spec directory-import, audit-trail; `05-domain-rules.md` §13.3). Тесты боевого импорта идут поверх фикстуры `app` (транзакция теста с SAVEPOINT). `dry_run` проверяется на отдельной -временной БД без SAVEPOINT-обёртки (`committed_app`): сервис делает явный `session.rollback()`, а внутри -SAVEPOINT-обёртки `db_session` такой откат снимает корневую точку сохранения сессии теста вместе с seed — поведение -отличалось бы от прода (там откатывается только транзакция запроса). Поэтому `dry_run` гоняется так же, как в проде: -сессия запроса `async with session.begin()`, данные seed зафиксированы. +временной БД без SAVEPOINT-обёртки (`committed_client`, `committed_sessionmaker`, `committed_storage` из `conftest.py`): +сервис делает явный `session.rollback()`, а внутри SAVEPOINT-обёртки `db_session` такой откат снимает корневую точку +сохранения сессии теста вместе с seed — поведение отличалось бы от прода (там откатывается только транзакция запроса). +Поэтому `dry_run` гоняется так же, как в проде: сессия запроса `async with session.begin()`, данные seed зафиксированы. """ import re import uuid -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import AsyncExitStack from pathlib import Path -from types import SimpleNamespace from typing import Any import httpx import pytest -import pytest_asyncio from fastapi import FastAPI from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from scripts.seed import run_seed -from src.api.router import build_api_router -from src.core.auth import build_verifier from src.core.config import get_settings -from src.core.exception_handlers import register_exception_handlers -from src.db.db import get_async_session from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.directories import ( @@ -41,9 +32,9 @@ from src.models.directories import ( ) from src.models.enums import AuditAction, AuditSection, EntityType, LeHistoryEvent, RelatedGroupKind, S3BucketRole from src.models.files import S3Object -from tests.conftest import AuthClientFactory +from src.services.directory_import import MAX_FILENAME_LENGTH, safe_filename +from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import make_company_group, make_legal_entity -from tests.pg_helpers import alembic_async, create_scratch_db, drop_scratch_db HTTP_OK = 200 HTTP_FORBIDDEN = 403 @@ -101,17 +92,6 @@ HEADER = ["ИНН", "iD ГК", "Базовый РФ ГК в CRM", "Группа KRASNODAR = "Краснодарский региональный филиал" -class FakeStorage: - """Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре.""" - - def __init__(self, bucket: str = "arm-ka") -> None: - self.bucket = bucket - self.objects: dict[str, bytes] = {} - - async def put_object(self, obj_name: str, file_data: bytes) -> None: - self.objects[obj_name] = file_data - - @pytest.fixture def storage(app: FastAPI) -> FakeStorage: fake = FakeStorage() @@ -450,65 +430,11 @@ async def test_import_requires_directory_admin(auth_client: AuthClientFactory, s # --------------------------------------------------------------------------- dry_run (как в проде, без SAVEPOINT) -ClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]] - - -@pytest_asyncio.fixture -async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: - """Отдельная временная БД: `alembic upgrade head` и seed (dev) ЗАФИКСИРОВАНЫ; удаляется после теста.""" - name = f"armcore_import_{uuid.uuid4().hex[:12]}" - url = (await create_scratch_db(name)).render_as_string(hide_password=False) - engine = create_async_engine(url, connect_args={"server_settings": {"search_path": "dkb"}}) - try: - await alembic_async(url, "upgrade", "head") - maker = async_sessionmaker(engine, expire_on_commit=False) - async with maker() as session, session.begin(): - await run_seed(session, dev=True) - yield maker - finally: - await engine.dispose() - await drop_scratch_db(name) - - -@pytest_asyncio.fixture -async def committed_client( - committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch -) -> AsyncIterator[ClientFactory]: - """Клиенты приложения, сессия запроса которого устроена как в проде (`src.db.db.get_async_session`).""" - monkeypatch.setattr("src.core.auth.logger", SimpleNamespace(warning=lambda *args, **kwargs: None)) - settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True}) - application = FastAPI() - register_exception_handlers(application) - application.include_router(build_api_router(settings)) - - async def production_like_session() -> AsyncIterator[AsyncSession]: - async with committed_sessionmaker() as session, session.begin(): - yield session - - application.dependency_overrides[get_async_session] = production_like_session - application.state.token_verifier = build_verifier(settings) - application.state.storage = FakeStorage() - - async with AsyncExitStack() as stack: - - async def factory(email: str) -> httpx.AsyncClient: - transport = httpx.ASGITransport(app=application) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: - response = await anonymous.post("/api/v1/dev/token", json={"email": email}) - assert response.status_code == HTTP_OK, response.text - token = response.json()["access_token"] - client = httpx.AsyncClient( - transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"} - ) - return await stack.enter_async_context(client) - - yield factory - assert isinstance(application.state.storage, FakeStorage) - - @pytest.mark.integration async def test_dry_run_leaves_no_trace_and_repeats_identically( - committed_client: ClientFactory, committed_sessionmaker: async_sessionmaker[AsyncSession] + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, ) -> None: """`dry_run` (по умолчанию): отчёт как у боевого, ни строк справочника, ни аудита, ни `s3_object`; повтор `dry_run` даёт тот же отчёт (Review Focus 4: коды из последовательностей расходуются, но не видны и не ломают прогон).""" @@ -529,6 +455,7 @@ async def test_dry_run_leaves_no_trace_and_repeats_identically( assert await counts() == before second = await _report(client, "dry.csv", content, dry_run=True) assert await counts() == before + assert committed_storage.objects == {} # T10.1: dry_run не пишет файл в S3 assert second == first assert {key: first[key] for key in COUNTER_KEYS} == { **dict.fromkeys(COUNTER_KEYS, 0), @@ -543,6 +470,7 @@ async def test_dry_run_leaves_no_trace_and_repeats_identically( real = await _report(client, "dry.csv", content, dry_run=False) + assert len(committed_storage.objects) == 1 # T10.1: боевой прогон — ровно один объект assert real == first # отчёт dry_run идентичен боевому (§13.3) after = await counts() assert {table: after[table] - before[table] for table in after} == { @@ -556,3 +484,103 @@ async def test_dry_run_leaves_no_trace_and_repeats_identically( "audit_event": 1, "s3_object": 1, } + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_dry_run_on_russkiy_biskvit_file_leaves_no_trace( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, + fixtures_dir: Path, +) -> None: + """T10.2: `dry_run` на реальном xlsx (не только csv): 200, 21 строка, таблицы и S3 без изменений.""" + client = await committed_client(ADMIN) + content = (fixtures_dir / "directories" / RB_FILE).read_bytes() + async with committed_sessionmaker() as session: + before = await _counts(session) + + report = await _report(client, RB_FILE, content, dry_run=True, content_type=XLSX_TYPE) + + assert report["rows"] == RB_ROWS + assert (report["groups_created"], report["entities_created"]) == (1, RB_ENTITIES) + async with committed_sessionmaker() as session: + assert await _counts(session) == before + assert committed_storage.objects == {} + + +@pytest.mark.integration +async def test_dry_run_with_update_leaves_history_and_audit_untouched( + committed_client: AuthClientFactory, committed_sessionmaker: async_sessionmaker[AsyncSession] +) -> None: + """T10.3: путь `update` пишет снимки истории и аудит ДО отката — после `dry_run` их нет, имена прежние.""" + client = await committed_client(ADMIN) + await _report(client, "first.csv", _csv(_row("7708000001", "1-DRYUP", "ГК Старая", "ООО Старое")), dry_run=False) + changed = _csv(_row("7708000001", "1-DRYUP", "ГК Новая", "ООО Новое")) + + async def state() -> tuple[dict[str, int], list[str]]: + async with committed_sessionmaker() as session: + names = [ + *(await session.execute(select(CompanyGroup.name).where(CompanyGroup.crm_id == "1-DRYUP"))).scalars(), + *(await session.execute(select(LegalEntity.name).where(LegalEntity.inn == "7708000001"))).scalars(), + ] + return await _counts(session), names + + before, names_before = await state() + assert names_before == ["ГК Старая", "ООО Старое"] + + report = await _report(client, "changed.csv", changed, dry_run=True, on_existing="update") + + assert _finals(report) == {2: "LE_UPDATED"} # план применялся: история и аудит изменения были записаны и откатились + assert (report["groups_updated"], report["entities_updated"]) == (1, 1) + after, names_after = await state() + assert after == before + assert after["company_group_history"] == 0 + assert names_after == names_before + + +# --------------------------------------------------------------------------- имя файла (T10.4) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("a\x00b.csv", "ab.csv"), + ("\x00", "import"), + ("../../etc/x\x07y.xlsx", "xy.xlsx"), + ("C:\\dir\\f.xlsx", "f.xlsx"), + ("..", "import"), + (None, "import"), + ("тест.csv", "тест.csv"), + ], +) +def test_safe_filename_drops_control_characters(raw: str | None, expected: str) -> None: + assert safe_filename(raw) == expected + + +def test_safe_filename_caps_length_keeping_extension() -> None: + long_name = safe_filename("я" * 1500 + ".xlsx") + assert len(long_name) == MAX_FILENAME_LENGTH + assert long_name.endswith(".xlsx") + # гигантское «расширение» отбрасывается + assert len(safe_filename("x" * 1500 + "." + "e" * 500)) == MAX_FILENAME_LENGTH + + +@pytest.mark.integration +@pytest.mark.parametrize("raw_name", ["a\x00b.csv", "я" * 1500 + ".csv"], ids=["nul", "over-1024-bytes"]) +async def test_hostile_filename_does_not_produce_500( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, raw_name: str +) -> None: + """T10.4: NUL и имя длиннее 1024 байт в `filename` multipart — не 500: имя очищено, ключ S3 в пределах лимита.""" + client = await auth_client(ADMIN) + + report = await _report(client, raw_name, _csv(_row("7709000001", "1-NAME", "ГК Имя", "ООО Имя")), dry_run=False) + + assert report["entities_created"] == 1 + (event,) = await _import_events(seeded) + assert event.description is not None + assert "\x00" not in event.description + assert len(event.description) <= MAX_FILENAME_LENGTH + assert event.description.endswith(".csv") + (key,) = storage.objects + assert len(key.encode()) <= 1024 # noqa: PLR2004 - предел длины ключа S3