From 7f891ddb48499a9633edef468a825d8a4dbcae3a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 02:25:12 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20extra=3Dforbid=20=D1=83=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=93=D0=9A/=D0=AE?= =?UTF-8?q?=D0=9B,=20=D0=B8=D0=BC=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20Cf,=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20README=20(M-1,=20M-2,=20M-4,=20M-8,=20T11.?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M-8: CompanyGroupCreate и LegalEntityCreate отвергают неизвестные поля (422, объект не создаётся); тесты. - T11.3: safe_filename отбрасывает и категорию Cf (U+202E, U+200B) до обрезки длины; тесты. - M-2: README «Ошибки API» — пример с ALIAS_TAKEN, INN_EXISTS без дополнительных полей. - M-1: README — требование к ingress по размеру тела для /directories/import. - M-4: README — зависимость формы пользователя от DIRECTORY_VIEW, /me не перечитывается, ASSIGNMENT_CHANGE (№35). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 17 +++++++++++++++-- api/src/schemas/directories.py | 4 ++++ api/src/services/directory_import.py | 10 ++++++++-- api/tests/test_api_company_groups.py | 14 +++++++++++++- api/tests/test_api_directory_import.py | 10 +++++++++- api/tests/test_api_legal_entities.py | 13 +++++++++++++ 6 files changed, 62 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0b3be84..baac7f3 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,11 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear | `MAX_IMPORT_ROWS` | 100 000 строк листа/записей csv, **включая пустые** | 422 `UNSUPPORTED_FILE` | | `MAX_UNCOMPRESSED_BYTES` | 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора | 422 `UNSUPPORTED_FILE` | +**Требование к ingress / reverse proxy (M-1).** FastAPI/Starlette разбирают multipart-тело **до** аутентификации: файл +сбрасывается во временный файл на диске ещё до ответов 401/403 и до проверки `IMPORT_MAX_BYTES`. Поэтому лимит тела +запроса должен ставить ingress: `client_max_body_size` (или аналог) не меньше 20 МиБ — только для пути +`/api/v1/directories/import`, а для остальных путей — небольшой (например, 1 МиБ). + Остальные причины `UNSUPPORTED_FILE`: расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8. ## Администрирование и экран /admin @@ -327,6 +332,10 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает «Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер). +Форма создания пользователя берёт список филиалов из `GET /directories/regional-offices` (право `DIRECTORY_VIEW`). +Матрица прав на этом же экране позволяет снять `DIRECTORY_VIEW` с роли администратора — тогда форма получит пустой +список / «недостаточно прав»; держите `DIRECTORY_VIEW` у роли администратора. + **Как запустить.** ```bash @@ -355,6 +364,10 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). +- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите + страницу или войдите заново. +- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` + (решение №35): выборка журнала по `GROUP_CHANGE` такое переименование не найдёт. - **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `PUT …/roles` на каждое нажатие, поэтому каждое нажатие — отдельное событие `audit_event(USER_ROLE_CHANGE)`. - **Показываются первые 200 пользователей** (`limit=200`, максимум страницы); постраничного просмотра в UI пока нет — @@ -367,7 +380,7 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: ```json -{"detail": {"code": "INN_EXISTS", "message": "ЮЛ с таким ИНН уже существует", "legal_entity_id": "…"}} +{"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}} ``` `code` — машинный код (по нему работает клиент), `message` — текст по-русски, дополнительные поля зависят от ошибки @@ -397,7 +410,7 @@ email без учёта регистра проверяет только сер | `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | | `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) | | `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть | -| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (`legal_entity_id`, если видно) | +| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) | | `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ | | `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК | | `ALREADY_CLOSED` | 409 | ЮЛ уже закрыто | diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 3b139b2..4852613 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -38,6 +38,8 @@ class RegionalOfficeOut(RegionalOfficeBrief): class CompanyGroupCreate(BaseModel): """Создание ГК: `code` и `valid_from` назначает сервис.""" + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) crm_id: str | None = None base_regional_office_id: uuid.UUID | None = None @@ -138,6 +140,8 @@ def _strip_required_name(value: str | None) -> str | None: class LegalEntityCreate(BaseModel): """Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик.""" + model_config = ConfigDict(extra="forbid") + inn: str entity_type: EntityType name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py index 80c6052..cf1c6b8 100644 --- a/api/src/services/directory_import.py +++ b/api/src/services/directory_import.py @@ -88,13 +88,19 @@ def check_size(size: int, limit: int) -> None: raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ") +_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"}) + + def safe_filename(filename: str | None) -> str: """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. - Управляющие символы (в т. ч. NUL — его не принимает PostgreSQL) отбрасываются; длина ограничена + Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E, + нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена `MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта. """ - cleaned = "".join(char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) != "Cc") + cleaned = "".join( + char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES + ) name = PurePosixPath(cleaned).name.strip() if name in {"", ".", ".."}: return DEFAULT_FILENAME diff --git a/api/tests/test_api_company_groups.py b/api/tests/test_api_company_groups.py index 817a27d..2bf27dc 100644 --- a/api/tests/test_api_company_groups.py +++ b/api/tests/test_api_company_groups.py @@ -12,7 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.audit_ex import AuditEvent -from src.models.directories import CompanyGroupHistory +from src.models.directories import CompanyGroup, CompanyGroupHistory from src.models.enums import AuditAction, AuditSection from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_legal_entity @@ -281,3 +281,15 @@ async def test_validation_and_unknown_group(auth_client: AuthClientFactory) -> N missing = await client.patch(f"{GROUPS}/{uuid.uuid4()}", json={"name": "X"}) assert missing.status_code == 404 # noqa: PLR2004 assert missing.json()["detail"]["message"] == "ГК не найдена" + + +@pytest.mark.integration +async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """M-8: `extra="forbid"` — служебное поле (`code`) в теле создания даёт 422 (массив ошибок), ГК не создаётся.""" + client = await auth_client("ka_go@armka.local") + before = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one() + response = await client.post(GROUPS, json={"name": "ГК Лишнее поле", "code": "GK-999999"}) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert isinstance(response.json()["detail"], list) + count = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one() + assert count == before diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py index db59ce4..fc8d60c 100644 --- a/api/tests/test_api_directory_import.py +++ b/api/tests/test_api_directory_import.py @@ -552,12 +552,20 @@ async def test_dry_run_with_update_leaves_history_and_audit_untouched( ("..", "import"), (None, "import"), ("тест.csv", "тест.csv"), + ("evil\u202efdp.csv", "evilfdp.csv"), + ("a\u200bb\u200d.xlsx", "ab.xlsx"), ], ) -def test_safe_filename_drops_control_characters(raw: str | None, expected: str) -> None: +def test_safe_filename_drops_control_and_format_characters(raw: str | None, expected: str) -> None: assert safe_filename(raw) == expected +def test_safe_filename_drops_format_characters_before_length_cap() -> None: + # невидимые символы не занимают место в лимите: после их удаления имя ровно в пределе и не обрезается + padded = "x" * (MAX_FILENAME_LENGTH - len(".csv")) + "\u200b" * 50 + ".csv" + assert safe_filename(padded) == "x" * (MAX_FILENAME_LENGTH - len(".csv")) + ".csv" + + def test_safe_filename_caps_length_keeping_extension() -> None: long_name = safe_filename("я" * 1500 + ".xlsx") assert len(long_name) == MAX_FILENAME_LENGTH diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 9f36cab..f4b180e 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -563,3 +563,16 @@ async def test_branch_patch_with_unchanged_foreign_office_is_not_rejected( to_foreign = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": None}) assert to_foreign.status_code == HTTP_OK # явный null у филиала — свой филиал (это изменение) assert to_foreign.json()["regional_office_id"] == str(rostov.id) + + +@pytest.mark.integration +async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """M-8: `extra="forbid"` — неизвестное поле (`close_date`) при создании даёт 422 (массив), ЮЛ не создаётся.""" + client = await auth_client("ka_go@armka.local") + before = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() + body = {"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Лишнее", "close_date": "2026-01-01"} + response = await client.post(ENTITIES, json=body) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert isinstance(response.json()["detail"], list) + count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() + assert count == before