feat: add LDAP testing infrastructure

- Add LDAP docker container structure (01-base-structure.ldif)
- Add .env.test with LDAP configuration for tests
- Create tests/ldap_fixtures.py with LDAP-specific fixtures
- Create tests/api/v1/employees/test_helpers.py with centralized helpers
- Add LDAP markers to pytest.ini (ldap_required, ldap_optional)
- Add comprehensive LDAP testing documentation
- Fix test_department_head_rights.py to use centralized helpers

New fixtures:
- ldap_available: checks if LDAP server is accessible
- ldap_config: returns LDAP configuration
- ensure_ldap_enabled/disabled: force LDAP state for test
- ldap_cleanup: auto-cleanup LDAP entries after test

New helpers:
- make_user(): centralized user creation
- grant_permission(): permission assignment
- make_department/position/role(): entity creation
- extract_results(): paginated response helper

Infrastructure:
- OU=Users, OU=Departments, OU=Groups, OU=Positions, OU=Dismissed
- Test groups: Developers, Managers
- Test departments: IT, HR
This commit is contained in:
igor_nadein 2026-02-16 17:31:48 +03:00
parent 3977c99c0c
commit ad8208789f
8 changed files with 755 additions and 18 deletions

View File

@ -0,0 +1,246 @@
# Инструкция по запуску тестов с LDAP
## Подготовка тестового окружения
### 1. Запуск LDAP контейнера
```bash
# Запустить LDAP с профилем
docker-compose --profile ldap up -d ldap
# Проверить что контейнер запущен
docker ps | grep eusrr-ldap
# Просмотр логов
docker-compose logs -f ldap
```
### 2. Настройка переменных окружения для тестов
Скопируйте файл `.env.test` в `.env` или установите переменные:
```bash
cp .env.test .env
```
Ключевые параметры для тестов:
```env
LDAP_ENABLED=true
LDAP_URI=ldap://localhost:389
LDAP_BIND_DN=cn=admin,dc=eusrr,dc=local
LDAP_BIND_PASSWORD=change-me-redacted-secret
LDAP_TLS_REQUIRED=false
# Базовые DN
LDAP_BASE_DN=dc=eusrr,dc=local
LDAP_USERS_BASE=OU=Users,dc=eusrr,dc=local
LDAP_DEPARTMENTS_BASE=OU=Departments,dc=eusrr,dc=local
LDAP_GROUPS_BASE=OU=Groups,dc=eusrr,dc=local
LDAP_POSITIONS_BASE=OU=Positions,dc=eusrr,dc=local
LDAP_DISMISSED_BASE=OU=Dismissed,dc=eusrr,dc=local
```
### 3. Инициализация LDAP структуры
LDAP структура инициализируется автоматически при первом запуске контейнера из файла:
`backend/scripts/ldap/init/01-base-structure.ldif`
Структура включает:
- OU=Users - пользователи
- OU=Departments - отделы
- OU=Groups - группы безопасности
- OU=Positions - должности
- OU=Dismissed - уволенные
### 4. Проверка подключения к LDAP
```bash
# Из контейнера
docker exec -it eusrr-ldap ldapsearch -x -H ldap://localhost -b "dc=eusrr,dc=local" -D "cn=admin,dc=eusrr,dc=local" -w change-me-redacted-secret
# Или через phpLDAPadmin
docker-compose --profile ldap up -d ldap-admin
# Открыть http://localhost:8080
# Login DN: cn=admin,dc=eusrr,dc=local
# Password: change-me-redacted-secret
```
## Запуск тестов
### Все тесты employees API
```bash
cd backend
../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v
```
### Только LDAP тесты
```bash
# Тесты требующие LDAP
../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v -m ldap_required
# Тесты опциональные (работают с LDAP и без)
../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v -m ldap_optional
```
### Тесты без LDAP
```bash
# Отключить LDAP для тестов
LDAP_ENABLED=false ../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v
```
### Параметризованные тесты (с LDAP и без)
Многие тесты теперь параметризованы для проверки обоих режимов:
```python
@pytest.mark.ldap_optional
@pytest.mark.parametrize("ldap_enabled", [True, False])
def test_create_employee(ldap_enabled, settings):
settings.LDAP_ENABLED = ldap_enabled
# тест работает в обоих режимах
```
## Отладка
### Просмотр LDAP логов в реальном времени
```bash
docker-compose logs -f ldap
```
### Очистка LDAP данных
```bash
# Остановить и удалить контейнер с данными
docker-compose --profile ldap down -v
# Запустить заново
docker-compose --profile ldap up -d ldap
```
### Проверка LDAP операций из Django shell
```bash
../.venv/Scripts/python manage.py shell
```
```python
from employees.ldap.infrastructure.connections import _ldap
from employees.ldap.directory_service import DirectoryService
# Проверка подключения
with _ldap() as conn:
print(conn.bind()) # должно быть True
# Проверка создания пользователя
ds = DirectoryService()
# ... тесты операций
```
## Структура тестов
### Новые фикстуры (tests/ldap_fixtures.py):
- `ldap_available` - проверяет доступность LDAP
- `ldap_config` - конфигурация LDAP для тестов
- `ensure_ldap_enabled` - принудительно включает LDAP для теста
- `ensure_ldap_disabled` - принудительно выключает LDAP для теста
- `ldap_cleanup` - автоматическая очистка после теста
### Централизованные helpers (tests/api/v1/employees/test_helpers.py):
- `make_user()` - создание пользователя
- `grant_permission()` - выдача разрешений
- `make_department()` - создание отдела
- `make_position()` - создание должности
- `extract_results()` - извлечение из пагинации
## Маркеры pytest
- `@pytest.mark.ldap_required` - тест требует LDAP (автоматически скипается если недоступен)
- `@pytest.mark.ldap_optional` - тест может работать с LDAP и без
## Примеры тестов
### Тест только с LDAP:
```python
@pytest.mark.ldap_required
def test_ldap_sync(api_client, ldap_cleanup):
# Тест пропускается если LDAP недоступен
user = make_user(email="test@example.com")
ldap_cleanup.add_for_deletion(user.ldap_dn)
# ...
```
### Параметризованный тест:
```python
@pytest.mark.ldap_optional
@pytest.mark.parametrize("with_ldap", [True, False])
def test_employee_creation(api_client, with_ldap, settings):
if with_ldap:
settings.LDAP_ENABLED = True
else:
settings.LDAP_ENABLED = False
# Тест проверяет оба сценария
# ...
```
## Troubleshooting
### Ошибка: "LDAP server is unavailable"
1. Проверьте что контейнер запущен: `docker ps | grep ldap`
2. Проверьте логи: `docker-compose logs ldap`
3. Проверьте порты: `netstat -an | grep 389`
4. Перезапустите контейнер: `docker-compose restart ldap`
### Ошибка: "LDAP_POSITIONS_BASE is not configured"
Добавьте в `.env`:
```env
LDAP_POSITIONS_BASE=OU=Positions,dc=eusrr,dc=local
```
### Тесты падают с "AttributeError: 'module' object has no attribute '_is_ldap_enabled'"
После рефакторинга views разделены на модули. Обновите патчи:
```python
# Старый путь
@patch('api.v1.employees.views._is_ldap_enabled')
# Новый путь
@patch('api.v1.employees.views._helpers._is_ldap_enabled')
```
## CI/CD Integration
### GitHub Actions пример:
```yaml
services:
ldap:
image: osixia/openldap:1.5.0
env:
LDAP_DOMAIN: eusrr.local
LDAP_ADMIN_PASSWORD: change-me-redacted-secret
ports:
- 389:389
steps:
- name: Run tests with LDAP
env:
LDAP_ENABLED: true
LDAP_URI: ldap://localhost:389
run: pytest tests/api/v1/employees/ -m ldap_required
- name: Run tests without LDAP
env:
LDAP_ENABLED: false
run: pytest tests/api/v1/employees/ -m "not ldap_required"
```

