Files
ACORE/apps/mattermost_connector/tests.py
T
lokses ebe75aa834 fix: sync Mattermost — не создавать дубль при конфликте профиля/email
Раньше любой сбой get_or_create обнулял ID и шёл в create. Теперь
привязка через _attach_mattermost_profile, поиск по username, а при
«email уже есть» — линк существующей учётки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:10:41 +03:00

541 lines
22 KiB
Python
Executable File
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.
"""
Тесты Mattermost-коннектора: генерация учётных данных и логика синхронизации
(MattermostService замокан — сеть не используется).
"""
from datetime import date
from unittest import mock
import httpx
from django.contrib.auth import get_user_model
from django.test import TestCase
from mattermostautodriver.exceptions import ResourceNotFound
from apps.mattermost_connector.models import MattermostUser
from apps.mattermost_connector.services import sync_service, token_service
Employee = get_user_model()
_employee_seq = 0
def make_employee(**overrides):
"""Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)"""
global _employee_seq
_employee_seq += 1
defaults = {
'username': f'mm.user{_employee_seq}',
'email': f'mm.user{_employee_seq}@company.local',
'first_name': 'Іван',
'last_name': 'Тестовий',
'hire_date': date(2026, 1, 1),
'user_location_type': 'office',
'mattermost_sync_enabled': False,
'ad_sync_enabled': False,
}
defaults.update(overrides)
return Employee.objects.create(**defaults)
def not_found():
return ResourceNotFound('not found', 'store.sql_user.missing', 'rid', False)
class GenerateUsernameTests(TestCase):
def test_uses_existing_username(self):
employee = make_employee(username='petrov.p')
self.assertEqual(sync_service.generate_username(employee), 'petrov.p')
def test_generates_from_email_when_no_username(self):
employee = make_employee()
employee.username = ''
employee.email = 'Ivan+Petrov@company.local'
username = sync_service.generate_username(employee)
self.assertEqual(username, 'ivan_petrov')
class GenerateRandomPasswordTests(TestCase):
def test_default_length(self):
self.assertEqual(len(sync_service.generate_random_password()), 16)
def test_custom_length(self):
self.assertEqual(len(sync_service.generate_random_password(24)), 24)
class SyncEmployeeTests(TestCase):
"""sync_employee с замоканным MattermostService"""
def setUp(self):
self.service = mock.MagicMock()
patcher = mock.patch.object(
sync_service, 'MattermostService', return_value=self.service,
)
patcher.start()
self.addCleanup(patcher.stop)
def test_finds_existing_user_by_email(self):
employee = make_employee()
self.service.get_user_by_email.return_value = {
'id': 'mm-abc', 'username': 'found.user',
'email': employee.email, 'delete_at': 0,
}
result = sync_service.sync_employee(employee)
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-abc')
self.assertEqual(employee.mattermost_username, 'found.user')
self.assertEqual(employee.mattermost_sync_status, 'synced')
self.assertTrue(MattermostUser.objects.filter(mattermost_id='mm-abc').exists())
# существующего обновляем, нового не создаём
self.service.update_user.assert_called_once()
self.service.create_user.assert_not_called()
def test_creates_new_user_when_not_found(self):
employee = make_employee(username='new.user')
self.service.get_user_by_email.side_effect = not_found()
self.service.get_user_by_username.side_effect = not_found()
self.service.create_user.return_value = {
'id': 'mm-new', 'username': 'new.user', 'email': employee.email,
}
result = sync_service.sync_employee(employee, password='Secret123')
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-new')
self.assertEqual(employee.mattermost_sync_status, 'synced')
user_data = self.service.create_user.call_args.args[0]
self.assertEqual(user_data['username'], 'new.user')
self.assertEqual(user_data['password'], 'Secret123')
self.assertEqual(user_data['email'], employee.email)
def test_username_conflict_appends_counter(self):
employee = make_employee(username='taken.name')
self.service.get_user_by_email.side_effect = not_found()
# lookup по username — свободно; при create имя уже занято → суффикс
self.service.get_user_by_username.side_effect = [
not_found(),
{'id': 'mm-other', 'username': 'taken.name'},
not_found(),
]
self.service.create_user.return_value = {
'id': 'mm-new2', 'username': 'taken.name1', 'email': employee.email,
}
result = sync_service.sync_employee(employee)
self.assertTrue(result)
user_data = self.service.create_user.call_args.args[0]
self.assertEqual(user_data['username'], 'taken.name1')
def test_username_check_does_not_swallow_network_errors(self):
"""Сетевая ошибка при проверке username — это ошибка синхронизации,
а не сигнал «имя свободно» (регресс голого except)."""
employee = make_employee()
self.service.get_user_by_email.side_effect = not_found()
self.service.get_user_by_username.side_effect = OSError('connection refused')
result = sync_service.sync_employee(employee)
self.assertFalse(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_sync_status, 'error')
self.service.create_user.assert_not_called()
def test_stale_mattermost_id_is_reset_and_user_recreated(self):
employee = make_employee(mattermost_user_id='mm-stale')
self.service.get_user.side_effect = not_found()
self.service.get_user_by_email.side_effect = not_found()
self.service.get_user_by_username.side_effect = not_found()
self.service.create_user.return_value = {
'id': 'mm-fresh', 'username': employee.username, 'email': employee.email,
}
result = sync_service.sync_employee(employee)
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-fresh')
def test_relinks_when_profile_points_at_duplicate(self):
"""Ручной mattermost_user_id на старую учётку при профиле на дубль — без create."""
employee = make_employee(
username='lokses',
email='admin@lokses.od.ua',
mattermost_user_id='mm-old',
)
MattermostUser.objects.create(
django_user=employee,
mattermost_id='mm-dup',
username='lokses1',
email=employee.email,
access_token_encrypted='old-pat-cipher',
)
self.service.get_user.return_value = {
'id': 'mm-old', 'username': 'lokses',
'email': employee.email, 'delete_at': 0,
}
result = sync_service.sync_employee(employee)
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-old')
self.assertEqual(employee.mattermost_username, 'lokses')
profile = MattermostUser.objects.get(django_user=employee)
self.assertEqual(profile.mattermost_id, 'mm-old')
self.assertEqual(profile.username, 'lokses')
self.assertEqual(profile.access_token_encrypted, '')
self.service.create_user.assert_not_called()
self.service.update_user.assert_called_once()
def test_links_by_username_before_create(self):
employee = make_employee(username='lokses', email='other@example.com')
self.service.get_user_by_email.side_effect = not_found()
self.service.get_user_by_username.return_value = {
'id': 'mm-by-name', 'username': 'lokses',
'email': 'mm@example.com', 'delete_at': 0,
}
result = sync_service.sync_employee(employee)
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-by-name')
self.service.create_user.assert_not_called()
def test_create_email_conflict_links_existing(self):
from mattermostautodriver.exceptions import InvalidMattermostError
employee = make_employee(username='lokses')
self.service.get_user_by_email.side_effect = [
not_found(),
{
'id': 'mm-exist', 'username': 'lokses',
'email': employee.email, 'delete_at': 0,
},
]
self.service.get_user_by_username.side_effect = not_found()
self.service.create_user.side_effect = InvalidMattermostError(
'Учётная запись с таким email-адресом уже существует.', 400,
)
result = sync_service.sync_employee(employee)
self.assertTrue(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_user_id, 'mm-exist')
self.service.update_user.assert_called_once()
def test_error_marks_sync_status(self):
employee = make_employee()
self.service.get_user_by_email.side_effect = not_found()
self.service.get_user_by_username.side_effect = not_found()
self.service.create_user.side_effect = OSError('boom')
result = sync_service.sync_employee(employee)
self.assertFalse(result)
employee.refresh_from_db()
self.assertEqual(employee.mattermost_sync_status, 'error')
class DeactivateActivateTests(TestCase):
def setUp(self):
self.service = mock.MagicMock()
patcher = mock.patch.object(
sync_service, 'MattermostService', return_value=self.service,
)
patcher.start()
self.addCleanup(patcher.stop)
def test_deactivate_requires_linked_account(self):
employee = make_employee()
self.assertFalse(sync_service.deactivate_mattermost_user(employee))
self.service.deactivate_user.assert_not_called()
def test_deactivate_linked_user(self):
employee = make_employee(mattermost_user_id='mm-abc')
result = sync_service.deactivate_mattermost_user(employee)
self.assertTrue(result)
self.service.deactivate_user.assert_called_once_with('mm-abc')
class MattermostSignalTests(TestCase):
def test_create_employee_with_sync_queues_task(self):
with mock.patch(
'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay',
) as delay:
employee = make_employee(mattermost_sync_enabled=True)
delay.assert_called_once_with(employee.pk)
def test_create_employee_without_sync_does_not_queue(self):
with mock.patch(
'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay',
) as delay:
make_employee(mattermost_sync_enabled=False)
delay.assert_not_called()
def test_block_employee_deactivates_account(self):
employee = make_employee(mattermost_user_id='mm-abc')
with mock.patch(
'apps.mattermost_connector.services.sync_service.deactivate_mattermost_user',
) as deactivate:
employee.is_active = False
employee.save()
deactivate.assert_called_once()
class EnsurePersonalAccessTokenTests(TestCase):
def test_no_mattermost_user_id_returns_none(self):
employee = make_employee()
self.assertIsNone(token_service.ensure_personal_access_token(employee))
def test_no_mattermost_user_row_returns_none(self):
employee = make_employee(mattermost_user_id='mm-abc')
self.assertIsNone(token_service.ensure_personal_access_token(employee))
def test_creates_and_encrypts_token_on_first_call(self):
employee = make_employee(mattermost_user_id='mm-abc')
MattermostUser.objects.create(
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
)
service = mock.Mock()
service.create_user_access_token.return_value = {'token': 'plain-secret-token'}
with mock.patch.object(token_service, 'MattermostService', return_value=service):
token = token_service.ensure_personal_access_token(employee)
self.assertEqual(token, 'plain-secret-token')
service.create_user_access_token.assert_called_once_with('mm-abc', 'ACORE Portal Chat')
mm_user = MattermostUser.objects.get(mattermost_id='mm-abc')
self.assertTrue(mm_user.access_token_encrypted)
self.assertNotEqual(mm_user.access_token_encrypted, 'plain-secret-token')
self.assertEqual(mm_user.get_access_token(), 'plain-secret-token')
def test_reuses_stored_token_without_recreating(self):
employee = make_employee(mattermost_user_id='mm-abc')
mm_user = MattermostUser.objects.create(
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
)
mm_user.set_access_token('already-stored-token')
mm_user.save(update_fields=['access_token_encrypted'])
service = mock.Mock()
with mock.patch.object(token_service, 'MattermostService', return_value=service):
token = token_service.ensure_personal_access_token(employee)
self.assertEqual(token, 'already-stored-token')
service.create_user_access_token.assert_not_called()
def test_recreates_token_when_ciphertext_unreadable(self):
employee = make_employee(mattermost_user_id='mm-abc')
mm_user = MattermostUser.objects.create(
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
access_token_encrypted='gAAAAABnot-a-valid-fernet-token',
)
service = mock.Mock()
service.create_user_access_token.return_value = {'token': 'fresh-token'}
with mock.patch.object(token_service, 'MattermostService', return_value=service):
token = token_service.ensure_personal_access_token(employee)
self.assertEqual(token, 'fresh-token')
service.create_user_access_token.assert_called_once()
mm_user.refresh_from_db()
self.assertEqual(mm_user.get_access_token(), 'fresh-token')
class TaskDoneActionAuthTests(TestCase):
"""task_done_action требует MATTERMOST_INTERACTIVE_TOKEN + подпись."""
def setUp(self):
from django.core import signing
from apps.tasks.models import Task
from apps.mattermost_connector.services.bot_service import TASK_ACTION_SALT
self.employee = make_employee(mattermost_user_id='mm-user-1')
self.task = Task.objects.create(
title='Test task',
creator=self.employee,
status='in_progress',
)
self.task.responsible.add(self.employee)
self.signed = signing.dumps(self.task.pk, salt=TASK_ACTION_SALT)
def _post(self, *, token=None, user_id='mm-user-1'):
import json
url = '/mattermost/actions/task-done/'
context = {'t': self.signed}
if token is not None:
context['token'] = token # токен в context (тело POST), не в URL
body = {'user_id': user_id, 'context': context}
return self.client.post(
url, data=json.dumps(body), content_type='application/json',
)
def test_rejects_without_configured_token(self):
with self.settings(MATTERMOST_INTERACTIVE_TOKEN=''):
resp = self._post(token='x')
self.assertEqual(resp.status_code, 403)
def test_rejects_wrong_token(self):
with self.settings(MATTERMOST_INTERACTIVE_TOKEN='secret'):
resp = self._post(token='wrong')
self.assertEqual(resp.status_code, 403)
def test_accepts_context_token(self):
with self.settings(MATTERMOST_INTERACTIVE_TOKEN='secret'):
resp = self._post(token='secret')
self.assertEqual(resp.status_code, 200)
self.task.refresh_from_db()
self.assertEqual(self.task.status, 'done')
from django.test import override_settings
from apps.mattermost_connector.services.bot_service import _task_done_attachment
class TaskDoneAttachmentTokenTests(TestCase):
@override_settings(MATTERMOST_INTERACTIVE_TOKEN='shhh', PORTAL_BASE_URL='https://portal.example')
def test_token_in_context_not_url(self):
class _T:
pk = 42
integ = _task_done_attachment(_T())['attachments'][0]['actions'][0]['integration']
self.assertNotIn('?', integ['url'])
self.assertNotIn('token', integ['url'])
self.assertEqual(integ['context']['token'], 'shhh')
self.assertIn('t', integ['context'])
@override_settings(MATTERMOST_INTERACTIVE_TOKEN='', PORTAL_BASE_URL='https://portal.example')
def test_no_token_key_when_unset(self):
class _T:
pk = 42
integ = _task_done_attachment(_T())['attachments'][0]['actions'][0]['integration']
self.assertNotIn('token', integ['context'])
class ChatApiProxyTests(TestCase):
def setUp(self):
from django.contrib.auth.models import Group
from core.models import ConnectorConfig
from core.test_utils import login_2fa
self.user = make_employee(mattermost_user_id='mm-proxy')
self.user.groups.add(Group.objects.create(name='chat-proxy-testers'))
MattermostUser.objects.create(
django_user=self.user, mattermost_id='mm-proxy', username='proxy',
email=self.user.email, is_active=True,
)
mm = MattermostUser.objects.get(django_user=self.user)
mm.set_access_token('user-pat-token')
mm.save(update_fields=['access_token_encrypted'])
ConnectorConfig.objects.create(
connector_type='mattermost',
is_enabled=True,
host='mm.test.local',
port=443,
use_ssl=True,
extra_config={'public_url': 'https://chat.example.com'},
)
login_2fa(self.client, self.user)
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
def test_proxies_get_channels(self, client_cls):
upstream = mock.Mock()
upstream.status_code = 200
upstream.content = b'[{"id":"ch1","type":"O"}]'
upstream.headers = {'content-type': 'application/json'}
client = mock.MagicMock()
client.__enter__.return_value = client
client.__exit__.return_value = False
client.request.return_value = upstream
client_cls.return_value = client
resp = self.client.get('/chat/api/users/me/channels?include_deleted=true')
self.assertEqual(resp.status_code, 200)
self.assertIn(b'ch1', resp.content)
args, kwargs = client.request.call_args
self.assertEqual(args[0], 'GET')
self.assertTrue(args[1].startswith('https://mm.test.local/api/v4/users/me/channels'))
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token')
def test_rejects_path_traversal(self):
resp = self.client.get('/chat/api/users/../admin')
self.assertEqual(resp.status_code, 400)
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
def test_post_users_ids_requires_csrf(self, client_cls):
"""POST без CSRF → 403 (как в проде при HttpOnly cookie + пустой X-CSRFToken)."""
from django.test import Client
csrf_client = Client(enforce_csrf_checks=True)
from core.test_utils import login_2fa
login_2fa(csrf_client, self.user)
resp = csrf_client.post(
'/chat/api/users/ids',
data='["mm-proxy"]',
content_type='application/json',
)
self.assertEqual(resp.status_code, 403)
upstream = mock.Mock()
upstream.status_code = 200
upstream.content = b'[{"id":"mm-proxy","username":"proxy"}]'
upstream.headers = {'content-type': 'application/json'}
client = mock.MagicMock()
client.__enter__.return_value = client
client.__exit__.return_value = False
client.request.return_value = upstream
client_cls.return_value = client
csrf_client.get('/chat/') # ensure csrf cookie
token = csrf_client.cookies.get('csrftoken')
self.assertIsNotNone(token)
resp_ok = csrf_client.post(
'/chat/api/users/ids',
data='["mm-proxy"]',
content_type='application/json',
HTTP_X_CSRFTOKEN=token.value,
)
self.assertEqual(resp_ok.status_code, 200)
self.assertIn(b'mm-proxy', resp_ok.content)
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
def test_proxies_multipart_file_upload(self, client_cls):
"""После разбора multipart Django request.body недоступен — прокси пересобирает."""
from django.core.files.uploadedfile import SimpleUploadedFile
upstream = mock.Mock()
upstream.status_code = 201
upstream.content = b'{"file_infos":[{"id":"f1","name":"a.txt"}]}'
upstream.headers = {'content-type': 'application/json'}
client = mock.MagicMock()
client.__enter__.return_value = client
client.__exit__.return_value = False
client.request.return_value = upstream
client_cls.return_value = client
uploaded = SimpleUploadedFile('a.txt', b'hello', content_type='text/plain')
resp = self.client.post(
'/chat/api/files',
data={'channel_id': 'ch1', 'files': uploaded},
)
self.assertEqual(resp.status_code, 201)
self.assertIn(b'file_infos', resp.content)
_args, kwargs = client.request.call_args
self.assertEqual(_args[0], 'POST')
self.assertTrue(_args[1].endswith('/api/v4/files'))
self.assertIn('files', kwargs)
self.assertNotIn('data', kwargs)
self.assertNotIn('content', kwargs)
self.assertNotIn('Content-Type', kwargs['headers'])
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token')
# httpx реально умеет закодировать этот формат
built = httpx.Request('POST', 'https://mm.test.local/api/v4/files', files=kwargs['files'])
self.assertIn('multipart/form-data', built.headers['content-type'])
body = built.read()
self.assertIn(b'channel_id', body)
self.assertIn(b'hello', body)