Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/api/main/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from .schema import (
PrimaryGroupRequest,
RenameRequest,
SearchRequest,
SearchResponse,
SearchResultDone,
Expand Down Expand Up @@ -123,6 +124,15 @@ async def modify_dn_many(
return results


@entry_router.put("/rename", error_map=error_map)
async def rename(
request: RenameRequest,
req: Request,
) -> LDAPResult:
"""LDAP rename entry request."""
return await request.handle_api(req.state.dishka_container)


@entry_router.delete("/delete", error_map=error_map)
async def delete(
request: DeleteRequest,
Expand Down
84 changes: 82 additions & 2 deletions app/api/main/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE
"""

from functools import cached_property
from ipaddress import IPv4Address, IPv6Address
from typing import final

from dishka import AsyncContainer
from pydantic import BaseModel, Field, PrivateAttr, SecretStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement, UnaryExpression

from entities import Directory
Expand All @@ -18,8 +20,17 @@
FilterInterpreterProtocol,
StringFilterInterpreter,
)
from ldap_protocol.ldap_requests import SearchRequest as LDAPSearchRequest
from ldap_protocol.ldap_responses import SearchResultDone, SearchResultEntry
from ldap_protocol.ldap_requests import (
ModifyDNRequest as LDAPModifyDNRequest,
ModifyRequest as LDAPModifyRequest,
SearchRequest as LDAPSearchRequest,
)
from ldap_protocol.ldap_responses import (
LDAPResult,
SearchResultDone,
SearchResultEntry,
)
from ldap_protocol.objects import Changes
from ldap_protocol.utils.const import GRANT_DN_STRING


Expand Down Expand Up @@ -153,3 +164,72 @@ class PrimaryGroupRequest(BaseModel):

directory_dn: GRANT_DN_STRING
group_dn: GRANT_DN_STRING


class RenameRequest(BaseModel):
"""Rename request schema.

Combines ModifyDN and Modify operations.
"""

object: str
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

сделай _object, чтобы не трогать зарезервированное

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

вообще, в ModifyDNRequest аналогичное поле называется entry. Мб сделать более менее едино?

Copy link
Copy Markdown
Collaborator Author

@milov-dmitriy milov-dmitriy Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

делал по аналогии с modify + делал по схеме как в ТЗ

нам бы везде поменять нейминг у реквестов, сейчас предлагаю везде делать по одному неидеальному шаблону

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

это надо в общий рефакторинг реквестов уносить, сейчас мы это красиво не обыграем, только разменом костыль на костыль

newrdn: str
changes: list[Changes]
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated

@cached_property
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated
def _new_object(self) -> str:
return f"{self.newrdn},{','.join(self.object.split(',')[1:])}"

@cached_property
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated
def _oldrdn(self) -> str:
return self.object.split(",")[0]
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated

async def _modify_dn_request(
self,
container: AsyncContainer,
entry: str,
newrdn: str,
) -> LDAPResult:
modify_dn_request = LDAPModifyDNRequest(
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated
entry=entry,
newrdn=newrdn,
deleteoldrdn=True,
new_superior=None,
)
return await modify_dn_request.handle_api(container)

async def _clear_session_cache(self, container: AsyncContainer) -> None:
session = await container.get(AsyncSession)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

мб логировать?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

не пон, зачем?

название еще не совсем удачное мне показалось, сделал так: _expire_session_objects

session.expire_all()

async def _modify_request(self, container: AsyncContainer) -> LDAPResult:
modify_request = LDAPModifyRequest(
object=self._new_object,
changes=self.changes,
)
return await modify_request.handle_api(container)

async def handle_api(self, container: AsyncContainer) -> LDAPResult:
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated
"""Handle RenameRequest by executing ModifyDN then Modify.

If ModifyRequest fails, rollback the ModifyDnRequest and return error.
"""
modify_dn_response = await self._modify_dn_request(
container,
self.object,
self.newrdn,
)
if not modify_dn_response or modify_dn_response.result_code != 0:
return modify_dn_response

await self._clear_session_cache(container)

modify_response = await self._modify_request(container)
if not modify_response or modify_response.result_code != 0:
await self._modify_dn_request(
container,
self._new_object,
self._oldrdn,
)

return modify_response
Comment thread
milov-dmitriy marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion interface
24 changes: 24 additions & 0 deletions tests/test_api/test_main/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ async def adding_test_user(
assert auth.cookies.get("id")


@pytest_asyncio.fixture(scope="function")
async def adding_test_computer(
http_client: AsyncClient,
) -> None:
"""Test api correct (name) add."""
response = await http_client.post(
"/entry/add",
json={
"entry": "cn=mycomputer,dc=md,dc=test",
"password": None,
"attributes": [
{"type": "name", "vals": ["mycomputer name"]},
{"type": "cn", "vals": ["mycomputer"]},
{"type": "objectClass", "vals": ["computer", "top"]},
],
},
)

data = response.json()

assert isinstance(data, dict)
assert data.get("resultCode") == LDAPCodes.SUCCESS


@pytest_asyncio.fixture(scope="function")
async def add_dns_settings(
session: AsyncSession,
Expand Down
138 changes: 138 additions & 0 deletions tests/test_api/test_main/test_router/test_rename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Test API Rename.

Copyright (c) 2026 MultiFactor
License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE
"""

import pytest
from httpx import AsyncClient

from ldap_protocol.ldap_codes import LDAPCodes
from ldap_protocol.ldap_requests.modify import Operation


@pytest.mark.asyncio
@pytest.mark.usefixtures("adding_test_user")
@pytest.mark.usefixtures("setup_session")
@pytest.mark.usefixtures("session")
async def test_api_correct_rename(http_client: AsyncClient) -> None:
response = await http_client.put(
Comment thread
Misha-Shvets marked this conversation as resolved.
"/entry/rename",
json={
"object": "cn=test,dc=md,dc=test",
"newrdn": "cn=admin2",
"changes": [
{
"operation": Operation.REPLACE,
"modification": {
"type": "sAMAccountName",
"vals": ["admin2"],
},
},
{
"operation": Operation.REPLACE,
"modification": {
"type": "displayName",
"vals": ["Administrator"],
},
},
],
},
)

data = response.json()
assert isinstance(data, dict)
assert data.get("resultCode") == LDAPCodes.SUCCESS

response = await http_client.post(
"entry/search",
Comment thread
milov-dmitriy marked this conversation as resolved.
json={
"base_object": "cn=admin2,dc=md,dc=test",
"scope": 0,
"deref_aliases": 0,
"size_limit": 1000,
"time_limit": 10,
"types_only": True,
Comment thread
milov-dmitriy marked this conversation as resolved.
"filter": "(objectClass=*)",
"attributes": ["*"],
"page_number": 1,
},
)

data = response.json()
assert data["resultCode"] == LDAPCodes.SUCCESS
assert data["search_result"][0]["object_name"] == "cn=admin2,dc=md,dc=test"

for attr in data["search_result"][0]["partial_attributes"]:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

тут тоже можно сжать. либо собрать в 1 цикл, либо сделать функцию проверки и снизу тоже заюзать

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

выписал это и отдельным пр сделаю по всем тестам

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

из вариантов такое могу предложить, вролде должно работать

sAMAccountName_found = False
displayName_found = False

for attr in data["search_result"][0]["partial_attributes"]:
    if attr["type"] == "sAMAccountName":
        assert attr["vals"][0] == "admin2"
        sAMAccountName_found = True
    elif attr["type"] == "displayName":
        assert attr["vals"][0] == "Administrator"
        displayName_found = True

    if sAMAccountName_found and displayName_found:
        break

if not sAMAccountName_found:
    raise Exception("User without sAMAccountName")
if not displayName_found:
    raise Exception("User without displayName")
#----------------------------
attrs_dict = {attr["type"]: attr["vals"][0] 
              for attr in data["search_result"][0]["partial_attributes"]}

assert attrs_dict.get("sAMAccountName") == "admin2"
assert attrs_dict.get("displayName") == "Administrator"

if attr["type"] == "sAMAccountName":
assert attr["vals"][0] == "admin2"
Comment thread
milov-dmitriy marked this conversation as resolved.
break
else:
raise Exception("User without sAMAccountName")
Comment thread
milov-dmitriy marked this conversation as resolved.

for attr in data["search_result"][0]["partial_attributes"]:
if attr["type"] == "displayName":
assert attr["vals"][0] == "Administrator"
break
else:
raise Exception("User without displayName")


@pytest.mark.asyncio
@pytest.mark.usefixtures("adding_test_computer")
@pytest.mark.usefixtures("setup_session")
@pytest.mark.usefixtures("session")
async def test_api_correct_rename_computer(http_client: AsyncClient) -> None:
response = await http_client.put(
"/entry/rename",
json={
"object": "cn=mycomputer,dc=md,dc=test",
"newrdn": "cn=maincomputer",
"changes": [
{
"operation": Operation.REPLACE,
"modification": {
"type": "sAMAccountName",
"vals": ["main computer"],
},
},
{
"operation": Operation.REPLACE,
"modification": {
"type": "displayName",
"vals": ["main computer"],
},
},
],
Comment thread
milov-dmitriy marked this conversation as resolved.
},
)

data = response.json()
assert isinstance(data, dict)
assert data.get("resultCode") == LDAPCodes.UNDEFINED_ATTRIBUTE_TYPE

response = await http_client.post(
"entry/search",
Comment thread
milov-dmitriy marked this conversation as resolved.
json={
"base_object": "cn=mycomputer,dc=md,dc=test",
"scope": 0,
"deref_aliases": 0,
"size_limit": 1000,
"time_limit": 10,
"types_only": True,
"filter": "(objectClass=*)",
"attributes": ["*"],
"page_number": 1,
},
)

data = response.json()
assert data["resultCode"] == LDAPCodes.SUCCESS
assert data["search_result"][0]["object_name"] == "cn=mycomputer,dc=md,dc=test" # noqa: E501 # fmt: skip

for attr in data["search_result"][0]["partial_attributes"]:
if attr["type"] == "name":
assert attr["vals"][0] == "mycomputer name"
break
else:
raise Exception("Computer without name")
Loading