Corp/backend/tests/conftest.py
Игорь Надеин 4631f076a4 **feat(feed, perms, tests): новая политика доступа для фида + полный набор автотестов**
Ключевые изменения

* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:

  * Используются `DepartmentRole.scoped_permissions` (M2M к `DepartmentPermission`).
  * Проверка прав идёт по **коду** из `DeptPerm` (`CREATE_POST`/`MANAGE_FEED`), а не по `auth.Permission`.
* **Company-посты** теперь контролируются **модельными правами** (`add_post`, `change_post`, `delete_post`) или статусом staff/superuser.
* Убраны остатки старой логики `publish_company_post` / `publish_department_post` (как глобальные права).

Пермишены / утилиты

* `user_has_dept_perm(user, dept, code)`:

  * true для staff/superuser и для руководителя отдела;
  * иначе проверка активной связи `EmployeeDepartment(is_active=True)` и наличия `role__scoped_permissions__code=...`.
  * ВАЖНО: используем поле **`code`**, не `codename`.
* `has_dept_perm(user, department_id, code)`:

  * те же правила, оптимизирован поиск (select\_related для роли).
* `AdminOrDeptAllowed`:

  * Корректно извлекает `department_id` из `kwargs`/`data`/`query_params` и из объекта (`obj.department_id/department.id`).
  * Если код не задан — SAFE-методы разрешены (чтение), небезопасные запрещены.
* `AdminOrActionOrModelPerms`:

  * staff/superuser — полный доступ;
  * поддержка явного `required_perm_code`/`required_perms_by_action`;
  * SAFE-методы требуют `view_<model>`, небезопасные — стандартная карта DjangoModelPermissions.
* `IsSelfOrStaff`/`IsSelfOrStaffOrModelPerms`:

  * Владелец объекта (по `author_id`/`author` и др.) или staff/superuser может изменять/удалять;
  * SAFE-методы — для авторизованных.

API: PostViewSet

* Обновлён `get_permissions()`:

  * `list/retrieve/create/like/unlike` → `IsAuthenticated`;
  * `update/partial_update/destroy` → `AdminOrDeptAllowed` с `required_code_map`:

    * `update/partial_update/destroy` → `DeptPerm.MANAGE_FEED`.
* Бизнес-проверки:

  * `perform_create`:

    * `type=department` → разрешено staff/superuser, head отдела, либо роль с `DeptPerm.CREATE_POST`;
    * `type=company` → нужны модельные права `add_post` (или staff).
    * `author` проставляется на `request.user`.
  * `perform_update/perform_destroy`:

    * dept-посты: staff/superuser, head, либо роль с `DeptPerm.MANAGE_FEED`;
    * company-посты: модельные права `change_post`/`delete_post` (или staff).
* Производительность:

  * Аннотация `is_liked` (через `Exists`).
  * Аннотация `last_comment_id` + батч-подтягивание последнего комментария в `list()`.
* Лайки:

  * `like/unlike` — идемпотентные; счётчик корректируется атомарно.

API: CommentViewSet

* `permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]`.
* `perform_create` всегда ставит `author=request.user`.
* Фильтры `?post=`/`?author=` и сортировка `created_at ASC`.
* Обновлён `IsSelfOrStaff` для чтения настроек владельца с вью (например, `owner_id_attrs=("author_id",)`).

Тесты

* Добавлен/переписан комплект для новой модели доступа (фид):

  * **Аноним**: все эндпоинты → 401.
  * **Обычный пользователь**:

    * `GET posts/comments` → 200;
    * `POST comment`, `like/unlike` → 201/200;
    * `POST/PUT/PATCH/DELETE dept-post` → 403 (без роли/прав).
  * **Роль отдела**:

    * С `DeptPerm.CREATE_POST` — может создать пост **в своём отделе**; **в чужом** → 403.
    * С `DeptPerm.MANAGE_FEED` — может редактировать/удалять **в своём отделе**.
  * **Head отдела** — полный доступ к постам своего отдела.
  * **Staff/superuser** — полный доступ; `pin/unpin` только staff/superuser.
  * **Глобальные модельные права на company**:

    * `add_post`/`change_post`/`delete_post` → CRUD **только** для company-постов; dept-посты по-прежнему 403.
  * **Комментарии**:

    * Создание — любой аутентифицированный;
    * Изменение/удаление — автор или staff/superuser.
  * **Лайки**:

    * Идемпотентность `like/unlike`, корректная синхронизация `is_liked`.
  * **Валидация**:

    * `type=department` требует `department`;
    * роль в одном отделе не даёт создавать в другом → 403.
* Тестовые хелперы:

  * `_ensure_permission(model, codename, name)` — создаёт/возвращает `auth.Permission` для company-кейсов;
  * `_ensure_dept_permission(code)` и `_give_dept_role_with_perms(...)` — для департаментных прав (через `DepartmentPermission`/`DepartmentRole.scoped_permissions`).

Fixed / багфиксы

* `FieldError: Unsupported lookup 'codename'…` — заменён на фильтр по `scoped_permissions__code`.
* `IntegrityError: NOT NULL constraint failed: feed_post.author_id` — в `perform_create` постов/комментариев author задаётся принудительно.
* Несоответствие тестам по правам чтения/изменения комментариев — исправлено через корректный пермишен владельца.

Файлы (основные)