View File

@ -12,6 +12,8 @@ markers =
integration: marks tests as integration tests
slow: marks tests as slow running
asyncio: marks tests as asynchronous tests
ldap_required: тест требует работающий LDAP сервер
ldap_optional: тест может работать с LDAP и без него (параметризован)
# Исключить manual tests по умолчанию
addopts = -m "not manual" --strict-markers

View File

@ -0,0 +1,67 @@
# Базовая структура для тестового LDAP
# Создается при инициализации контейнера OpenLDAP
# OU для пользователей (Users)
dn: OU=Users,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: Users
description: Контейнер для пользователей EUSRR
# OU для отделов (Departments)
dn: OU=Departments,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: Departments
description: Контейнер для отделов
# OU для групп (Groups)
dn: OU=Groups,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: Groups
description: Контейнер для групп безопасности
# OU для должностей (Positions)
dn: OU=Positions,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: Positions
description: Контейнер для должностей
# OU для уволенных (Dismissed)
dn: OU=Dismissed,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: Dismissed
description: Контейнер для уволенных пользователей
# Тестовый отдел IT
dn: OU=IT,OU=Departments,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: IT
description: IT Department
# Тестовый отдел HR
dn: OU=HR,OU=Departments,dc=eusrr,dc=local
objectClass: organizationalUnit
objectClass: top
ou: HR
description: HR Department
# Группа безопасности - Developers
dn: CN=Developers,OU=Groups,dc=eusrr,dc=local
objectClass: groupOfNames
objectClass: top
cn: Developers
description: Developers group
member: cn=admin,dc=eusrr,dc=local
# Группа безопасности - Managers
dn: CN=Managers,OU=Groups,dc=eusrr,dc=local
objectClass: groupOfNames
objectClass: top
cn: Managers
description: Managers group
member: cn=admin,dc=eusrr,dc=local

