Files
ACORE/apps/employees/tests.py
T

417 lines
18 KiB
Python

import datetime
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
from apps.employees.models import Employee, EmployeePermissionOverride
from core.test_utils import login_2fa
def make_employee(username, **kw):
kw.setdefault('first_name', 'Иван')
kw.setdefault('last_name', 'Иванов')
kw.setdefault('hire_date', datetime.date(2020, 1, 1))
kw.setdefault('mattermost_sync_enabled', False)
kw.setdefault('ad_sync_enabled', False)
return Employee.objects.create_user(username=username, **kw)
class EmployeeCleanTests(TestCase):
"""Валидация clean(): разделение фармацевтов и офисных/складских, даты, руководитель."""
def test_pharmacy_requires_pharmacy(self):
e = make_employee('e1', user_location_type='pharmacy')
with self.assertRaises(ValidationError) as cm:
e.clean()
self.assertIn('user_pharmacy', cm.exception.message_dict)
def test_office_requires_department(self):
e = make_employee('e2', user_location_type='office')
with self.assertRaises(ValidationError) as cm:
e.clean()
self.assertIn('department', cm.exception.message_dict)
def test_termination_before_hire_rejected(self):
e = make_employee('e3', hire_date=datetime.date(2022, 1, 1),
termination_date=datetime.date(2021, 1, 1))
with self.assertRaises(ValidationError) as cm:
e.clean()
self.assertIn('termination_date', cm.exception.message_dict)
def test_employment_type_defaults_to_staff(self):
e = make_employee('e_staff')
self.assertEqual(e.employment_type, Employee.EMPLOYMENT_STAFF)
self.assertFalse(e.is_contract_worker)
def test_contract_worker_flag(self):
e = make_employee('e_cpx', employment_type=Employee.EMPLOYMENT_CONTRACT)
self.assertTrue(e.is_contract_worker)
def test_cannot_be_own_manager(self):
e = make_employee('e4')
e.manager = e
with self.assertRaises(ValidationError) as cm:
e.clean()
self.assertIn('manager', cm.exception.message_dict)
class EmployeePermissionOverrideTests(TestCase):
"""has_perm с индивидуальными override: grant выдаёт, revoke отзывает даже
групповое право; неактивные/просроченные/будущие — игнорируются."""
@classmethod
def setUpTestData(cls):
cls.perm = Permission.objects.first()
cls.perm_str = f'{cls.perm.content_type.app_label}.{cls.perm.codename}'
def _override(self, employee, **kw):
kw.setdefault('override_type', 'grant')
return EmployeePermissionOverride.objects.create(
employee=employee, permission=self.perm, **kw)
def test_grant_override_gives_permission(self):
e = make_employee('g1')
self._override(e, override_type='grant')
self.assertTrue(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
def test_revoke_override_removes_group_permission(self):
e = make_employee('r1')
group = Group.objects.create(name='grp')
group.permissions.add(self.perm)
e.groups.add(group)
# базово право есть через группу
self.assertTrue(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
self._override(e, override_type='revoke')
self.assertFalse(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
def test_inactive_override_ignored(self):
e = make_employee('i1')
self._override(e, override_type='grant', is_active=False)
self.assertFalse(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
def test_expired_override_ignored(self):
e = make_employee('x1')
self._override(e, override_type='grant',
valid_until=timezone.now().date() - datetime.timedelta(days=1))
self.assertFalse(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
def test_future_override_ignored(self):
e = make_employee('f1')
self._override(e, override_type='grant',
valid_from=timezone.now().date() + datetime.timedelta(days=1))
self.assertFalse(Employee.objects.get(pk=e.pk).has_perm(self.perm_str))
def test_is_currently_active(self):
e = make_employee('c1')
today = timezone.now().date()
o = EmployeePermissionOverride(
employee=e, permission=self.perm, override_type='grant',
valid_from=today - datetime.timedelta(days=1),
valid_until=today + datetime.timedelta(days=1))
self.assertTrue(o.is_currently_active())
o.is_active = False
self.assertFalse(o.is_currently_active())
class EmployeeAPITests(TestCase):
"""EmployeeViewSet — только чтение и только для staff."""
def setUp(self):
self.staff = make_employee('staff1', is_staff=True)
self.regular = make_employee('user1')
self.target = make_employee('target1')
def test_anonymous_denied(self):
self.assertEqual(self.client.get('/api/employees/').status_code, 403)
def test_regular_user_denied(self):
self.client.force_login(self.regular)
self.assertEqual(self.client.get('/api/employees/').status_code, 403)
def test_staff_can_list(self):
login_2fa(self.client, self.staff)
resp = self.client.get('/api/employees/')
self.assertEqual(resp.status_code, 200)
def test_staff_cannot_create_via_api(self):
login_2fa(self.client, self.staff)
resp = self.client.post('/api/employees/', {
'username': 'hacker',
'first_name': 'H',
'last_name': 'H',
}, content_type='application/json')
self.assertEqual(resp.status_code, 405)
def test_staff_cannot_delete_via_api(self):
login_2fa(self.client, self.staff)
resp = self.client.delete(f'/api/employees/{self.target.pk}/')
self.assertEqual(resp.status_code, 405)
class TwoFactorTests(TestCase):
"""Обязательная 2FA для staff: enforcement middleware, setup, verify, резервные коды."""
def setUp(self):
self.staff = make_employee('tfa_staff', is_staff=True)
self.regular = make_employee('tfa_user')
def test_staff_without_2fa_redirected_to_setup(self):
self.client.force_login(self.staff)
r = self.client.get('/dashboard/')
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/setup/', r['Location'])
def test_regular_user_not_forced(self):
self.client.force_login(self.regular)
r = self.client.get('/dashboard/')
self.assertNotIn('/2fa/', r.get('Location', '') or '')
def test_totp_enabled_regular_user_must_verify(self):
self.regular.totp_enabled = True
self.regular.save(update_fields=['totp_enabled'])
self.client.force_login(self.regular)
r = self.client.get('/dashboard/')
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/verify/', r['Location'])
def test_setup_enables_2fa_and_issues_backup_codes(self):
import pyotp
self.client.force_login(self.staff)
self.client.get('/2fa/setup/') # генерируем секрет в сессии
secret = self.client.session['2fa_setup_secret']
code = pyotp.TOTP(secret).now()
r = self.client.post('/2fa/setup/', {'code': code})
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/backup-codes/', r['Location'])
self.staff.refresh_from_db()
self.assertTrue(self.staff.totp_enabled)
self.assertTrue(self.staff.totp_secret)
self.assertEqual(len(self.staff.totp_backup_codes), 10)
def test_setup_rejects_wrong_code(self):
self.client.force_login(self.staff)
self.client.get('/2fa/setup/')
r = self.client.post('/2fa/setup/', {'code': '000000'})
self.assertEqual(r.status_code, 200)
self.staff.refresh_from_db()
self.assertFalse(self.staff.totp_enabled)
def test_verify_corrupted_secret_redirects_to_setup(self):
self.staff.totp_secret = 'gAAAAABinvalid-fernet-blob-not-base32'
self.staff.totp_enabled = True
self.staff.save()
self.client.force_login(self.staff)
r = self.client.get('/2fa/verify/')
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/setup/', r['Location'])
self.assertIn('reset=1', r['Location'])
self.staff.refresh_from_db()
self.assertFalse(self.staff.totp_enabled)
def test_verify_invalid_secret_post_does_not_500(self):
self.staff.totp_secret = 'not-valid-base32!!!'
self.staff.totp_enabled = True
self.staff.save()
self.client.force_login(self.staff)
r = self.client.post('/2fa/verify/', {'code': '123456', 'next': '/'})
self.assertIn(r.status_code, (200, 302))
self.assertNotEqual(r.status_code, 500)
import pyotp
secret = pyotp.random_base32()
self.staff.totp_secret = secret
self.staff.totp_enabled = True
self.staff.save()
self.client.force_login(self.staff)
r = self.client.get('/dashboard/')
self.assertIn('/2fa/verify/', r['Location'])
bad = self.client.post('/2fa/verify/', {'code': '000000', 'next': '/dashboard/'})
self.assertEqual(bad.status_code, 200)
code = pyotp.TOTP(secret).now()
ok = self.client.post('/2fa/verify/', {'code': code, 'next': '/dashboard/'})
self.assertEqual(ok.status_code, 302)
self.assertIn('/dashboard/', ok['Location'])
self.assertEqual(self.client.get('/dashboard/').status_code, 200)
def test_backup_code_consumed_on_use(self):
import pyotp
from apps.employees.services import twofa
codes, hashes = twofa.generate_backup_codes()
self.assertEqual(len(codes), 10)
self.assertRegex(codes[0], r'^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$')
self.staff.totp_secret = pyotp.random_base32()
self.staff.totp_enabled = True
self.staff.totp_backup_codes = hashes
self.staff.save()
self.client.force_login(self.staff)
r = self.client.post('/2fa/verify/', {'code': codes[0], 'next': '/dashboard/'})
self.assertEqual(r.status_code, 302)
self.staff.refresh_from_db()
self.assertEqual(len(self.staff.totp_backup_codes), 9)
# повторное использование того же кода не проходит
again = twofa.verify_and_consume_backup(self.staff, codes[0])
self.assertFalse(again)
def test_disable_resets_all_fields(self):
import pyotp
from apps.employees.services import twofa
self.staff.totp_secret = pyotp.random_base32()
self.staff.totp_enabled = True
self.staff.totp_backup_codes = ['x']
self.staff.save()
twofa.disable(self.staff)
self.staff.refresh_from_db()
self.assertFalse(self.staff.totp_enabled)
self.assertEqual(self.staff.totp_secret, '')
self.assertEqual(self.staff.totp_backup_codes, [])
def test_settings_redirects_to_profile_settings(self):
self.client.force_login(self.regular)
r = self.client.get('/settings/')
self.assertEqual(r.status_code, 302)
self.assertIn(f'/employees/{self.regular.pk}/?tab=settings', r['Location'])
def test_profile_settings_shows_2fa_block(self):
self.client.force_login(self.regular)
r = self.client.get(f'/employees/{self.regular.pk}/?tab=settings')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Двухфакторная аутентификация')
self.assertContains(r, 'Настроить 2FA')
def test_setup_via_profile_return_to(self):
import pyotp
self.client.force_login(self.regular)
r = self.client.get('/2fa/setup/?return_to=profile')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Включить 2FA')
self.assertContains(r, 'data:image/png;base64')
secret = self.client.session['2fa_setup_secret']
code = pyotp.TOTP(secret).now()
r = self.client.post('/2fa/setup/', {'code': code, 'return_to': 'profile'})
self.assertEqual(r.status_code, 200)
self.assertContains(r, '2FA успешно включена')
self.regular.refresh_from_db()
self.assertTrue(self.regular.totp_enabled)
def test_disable_optional_2fa_via_profile(self):
import pyotp
secret = pyotp.random_base32()
self.regular.totp_secret = secret
self.regular.totp_enabled = True
self.regular.totp_backup_codes = ['abc']
self.regular.save()
self.client.force_login(self.regular)
self.client.session['2fa_verified'] = True
self.client.session.save()
code = pyotp.TOTP(secret).now()
r = self.client.post('/2fa/disable/', {'code': code})
self.assertEqual(r.status_code, 200)
self.assertContains(r, '2FA отключена')
self.regular.refresh_from_db()
self.assertFalse(self.regular.totp_enabled)
def test_staff_cannot_disable_2fa(self):
import pyotp
secret = pyotp.random_base32()
self.staff.totp_secret = secret
self.staff.totp_enabled = True
self.staff.save()
self.client.force_login(self.staff)
self.client.session['2fa_verified'] = True
self.client.session.save()
code = pyotp.TOTP(secret).now()
r = self.client.post('/2fa/disable/', {'code': code})
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Отключение 2FA недоступно')
self.staff.refresh_from_db()
self.assertTrue(self.staff.totp_enabled)
def test_staff_can_access_profile_settings_before_2fa(self):
self.client.force_login(self.staff)
r = self.client.get(f'/employees/{self.staff.pk}/?tab=settings')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Настроить 2FA')
"""Staff без 2FA не должен дотягиваться до /api/ (2FA больше не в ALLOWED_PREFIXES)."""
def test_api_path_enforced_for_staff_without_2fa(self):
staff = make_employee('api_staff', is_staff=True)
self.client.force_login(staff)
r = self.client.get('/api/attendance/')
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/', r['Location'])
class EmployeeAdminPasswordTests(TestCase):
"""Админ может задать пароль сотрудника из карточки Unfold."""
def setUp(self):
self.employee = make_employee('pwd.target', password='OldPass123')
def _form(self, **overrides):
from apps.employees.admin import EmployeeAdminForm
emp = self.employee
data = {
'username': emp.username,
'email': emp.email or '',
'last_name': emp.last_name,
'first_name': emp.first_name,
'hire_date': emp.hire_date.isoformat(),
'employment_type': emp.employment_type,
'date_joined': emp.date_joined.strftime('%Y-%m-%d %H:%M:%S'),
'is_active': True,
'mattermost_sync_status': emp.mattermost_sync_status,
'ad_sync_status': emp.ad_sync_status,
'default_page_size': emp.default_page_size,
}
data.update(overrides)
return EmployeeAdminForm(data, instance=emp)
def test_blank_password_keeps_current(self):
form = self._form(new_password='', new_password_confirm='')
self.assertTrue(form.is_valid(), form.errors)
form.save()
self.employee.refresh_from_db()
self.assertTrue(self.employee.check_password('OldPass123'))
def test_mismatch_rejected(self):
form = self._form(new_password='GoodPass123!', new_password_confirm='other')
self.assertFalse(form.is_valid())
self.assertIn('new_password_confirm', form.errors)
def test_sets_hashed_password(self):
form = self._form(new_password='GoodPass123!', new_password_confirm='GoodPass123!')
self.assertTrue(form.is_valid(), form.errors)
form.save()
self.employee.refresh_from_db()
self.assertTrue(self.employee.check_password('GoodPass123!'))
self.assertFalse(self.employee.check_password('OldPass123'))
def test_admin_change_view_shows_password_fields(self):
admin_user = make_employee('pwd.admin', password='AdminPass123', is_staff=True, is_superuser=True)
login_2fa(self.client, admin_user)
from django.urls import reverse
url = reverse('admin:employees_employee_change', args=[self.employee.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'new_password')
self.assertContains(response, 'Новый пароль')
self.assertTrue(url.startswith('/acore-a-panel/'))
self.assertNotEqual(
url,
f'/admin/employees/employee/{self.employee.pk}/change/',
)
def test_profile_edit_link_points_to_unfold_admin(self):
from django.urls import reverse
admin_user = make_employee('pwd.admin2', password='AdminPass123', is_staff=True, is_superuser=True)
login_2fa(self.client, admin_user)
resp = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 200)
expected = reverse('admin:employees_employee_change', args=[self.employee.pk])
self.assertContains(resp, expected)
self.assertNotContains(resp, f'/admin/employees/employee/{self.employee.pk}/change/')