fix: extra=forbid у создания ГК/ЮЛ, имя файла без Cf, поправки README (M-1, M-2, M-4, M-8, T11.3)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 02:25:12 +04:00
parent bea295d222
commit 7f891ddb48
6 changed files with 62 additions and 6 deletions

View File

@ -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 | ЮЛ уже закрыто |

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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