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:
parent
bea295d222
commit
7f891ddb48
17
README.md
17
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 | ЮЛ уже закрыто |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user