mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
959 lines
43 KiB
Python
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"])
|