Files
chatballs/apps/backend/hub_platform/identity/tests.py
T

959 lines
43 KiB
Python

import json
from unittest import mock
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sessions.backends.db import SessionStore
from django.core import mail
from django.test import Client, TestCase, override_settings
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from hub_platform.testing import TenantAPIClient as APIClient
from rest_framework.throttling import ScopedRateThrottle
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.auth.totp_utils import _totp_code
from hub_platform.identity.models import (
AuditEvent,
Department,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.identity.policy import ResourceScope, authorize
from hub_platform.products.models import Product
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
class BootstrapOwnerTests(TestCase):
def test_bootstrap_creates_edevs_foundation(self) -> None:
result = bootstrap_edevs_owner(
email="owner@edevs.tech",
password="temporary-password",
full_name="Owner",
)
self.assertTrue(result.created_owner)
self.assertEqual(Organization.objects.get().slug, "edevs")
# Seed создаёт отделы продаж и поддержки (SPEC-HUB-0010 §4.1).
self.assertEqual(
set(Department.objects.values_list("code", flat=True)), {"sales", "support"}
)
self.assertEqual(result.support_department.code, "support")
self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"firepage", "foxray"})
self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER)
# TOTP выключен по умолчанию (намеренно, локальная разработка).
self.assertFalse(result.owner.memberships.get().totp_required)
self.assertTrue(result.owner.is_staff)
self.assertTrue(result.owner.is_superuser)
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.full_name, "Анна Котова")
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 02")
assignment = operator.memberships.get().access_assignments.get(revoked_at__isnull=True)
self.assertEqual(assignment.scope_type, "DEPARTMENT")
self.assertEqual(assignment.department.code, "sales")
self.assertIn(
"conversations.view",
assignment.access_profile.capability_links.values_list(
"capability_code", flat=True
),
)
self.assertTrue(AuditEvent.objects.filter(action="identity.owner_bootstrapped").exists())
def test_bootstrap_is_idempotent_for_owner(self) -> None:
first = bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
second = bootstrap_edevs_owner(email="owner@edevs.tech", password="another-password")
self.assertTrue(first.created_owner)
self.assertFalse(second.created_owner)
self.assertEqual(HumanUser.objects.count(), 2)
self.assertEqual(Organization.objects.count(), 1)
self.assertEqual(Product.objects.count(), 2)
def test_bootstrap_promotes_existing_owner_to_django_admin_access(self) -> None:
user = HumanUser.objects.create_user(email="owner@edevs.tech", password="temporary-password")
result = bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
user.refresh_from_db()
self.assertFalse(result.created_owner)
self.assertTrue(user.is_staff)
self.assertTrue(user.is_superuser)
class PermissionTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
def test_owner_can_access_global_settings_and_sales_workspace(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
membership = owner.memberships.get(organization=self.organization)
self.assertTrue(
authorize(membership, "settings.manage", ResourceScope(self.organization.id))
)
self.assertTrue(
authorize(
membership,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
)
def test_operator_cannot_access_global_settings(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
membership = operator.memberships.get(organization=self.organization)
self.assertFalse(
authorize(membership, "settings.manage", ResourceScope(self.organization.id))
)
self.assertTrue(
authorize(
membership,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
)
class AuthEndpointTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.client = Client()
def test_login_returns_session_payload(self) -> None:
response = self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "temporary-password"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertTrue(payload["authenticated"])
self.assertNotIn("organizationName", payload["user"])
self.assertNotIn("role", payload["user"])
self.assertEqual(len(payload["user"]["memberships"]), 1)
membership = payload["user"]["memberships"][0]
self.assertIn("accessScopes", membership)
self.assertIn("employees.manage_privileged", membership["capabilities"])
self.assertEqual(membership["organizationName"], "Edevs")
self.assertEqual(membership["role"], EmployeeRole.OWNER)
def test_session_sets_csrf_cookie_for_spa(self) -> None:
response = self.client.get("/api/v1/auth/session/")
self.assertEqual(response.status_code, 200)
self.assertIn(settings.CSRF_COOKIE_NAME, response.cookies)
def test_login_rejects_invalid_password(self) -> None:
response = self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "wrong-password"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
self.assertTrue(AuditEvent.objects.filter(action="identity.login_failed").exists())
def test_password_reset_request_enqueues_outbox_event(self) -> None:
from hub_platform.events.models import OutboxEvent
owner = HumanUser.objects.get(email="owner@edevs.tech")
response = self.client.post(
"/api/v1/auth/password-reset/request/",
data=json.dumps({"email": "owner@edevs.tech"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["ok"])
# Письмо отправляется воркером асинхронно, не в запросе.
self.assertEqual(len(mail.outbox), 0)
event = OutboxEvent.objects.get(event_type="identity.password_reset_requested")
self.assertEqual(event.payload, {"userId": owner.id})
self.assertTrue(AuditEvent.objects.filter(action="identity.password_reset_requested").exists())
def test_password_reset_request_does_not_reveal_unknown_email(self) -> None:
from hub_platform.events.models import OutboxEvent
response = self.client.post(
"/api/v1/auth/password-reset/request/",
data=json.dumps({"email": "nobody@edevs.tech"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["ok"])
self.assertEqual(len(mail.outbox), 0)
self.assertFalse(OutboxEvent.objects.filter(event_type="identity.password_reset_requested").exists())
def test_password_reset_handler_sends_email(self) -> None:
from hub_platform.identity.event_handlers import handle_password_reset_requested
owner = HumanUser.objects.get(email="owner@edevs.tech")
handle_password_reset_requested({"userId": owner.id}, None)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["owner@edevs.tech"])
self.assertIn("reset-password", mail.outbox[0].body)
def _reset_link(self, email: str) -> tuple[str, str]:
user = HumanUser.objects.get(email=email)
return urlsafe_base64_encode(force_bytes(user.pk)), default_token_generator.make_token(user)
def test_password_reset_confirm_sets_new_password(self) -> None:
uid, token = self._reset_link("owner@edevs.tech")
response = self.client.post(
"/api/v1/auth/password-reset/confirm/",
data=json.dumps({"uid": uid, "token": token, "newPassword": "Fresh-Pass-99"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["ok"])
self.assertTrue(HumanUser.objects.get(email="owner@edevs.tech").check_password("Fresh-Pass-99"))
self.assertTrue(AuditEvent.objects.filter(action="identity.password_reset_completed").exists())
def test_password_reset_confirm_rejects_invalid_token(self) -> None:
uid, _ = self._reset_link("owner@edevs.tech")
response = self.client.post(
"/api/v1/auth/password-reset/confirm/",
data=json.dumps({"uid": uid, "token": "bad-token", "newPassword": "Fresh-Pass-99"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(HumanUser.objects.get(email="owner@edevs.tech").check_password("Fresh-Pass-99"))
def test_password_reset_validate_reflects_token_state(self) -> None:
uid, token = self._reset_link("owner@edevs.tech")
valid = self.client.get(f"/api/v1/auth/password-reset/validate/?uid={uid}&token={token}")
invalid = self.client.get(f"/api/v1/auth/password-reset/validate/?uid={uid}&token=bad-token")
self.assertTrue(valid.json()["valid"])
self.assertFalse(invalid.json()["valid"])
def test_change_temporary_password_clears_user_flag(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
owner.must_change_password = True
owner.save(update_fields=["must_change_password"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
response = self.client.post(
"/api/v1/auth/change-temporary-password/",
data=json.dumps(
{
"currentPassword": "temporary-password",
"newPassword": "New-Temporary-99",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
owner.refresh_from_db()
self.assertFalse(owner.must_change_password)
def test_change_temporary_password_rejects_weak_password(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
owner.must_change_password = True
owner.save(update_fields=["must_change_password"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
response = self.client.post(
"/api/v1/auth/change-temporary-password/",
data=json.dumps({"currentPassword": "temporary-password", "newPassword": "onlyletters"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
owner.refresh_from_db()
self.assertTrue(owner.must_change_password)
def test_profile_update_changes_current_user_identity(self) -> None:
self.client.login(username="owner@edevs.tech", password="temporary-password")
response = self.client.post(
"/api/v1/auth/profile/update/",
data=json.dumps({"fullName": "Иван Петров", "email": "ivan@edevs.tech"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
owner = HumanUser.objects.get(id=response.json()["user"]["id"])
self.assertEqual(owner.full_name, "Иван Петров")
self.assertEqual(owner.email, "ivan@edevs.tech")
self.assertTrue(AuditEvent.objects.filter(action="identity.profile_updated").exists())
def test_profile_password_changes_current_user_password(self) -> None:
self.client.login(username="owner@edevs.tech", password="temporary-password")
response = self.client.post(
"/api/v1/auth/profile/password/",
data=json.dumps({"currentPassword": "temporary-password", "newPassword": "New-Profile-99"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertTrue(owner.check_password("New-Profile-99"))
self.assertTrue(AuditEvent.objects.filter(action="identity.profile_password_changed").exists())
def test_profile_totp_start_marks_setup_required(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.memberships.get()
profile.totp_required = False
profile.save(update_fields=["totp_required"])
owner.totp_enabled = False
owner.totp_secret = "JBSWY3DPEHPK3PXP"
owner.save(update_fields=["totp_enabled", "totp_secret"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
response = self.client.post("/api/v1/auth/profile/totp/start/")
self.assertEqual(response.status_code, 200)
profile.refresh_from_db()
owner.refresh_from_db()
self.assertFalse(profile.totp_required)
self.assertFalse(owner.totp_enabled)
self.assertEqual(owner.totp_secret, "")
def test_profile_totp_disable_requires_password_and_revokes_other_sessions(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.memberships.get()
owner.totp_enabled = True
owner.totp_secret = "JBSWY3DPEHPK3PXP"
owner.save(update_fields=["totp_enabled", "totp_secret"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
other_session = SessionStore()
other_session["_auth_user_id"] = str(owner.id)
other_session["_auth_user_backend"] = "django.contrib.auth.backends.ModelBackend"
other_session["_auth_user_hash"] = owner.get_session_auth_hash()
other_session.save()
response = self.client.post(
"/api/v1/auth/profile/totp/disable/",
data=json.dumps({"currentPassword": "temporary-password"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["revoked"], 1)
profile.refresh_from_db()
owner.refresh_from_db()
self.assertFalse(profile.totp_required)
self.assertFalse(owner.totp_enabled)
self.assertEqual(owner.totp_secret, "")
def test_totp_setup_and_confirm_enables_profile_totp(self) -> None:
# TOTP по умолчанию не требуется; включаем требование, чтобы пройти setup→confirm.
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.memberships.get()
profile.totp_required = True
profile.save(update_fields=["totp_required"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
setup_response = self.client.get("/api/v1/auth/totp/setup/")
self.assertEqual(setup_response.status_code, 200)
secret = setup_response.json()["secret"]
self.assertTrue(secret)
self.assertIn("otpauth://totp/", setup_response.json()["otpauthUrl"])
confirm_response = self.client.post(
"/api/v1/auth/totp/confirm/",
data=json.dumps({"code": _totp_code(secret)}),
content_type="application/json",
)
self.assertEqual(confirm_response.status_code, 200)
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertTrue(owner.totp_enabled)
def test_enabled_totp_requires_second_factor_before_session(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
owner.totp_secret = "JBSWY3DPEHPK3PXP"
owner.totp_enabled = True
owner.save(update_fields=["totp_secret", "totp_enabled"])
login_response = self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "temporary-password"}),
content_type="application/json",
)
self.assertEqual(login_response.status_code, 200)
self.assertFalse(login_response.json()["authenticated"])
self.assertTrue(login_response.json()["totpRequired"])
verify_response = self.client.post(
"/api/v1/auth/totp/verify/",
data=json.dumps({"code": _totp_code(owner.totp_secret)}),
content_type="application/json",
)
self.assertEqual(verify_response.status_code, 200)
self.assertTrue(verify_response.json()["authenticated"])
@override_settings(ROOT_URLCONF="hub_backend.urls_admin")
class DjangoAdminTests(TestCase):
def test_bootstrapped_owner_can_access_django_admin(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
client = Client()
self.assertTrue(client.login(username="owner@edevs.tech", password="temporary-password"))
response = client.get("/admin/")
self.assertEqual(response.status_code, 200)
class EmployeeEndpointTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_owner_cannot_create_operator_with_temporary_password(self) -> None:
response = self.client.post(
"/api/v1/employees/operators/",
data=json.dumps(
{
"email": "operator@edevs.tech",
"fullName": "Operator",
"positionTitle": "Оператор продаж",
"temporaryPassword": "operator-password",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(HumanUser.objects.filter(email="operator@edevs.tech").exists())
def test_operator_cannot_create_operator(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
self.client.logout()
self.client.login(username="operator@edevs.tech", password="operator-password")
response = self.client.post(
"/api/v1/employees/operators/",
data=json.dumps(
{
"email": "another@edevs.tech",
"temporaryPassword": "operator-password",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertTrue(
AuditEvent.objects.filter(
action="identity.employee_privileged_action_denied"
).exists()
)
def test_owner_blocks_operator(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
response = self.client.post(f"/api/v1/employees/{operator.id}/block/")
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
self.assertTrue(operator.is_active)
self.assertTrue(operator.memberships.get().is_blocked)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_blocked").exists())
def test_owner_updates_operator_card_fields(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
response = self.client.post(
f"/api/v1/employees/{operator.id}/update/",
data=json.dumps(
{
"fullName": "Анна Котова",
"email": "anna.kotova@edevs.tech",
"phone": "+7 916 245 14 03",
"positionTitle": "Оператор продаж",
"role": EmployeeRole.EMPLOYEE,
"department": "sales",
"totpEnabled": True,
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
operator.memberships.get().refresh_from_db()
self.assertEqual(operator.email, "anna.kotova@edevs.tech")
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 03")
self.assertFalse(operator.totp_enabled)
def test_owner_cannot_reset_operator_global_password(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
password_hash = operator.password
response = self.client.post(f"/api/v1/employees/{operator.id}/reset-password/")
self.assertEqual(response.status_code, 403)
operator.refresh_from_db()
self.assertEqual(operator.password, password_hash)
self.assertFalse(operator.must_change_password)
def test_owner_unblocks_operator(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
operator.memberships.get().block()
response = self.client.post(f"/api/v1/employees/{operator.id}/unblock/")
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
operator.memberships.get().refresh_from_db()
self.assertTrue(operator.is_active)
self.assertFalse(operator.memberships.get().is_blocked)
class CompanyEndpointTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_owner_reads_departments_and_products(self) -> None:
departments_response = self.client.get("/api/v1/company/departments/")
products_response = self.client.get("/api/v1/company/products/")
self.assertEqual(departments_response.status_code, 200)
self.assertEqual(products_response.status_code, 200)
self.assertEqual(
set(d["code"] for d in departments_response.json()["items"]),
{"sales", "support"},
)
self.assertEqual(
set(product["code"] for product in products_response.json()["items"]),
{"firepage", "foxray"},
)
def test_owner_creates_and_deactivates_product(self) -> None:
create_response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(create_response.status_code, 201)
product_id = create_response.json()["product"]["id"]
deactivate_response = self.client.post(f"/api/v1/company/products/{product_id}/deactivate/")
self.assertEqual(deactivate_response.status_code, 200)
self.assertEqual(deactivate_response.json()["product"]["status"], "DISABLED")
self.assertTrue(AuditEvent.objects.filter(action="products.product_created").exists())
self.assertTrue(AuditEvent.objects.filter(action="products.product_disabled").exists())
def test_operator_cannot_create_product(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
primary_department=self.sales,
)
self.client.logout()
self.client.login(username="operator@edevs.tech", password="operator-password")
response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
class TotpSecretEncryptionTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
def test_totp_secret_is_encrypted_at_rest_and_decrypted_on_load(self) -> None:
from django.db import connection
from hub_platform.identity.crypto import decrypt_secret
owner = HumanUser.objects.get(email="owner@edevs.tech")
owner.totp_secret = "JBSWY3DPEHPK3PXP"
owner.save(update_fields=["totp_secret"])
with connection.cursor() as cursor:
cursor.execute("SELECT totp_secret FROM identity_humanuser WHERE id = %s", [owner.id])
stored = cursor.fetchone()[0]
self.assertNotEqual(stored, "JBSWY3DPEHPK3PXP")
self.assertEqual(decrypt_secret(stored), "JBSWY3DPEHPK3PXP")
owner.refresh_from_db()
self.assertEqual(owner.totp_secret, "JBSWY3DPEHPK3PXP")
@override_settings(CACHES=_LOCMEM_CACHE)
class ThrottlingTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.client = Client()
def _login(self):
return self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "wrong"}),
content_type="application/json",
)
def test_login_endpoint_is_rate_limited(self) -> None:
# DRF биндит THROTTLE_RATES на импорте, поэтому ставим лимит напрямую.
with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {"login": "1/min"}):
first = self._login()
second = self._login()
self.assertEqual(first.status_code, 401)
self.assertEqual(second.status_code, 429)
class SeedIdempotencyTests(TestCase):
"""seed_hub_initial_data — посев только при первичной установке: на уже
развёрнутой установке (OWNER существует) команда должна быть no-op, чтобы
не затирать данные, изменённые через UI/API (см. seed_catalog и цены офферов).
"""
def test_seed_skipped_when_owner_exists(self) -> None:
from django.core.management import call_command
from io import StringIO
from hub_platform.products.models import Offer
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
offers_before = Offer.objects.count()
out = StringIO()
call_command("seed_hub_initial_data", "--owner-email=owner@edevs.tech", "--owner-password=temporary-password", stdout=out)
self.assertIn("seed skipped", out.getvalue())
# Ни новые офферы, ни цены не создаются и не изменяются на развёрнутой установке.
self.assertEqual(Offer.objects.count(), offers_before)
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-HUB-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
размещение владельца на уровне компании и ровно один владелец на организацию."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
def test_bootstrap_owner_is_company_level_with_title(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertEqual(owner.memberships.get().role, EmployeeRole.OWNER)
self.assertIsNone(owner.memberships.get().primary_department)
self.assertTrue(owner.memberships.get().position_title)
def test_bootstrapped_operator_is_employee_in_sales(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.memberships.get().primary_department, self.sales)
self.assertTrue(operator.memberships.get().position_title)
def test_second_owner_is_rejected(self) -> None:
from django.db import IntegrityError, transaction
second = HumanUser.objects.create_user(email="owner2@edevs.tech", password="temporary-password")
with self.assertRaises(IntegrityError):
with transaction.atomic():
OrganizationMembership.objects.create(
user=second,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Второй владелец",
primary_department=None,
)
def test_owner_with_primary_department_is_rejected(self) -> None:
from django.db import IntegrityError, transaction
user = HumanUser.objects.create_user(email="owner3@edevs.tech", password="temporary-password")
other_org = Organization.objects.create(name="Other", slug="other")
with self.assertRaises(IntegrityError):
with transaction.atomic():
OrganizationMembership.objects.create(
user=user,
organization=other_org,
role=EmployeeRole.OWNER,
position_title="Владелец",
primary_department=self.sales,
)
def test_create_employee_requires_position_title(self) -> None:
client = APIClient()
client.login(username="owner@edevs.tech", password="temporary-password")
response = client.post(
"/api/v1/employees/operators/",
data=json.dumps(
{
"email": "no-title@edevs.tech",
"fullName": "No Title",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(HumanUser.objects.filter(email="no-title@edevs.tech").exists())
class EmployeeGovernanceTests(TestCase):
"""ADR-HUB-0027 этап 2 / SPEC-HUB-0016 §8,§12: административная иерархия
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.sales = Department.objects.get(code="sales")
def _make(self, email: str, role: str, department: Department | None = None) -> HumanUser:
user = HumanUser.objects.create_user(email=email, password="member-password-123")
OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
position_title="Позиция",
primary_department=None if role in (EmployeeRole.OWNER, EmployeeRole.ADMIN) else (department or self.sales),
)
return user
def _client(self, email: str, password: str = "member-password-123") -> APIClient:
client = APIClient()
client.login(username=email, password=password)
return client
def _create(self, client: APIClient, email: str, role: str) -> "object":
return client.post(
"/api/v1/employees/operators/",
data=json.dumps(
{
"email": email,
"fullName": "New Member",
"positionTitle": "Позиция",
"role": role,
}
),
content_type="application/json",
)
# --- создание и назначение ролей ---
def test_owner_creates_admin_at_company_level(self) -> None:
client = self._client("owner@edevs.tech", "temporary-password")
response = self._create(client, "admin@edevs.tech", EmployeeRole.ADMIN)
self.assertEqual(response.status_code, 201)
profile = HumanUser.objects.get(email="admin@edevs.tech").memberships.get()
self.assertEqual(profile.role, EmployeeRole.ADMIN)
self.assertIsNone(profile.primary_department)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists())
def test_admin_cannot_create_admin(self) -> None:
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._create(self._client("admin@edevs.tech"), "admin2@edevs.tech", EmployeeRole.ADMIN)
self.assertEqual(response.status_code, 403)
self.assertFalse(HumanUser.objects.filter(email="admin2@edevs.tech").exists())
self.assertTrue(
AuditEvent.objects.filter(action="identity.employee_privileged_action_denied").exists()
)
def test_admin_creates_employee(self) -> None:
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._create(self._client("admin@edevs.tech"), "emp@edevs.tech", EmployeeRole.EMPLOYEE)
self.assertEqual(response.status_code, 201)
self.assertEqual(
HumanUser.objects.get(email="emp@edevs.tech").memberships.get().role,
EmployeeRole.EMPLOYEE,
)
def test_create_owner_via_flow_is_rejected(self) -> None:
client = self._client("owner@edevs.tech", "temporary-password")
response = self._create(client, "owner2@edevs.tech", EmployeeRole.OWNER)
self.assertEqual(response.status_code, 400)
self.assertFalse(HumanUser.objects.filter(email="owner2@edevs.tech").exists())
# --- target-aware управление ---
def test_admin_can_block_employee(self) -> None:
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{emp.id}/block/")
self.assertEqual(response.status_code, 200)
emp.refresh_from_db()
self.assertTrue(emp.is_active)
self.assertTrue(emp.memberships.get().is_blocked)
def test_admin_cannot_block_another_admin(self) -> None:
other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{other.id}/block/")
self.assertEqual(response.status_code, 403)
other.refresh_from_db()
self.assertTrue(other.is_active)
def test_admin_cannot_block_owner(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{owner.id}/block/")
self.assertEqual(response.status_code, 403)
owner.refresh_from_db()
self.assertTrue(owner.is_active)
def test_admin_cannot_update_another_admin(self) -> None:
other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(
f"/api/v1/employees/{other.id}/update/",
data=json.dumps({"fullName": "Hacked", "email": "admin2@edevs.tech", "positionTitle": "X"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_admin_cannot_change_employee_role(self) -> None:
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(
f"/api/v1/employees/{emp.id}/update/",
data=json.dumps(
{"fullName": "Emp", "email": "emp@edevs.tech", "positionTitle": "X", "role": EmployeeRole.ADMIN}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
emp.memberships.get().refresh_from_db()
self.assertEqual(emp.memberships.get().role, EmployeeRole.EMPLOYEE)
def test_owner_promotes_employee_to_admin(self) -> None:
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
response = self._client("owner@edevs.tech", "temporary-password").post(
f"/api/v1/employees/{emp.id}/update/",
data=json.dumps(
{"fullName": "Emp", "email": "emp@edevs.tech", "positionTitle": "X", "role": EmployeeRole.ADMIN}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
emp.memberships.get().refresh_from_db()
self.assertEqual(emp.memberships.get().role, EmployeeRole.ADMIN)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_role_changed").exists())
def test_employee_cannot_manage(self) -> None:
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
other = self._make("emp2@edevs.tech", EmployeeRole.EMPLOYEE)
response = self._client("emp@edevs.tech").post(f"/api/v1/employees/{other.id}/block/")
self.assertEqual(response.status_code, 403)
emp.refresh_from_db()
def test_cross_org_target_is_not_found(self) -> None:
other_org = Organization.objects.create(name="Other", slug="other")
outsider = HumanUser.objects.create_user(email="out@other.tech", password="member-password-123")
OrganizationMembership.objects.create(
user=outsider, organization=other_org, role=EmployeeRole.EMPLOYEE, position_title="X"
)
response = self._client("owner@edevs.tech", "temporary-password").post(
f"/api/v1/employees/{outsider.id}/block/"
)
self.assertEqual(response.status_code, 404)
# --- передача владения ---
def test_owner_transfers_ownership_atomically(self) -> None:
target = self._make("heir@edevs.tech", EmployeeRole.EMPLOYEE)
owner = HumanUser.objects.get(email="owner@edevs.tech")
response = self._client("owner@edevs.tech", "temporary-password").post(
f"/api/v1/employees/{target.id}/transfer-ownership/",
data=json.dumps({"previousOwnerRole": EmployeeRole.ADMIN}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
target.memberships.get().refresh_from_db()
owner.memberships.get().refresh_from_db()
self.assertEqual(target.memberships.get().role, EmployeeRole.OWNER)
self.assertIsNone(target.memberships.get().primary_department)
self.assertEqual(owner.memberships.get().role, EmployeeRole.ADMIN)
self.assertEqual(
OrganizationMembership.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
1,
)
self.assertTrue(AuditEvent.objects.filter(action="identity.ownership_transferred").exists())
def test_admin_cannot_transfer_ownership(self) -> None:
target = self._make("heir@edevs.tech", EmployeeRole.EMPLOYEE)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._client("admin@edevs.tech").post(
f"/api/v1/employees/{target.id}/transfer-ownership/",
data=json.dumps({"previousOwnerRole": EmployeeRole.EMPLOYEE}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(
OrganizationMembership.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
1,
)
# --- обычные capability ADMIN ---
def test_admin_has_normal_capabilities(self) -> None:
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
client = self._client("admin@edevs.tech")
overview = client.get("/api/v1/conversations/command-overview/?period=today")
self.assertEqual(overview.status_code, 200)
product = client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(product.status_code, 201)
def test_employee_denied_command_overview(self) -> None:
self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
response = self._client("emp@edevs.tech").get("/api/v1/conversations/command-overview/?period=today")
self.assertEqual(response.status_code, 403)
def test_payload_permissions_reflect_actor(self) -> None:
self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
items = self._client("admin@edevs.tech").get("/api/v1/employees/").json()["items"]
by_email = {item["email"]: item for item in items}
# ADMIN может управлять EMPLOYEE, но не OWNER и не менять роли.
self.assertTrue(by_email["emp@edevs.tech"]["permissions"]["canBlock"])
self.assertFalse(by_email["emp@edevs.tech"]["permissions"]["canChangeRole"])
self.assertFalse(by_email["owner@edevs.tech"]["permissions"]["canBlock"])