Corp/backend/tests/guests/test_guest_ldap.py
2026-09-07 19:43:48 +03:00

981 lines
31 KiB
Python

from contextlib import contextmanager
from datetime import timedelta
import pytest
from django.core.files.base import ContentFile
from django.utils import timezone
from employees.ldap.services.constants import UserAccountControl
from employees.models import LdapSyncQueue, LdapSyncState
from employees.tasks import process_ldap_queue_item
from eusrr_backend.db_routers import LdapRouter
from guests.constants import GuestVisitStatus
from guests.ldap.orm_models import LdapGuestUser
from guests.models import Guest, GuestVisit
from guests.services import GuestLdapService, GuestVisitWorkflow
from guests.tasks import execute_guest_queue_operation
class FakeLdapConnection:
def __init__(self):
self.entries = []
self.add_calls = []
self.modify_calls = []
self.existing_dns = set()
self.result = {}
def search(self, search_base=None, search_filter=None, *args, **kwargs):
if search_filter == "(objectClass=organizationalUnit)":
self.entries = (
[FakeLdapEntry(search_base)] if search_base in self.existing_dns else []
)
return True
self.entries = []
return True
def add(self, dn, object_classes, attrs):
self.add_calls.append((dn, object_classes, attrs))
self.existing_dns.add(dn)
return True
def modify(self, dn, changes):
self.modify_calls.append((dn, changes))
return True
def unbind(self):
return None
class FakeLdapEntry:
def __init__(self, dn):
self.entry_dn = dn
class ConflictingEmployeeNumberConnection(FakeLdapConnection):
def search(self, *args, **kwargs):
self.entries = [FakeLdapEntry("CN=Employee,OU=Users,DC=example,DC=test")]
return True
class ExistingGuestEmployeeNumberConnection(FakeLdapConnection):
def __init__(self, guest_dn):
super().__init__()
self.guest_dn = guest_dn
def search(self, search_base=None, search_filter=None, *args, **kwargs):
if search_filter and search_filter.startswith("(employeeNumber="):
self.entries = [FakeLdapEntry(self.guest_dn)]
return True
return super().search(search_base, search_filter, *args, **kwargs)
class FakeLdapGuestUser:
def __init__(self, dn):
self.dn = dn
self.moves = []
self.saved = False
self.update_fields = None
def move_to(self, new_base_dn):
self.moves.append(new_base_dn)
rdn = self.dn.split(",", 1)[0]
self.dn = f"{rdn},{new_base_dn}"
def apply_guest(self, guest):
self.sam_account_name = f"g{guest.id}"
self.user_principal_name = f"g{guest.id}@guest.local"
self.given_name = guest.first_name or ""
self.sn = guest.last_name or "."
self.display_name = guest.full_name or f"Guest {guest.id}"
self.mail = guest.email or ""
self.telephone_number = guest.phone or ""
self.employee_number = str(guest.id)
self.description = f"Guest account for {guest.organization}".strip()
self.user_account_control = UserAccountControl.DISABLED
self.thumbnail_photo = (
b"processed-avatar" if getattr(guest, "avatar", None) else b""
)
return [
"sam_account_name",
"user_principal_name",
"given_name",
"sn",
"display_name",
"mail",
"telephone_number",
"employee_number",
"description",
"user_account_control",
"thumbnail_photo",
]
def save(self, *args, **kwargs):
self.saved = True
self.update_fields = kwargs.get("update_fields")
def get_guest_user_add(fake_conn):
return next(
call
for call in fake_conn.add_calls
if "user" in [item.lower() for item in call[1]]
)
def test_ldap_guest_user_is_routed_to_ldap_database(settings):
settings.DATABASES["ldap"] = {"ENGINE": "ldapdb.backends.ldap"}
router = LdapRouter()
assert router.db_for_read(LdapGuestUser) == "ldap"
assert router.db_for_write(LdapGuestUser) == "ldap"
assert router.allow_migrate("default", "guests", "ldapguestuser") is False
@pytest.mark.django_db
def test_guest_ldap_service_writes_guest_id_to_employee_number(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
)
inviter = user_factory()
guest = Guest.objects.create(first_name="Ldap", last_name="Guest")
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="LDAP test",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestLdapService().sync_guest_for_visit(visit)
assert fake_conn.add_calls
created_ou_dns = [
dn
for dn, object_classes, _attrs in fake_conn.add_calls
if "organizationalUnit" in object_classes
]
assert created_ou_dns == [
"OU=Guests,DC=example,DC=test",
"OU=Active,OU=Guests,DC=example,DC=test",
]
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["employeeNumber"] == str(guest.id)
assert attrs["userAccountControl"] == UserAccountControl.DISABLED
assert not fake_conn.modify_calls
guest.refresh_from_db()
assert guest.is_active is True
assert guest.ldap_username == f"g{guest.id}"
@pytest.mark.django_db
def test_guest_ldap_creation_writes_processed_avatar_to_thumbnail_photo(
settings,
monkeypatch,
tmp_path,
user_factory,
):
settings.MEDIA_ROOT = tmp_path
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.UserMapperService.process_avatar",
lambda self, avatar_bytes, **kwargs: b"processed-avatar",
)
inviter = user_factory()
guest = Guest.objects.create(first_name="Avatar", last_name="Guest")
guest.avatar.save("guest.jpg", ContentFile(b"raw-avatar"), save=True)
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Avatar LDAP test",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestLdapService().sync_guest_for_visit(visit)
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["thumbnailPhoto"] == b"processed-avatar"
assert attrs["employeeNumber"] == str(guest.id)
assert attrs["userAccountControl"] == UserAccountControl.DISABLED
@pytest.mark.django_db
def test_guest_ldap_orm_apply_guest_keeps_disabled_and_updates_thumbnail_photo(
settings,
monkeypatch,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
monkeypatch.setattr(
"guests.ldap.orm_models.UserMapperService.process_avatar",
lambda self, avatar_bytes, **kwargs: b"processed-avatar",
)
guest = Guest.objects.create(
first_name="Existing",
last_name="Avatar",
email="guest@example.test",
phone="+70000000000",
)
guest.avatar.save("guest.jpg", ContentFile(b"raw-avatar"), save=True)
ldap_guest = LdapGuestUser()
ldap_guest.apply_guest(guest)
assert ldap_guest.employee_number == str(guest.id)
assert ldap_guest.thumbnail_photo == b"processed-avatar"
assert ldap_guest.user_account_control == UserAccountControl.DISABLED
@pytest.mark.django_db
def test_guest_ldap_service_disables_and_moves_to_deactivated(
settings,
monkeypatch,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
guest = Guest.objects.create(
first_name="Disable",
last_name="Guest",
is_active=True,
is_blacklisted=True,
ldap_username="g900000000000001",
)
active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=active_dn,
)
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().disable_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == (
f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
)
assert guest.is_active is False
assert guest.is_blacklisted is True
assert fake_orm_guest.moves == ["OU=Deactivated,OU=Guests,DC=example,DC=test"]
assert fake_orm_guest.saved is True
assert "when_changed" not in fake_orm_guest.update_fields
assert "when_created" not in fake_orm_guest.update_fields
assert "member_of" not in fake_orm_guest.update_fields
assert fake_orm_guest.employee_number == str(guest.id)
assert fake_orm_guest.user_account_control == UserAccountControl.DISABLED
@pytest.mark.django_db
def test_guest_ldap_service_moves_existing_guest_to_active_ou_without_enabling(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(first_name="Active", last_name="Guest")
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=deactivated_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Active LDAP move",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
assert fake_orm_guest.saved is True
assert fake_orm_guest.user_account_control == UserAccountControl.DISABLED
assert fake_conn.modify_calls == []
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
@pytest.mark.django_db
def test_guest_ldap_service_adopts_existing_guest_account_when_state_is_missing(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Adopt", last_name="Guest")
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
fake_conn = ExistingGuestEmployeeNumberConnection(deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Adopt existing LDAP account",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
assert not [
call
for call in fake_conn.add_calls
if "user" in [item.lower() for item in call[1]]
]
@pytest.mark.django_db
def test_guest_ldap_service_prefers_real_employee_number_dn_over_stale_state(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Stale", last_name="State")
stale_active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
real_deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=stale_active_dn,
)
LdapSyncQueue.objects.create(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
payload={"guest_id": str(guest.id), "_operation": "guest_sync"},
)
fake_conn = ExistingGuestEmployeeNumberConnection(real_deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Real LDAP DN wins",
status=GuestVisitStatus.REJECTED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(real_deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == real_deactivated_dn
assert guest.is_active is False
assert guest.ldap_last_error == ""
assert fake_orm_guest.moves == []
assert fake_orm_guest.saved is True
assert not LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
status=LdapSyncQueue.Status.PENDING,
).exists()
@pytest.mark.django_db
def test_guest_ldap_service_recreates_account_when_state_dn_is_missing(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Missing", last_name="Ldap")
stale_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=stale_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Recreate missing LDAP account",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: (_ for _ in ()).throw(LdapGuestUser.DoesNotExist()),
)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["employeeNumber"] == str(guest.id)
assert state.ldap_dn == "CN=Ldap Missing,OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert guest.ldap_last_error == ""
@pytest.mark.django_db
def test_guest_ldap_service_adopts_guest_account_after_create_conflict(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Conflict", last_name="Guest")
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
fake_conn = ExistingGuestEmployeeNumberConnection(deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Adopt after conflict",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.find_existing_dn_for_guest",
lambda synced_guest: "",
)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert guest.ldap_last_error == ""
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
@pytest.mark.django_db
def test_blacklisted_guest_stays_in_deactivated_ou_even_with_active_visit(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(
first_name="Blocked",
last_name="Guest",
is_active=True,
is_blacklisted=True,
)
active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=active_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Blocked active visit",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
assert fake_orm_guest.moves == ["OU=Deactivated,OU=Guests,DC=example,DC=test"]
assert guest.is_active is False
assert guest.is_blacklisted is True
@pytest.mark.django_db
def test_unblacklist_guest_allows_active_ou_when_active_visit_exists(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(
first_name="Restored",
last_name="Guest",
is_blacklisted=True,
)
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=deactivated_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Restore access",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().unblacklist_guest(guest)
guest.refresh_from_db()
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
assert guest.is_active is True
assert guest.is_blacklisted is False
@pytest.mark.django_db
def test_guest_ldap_service_keeps_active_ou_when_other_active_visit_exists(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(
first_name="Multi",
last_name="Visit",
is_active=True,
)
active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=active_dn,
)
ending_visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Ending",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(days=2),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Still active",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(days=1),
access_expires_at=timezone.now() + timedelta(days=2),
)
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestVisitWorkflow.revoke(ending_visit, actor=inviter)
guest.refresh_from_db()
assert fake_orm_guest.moves == []
assert guest.is_active is True
@pytest.mark.django_db
def test_execute_guest_queue_operation_dispatches_sync(monkeypatch):
guest = Guest.objects.create(first_name="Queue", last_name="Guest")
called = {}
def fake_sync(self, synced_guest, *, enqueue_on_error=True, raise_on_error=False):
called["guest_id"] = synced_guest.id
called["enqueue_on_error"] = enqueue_on_error
called["raise_on_error"] = raise_on_error
monkeypatch.setattr("guests.services.GuestLdapService.sync_guest", fake_sync)
execute_guest_queue_operation("guest_sync", {"guest_id": str(guest.id)})
assert called["guest_id"] == guest.id
assert called["enqueue_on_error"] is False
assert called["raise_on_error"] is True
@pytest.mark.django_db
def test_enqueue_guest_sync_reuses_pending_item():
guest = Guest.objects.create(first_name="Queue", last_name="Dedupe")
GuestLdapService.enqueue_guest_sync(guest)
GuestLdapService.enqueue_guest_sync(guest)
pending = LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
status=LdapSyncQueue.Status.PENDING,
)
assert pending.count() == 1
assert pending.get().payload == {
"guest_id": str(guest.id),
"_operation": "guest_sync",
}
@pytest.mark.django_db
def test_process_ldap_queue_item_completes_guest_sync(
settings,
monkeypatch,
):
settings.LDAP_ENABLED = True
guest = Guest.objects.create(first_name="Queue", last_name="Complete")
item = LdapSyncQueue.objects.create(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
payload={"guest_id": str(guest.id), "_operation": "guest_sync"},
)
called = {}
def fake_sync(self, synced_guest, *, enqueue_on_error=True, raise_on_error=False):
called["guest_id"] = synced_guest.id
called["raise_on_error"] = raise_on_error
monkeypatch.setattr("guests.services.GuestLdapService.sync_guest", fake_sync)
process_ldap_queue_item.run(item.id)
item.refresh_from_db()
assert item.status == LdapSyncQueue.Status.COMPLETED
assert called == {"guest_id": guest.id, "raise_on_error": True}
@pytest.mark.django_db
def test_process_ldap_queue_item_schedules_retry_on_guest_sync_error(
settings,
monkeypatch,
):
settings.LDAP_ENABLED = True
guest = Guest.objects.create(first_name="Queue", last_name="Retry")
item = LdapSyncQueue.objects.create(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
payload={"guest_id": str(guest.id), "_operation": "guest_sync"},
)
def failing_sync(
self, synced_guest, *, enqueue_on_error=True, raise_on_error=False
):
raise RuntimeError("retry me")
monkeypatch.setattr("guests.services.GuestLdapService.sync_guest", failing_sync)
process_ldap_queue_item.run(item.id)
item.refresh_from_db()
assert item.status == LdapSyncQueue.Status.PENDING
assert item.attempts == 1
assert "retry me" in item.last_error
assert item.next_retry_at is not None
@pytest.mark.django_db
def test_guest_ldap_employee_number_conflict_queues_retry(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
conflict_conn = ConflictingEmployeeNumberConnection()
@contextmanager
def fake_ldap():
yield conflict_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
inviter = user_factory()
admin = user_factory(staff=True)
guest = Guest.objects.create(first_name="Conflict", last_name="Guest")
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Conflict",
status=GuestVisitStatus.PENDING,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestVisitWorkflow.approve(visit, actor=admin)
guest.refresh_from_db()
assert "employeeNumber" in guest.ldap_last_error
assert LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
).exists()
@pytest.mark.django_db
def test_approve_keeps_status_and_queues_retry_on_ldap_error(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
@contextmanager
def failing_ldap():
raise RuntimeError("LDAP unavailable")
yield
monkeypatch.setattr("guests.ldap.orm_models._ldap", failing_ldap)
inviter = user_factory()
admin = user_factory(staff=True)
guest = Guest.objects.create(first_name="Retry", last_name="Guest")
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Retry LDAP",
status=GuestVisitStatus.PENDING,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestVisitWorkflow.approve(visit, actor=admin)
visit.refresh_from_db()
guest.refresh_from_db()
assert visit.status == GuestVisitStatus.APPROVED
assert "LDAP unavailable" in guest.ldap_last_error
assert visit.events.filter(event_type="ldap_failed").exists()
assert LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
).exists()