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_IMPORT_ROWS` | 100 000 строк листа/записей csv, **включая пустые** | 422 `UNSUPPORTED_FILE` |
| `MAX_UNCOMPRESSED_BYTES` | 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора | 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. Остальные причины `UNSUPPORTED_FILE`: расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8.
## Администрирование и экран /admin ## Администрирование и экран /admin
@ -327,6 +332,10 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает
«Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер). «Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер).
Форма создания пользователя берёт список филиалов из `GET /directories/regional-offices` (право `DIRECTORY_VIEW`).
Матрица прав на этом же экране позволяет снять `DIRECTORY_VIEW` с роли администратора — тогда форма получит пустой
список / «недостаточно прав»; держите `DIRECTORY_VIEW` у роли администратора.
**Как запустить.** **Как запустить.**
```bash ```bash
@ -355,6 +364,10 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли
в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed,
seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0).
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
страницу или войдите заново.
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after`
(решение №35): выборка журнала по `GROUP_CHANGE` такое переименование не найдёт.
- **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `PUT …/roles` на каждое - **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `PUT …/roles` на каждое
нажатие, поэтому каждое нажатие — отдельное событие `audit_event(USER_ROLE_CHANGE)`. нажатие, поэтому каждое нажатие — отдельное событие `audit_event(USER_ROLE_CHANGE)`.
- **Показываются первые 200 пользователей** (`limit=200`, максимум страницы); постраничного просмотра в UI пока нет — - **Показываются первые 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`) отдаются объектом: Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
```json ```json
{"detail": {"code": "INN_EXISTS", "message": "ЮЛ с таким ИНН уже существует", "legal_entity_id": "…"}} {"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}}
``` ```
`code` — машинный код (по нему работает клиент), `message` — текст по-русски, дополнительные поля зависят от ошибки `code` — машинный код (по нему работает клиент), `message` — текст по-русски, дополнительные поля зависят от ошибки
@ -397,7 +410,7 @@ email без учёта регистра проверяет только сер
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | | `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) | | `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть | | `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |
| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (`legal_entity_id`, если видно) | | `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) |
| `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ | | `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ |
| `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК | | `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК |
| `ALREADY_CLOSED` | 409 | ЮЛ уже закрыто | | `ALREADY_CLOSED` | 409 | ЮЛ уже закрыто |

View File

@ -38,6 +38,8 @@ class RegionalOfficeOut(RegionalOfficeBrief):
class CompanyGroupCreate(BaseModel): class CompanyGroupCreate(BaseModel):
"""Создание ГК: `code` и `valid_from` назначает сервис.""" """Создание ГК: `code` и `valid_from` назначает сервис."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
crm_id: str | None = None crm_id: str | None = None
base_regional_office_id: uuid.UUID | 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): class LegalEntityCreate(BaseModel):
"""Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик.""" """Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик."""
model_config = ConfigDict(extra="forbid")
inn: str inn: str
entity_type: EntityType entity_type: EntityType
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) 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} МБ") 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: def safe_filename(filename: str | None) -> str:
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. """Имя файла без пути (клиент может прислать `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 байта. `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() name = PurePosixPath(cleaned).name.strip()
if name in {"", ".", ".."}: if name in {"", ".", ".."}:
return DEFAULT_FILENAME return DEFAULT_FILENAME

View File

@ -12,7 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent 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 src.models.enums import AuditAction, AuditSection
from tests.conftest import AuthClientFactory from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity 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"}) missing = await client.patch(f"{GROUPS}/{uuid.uuid4()}", json={"name": "X"})
assert missing.status_code == 404 # noqa: PLR2004 assert missing.status_code == 404 # noqa: PLR2004
assert missing.json()["detail"]["message"] == "ГК не найдена" 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"), ("..", "import"),
(None, "import"), (None, "import"),
("тест.csv", "тест.csv"), ("тест.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 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: def test_safe_filename_caps_length_keeping_extension() -> None:
long_name = safe_filename("я" * 1500 + ".xlsx") long_name = safe_filename("я" * 1500 + ".xlsx")
assert len(long_name) == MAX_FILENAME_LENGTH 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}) 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.status_code == HTTP_OK # явный null у филиала — свой филиал (это изменение)
assert to_foreign.json()["regional_office_id"] == str(rostov.id) 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