* `api/v1/permissions.py`: `AdminOrDeptAllowed`, `AdminOrActionOrModelPerms`, `IsSelfOrStaff`(+вариант `IsSelfOrStaffOrModelPerms`), `user_has_dept_perm`, `has_dept_perm`.
* `api/v1/feed/views.py`: `PostViewSet`, `CommentViewSet` (правки прав, `perform_*`, аннотации, like/unlike).
* `tests/api/v1/feed/test_posts.py`, `tests/api/v1/feed/test_comments.py` (и/или `test_feed_api.py`): полный набор сценариев под новую политику.
* Хелперы тестов: функции создания отделов, прав и ролей, генераторы payload.

Итог

* Политика доступа фида приведена к спецификации: **dept-права — через роли внутри отдела**, **company — через модельные права**.
* Исправлены ошибки в проверках прав и авторстве, покрыто автотестами (CRUD, лайки, комменты, валидация, пермишены).
* Производительность чтения ленты улучшена (аннотации `is_liked`, `last_comment_id` без N+1).
2025-09-11 17:56:33 +03:00

220 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# tests/conftest.py
import itertools
import mimetypes
import os
from datetime import datetime, date
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from employees.models import Department, DepartmentRole, EmployeeDepartment
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
User = get_user_model()
# -------------------------
# Speed & isolation
# -------------------------
@pytest.fixture(autouse=True)
def _fast_hashers_and_email(settings, tmp_path):
# быстрые хеши паролей и локальная почта
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# изоляция медиа
media_root = tmp_path / "test_media"
media_root.mkdir(exist_ok=True)
settings.MEDIA_ROOT = media_root
yield
# -------------------------
# API clients
# -------------------------
@pytest.fixture
def api_client() -> APIClient:
"""DRF APIClient (имеет force_authenticate)."""
return APIClient()
@pytest.fixture
def auth_client_factory():
"""Фабрика аутентифицированных клиентов: client = auth_client_factory(user)."""
def _make(user=None):
client = APIClient()
if user:
client.force_authenticate(user=user)
return client
return _make
# -------------------------
# Factories
# -------------------------
_phone_seq = itertools.count(1000)
def _unique_phone() -> str:
# валидный E.164 и уникальный в рамках сессии
return f"+7999000{next(_phone_seq):03d}"
@pytest.fixture
def user_factory():
"""
user = user_factory(email="x@example.com", staff=False, superuser=False,
verified=True, active=True, **extra)
Создаёт пользователя напрямую (без менеджера create_user, чтобы не слать письма).
"""
def _make(
email: str,
*,
staff: bool = False,
superuser: bool = False,
verified: bool = True,
active: bool = True,
password: str = "pass",
**extra,
):
u = User.objects.create(
email=email,
phone_number=extra.pop("phone_number", _unique_phone()),
first_name=extra.pop("first_name", "FN"),
last_name=extra.pop("last_name", "LN"),
is_staff=staff,
is_superuser=superuser,
is_active=active,
email_verified=verified,
**extra,
)
u.set_password(password)
u.save()
return u
return _make
@pytest.fixture
def department_factory():
def _make(**kwargs) -> Department:
return Department.objects.create(**kwargs)
return _make
@pytest.fixture
def perm_for_department():
"""
p = perm_for_department("manage_department")
Гарантированно возвращает Permission для модели Department с нужным codename.
"""
def _make(codename: str) -> Permission:
ct = ContentType.objects.get_for_model(Department)
p, _ = Permission.objects.get_or_create(
content_type=ct,
codename=codename,
defaults={"name": codename},
)
return p
return _make
@pytest.fixture
def role_factory(perm_for_department):
"""
role = role_factory(dept, name="mgr", codes=["manage_department", ...])
"""
def _make(dept: Department, name: str = "role", codes=None) -> DepartmentRole:
r = DepartmentRole.objects.create(department=dept, name=name)
if codes:
perms = [perm_for_department(code) for code in codes]
r.permissions.add(*perms)
return r
return _make
@pytest.fixture
def link_factory():
"""
link = link_factory(employee, department, is_active=True, role=None, **dates)
"""
def _make(
employee, department, is_active=True, role=None, **dates
) -> EmployeeDepartment:
return EmployeeDepartment.objects.create(
employee=employee,
department=department,
is_active=is_active,
role=role,
**dates,
)
return _make
# -------------------------
# Test helpers
# -------------------------
@pytest.fixture
def extract_results():
"""
items = extract_results(resp.json()) — поддерживает DRF пагинацию и «плоский» список.
"""
def _do(payload):
if isinstance(payload, dict) and "results" in payload:
return payload["results"]
return payload
return _do
# -------------------------
# Small misc utilities
# -------------------------
@pytest.fixture
def data_uri_image(tmp_path):
"""
Возвращает data URI маленькой PNG-картинки (1x1), удобно для аватаров.
"""
# минимальный валидный PNG 1x1 (чёрный пиксель)
import base64
png_b64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMA"
"AQAABQABDQottAAAAABJRU5ErkJggg=="
)
return f"data:image/png;base64,{png_b64}"
@pytest.fixture
def content_file_from_b64():
"""
ContentFile <- base64 строки (без data: префикса).
"""
import base64
def _make(b64: str, name: str = "upload.bin") -> ContentFile:
return ContentFile(base64.b64decode(b64), name=name)
return _make