173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from govoplan_addresses.backend.db.models import AddressBook, AddressSyncSource, Contact
|
|
from govoplan_addresses.backend.ldap import (
|
|
AddressLdapClient,
|
|
AddressLdapEntry,
|
|
AddressLdapError,
|
|
AddressLdapSearchResult,
|
|
)
|
|
from govoplan_addresses.backend.ldap_schemas import AddressLdapSourceCreateRequest
|
|
from govoplan_addresses.backend.service import (
|
|
create_ldap_sync_source,
|
|
preview_sync_source,
|
|
run_sync_source,
|
|
)
|
|
from govoplan_core.db.base import Base
|
|
|
|
|
|
class Principal:
|
|
account_id = "account-1"
|
|
group_ids = frozenset()
|
|
|
|
@property
|
|
def tenant_id(self) -> str:
|
|
return "tenant-1"
|
|
|
|
def has(self, scope: str) -> bool:
|
|
return scope in {
|
|
"addresses:address_book:read",
|
|
"addresses:address_book:write",
|
|
"addresses:contact:read",
|
|
"addresses:contact:write",
|
|
"addresses:contact:delete",
|
|
"addresses:sync:read",
|
|
"addresses:sync:write",
|
|
}
|
|
|
|
|
|
class FakeLdapClient:
|
|
def __init__(self, entries: list[AddressLdapEntry], *, complete: bool = True) -> None:
|
|
self.entries = entries
|
|
self.complete = complete
|
|
|
|
def search(self, *, base_dn: str, search_filter: str, attributes: tuple[str, ...], page_size: int, max_entries: int) -> AddressLdapSearchResult:
|
|
del search_filter, attributes, max_entries
|
|
return AddressLdapSearchResult(
|
|
base_dn=base_dn,
|
|
entries=tuple(self.entries),
|
|
complete=self.complete,
|
|
page_size=page_size,
|
|
)
|
|
|
|
|
|
def ldap_entry(
|
|
key: str,
|
|
*,
|
|
revision: str = "20260802090000Z",
|
|
organization: str = "Analysis Office",
|
|
) -> AddressLdapEntry:
|
|
return AddressLdapEntry(
|
|
dn=f"uid={key},ou=people,dc=example,dc=test",
|
|
attributes={
|
|
"entryUUID": key,
|
|
"modifyTimestamp": revision,
|
|
"displayName": "Ada Lovelace",
|
|
"givenName": "Ada",
|
|
"sn": "Lovelace",
|
|
"mail": "ada@example.test",
|
|
"o": organization,
|
|
"memberOf": ["cn=analysts,ou=groups,dc=example,dc=test"],
|
|
},
|
|
)
|
|
|
|
|
|
class AddressLdapSyncTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
|
|
self.principal = Principal()
|
|
self.book = AddressBook(
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
name="Directory",
|
|
source_kind="local",
|
|
read_only=False,
|
|
)
|
|
self.session.add(self.book)
|
|
self.session.flush()
|
|
self.source = create_ldap_sync_source(
|
|
self.session,
|
|
self.principal,
|
|
self.book.id,
|
|
AddressLdapSourceCreateRequest(
|
|
url="ldaps://directory.example.test",
|
|
display_name="Corporate directory",
|
|
base_dn="ou=people,dc=example,dc=test",
|
|
),
|
|
)
|
|
self.session.flush()
|
|
|
|
def test_preview_is_nonmutating_and_full_sync_is_idempotent(self) -> None:
|
|
client = FakeLdapClient([ldap_entry("person-1")])
|
|
preview = preview_sync_source(self.session, self.principal, self.source.id, client=client)
|
|
self.assertEqual(1, preview.stats.created)
|
|
self.assertEqual(0, self.session.query(Contact).count())
|
|
|
|
first = run_sync_source(self.session, self.principal, self.source.id, client=client)
|
|
self.assertEqual(1, first.stats.created)
|
|
contact = self.session.query(Contact).one()
|
|
self.assertEqual("ldap", contact.source_kind)
|
|
self.assertEqual("person-1", contact.provenance["ldap"]["source_key"])
|
|
self.assertEqual("succeeded", self.source.status)
|
|
|
|
repeated = run_sync_source(self.session, self.principal, self.source.id, client=client)
|
|
self.assertEqual(1, repeated.stats.unchanged)
|
|
self.assertEqual(1, self.session.query(Contact).count())
|
|
|
|
changed_client = FakeLdapClient(
|
|
[ldap_entry("person-1", revision="20260802100000Z", organization="Computing Office")]
|
|
)
|
|
changed = run_sync_source(self.session, self.principal, self.source.id, client=changed_client)
|
|
self.assertEqual(1, changed.stats.updated)
|
|
self.assertEqual("Computing Office", self.session.query(Contact).one().organization)
|
|
|
|
def test_only_complete_scans_plan_authoritative_deletes(self) -> None:
|
|
run_sync_source(
|
|
self.session,
|
|
self.principal,
|
|
self.source.id,
|
|
client=FakeLdapClient([ldap_entry("person-1")]),
|
|
)
|
|
incomplete = preview_sync_source(
|
|
self.session,
|
|
self.principal,
|
|
self.source.id,
|
|
client=FakeLdapClient([], complete=False),
|
|
)
|
|
self.assertEqual(0, incomplete.stats.deleted)
|
|
self.assertEqual(1, incomplete.stats.errors)
|
|
self.assertIsNone(self.session.query(Contact).one().deleted_at)
|
|
|
|
complete = run_sync_source(
|
|
self.session,
|
|
self.principal,
|
|
self.source.id,
|
|
client=FakeLdapClient([], complete=True),
|
|
)
|
|
self.assertEqual(1, complete.stats.deleted)
|
|
self.assertIsNotNone(self.session.query(Contact).one().deleted_at)
|
|
|
|
def test_connector_requires_encrypted_transport(self) -> None:
|
|
with self.assertRaisesRegex(AddressLdapError, "require StartTLS"):
|
|
AddressLdapClient(url="ldap://directory.example.test", start_tls=False)
|
|
with self.assertRaisesRegex(AddressLdapError, "must not contain credentials"):
|
|
AddressLdapClient(url="ldaps://user:secret@directory.example.test")
|
|
|
|
def test_source_is_always_read_only(self) -> None:
|
|
source = self.session.get(AddressSyncSource, self.source.id)
|
|
self.assertTrue(source.read_only)
|
|
self.assertEqual("read_only", source.sync_direction)
|
|
self.assertTrue(source.address_book.read_only)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|