31dd3b4ffc
Co-authored-by: Cursor <cursoragent@cursor.com>
417 lines
18 KiB
Python
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/')
|