Merge main в feat/45-kpp (после #51 и docs#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-07 19:10:20 +04:00
commit a76fc3ed99
18 changed files with 580 additions and 52 deletions

View File

@ -223,6 +223,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
`src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и `src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и
ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403). ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403).
Филиал в ответах ЮЛ и ГК приходит объектом рядом с `*_id`: `regional_office` (ЮЛ, их история) и `base_regional_office`
(ГК, их история) — `{id, name, is_head_office}` или `null`; неактивный филиал тоже отдаётся (имя нужно для истории).
| Метод и путь | Право | Что делает | | Метод и путь | Право | Что делает |
|---|---|---| |---|---|---|
| `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) | | `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) |
@ -873,6 +876,7 @@ email без учёта регистра проверяет только сер
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
|---|---|---| |---|---|---|
| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`NATS_ENABLED`, `DEBUG`, `AUTH_DEV_ISSUER`, `FORWARDED_ALLOW_IPS`, `CORS_ORIGINS`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__NATS_ENABLED=true`. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` |
| `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) |
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |

View File

@ -1,6 +1,15 @@
# Пример локального окружения. Скопируйте в api/.env: cp .env.example .env # Пример локального окружения. Скопируйте в api/.env: cp .env.example .env
# Порты — стандартные для docs/dev/docker-compose.yml. # Порты — стандартные для docs/dev/docker-compose.yml.
# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*).
# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например:
# OPENBAO__APP__NATS_ENABLED=true
# OPENBAO__APP__DEBUG=true
# OPENBAO__APP__AUTH_DEV_ISSUER=true
# OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2
# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте.
# В .env-файле OPENBAO__APP__* не читается (только процесс окружения).
DEBUG=true DEBUG=true
# Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает # Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает

View File

@ -7,7 +7,7 @@ pytest>=8.3
pytest-asyncio>=0.24 pytest-asyncio>=0.24
pytest-env>=1.1 pytest-env>=1.1
pytest-cov>=6.0 pytest-cov>=6.0
PyJWT[crypto]>=2.9 PyJWT[crypto]==2.8.0
pydbml>=1.1 pydbml>=1.1
jsonschema>=4.23 jsonschema>=4.23
types-PyYAML>=6.0 types-PyYAML>=6.0

View File

@ -13,7 +13,7 @@ attrs==26.1.0
certifi==2025.11.12 certifi==2025.11.12
cffi==2.1.0 cffi==2.1.0
click==8.4.2 click==8.4.2
cryptography==50.0.2 cryptography==43.0.3
dnspython==2.8.0 dnspython==2.8.0
email-validator==2.3.0 email-validator==2.3.0
et_xmlfile==2.0.0 et_xmlfile==2.0.0
@ -47,7 +47,7 @@ pydantic-extra-types==2.11.0
pydantic-settings==2.14.2 pydantic-settings==2.14.2
pydantic_core==2.46.4 pydantic_core==2.46.4
Pygments==2.20.0 Pygments==2.20.0
PyJWT[crypto]==2.15.1 PyJWT[crypto]==2.8.0
PyNaCl==1.5.0 PyNaCl==1.5.0
python-dotenv==1.2.2 python-dotenv==1.2.2
python-multipart==0.0.32 python-multipart==0.0.32

View File

@ -10,7 +10,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.directories import CompanyGroupServiceDep from src.api.dependencies.directories import CompanyGroupServiceDep
from src.models.directories import CompanyGroup
from src.schemas.common import Page, PageParams from src.schemas.common import Page, PageParams
from src.schemas.directories import ( from src.schemas.directories import (
CompanyGroupCreate, CompanyGroupCreate,
@ -30,12 +29,6 @@ class CompanyGroupListQuery(CompanyGroupFilter, PageParams):
"""Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" """Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = legal_entities_count
return out
@router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК") @router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК")
async def list_company_groups( async def list_company_groups(
service: CompanyGroupServiceDep, service: CompanyGroupServiceDep,
@ -44,9 +37,7 @@ async def list_company_groups(
"""Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени.""" """Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени."""
page = PageParams(limit=query.limit, offset=query.offset) page = PageParams(limit=query.limit, offset=query.offset)
rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page) rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page)
return Page[CompanyGroupOut]( return Page[CompanyGroupOut](items=await service.to_outs(rows), total=total, limit=page.limit, offset=page.offset)
items=[_out(group, count) for group, count in rows], total=total, limit=page.limit, offset=page.offset
)
@router.post( @router.post(
@ -54,13 +45,13 @@ async def list_company_groups(
) )
async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut:
"""Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим.""" """Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим."""
return _out(await service.create(data), 0) return await service.to_out(await service.create(data), 0)
@router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК") @router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК")
async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.get(group_id) group = await service.get(group_id)
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК") @router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК")
@ -69,19 +60,19 @@ async def update_company_group(
) -> CompanyGroupOut: ) -> CompanyGroupOut:
"""Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю.""" """Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю."""
group = await service.update(group_id, data) group = await service.update(group_id, data)
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК") @router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК")
async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, True) # noqa: FBT003 group = await service.set_archived(group_id, True) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива") @router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива")
async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut:
group = await service.set_archived(group_id, False) # noqa: FBT003 group = await service.set_archived(group_id, False) # noqa: FBT003
return _out(group, await service.legal_entities_count(group.id)) return await service.to_out(group, await service.legal_entities_count(group.id))
@router.get( @router.get(
@ -91,4 +82,4 @@ async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServ
summary="История ГК (новые первыми)", summary="История ГК (новые первыми)",
) )
async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]: async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]:
return [CompanyGroupHistoryOut.model_validate(row) for row in await service.history(group_id)] return await service.history(group_id)

View File

@ -11,7 +11,6 @@ from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep
from src.models.directories import LegalEntity
from src.schemas.common import Page, PageParams from src.schemas.common import Page, PageParams
from src.schemas.directories import ( from src.schemas.directories import (
AliasCreate, AliasCreate,
@ -37,13 +36,6 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams):
"""Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут)."""
def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов."""
out = LegalEntityOut.model_validate(entity)
out.documents_count = documents_count
return out
@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП") @router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП")
async def list_legal_entities( async def list_legal_entities(
service: LegalEntityServiceDep, service: LegalEntityServiceDep,
@ -56,7 +48,7 @@ async def list_legal_entities(
entity_ids = [entity.id for entity in items] entity_ids = [entity.id for entity in items]
counts = await service.documents_count_by_entities(entity_ids) counts = await service.documents_count_by_entities(entity_ids)
return Page[LegalEntityOut]( return Page[LegalEntityOut](
items=[_out(entity, counts.get(entity.id, 0)) for entity in items], items=await service.to_outs(items, counts),
total=total, total=total,
limit=page.limit, limit=page.limit,
offset=page.offset, offset=page.offset,
@ -69,7 +61,7 @@ async def list_legal_entities(
async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut:
"""Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён.""" """Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён."""
entity = await service.create(data) entity = await service.create(data)
return _out(entity, 0) return await service.to_out(entity, 0)
@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП") @router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП")
@ -86,7 +78,7 @@ async def update_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`).""" """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`)."""
entity = await service.update(entity_id, data) entity = await service.update(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК")
@ -95,7 +87,7 @@ async def move_legal_entity(
) -> LegalEntityOut: ) -> LegalEntityOut:
"""Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются."""
entity = await service.move(entity_id, data) entity = await service.move(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.post( @router.post(
@ -107,7 +99,7 @@ async def withdraw_legal_entity(
"""`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства
закрываются.""" закрываются."""
entity = await service.withdraw(entity_id, data) entity = await service.withdraw(entity_id, data)
return _out(entity, await service.documents_count(entity_id)) return await service.to_out(entity, await service.documents_count(entity_id))
@router.get( @router.get(
@ -117,7 +109,7 @@ async def withdraw_legal_entity(
summary="История ЮЛ/ИП (новые первыми)", summary="История ЮЛ/ИП (новые первыми)",
) )
async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]: async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]:
return [LegalEntityHistoryOut.model_validate(row) for row in await service.history(entity_id)] return await service.history(entity_id)
@router.post( @router.post(

View File

@ -31,8 +31,6 @@ from loguru import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from src.core.config import Settings from src.core.config import Settings
# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма).
JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256")
DEV_ALGORITHM = "HS256" DEV_ALGORITHM = "HS256"
# Допуск на расхождение часов с эмитентом токена # Допуск на расхождение часов с эмитентом токена
JWKS_LEEWAY_SECONDS = 10 JWKS_LEEWAY_SECONDS = 10
@ -152,6 +150,9 @@ class DevHs256Verifier:
) )
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
raise _to_auth_error(exc) from exc raise _to_auth_error(exc) from exc
except (TypeError, ValueError) as exc:
# PyJWT 2.8 не оборачивает нечисловой exp (например, список) в InvalidTokenError: страховка от 500
raise AuthError("недействительный токен") from exc
return _claims_from_payload(payload, self._email_claim) return _claims_from_payload(payload, self._email_claim)
def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str: def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str:
@ -168,11 +169,19 @@ def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]:
key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode()) key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode())
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc
algorithms = _algorithms_for_key(key)
if algorithms is None:
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)")
return key, algorithms
def _algorithms_for_key(key: Any) -> tuple[str, ...] | None:
"""Допустимые алгоритмы подписи по типу открытого ключа: RSA → RS256, EC P-256 → ES256; иначе ``None``."""
if isinstance(key, rsa.RSAPublicKey): if isinstance(key, rsa.RSAPublicKey):
return key, ("RS256",) return ("RS256",)
if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1": if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1":
return key, ("ES256",) return ("ES256",)
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)") return None
class JwksVerifier: class JwksVerifier:
@ -221,9 +230,14 @@ class JwksVerifier:
return self._static_key, list(self._static_algorithms) return self._static_key, list(self._static_algorithms)
# PyJWKClient ходит в сеть блокирующе — выносим из цикла событий # PyJWKClient ходит в сеть блокирующе — выносим из цикла событий
signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token)
# Отдаём сам PyJWK, а не signing_key.key: PyJWT тогда сверяет алгоритм из заголовка токена с типом ключа # Передаём signing_key.key, а не сам PyJWK: PyJWT < 2.10 (в контуре 2.8.0) не принимает PyJWK в decode
# (RSA-ключ с ES256 и наоборот — InvalidAlgorithmError, а не TypeError из недр библиотеки) # (TypeError «Expecting a PEM-formatted key»). Допустимый алгоритм выводим из типа ключа сами —
return signing_key, list(JWKS_ALGORITHMS) # RSA-ключ не принимает ES256-токен и наоборот (InvalidAlgorithmError → 401, а не 500)
key = signing_key.key
algorithms = _algorithms_for_key(key)
if algorithms is None:
raise jwt.InvalidAlgorithmError("тип ключа JWKS не поддерживается")
return key, list(algorithms)
async def verify(self, token: str) -> Claims: async def verify(self, token: str) -> Claims:
try: try:

View File

@ -2,8 +2,10 @@ import json
import os import os
from typing import Annotated, Any from typing import Annotated, Any
from pydantic import NonNegativeInt, PositiveInt, field_validator from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict
from src.core.settings_sources import OpenBaoAppSettingsSource
class DBSettings(BaseSettings): class DBSettings(BaseSettings):
@ -40,6 +42,16 @@ class OpenBAOSettings(BaseSettings):
NATS: NATSSettings NATS: NATSSettings
SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings) SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings)
@model_validator(mode="before")
@classmethod
def _drop_app(cls, data: Any) -> Any:
"""`OPENBAO__APP__<ИМЯ>` — настройки верхнего уровня `Settings` (читает `OpenBaoAppSettingsSource`), не группа
секретов. Вложенный разбор `__` кладёт их сюда ключом `APP`, а `extra=forbid` отверг бы его; убирается только
этот ключ — опечатка в имени группы (`OPENBAO__DATABSE__...`) по-прежнему ошибка."""
if isinstance(data, dict):
return {key: value for key, value in data.items() if str(key).upper() != "APP"}
return data
class ServerSettings(BaseSettings): class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
@ -94,6 +106,24 @@ class Settings(BaseSettings):
MAPPING_POLL_SECONDS: PositiveInt = 5 MAPPING_POLL_SECONDS: PositiveInt = 5
MAPPING_MAX_ATTEMPTS: PositiveInt = 3 MAPPING_MAX_ATTEMPTS: PositiveInt = 3
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию."""
return (
init_settings,
env_settings,
OpenBaoAppSettingsSource(settings_cls),
dotenv_settings,
file_secret_settings,
)
@field_validator("CORS_ORIGINS", mode="before") @field_validator("CORS_ORIGINS", mode="before")
@classmethod @classmethod
def _split_origins(cls, value: Any) -> Any: def _split_origins(cls, value: Any) -> Any:

View File

@ -0,0 +1,50 @@
"""Источник настроек `OPENBAO__APP__<ИМЯ>`: несекретные настройки верхнего уровня из OpenBao.
Платформа передаёт в под только переменные `OPENBAO__*`, обычные переменные окружения задать нельзя. Поэтому любая
настройка верхнего уровня `Settings` может прийти как `OPENBAO__APP__<ИМЯ>`
(например `OPENBAO__APP__NATS_ENABLED=true`).
Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.
Файл одинаковый в arm-core, arm-scan-gateway, arm-tabular-parser (менять во всех трёх).
"""
from typing import Any
from pydantic_settings import BaseSettings, EnvSettingsSource
APP_PREFIX = "OPENBAO__APP__"
# Группа секретов не настраивается через APP: секреты — только `OPENBAO__<ГРУППА>__<ИМЯ>`
RESERVED_FIELD = "OPENBAO"
class OpenBaoAppSettingsSource(EnvSettingsSource):
"""Читает `OPENBAO__APP__<ИМЯ>` как обычный env: те же разбор типов, `NoDecode`, вложенность `__`, регистр.
Только процесс окружения (не `.env`): так платформа и передаёт значения. Неизвестное имя — `ValueError`.
"""
def __init__(self, settings_cls: type[BaseSettings]) -> None:
super().__init__(
settings_cls,
env_prefix=APP_PREFIX,
env_nested_delimiter=settings_cls.model_config.get("env_nested_delimiter"),
)
def __call__(self) -> dict[str, Any]:
self._check_names()
return super().__call__()
def _check_names(self) -> None:
known = {name.lower() for name in self.settings_cls.model_fields} - {RESERVED_FIELD.lower()}
delimiter = self.env_nested_delimiter or "__"
prefix = self.env_prefix.lower()
unknown = sorted(
env_name
for env_name in self.env_vars
if env_name.startswith(prefix) and env_name[len(prefix) :].split(delimiter, 1)[0] not in known
)
if unknown:
raise ValueError(
f"Неизвестная настройка в {APP_PREFIX}*: {', '.join(name.upper() for name in unknown)}. "
"Допустимы имена полей верхнего уровня Settings (кроме OPENBAO)."
)

View File

@ -7,6 +7,7 @@ SQL без бизнес-правил; видимость — `src.domain.policy`
import datetime import datetime
import uuid import uuid
from collections.abc import Iterable
from typing import Any from typing import Any
from sqlalchemy import Select, Sequence, and_, delete, func, or_, select from sqlalchemy import Select, Sequence, and_, delete, func, or_, select
@ -419,6 +420,17 @@ async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> Re
return await session.get(RegionalOffice, office_id) return await session.get(RegionalOffice, office_id)
async def regional_offices_by_ids(
session: AsyncSession, office_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID, RegionalOffice]:
"""Филиалы по набору id одним запросом (включая неактивные); `None` и дубли игнорируются."""
ids = {office_id for office_id in office_ids if office_id is not None}
if not ids:
return {}
rows = (await session.execute(select(RegionalOffice).where(RegionalOffice.id.in_(ids)))).scalars().all()
return {office.id: office for office in rows}
async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]: async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]:
"""Все филиалы по имени.""" """Все филиалы по имени."""
return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all()) return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all())

View File

@ -99,6 +99,9 @@ class CompanyGroupOut(BaseModel):
crm_id: str | None crm_id: str | None
name: str name: str
base_regional_office_id: uuid.UUID | None base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = (
None # объект филиала; неактивный тоже отдаётся (имя для истории)
)
is_archived: bool is_archived: bool
valid_from: datetime.datetime valid_from: datetime.datetime
legal_entities_count: int = 0 legal_entities_count: int = 0
@ -115,6 +118,7 @@ class CompanyGroupHistoryOut(BaseModel):
crm_id: str | None crm_id: str | None
name: str | None name: str | None
base_regional_office_id: uuid.UUID | None base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
valid_from: datetime.datetime | None valid_from: datetime.datetime | None
valid_to: datetime.datetime | None valid_to: datetime.datetime | None
changed_by: uuid.UUID | None changed_by: uuid.UUID | None
@ -226,6 +230,7 @@ class LegalEntityOut(BaseModel):
name: str name: str
company_group_id: uuid.UUID | None company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся
close_date: datetime.date | None close_date: datetime.date | None
valid_from: datetime.datetime valid_from: datetime.datetime
created_at: datetime.datetime | None created_at: datetime.datetime | None
@ -290,6 +295,7 @@ class LegalEntityHistoryOut(BaseModel):
name: str | None name: str | None
company_group_id: uuid.UUID | None company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
close_date: datetime.date | None close_date: datetime.date | None
event_type: LeHistoryEvent | None event_type: LeHistoryEvent | None
effective_date: datetime.date | None effective_date: datetime.date | None

View File

@ -6,7 +6,7 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Mapping from collections.abc import Iterable, Mapping, Sequence
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -19,7 +19,14 @@ from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, Company
from src.models.enums import AuditAction, AuditSection from src.models.enums import AuditAction, AuditSection
from src.repositories import directories as repo from src.repositories import directories as repo
from src.schemas.common import PageParams from src.schemas.common import PageParams
from src.schemas.directories import CompanyGroupCreate, CompanyGroupFilter, CompanyGroupUpdate from src.schemas.directories import (
CompanyGroupCreate,
CompanyGroupFilter,
CompanyGroupHistoryOut,
CompanyGroupOut,
CompanyGroupUpdate,
RegionalOfficeBrief,
)
COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy) COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy)
COMPANY_GROUP_CODE_PREFIX = "GK" COMPANY_GROUP_CODE_PREFIX = "GK"
@ -149,14 +156,43 @@ class CompanyGroupService:
raise NotFoundError(COMPANY_GROUP_NOT_FOUND) raise NotFoundError(COMPANY_GROUP_NOT_FOUND)
return group return group
async def offices_by_ids(self, office_ids: Iterable[uuid.UUID | None]) -> dict[uuid.UUID, RegionalOfficeBrief]:
"""Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1."""
offices = await repo.regional_offices_by_ids(self._session, office_ids)
return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()}
async def to_out(self, group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut:
"""Ответ по одной ГК с объектом филиала."""
return (await self.to_outs([(group, legal_entities_count)]))[0]
async def to_outs(self, rows: Sequence[tuple[CompanyGroup, int]]) -> list[CompanyGroupOut]:
"""Ответы по набору ГК; филиалы — одним батч-запросом."""
offices = await self.offices_by_ids(group.base_regional_office_id for group, _ in rows)
result: list[CompanyGroupOut] = []
for group, count in rows:
out = CompanyGroupOut.model_validate(group)
out.legal_entities_count = count
out.base_regional_office = (
offices.get(group.base_regional_office_id) if group.base_regional_office_id else None
)
result.append(out)
return result
async def legal_entities_count(self, group_id: uuid.UUID) -> int: async def legal_entities_count(self, group_id: uuid.UUID) -> int:
"""Число ЮЛ ГК (для карточки).""" """Число ЮЛ ГК (для карточки)."""
return await repo.count_legal_entities(self._session, group_id) return await repo.count_legal_entities(self._session, group_id)
async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistory]: async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistoryOut]:
"""Снимки ГК по убыванию `valid_from`; видимость проверяется как для карточки.""" """Снимки ГК по убыванию `valid_from`; видимость — как для карточки; филиалы снимков — одним батчем."""
group = await self.get(group_id) group = await self.get(group_id)
return await repo.list_company_group_history(self._session, group.id) rows = await repo.list_company_group_history(self._session, group.id)
offices = await self.offices_by_ids(row.base_regional_office_id for row in rows)
result: list[CompanyGroupHistoryOut] = []
for row in rows:
out = CompanyGroupHistoryOut.model_validate(row)
out.base_regional_office = offices.get(row.base_regional_office_id) if row.base_regional_office_id else None
result.append(out)
return result
async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]: async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]:
"""Страница видимых ГК с числом ЮЛ и общее число по фильтру.""" """Страница видимых ГК с числом ЮЛ и общее число по фильтру."""

View File

@ -8,7 +8,7 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Mapping from collections.abc import Iterable, Mapping, Sequence
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -28,10 +28,13 @@ from src.schemas.directories import (
LegalEntityCreate, LegalEntityCreate,
LegalEntityDetail, LegalEntityDetail,
LegalEntityFilter, LegalEntityFilter,
LegalEntityHistoryOut,
LegalEntityMove, LegalEntityMove,
LegalEntityOut,
LegalEntityUpdate, LegalEntityUpdate,
LegalEntityWithdraw, LegalEntityWithdraw,
MembershipOut, MembershipOut,
RegionalOfficeBrief,
) )
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
@ -248,6 +251,7 @@ class LegalEntityService:
"""Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404.""" """Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404."""
entity = await self._get_entity(entity_id) entity = await self._get_entity(entity_id)
detail = LegalEntityDetail.model_validate(entity) detail = LegalEntityDetail.model_validate(entity)
detail.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id)
detail.memberships = [ detail.memberships = [
MembershipOut( MembershipOut(
related_group_id=group.id, related_group_id=group.id,
@ -261,10 +265,39 @@ class LegalEntityService:
detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)] detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)]
return detail return detail
async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistory]: async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistoryOut]:
"""Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки.""" """Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки; филиалы снимков — одним батчем."""
entity = await self._get_entity(entity_id) entity = await self._get_entity(entity_id)
return await repo.list_legal_entity_history(self._session, entity.id) rows = await repo.list_legal_entity_history(self._session, entity.id)
offices = await self.offices_by_ids(row.regional_office_id for row in rows)
result: list[LegalEntityHistoryOut] = []
for row in rows:
out = LegalEntityHistoryOut.model_validate(row)
out.regional_office = offices.get(row.regional_office_id)
result.append(out)
return result
async def offices_by_ids(
self, office_ids: Iterable[uuid.UUID | None]
) -> dict[uuid.UUID | None, RegionalOfficeBrief]:
"""Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1."""
offices = await repo.regional_offices_by_ids(self._session, office_ids)
return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()}
async def to_outs(self, entities: Sequence[LegalEntity], counts: Mapping[uuid.UUID, int]) -> list[LegalEntityOut]:
"""Ответы по набору ЮЛ с числом документов; филиалы — одним батч-запросом."""
offices = await self.offices_by_ids(entity.regional_office_id for entity in entities)
result: list[LegalEntityOut] = []
for entity in entities:
out = LegalEntityOut.model_validate(entity)
out.documents_count = counts.get(entity.id, 0)
out.regional_office = offices.get(entity.regional_office_id)
result.append(out)
return result
async def to_out(self, entity: LegalEntity, documents_count: int) -> LegalEntityOut:
"""Ответ по одному ЮЛ с объектом филиала."""
return (await self.to_outs([entity], {entity.id: documents_count}))[0]
async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]: async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]:
"""Страница видимых ЮЛ и общее число по фильтру.""" """Страница видимых ЮЛ и общее число по фильтру."""

View File

@ -265,6 +265,7 @@ async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFact
"crm_id", "crm_id",
"name", "name",
"base_regional_office_id", "base_regional_office_id",
"base_regional_office",
"valid_from", "valid_from",
"valid_to", "valid_to",
"changed_by", "changed_by",

View File

@ -390,6 +390,7 @@ async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFac
"company_group_id", "company_group_id",
"regional_office_id", "regional_office_id",
"close_date", "close_date",
"regional_office",
"event_type", "event_type",
"effective_date", "effective_date",
"valid_from", "valid_from",

View File

@ -0,0 +1,205 @@
"""Основной РФ объектом в ответах ЮЛ и ГК (arm-core#47): `regional_office` / `base_regional_office`, без N+1."""
import datetime
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from src.common.clock import moscow_date
from src.models.directories import CompanyGroupHistory, LegalEntityHistory
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_regional_office
HTTP_OK = 200
HTTP_CREATED = 201
SMALL = 2
ENTITIES = "/directories/legal-entities"
GROUPS = "/directories/company-groups"
KA_GO = "ka_go@armka.local"
def _brief(office: Any) -> dict[str, Any]:
return {"id": str(office.id), "name": office.name, "is_head_office": office.is_head_office}
@pytest.fixture
def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]:
statements: list[str] = []
def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None:
statements.append(statement)
event.listen(db_engine.sync_engine, "before_cursor_execute", record)
try:
yield statements
finally:
event.remove(db_engine.sync_engine, "before_cursor_execute", record)
def _office_selects(statements: list[str]) -> int:
return sum(1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.regional_office" in s)
@pytest.mark.integration
async def test_legal_entity_responses_carry_office_object(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Объект")
other = await make_regional_office(seeded, name="Филиал Другой")
group = await make_company_group(seeded, name="ГК Объект", office=office)
entity = await make_legal_entity(seeded, name="ООО Объект", group=group, office=office)
new_group = await make_company_group(seeded, name="ГК Новая цель", office=other)
mono = await make_legal_entity(seeded, name="ООО Без филиала")
client = await auth_client(KA_GO)
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert card["regional_office"] == _brief(office)
assert card["regional_office_id"] == str(office.id)
no_office = (await client.get(f"{ENTITIES}/{mono.id}")).json()
assert no_office["regional_office"] is None and no_office["regional_office_id"] is None
listing = (await client.get(ENTITIES, params={"q": "ООО"})).json()["items"]
by_name = {item["name"]: item for item in listing}
assert by_name["ООО Объект"]["regional_office"] == _brief(office)
assert by_name["ООО Без филиала"]["regional_office"] is None
patched = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["regional_office"] == _brief(other)
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert history[0]["regional_office_id"] == str(office.id)
assert history[0]["regional_office"] == _brief(office) # филиал снимка, не текущий
move = await client.post(
f"{ENTITIES}/{entity.id}/move",
json={
"company_group_id": str(new_group.id),
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert move.status_code == HTTP_OK, move.text
assert move.json()["regional_office"] == _brief(other)
withdraw = await client.post(
f"{ENTITIES}/{entity.id}/withdraw",
json={
"event": "REMOVE_FROM_GROUP",
"effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(),
},
)
assert withdraw.status_code == HTTP_OK, withdraw.text
assert withdraw.json()["regional_office"] == _brief(other)
created = await client.post(
ENTITIES,
json={"inn": "7701234567", "name": "ООО Новое", "entity_type": "ЮЛ", "regional_office_id": str(office.id)},
)
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["regional_office"] == _brief(office)
@pytest.mark.integration
async def test_inactive_office_is_still_returned(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
office = await make_regional_office(seeded, name="Филиал Закрытый")
office.is_active = False
await seeded.flush()
group = await make_company_group(seeded, name="ГК Закрытая", office=office)
entity = await make_legal_entity(seeded, name="ООО Закрытое", group=group)
client = await auth_client(KA_GO)
assert (await client.get(f"{ENTITIES}/{entity.id}")).json()["regional_office"] == _brief(office)
assert (await client.get(f"{GROUPS}/{group.id}")).json()["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_company_group_responses_carry_office_object(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
office = await make_regional_office(seeded, name="Филиал ГК")
other = await make_regional_office(seeded, name="Филиал ГК 2")
group = await make_company_group(seeded, name="ГК Один", office=office)
bare = await make_company_group(seeded, name="ГК Два")
client = await auth_client(KA_GO)
card = (await client.get(f"{GROUPS}/{group.id}")).json()
assert card["base_regional_office"] == _brief(office) and card["base_regional_office_id"] == str(office.id)
assert (await client.get(f"{GROUPS}/{bare.id}")).json()["base_regional_office"] is None
items = {i["name"]: i for i in (await client.get(GROUPS, params={"q": "ГК "})).json()["items"]}
assert items["ГК Один"]["base_regional_office"] == _brief(office)
assert items["ГК Два"]["base_regional_office"] is None
patched = await client.patch(f"{GROUPS}/{group.id}", json={"base_regional_office_id": str(other.id)})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["base_regional_office"] == _brief(other)
for action in ("archive", "unarchive"):
resp = await client.post(f"{GROUPS}/{group.id}/{action}")
assert resp.json()["base_regional_office"] == _brief(other)
created = await client.post(GROUPS, json={"name": "ГК Новая", "base_regional_office_id": str(office.id)})
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["base_regional_office"] == _brief(office)
history = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert history[0]["base_regional_office_id"] == str(office.id)
assert history[0]["base_regional_office"] == _brief(office)
@pytest.mark.integration
async def test_history_loads_offices_in_one_query(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
offices = [await make_regional_office(seeded, name=f"Филиал история {n}") for n in range(4)]
group = await make_company_group(seeded, name="ГК История", office=offices[0])
entity = await make_legal_entity(seeded, name="ООО История", group=group, office=offices[0])
now = datetime.datetime.now(datetime.UTC)
for n, office in enumerate(offices):
stamp = now - datetime.timedelta(days=n + 1)
seeded.add(
CompanyGroupHistory(
company_group_id=group.id, name="x", base_regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
seeded.add(
LegalEntityHistory(
legal_entity_id=entity.id, name="x", regional_office_id=office.id, valid_from=stamp, valid_to=now
)
)
await seeded.flush()
client = await auth_client(KA_GO)
sql_log.clear()
rows = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert {r["regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
sql_log.clear()
rows = (await client.get(f"{GROUPS}/{group.id}/history")).json()
assert {r["base_regional_office"]["name"] for r in rows} == {o.name for o in offices}
assert _office_selects(sql_log) == 1
@pytest.mark.integration
async def test_lists_do_not_grow_queries_with_rows(
auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str]
) -> None:
client = await auth_client(KA_GO)
await client.get(ENTITIES) # прогрев (первый запрос сессии тянет лишнее)
await client.get(GROUPS)
total_before: dict[str, int] = {}
for size in (2, 12):
for n in range(size):
office = await make_regional_office(seeded, name=f"Филиал список {size}-{n}")
group = await make_company_group(seeded, name=f"ГК список {size}-{n}", office=office)
await make_legal_entity(seeded, name=f"ООО список {size}-{n}", group=group, office=office)
for url, key in ((ENTITIES, "regional_office"), (GROUPS, "base_regional_office")):
sql_log.clear()
response = await client.get(url, params={"q": "список", "limit": 200})
assert response.status_code == HTTP_OK, response.text
items = response.json()["items"]
assert all(item[key] is not None for item in items)
total = len(sql_log)
if size == SMALL:
total_before[url] = total
else:
assert len(items) == 14 # noqa: PLR2004 - 2 + 12
assert total == total_before[url]
assert _office_selects(sql_log) == 1

View File

@ -455,6 +455,18 @@ async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None:
assert (await verifier.verify(token)).email == EMAIL assert (await verifier.verify(token)).email == EMAIL
async def test_jwks_client_rsa_key_rejects_es256_token(
rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey
) -> None:
"""RSA-ключ из JWKS и ES256-токен — AuthError (401), а не TypeError/500; и наоборот."""
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
with pytest.raises(AuthError, match="недопустимый алгоритм"):
await verifier.verify(jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256"))
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key()))
with pytest.raises(AuthError, match="недопустимый алгоритм"):
await verifier.verify(jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256"))
async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None: async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None:
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key())) verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
with pytest.raises(AuthError): with pytest.raises(AuthError):

View File

@ -0,0 +1,132 @@
"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #44; без БД и сети).
Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.
"""
import os
from pathlib import Path
import pytest
from pydantic import ValidationError
from src.core.config import Settings
REQUIRED_ENV = {
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
}
# Плоские настройки, которые тесты ниже задают/проверяют
POLL = 7
PORT = 9000
FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "AUTH_DEV_ISSUER", "FORWARDED_ALLOW_IPS", "CORS_ORIGINS", "OUTBOX_POLL_SECONDS"}
@pytest.fixture
def env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
"""Чистое окружение с обязательными OPENBAO__*; остальное из процесса убрано."""
for key in list(os.environ):
if key.startswith(("OPENBAO__", "SERVER__")) or key in FLAT_KEYS:
monkeypatch.delenv(key)
for key, value in REQUIRED_ENV.items():
monkeypatch.setenv(key, value)
return monkeypatch
def _settings(env_file: Path | None = None) -> Settings:
return Settings(_env_file=env_file) # type: ignore[call-arg]
def test_app_value_applied(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
env.setenv("OPENBAO__APP__DEBUG", "true")
s = _settings()
assert s.NATS_ENABLED is True
assert s.DEBUG is True
# Секреты OpenBao по-прежнему читаются во вложенные группы
assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222"
def test_default_without_app(env: pytest.MonkeyPatch) -> None:
assert _settings().NATS_ENABLED is False
def test_plain_env_overrides_app(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
env.setenv("NATS_ENABLED", "false")
assert _settings().NATS_ENABLED is False
def test_app_overrides_dotenv(env: pytest.MonkeyPatch, tmp_path: Path) -> None:
dotenv = tmp_path / ".env"
dotenv.write_text("NATS_ENABLED=false\nFORWARDED_ALLOW_IPS=1.1.1.1\n", encoding="utf-8")
env.setenv("OPENBAO__APP__NATS_ENABLED", "true")
s = _settings(dotenv)
assert s.NATS_ENABLED is True # OPENBAO__APP__ сильнее .env
assert s.FORWARDED_ALLOW_IPS == "1.1.1.1" # то, чего нет в OPENBAO__APP__, остаётся из .env
def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", str(POLL))
env.setenv("OPENBAO__APP__AUTH_DEV_ISSUER", "1")
env.setenv("OPENBAO__APP__FORWARDED_ALLOW_IPS", "10.0.0.1,10.0.0.2")
s = _settings()
assert s.OUTBOX_POLL_SECONDS == POLL
assert s.AUTH_DEV_ISSUER is True
assert s.FORWARDED_ALLOW_IPS == "10.0.0.1,10.0.0.2"
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://a.example.ru,https://b.example.ru", ["https://a.example.ru", "https://b.example.ru"]),
('["https://a.example.ru"]', ["https://a.example.ru"]),
("", []),
],
ids=["comma", "json", "empty"],
)
def test_cors_origins_list_parsed_like_plain_env(env: pytest.MonkeyPatch, raw: str, expected: list[str]) -> None:
"""NoDecode-список разбирается валидатором так же, как из обычного env."""
env.setenv("OPENBAO__APP__CORS_ORIGINS", raw)
assert _settings().CORS_ORIGINS == expected
def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", "0")
with pytest.raises(ValidationError, match="OUTBOX_POLL_SECONDS"):
_settings()
def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None:
"""Опечатка не игнорируется молча: ошибка при создании Settings (то есть при старте)."""
env.setenv("OPENBAO__APP__NATS_ENABLD", "true")
with pytest.raises(ValueError, match="NATS_ENABLD"):
_settings()
def test_openbao_group_not_settable_via_app(env: pytest.MonkeyPatch) -> None:
"""Секреты — только `OPENBAO__<ГРУППА>__*`; `OPENBAO__APP__OPENBAO__...` — не имя настройки."""
env.setenv("OPENBAO__APP__OPENBAO__NATS__NATS_URL", "nats://x:4222")
with pytest.raises(ValueError, match="OPENBAO"):
_settings()
def test_app_key_does_not_break_openbao_validation(env: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Ключ APP не попадает в OpenBAOSettings (там extra=forbid) — ни из env, ни из env-файла."""
dotenv = tmp_path / ".env"
dotenv.write_text("OPENBAO__APP__NATS_ENABLED=true\n", encoding="utf-8")
env.setenv("OPENBAO__APP__DEBUG", "true")
s = _settings(dotenv)
assert s.DEBUG is True
assert not hasattr(s.OPENBAO, "APP")
def test_nested_group_still_works(env: pytest.MonkeyPatch) -> None:
env.setenv("OPENBAO__MINIO__MINIO_SECURE", "false")
env.setenv("OPENBAO__APP__SERVER__PORT", str(PORT))
s = _settings()
assert s.OPENBAO.MINIO.MINIO_SECURE is False
assert s.server.PORT == PORT