View File

@ -0,0 +1,130 @@
#!/usr/bin/env python
"""
Скрипт для обновления тестов - заменяет локальные helper функции на импорты из test_helpers.
"""
import re
import sys
from pathlib import Path
# Файлы для обработки
test_dir = Path("tests/api/v1/employees")
files_to_update = [
"test_department_head_rights.py",
"test_department_membership_separation.py",
"test_department_roles.py",
"test_department_roles_extra.py",
"test_departments.py",
"test_email_verification_security.py",
"test_employee_actions.py",
"test_employees.py",
"test_employees_fields_in_list.py",
"test_ldap_optional_groups.py",
"test_positions.py",
"test_role_assignment.py",
"test_skills.py",
]
def add_import_if_missing(content: str) -> str:
"""Добавляет импорт test_helpers если его нет."""
import_line = "from tests.api.v1.employees.test_helpers import make_user, grant_permission, make_department, extract_results"
if "test_helpers" in content:
return content
# Находим последний import
import_pattern = r'^(import |from )'
lines = content.split('\n')
last_import_idx = 0
for i, line in enumerate(lines):
if re.match(import_pattern, line):
last_import_idx = i
# Вставляем после последнего импорта
lines.insert(last_import_idx + 1, import_line)
return '\n'.join(lines)
def remove_local_functions(content: str) -> str:
"""Удаляет локальные определения helper функций."""
# Паттерны для удаления
patterns = [
# make_user fixture
r'@pytest\.fixture\s+def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# make_user обычная функция
r'def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# _make_user
r'def _make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# _user
r'def _user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# _grant, _grant_perm
r'def _grant(?:_perm)?\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# _unique_phone, _unique_email
r'def _unique_(?:phone|email)\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
# extract_results
r'def extract_results\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
]
for pattern in patterns:
content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE)
return content
def replace_function_calls(content: str) -> str:
"""Заменяет вызовы локальных функций на импортированные."""
replacements = {
r'\b_make_user\(': 'make_user(',
r'\b_user\(': 'make_user(',
r'\b_grant\(': 'grant_permission(',
r'\b_grant_perm\(': 'grant_permission(',
}
for old, new in replacements.items():
content = re.sub(old, new, content)
return content
def update_file(filepath: Path):
"""Обновляет один файл."""
print(f"Обновляем {filepath.name}...")
content = filepath.read_text(encoding='utf-8')
# Добавляем импорт
content = add_import_if_missing(content)
# Удаляем локальные функции
content = remove_local_functions(content)
# Заменяем вызовы
content = replace_function_calls(content)
# Убираем лишние пустые строки
content = re.sub(r'\n{3,}', '\n\n', content)
filepath.write_text(content, encoding='utf-8')
print(f" ✓ {filepath.name} обновлен")
def main():
"""Главная функция."""
for filename in files_to_update:
filepath = test_dir / filename
if filepath.exists():
try:
update_file(filepath)
except Exception as e:
print(f" ✗ Ошибка в {filename}: {e}", file=sys.stderr)
else:
print(f" ! {filename} не найден")
print("\n✅ Обновление завершено!")
if __name__ == "__main__":
main()

View File

