Tons of new tests and new_model refinement

This commit is contained in:
sfigato 2020-12-27 19:48:57 +01:00
parent b0e5d00994
commit c5a4b86349
Signed by: blallo
GPG Key ID: 0CBE577C9B72DC3F
4 changed files with 454 additions and 56 deletions

View File

@ -8,6 +8,7 @@ import pytest
from phi.async_ldap.new_model import get_dn, User from phi.async_ldap.new_model import get_dn, User
from phi.async_ldap.mixins import Member from phi.async_ldap.mixins import Member
from phi.exceptions import PhiCannotExecute
BASE_DN = "dc=test,dc=domain,dc=tld" BASE_DN = "dc=test,dc=domain,dc=tld"
@ -87,6 +88,34 @@ def test_get_dn_raises():
assert "Unacceptable input:" in str(e.value) assert "Unacceptable input:" in str(e.value)
def test_repr():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
assert repr(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
def test_str():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
assert str(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
@pytest.mark.parametrize(
"input_obj",
[
User(cl, "test_user"),
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
f"uid=test_user,ou=Hackers,{BASE_DN}",
],
)
def test_eq(input_obj):
u = User(cl, "test_user")
assert u == input_obj
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_User_add(): async def test_User_add():
_cl = MockClient(return_value=None) _cl = MockClient(return_value=None)
@ -141,3 +170,27 @@ async def test_User_delete():
assert _cl.connect_called assert _cl.connect_called
assert await _cl.connect_called_with_delete() assert await _cl.connect_called_with_delete()
@pytest.mark.asyncio
async def test_User_get_invalid_attr_raises():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
with pytest.raises(PhiCannotExecute) as ex:
_ = u["iDoNotExist"]
assert "iDoNotExist" in str(ex.value)
assert "is not an allowed ldap attribute" in str(ex.value)
@pytest.mark.asyncio
async def test_User_set_invalid_attr_raises():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
with pytest.raises(PhiCannotExecute) as ex:
u["iDoNotExist"] = "hello"
assert "iDoNotExist" in str(ex.value)
assert "is not an allowed ldap attribute" in str(ex.value)

View File

@ -61,6 +61,27 @@ async def init_achilles():
return u return u
async def init_patroclus():
u = User(cl, "patroclus")
u["cn"] = "Patroclus"
u["sn"] = "patroclus"
u["mail"] = "patroclus@phthia.gr"
u["userPassword"] = "WannabeAnHero"
await u.save()
return u
async def init_athena():
s = Service(cl, "athena")
s["userPassword"] = "ἁ θεονόα"
await s.save()
return s
async def init_group(group_name, members): async def init_group(group_name, members):
g = Group(cl, group_name, member=members) g = Group(cl, group_name, member=members)
@ -82,6 +103,49 @@ async def test_User_init():
assert u == res assert u == res
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_exists():
async with clean_db():
u1 = await init_achilles()
u2 = User(cl, "enea")
assert await u1.exists()
assert not await u2.exists()
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_double_save_raises():
async with clean_db():
u = await init_achilles()
# Read all the data from the db
await u.sync()
with pytest.raises(e.PhiEntryExists) as ex:
await u.save()
assert u.dn in str(ex.value)
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_describe():
async with clean_db():
u = await init_achilles()
res = await u.describe()
assert res == {
"ou": "Hackers",
"uid": "achilles",
"cn": "Achilles",
"sn": "achilles",
"dn": f"uid=achilles,ou=Hackers,{BASE_DN}",
"mail": "achilles@phthia.gr",
}
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration_test @pytest.mark.integration_test
async def test_User_modify(): async def test_User_modify():
@ -101,6 +165,19 @@ async def test_User_modify():
assert u[attr] == res[attr] assert u[attr] == res[attr]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_modify_raises():
"""Modifying a not-yet-existing user raises."""
async with clean_db():
u = User(cl, "enea")
with pytest.raises(e.PhiEntryDoesNotExist) as ex:
await u.modify()
assert u.dn in str(ex.value)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration_test @pytest.mark.integration_test
async def test_User_delete(): async def test_User_delete():
@ -116,6 +193,34 @@ async def test_User_delete():
assert u.dn in str(ex.value) assert u.dn in str(ex.value)
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Service_init():
async with clean_db():
s = await init_athena()
r = Robots(cl)
res = await r.search("athena")
assert s == res
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Service_describe():
async with clean_db():
s = await init_athena()
res = await s.describe()
assert res == {
"ou": "Robots",
"uid": "athena",
"dn": f"uid=athena,ou=Robots,{BASE_DN}",
}
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration_test @pytest.mark.integration_test
async def test_Group_init(): async def test_Group_init():
@ -128,4 +233,130 @@ async def test_Group_init():
res = await c.search("achaeans") res = await c.search("achaeans")
assert g == res assert g == res
assert [u] == [a for a in g.get_members()] assert [u] == [a async for a in g.get_members()]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_describe():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
g = await init_group("achaeans", [u1, u2])
res = await g.describe()
assert res == {
"ou": "Congregations",
"cn": "achaeans",
"dn": f"cn=achaeans,ou=Congregations,{BASE_DN}",
"member": [u1.dn, u2.dn],
}
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_add_member():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g1 = await init_group("achaeans", [u])
g2 = await init_group("gods", [u])
await g2.add_member(a)
m1 = [m async for m in g1.get_members()]
m2 = [m async for m in g2.get_members()]
assert u in m1
assert u in m2
assert a not in m1
assert a in m2
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_remove_member():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g = await init_group("achaeans", [u, a])
m = [a async for a in g.get_members()]
assert u in m
assert a in m
await g.remove_member(a)
assert [u] == [el async for el in g.get_members()]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_groups():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g1 = await init_group("achaeans", [u])
g2 = await init_group("gods", [u, a])
res1 = await u.groups()
res2 = await a.groups()
assert g1 in res1
assert g2 in res1
assert g1 not in res2
assert g2 in res2
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_OU_delete_raises():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
a = await init_athena()
g1 = await init_group("achaeans", [u1, u2])
g2 = await init_group("gods", [a, u1])
h = Hackers(cl)
_saved_val = h.delete_cascade
h.delete_cascade = False
assert not h.delete_cascade
with pytest.raises(e.PhiCannotExecute) as ex:
await h.delete()
assert "delete_cascade is not set" in str(ex.value)
h.delete_cascade = _saved_val
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_OU_delete_cascade():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
a = await init_athena()
g1 = await init_group("achaeans", [u1, u2])
g2 = await init_group("gods", [a, u1])
h = Hackers(cl)
_saved_val = h.delete_cascade
h.delete_cascade = True
assert h.delete_cascade
await h.delete()
g2_members = [e async for e in g2.get_members()]
h_members = [e async for e in h]
assert not await u1.exists()
assert not await u2.exists()
assert h_members == []
assert not await g1.exists()
assert u1 not in g2_members
assert a in g2_members
h.delete_cascade = _saved_val

View File

@ -15,6 +15,15 @@ from phi.exceptions import (
from phi.security import hash_pass, handle_password from phi.security import hash_pass, handle_password
def de_listify(elems):
if not isinstance(elems, list):
return elems
if len(elems) == 1:
return elems[0]
else:
return elems
async def build_heritage(obj, child_class, attribute_id="uid"): async def build_heritage(obj, child_class, attribute_id="uid"):
""" """
Given the object and the child class, yields the Given the object and the child class, yields the
@ -44,40 +53,17 @@ class Singleton(object):
return cls._instances[name] return cls._instances[name]
def get_value(obj, attr):
"""
Return the tuple (attribute_name, attribute_value) from obj. Extract the value,
either it being a constant or the result of a function call.
"""
if not isinstance(getattr(type(obj), attr), property) and callable(
getattr(type(obj), attr)
):
return attr, getattr(obj, attr)()
else:
return attr, getattr(obj, attr)
class Entry(object): class Entry(object):
""" """
Mixin to interact with LDAP. Mixin to interact with LDAP.
""" """
def get_all_ldap_attributes(self):
return [get_value(self, attr) for attr in self.ldap_attributes]
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {self.dn}>" return f"<{self.__class__.__name__} {self.dn}>"
def __str__(self): def __str__(self):
return f"<{self.__class__.__name__} {self.dn}>" return f"<{self.__class__.__name__} {self.dn}>"
def __iter__(self):
for k, v in self.get_all_ldap_attributes():
yield k, v
def __dict__(self):
return dict(self)
async def _create_new(self): async def _create_new(self):
self._entry["objectClass"] = self.object_class self._entry["objectClass"] = self.object_class
async with self.client.connect(is_async=True) as conn: async with self.client.connect(is_async=True) as conn:
@ -87,10 +73,10 @@ class Entry(object):
async def _get(self): async def _get(self):
async with self.client.connect(is_async=True) as conn: async with self.client.connect(is_async=True) as conn:
# This returns a list of dicts. It should always contain only one item: # This returns a list of dicts. It should always contain only one item:
# the one we are interested. # the one we are interested in.
_res = await conn.search(self.dn, 0) _res = await conn.search(self.dn, 0)
if len(_res) == 0: if len(_res) == 0:
raise PhiEntryDoesNotExist() raise PhiEntryDoesNotExist(self.dn)
elif len(_res) > 1: elif len(_res) > 1:
raise PhiUnexpectedRuntimeValue( raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", res "return value should be no more than one", res
@ -105,10 +91,15 @@ class Entry(object):
async def _delete(self): async def _delete(self):
async with self.client.connect(is_async=True) as conn: async with self.client.connect(is_async=True) as conn:
await conn.delete(self.dn) await conn.delete(self.dn, recursive=self.delete_cascade)
async def describe(self): async def describe(self):
return dict(await self._get()) _internal = await self._get()
values = dict((k, de_listify(_internal[k])) for k in self.ldap_attributes)
if "userPassword" in self.ldap_attributes:
values.pop("userPassword")
values["dn"] = self.dn
return values
@property @property
def delete_cascade(self): def delete_cascade(self):
@ -140,7 +131,10 @@ class OrganizationalUnit(object):
self.children = build_heritage(self, self.child_class, self.child_class.id_tag) self.children = build_heritage(self, self.child_class, self.child_class.id_tag)
self._entry = LDAPEntry(self.dn) self._entry = LDAPEntry(self.dn)
for k, v in kwargs.items(): for k, v in kwargs.items():
if k in self.ldap_attributes:
self._entry[k] = v self._entry[k] = v
if "delete_cascade" in kwargs:
self.delete_cascade = delete_cascade
def __aiter__(self): def __aiter__(self):
return self return self
@ -164,12 +158,21 @@ class OrganizationalUnit(object):
return f"ou={self.ou},{self.base_dn}" return f"ou={self.ou},{self.base_dn}"
async def save(self): async def save(self):
"""
This function iterates over the OU's children and invokes its `save` method,
ignoring errors from yet existing ones.
"""
async for child in self:
try: try:
await self._create_new() await child.save()
except bonsai.errors.AlreadyExists: except PhiEntryExists:
raise PhiEntryExists(self.dn) pass
async def search(self, member_name): async def search(self, member_name):
"""
This function allows one to search through the OU's children. The search
function is the one from the underlying library (bonsai) and is strict as such.
"""
result = None result = None
async with self.client.connect(is_async=True) as conn: async with self.client.connect(is_async=True) as conn:
result = await conn.search( result = await conn.search(
@ -226,9 +229,31 @@ class Member(object):
It provides the methods to interact with the LDAP db. It provides the methods to interact with the LDAP db.
To properly use, `ou`, `object_class` and `ldap_attributes` class attributes must To properly use, `ou`, `object_class` and `ldap_attributes` class attributes must
be specified when inheriting. be specified when inheriting.
## Usage
The initialization needs an `phi.async_ldap.AsyncClient` and a `name`, that is used
as value in the identification attribute (i.e. `uid`).
This inits an object in memory that may or may not exist in the ldap database yet.
To test it, one can invoke the async method `exists` or may try to `sync`, handling
the corresponding exception (`PhiEntryDoesNotExist`).
To save a new instance, one can `save`. The instance accepts dict-like get and set
on the aforementioned `ldap_attributes`. Once an attribute value has been modified,
one can invoke `modify` to persist the changes.
To remove an instance from the database, one can invoke `delete`.
## Comparisons
The comparison operation with a `Member` is quite loose: it returns `True` with
either:
- an instance of the same `type` (i.e. the same class whose this mixin is used
into) whose `dn` matches
- an `LDAPEntry` whose `dn` matches
- a string matching the `dn`
""" """
def __init__(self, client, name, **kwargs): def __init__(self, client, name, **kwargs):
super().__init__()
self.client = client self.client = client
self.base_dn = client.base_dn self.base_dn = client.base_dn
self.name = name self.name = name
@ -244,7 +269,7 @@ class Member(object):
elif isinstance(other, str): elif isinstance(other, str):
return other == self.dn return other == self.dn
elif isinstance(other, LDAPEntry): elif isinstance(other, LDAPEntry):
return str(other) == self.dn return other["dn"] == self.dn
else: else:
return False return False
@ -255,30 +280,61 @@ class Member(object):
def __setitem__(self, attr, val): def __setitem__(self, attr, val):
if attr not in self.ldap_attributes: if attr not in self.ldap_attributes:
raise PhiCannotExecute( raise PhiCannotExecute(
f"{attr} is not an allowed ldap_attribute: {self.ldap_attributes}" f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}"
) )
self._entry[attr] = val self._entry[attr] = val
def __getitem__(self, attr): def __getitem__(self, attr):
if attr not in self.ldap_attributes: if attr not in self.ldap_attributes:
raise PhiCannotExecute( raise PhiCannotExecute(
f"{attr} is not an allowed ldap_attribute: {self.ldap_attributes}" f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}"
) )
return self._entry[attr][0] return self._entry[attr][0]
async def save(self): async def save(self):
"""
This method persists on the ldap database an inited instance. Raises
`PhiEntryExists` in case of a yet existing instance. Raises a specific error if
the instance misses any of the needed attributes (accoding to
`ldap_attributes`).
"""
try: try:
await self._create_new() await self._create_new()
except bonsai.errors.AlreadyExists: except bonsai.errors.AlreadyExists:
raise PhiEntryExists(self.dn) raise PhiEntryExists(self.dn)
async def modify(self): async def modify(self):
"""
This method saves the changes made to the instance on the ldap database. Raises
`PhiEntryDoesNotExist` in case of an instance not yet persisted.
"""
await self._modify() await self._modify()
async def delete(self): async def delete(self):
"""
This method removes the instance from the database. Raises
`PhiEntryDoesNotExist` in case the entry does not exist.
"""
await self._delete() await self._delete()
async def sync(self): async def sync(self):
"""
This method reads the `ldap_attributes` of an existing instance from the ldap
database and assigns the values to `self`. It is needed at first instantiation
of the object, in case an instance exists on the database.
"""
res = await self._get() res = await self._get()
_hydrate(self, res) _hydrate(self, res)
return self return self
async def exists(self):
"""
This method returns `True` if the instance exists on the ldap database, `False`
if it does not. It might raise `PhiUnexpectedRuntimeValue` if the ldap state is
inconsistent.
"""
try:
_ = await self.sync()
return True
except PhiEntryDoesNotExist:
return False

View File

@ -6,7 +6,22 @@ from multidict import MultiDict
from phi.async_ldap import mixins from phi.async_ldap import mixins
class User(mixins.Singleton, mixins.Entry, mixins.Member): def parse_dn(dn):
return MultiDict(e.split("=") for e in dn.split(","))
def get_dn(obj):
if isinstance(obj, mixins.Entry):
return obj.dn
elif isinstance(obj, LDAPEntry):
return obj["dn"]
elif isinstance(obj, str):
return obj
else:
raise ValueError(f"Unacceptable input: {obj}")
class User(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = [ object_class = [
"inetOrgPerson", "inetOrgPerson",
"simpleSecurityObject", "simpleSecurityObject",
@ -19,61 +34,104 @@ class User(mixins.Singleton, mixins.Entry, mixins.Member):
ou = "Hackers" ou = "Hackers"
ldap_attributes = ["uid", "ou", "cn", "sn", "mail", "userPassword"] ldap_attributes = ["uid", "ou", "cn", "sn", "mail", "userPassword"]
async def iter_groups(self): # To be monkeypatched later
pass # pragma: no cover
class Hackers(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): async def groups(self):
return [g async for g in self.iter_groups()]
async def delete(self):
async for group in self.iter_groups():
await group.remove_member(self)
await super().delete()
class Hackers(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore _instances = dict() # type: ignore
ou = "Hackers" ou = "Hackers"
child_class = User child_class = User
class Service(mixins.Singleton, mixins.Entry, mixins.Member): class Service(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = ["simpleSecurityObject", "account", "top"] object_class = ["simpleSecurityObject", "account", "top"]
_instances = dict() # type: ignore _instances = dict() # type: ignore
id_tag = "uid" id_tag = "uid"
ou = "Robots" ou = "Robots"
ldap_attributes = ["uid", "ou", "userPassword"] ldap_attributes = ["uid", "ou", "userPassword"]
async def iter_groups(self): # To be monkeypatched later
pass # pragma: no cover
class Robots(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): async def groups(self):
return [g async for g in self.iter_groups()]
async def delete(self):
async for group in self.iter_groups():
await group.remove_member(self)
await super().delete()
class Robots(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore _instances = dict() # type: ignore
ou = "Robots" ou = "Robots"
child_class = Service child_class = Service
class Group(mixins.Singleton, mixins.Entry, mixins.Member): class Group(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = ["groupOfNames", "top"] object_class = ["groupOfNames", "top"]
_instances = dict() # type: ignore _instances = dict() # type: ignore
id_tag = "cn" id_tag = "cn"
ou = "Congregations" ou = "Congregations"
ldap_attributes = ["cn", "ou", "member"] ldap_attributes = ["cn", "ou", "member"]
memeber_classes = {"Hackers": User, "Robots": Service} memeber_classes = {"Hackers": User, "Robots": Service}
empty = False
async def add_member(self, member): async def add_member(self, member):
self._entry["member"].append(get_dn(member)) member_dn = get_dn(member)
self._entry["member"].append(member_dn)
await self.modify() await self.modify()
async def remove_member(self, member): async def remove_member(self, member):
self._entry["member"] = [get_dn(m) for m in self.get_members() if member != m] new_group_members = [get_dn(m) async for m in self.get_members() if member != m]
if len(new_group_members) == 0:
await self.delete()
self.empty = True
else:
self._entry["member"] = new_group_members
await self.modify() await self.modify()
def get_members(self): async def get_members(self):
await self.sync()
for member in self._entry.get("member", []): for member in self._entry.get("member", []):
dn = MultiDict(e.split("=") for e in member.split(",")) dn = parse_dn(member)
yield self.memeber_classes.get(dn["ou"])(self.client, dn["uid"]) yield self.memeber_classes.get(dn["ou"])(self.client, dn["uid"])
class Congregations(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): class Congregations(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore _instances = dict() # type: ignore
ou = "Congregations" ou = "Congregations"
child_class = Group child_class = Group
def get_dn(obj): # We define this async method here **after** `User`, `Service` and `Group` have been
if isinstance(obj, mixins.Entry): # defined, in order to avoid definition loops that would prevent the code from running.
return obj.dn # Indeed, this function explicitely uses `Group` but is needed as a `User` and `Service`
elif isinstance(obj, LDAPEntry): # method. In turn, `Group` definition relies on both `User` and `Service` being yet
return obj["dn"] # defined.
elif isinstance(obj, str): async def iter_groups(self):
return obj async with self.client.connect(is_async=True) as conn:
res = await conn.search(f"{self.dn}", 2, attrlist=["memberOf"])
if not res or len(res) == 0:
return
elif len(res) == 1:
for group in res[0].get("memberOf", []):
yield Group(self.client, parse_dn(get_dn(group))["cn"])
else: else:
raise ValueError(f"Unacceptable input: {obj}") raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", res
)
# Monkeypatch
User.iter_groups = iter_groups # type: ignore
Service.iter_groups = iter_groups # type: ignore