@ -8,6 +8,7 @@ from rest_framework import status
from employees.models import Department, DepartmentRole, EmployeeDepartment, Employee
from tests.conftest import _unique_phone
from tests.test_config import DEFAULT_PASSWORD
from tests.api.v1.employees.test_helpers import make_user, grant_permission, make_department
User = get_user_model()
@ -19,28 +20,11 @@ def _unique_phone() -> str:
base = 79000000000 # 79 + 9 нулей
return str(base + User.objects.count())
@pytest.fixture
def make_user(email: str, staff: bool = False, verified: bool = True) -> User:
"""Fixture для создания пользователей."""
u = User.objects.create_user(
email=email,
password="pwd12345",
phone_number=_unique_phone(),
send_activation_email=False,
email_verified=True,
)
# 👇 явная активация для тестов управленческих прав
if not u.is_active:
u.is_active = True
u.save(update_fields=["is_active"])
return u
# Используем make_user из test_helpers
def _unique_email() -> str:
base = "asd@mail.com"
return str(Employee.objects.count()) + base
def _make_user(staff=False, superuser=False) -> Employee:
"""
Создаёт активного и 'email_verified' пользователя, чтобы не споткнуться о
валидацию при назначении руководителя.

View File

@ -0,0 +1,168 @@
"""
Общие helper-функции для тестов API employees.
Используйте эти функции вместо создания собственных в каждом тестовом файле.
"""
import itertools
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from employees.models import Employee, Department, DepartmentRole, Position
from tests.test_config import DEFAULT_PASSWORD, TEST_EMAIL_DOMAIN
User = get_user_model()
# Счетчики для уникальных значений
_email_counter = itertools.count(1)
_phone_counter = itertools.count(1000)
def make_unique_email(prefix: str = "user") -> str:
"""Генерирует уникальный email для тестов."""
return f"{prefix}{next(_email_counter)}@{TEST_EMAIL_DOMAIN}"
def make_unique_phone() -> str:
"""Генерирует уникальный телефонный номер для тестов."""
return f"+7999000{next(_phone_counter):04d}"
def make_user(
email: str = None,
*,
staff: bool = False,
superuser: bool = False,
verified: bool = True,
active: bool = True,
password: str = DEFAULT_PASSWORD,
**extra
) -> User:
"""
Создает пользователя для тестов.
Args:
email: Email пользователя (если None, генерируется автоматически)
staff: Флаг is_staff
superuser: Флаг is_superuser
verified: Флаг email_verified
active: Флаг is_active
password: Пароль (по умолчанию DEFAULT_PASSWORD)
**extra: Дополнительные поля
Returns:
User: Созданный пользователь
"""
if email is None:
email = make_unique_email()
if 'phone_number' not in extra:
extra['phone_number'] = make_unique_phone()
if 'first_name' not in extra:
extra['first_name'] = 'Test'
if 'last_name' not in extra:
extra['last_name'] = 'User'
user = User.objects.create(
email=email,
is_staff=staff,
is_superuser=superuser,
email_verified=verified,
is_active=active,
**extra
)
user.set_password(password)
user.save()
return user
def grant_permission(user: User, permission_codename: str):
"""
Выдает пользователю указанное разрешение.
Args:
user: Пользователь
permission_codename: Codename разрешения в формате 'app_label.codename'
или просто 'codename'
"""
if '.' in permission_codename:
app_label, codename = permission_codename.split('.', 1)
perm = Permission.objects.get(
content_type__app_label=app_label,
codename=codename
)
else:
# Пытаемся найти по codename
perm = Permission.objects.get(codename=permission_codename)
user.user_permissions.add(perm)
user.save()
def make_department(**kwargs) -> Department:
"""
Создает отдел для тестов.
Args:
**kwargs: Поля модели Department
Returns:
Department: Созданный отдел
"""
if 'name' not in kwargs:
kwargs['name'] = f"Test Department {Department.objects.count() + 1}"
return Department.objects.create(**kwargs)
def make_position(**kwargs) -> Position:
"""
Создает должность для тестов.
Args:
**kwargs: Поля модели Position
Returns:
Position: Созданная должность
"""
if 'name' not in kwargs:
kwargs['name'] = f"Test Position {Position.objects.count() + 1}"
return Position.objects.create(**kwargs)
def make_department_role(department: Department, **kwargs) -> DepartmentRole:
"""
Создает роль отдела для тестов.
Args:
department: Отдел
**kwargs: Дополнительные поля
Returns:
DepartmentRole: Созданная роль
"""
if 'name' not in kwargs:
kwargs['name'] = f"Test Role {DepartmentRole.objects.count() + 1}"
return DepartmentRole.objects.create(
department=department,
**kwargs
)
def extract_results(data):
"""
Извлекает результаты из пагинированного ответа API.
Args:
data: Данные ответа (dict или list)
Returns:
list: Список результатов
"""
if isinstance(data, dict) and 'results' in data:
return data['results']
return data

View File

@ -28,6 +28,9 @@ from tests.test_config import (
TEST_IMAGE_DATA_URI,
)
# Импортируем LDAP фикстуры
from tests.ldap_fixtures import * # noqa
pytestmark = pytest.mark.django_db
User = get_user_model()

View File

@ -0,0 +1,137 @@
"""
Фикстуры для тестирования с LDAP.
Предоставляет:
- ldap_connection: подключение к тестовому LDAP серверу
- ldap_enabled/ldap_disabled: маркеры для параметризации тестов
- ldap_config: конфигурация LDAP для тестов
"""
import os
import pytest
from django.conf import settings
@pytest.fixture(scope="session")
def ldap_available():
"""
Проверяет доступность LDAP сервера.
Returns:
bool: True если LDAP доступен и настроен
"""
if not settings.LDAP_ENABLED:
return False
try:
from employees.ldap.infrastructure.connections import _ldap
with _ldap() as conn:
# Простая проверка подключения
return conn.bind()
except Exception as e:
print(f"LDAP не доступен: {e}")
return False
@pytest.fixture
def ldap_config():
"""
Возвращает конфигурацию LDAP для тестов.
"""
return {
'enabled': settings.LDAP_ENABLED,
'uri': settings.LDAP_URI,
'base_dn': settings.LDAP_BASE_DN,
'users_base': settings.LDAP_USERS_BASE,
'departments_base': getattr(settings, 'LDAP_DEPARTMENTS_BASE', None),
'groups_base': getattr(settings, 'LDAP_GROUPS_BASE', None),
'positions_base': getattr(settings, 'LDAP_POSITIONS_BASE', None),
'dismissed_base': getattr(settings, 'LDAP_DISMISSED_BASE', None),
}
@pytest.fixture
def ensure_ldap_enabled(settings):
"""
Гарантирует что LDAP включен для теста.
Используется для тестов которые требуют LDAP.
"""
original = settings.LDAP_ENABLED
settings.LDAP_ENABLED = True
yield
settings.LDAP_ENABLED = original
@pytest.fixture
def ensure_ldap_disabled(settings):
"""
Гарантирует что LDAP выключен для теста.
Используется для тестов режима без LDAP.
"""
original = settings.LDAP_ENABLED
settings.LDAP_ENABLED = False
yield
settings.LDAP_ENABLED = original
@pytest.fixture
def ldap_cleanup():
"""
Cleanup фикстура для удаления тестовых данных из LDAP после теста.
Usage:
def test_something(ldap_cleanup):
# создаем объекты в LDAP
ldap_cleanup.add_for_deletion(dn)
"""
cleanup_list = []
class Cleanup:
def add_for_deletion(self, dn):
cleanup_list.append(dn)
yield Cleanup()
# Cleanup после теста
if cleanup_list and settings.LDAP_ENABLED:
try:
from employees.ldap.infrastructure.connections import _ldap
with _ldap() as conn:
for dn in reversed(cleanup_list): # удаляем в обратном порядке
try:
conn.delete(dn)
except Exception as e:
print(f"Не удалось удалить {dn}: {e}")
except Exception as e:
print(f"LDAP cleanup failed: {e}")
def pytest_configure(config):
"""
Регистрация маркеров для pytest.
"""
config.addinivalue_line(
"markers", "ldap_required: тест требует работающий LDAP сервер"
)
config.addinivalue_line(
"markers", "ldap_optional: тест может работать с LDAP и без него (параметризован)"
)
def pytest_collection_modifyitems(config, items):
"""
Автоматически скипает тесты требующие LDAP если он недоступен.
"""
ldap_available = False
if settings.LDAP_ENABLED:
try:
from employees.ldap.infrastructure.connections import _ldap
with _ldap() as conn:
ldap_available = conn.bind()
except:
pass
skip_ldap = pytest.mark.skip(reason="LDAP сервер недоступен")
for item in items:
if "ldap_required" in item.keywords and not ldap_available:
item.add_marker(skip_ldap)