sources/ldap: add e2e LDAP source tests (#4462)
* start adding more LDAP source tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improve healthcheck Signed-off-by: Jens Langhammer <jens@goauthentik.io> * try local webdriver Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add full samba tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix locale types Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
8709f3300c
commit
c61529e4d4
|
@ -124,7 +124,7 @@ jobs:
|
||||||
- name: saml
|
- name: saml
|
||||||
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||||
- name: ldap
|
- name: ldap
|
||||||
glob: tests/e2e/test_provider_ldap*
|
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
||||||
- name: flows
|
- name: flows
|
||||||
glob: tests/e2e/test_flows*
|
glob: tests/e2e/test_flows*
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -448,7 +448,7 @@ class NotificationTransport(SerializerModel):
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
||||||
except (SMTPException, ConnectionError, OSError) as exc:
|
except (SMTPException, ConnectionError, OSError) as exc:
|
||||||
raise NotificationTransportError from exc
|
raise NotificationTransportError(exc) from exc
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> "Serializer":
|
def serializer(self) -> "Serializer":
|
||||||
|
|
|
@ -38,7 +38,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||||
try:
|
try:
|
||||||
defaults = self.build_group_properties(group_dn, **attributes)
|
defaults = self.build_group_properties(group_dn, **attributes)
|
||||||
defaults["parent"] = self._source.sync_parent_group
|
defaults["parent"] = self._source.sync_parent_group
|
||||||
self._logger.debug("Creating group with attributes", **defaults)
|
|
||||||
if "name" not in defaults:
|
if "name" not in defaults:
|
||||||
raise IntegrityError("Name was not set by propertymappings")
|
raise IntegrityError("Name was not set by propertymappings")
|
||||||
# Special check for `users` field, as this is an M2M relation, and cannot be sync'd
|
# Special check for `users` field, as this is an M2M relation, and cannot be sync'd
|
||||||
|
@ -51,6 +50,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||||
},
|
},
|
||||||
defaults,
|
defaults,
|
||||||
)
|
)
|
||||||
|
self._logger.debug("Created group with attributes", **defaults)
|
||||||
except (IntegrityError, FieldError, TypeError, AttributeError) as exc:
|
except (IntegrityError, FieldError, TypeError, AttributeError) as exc:
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.CONFIGURATION_ERROR,
|
EventAction.CONFIGURATION_ERROR,
|
||||||
|
|
|
@ -33,11 +33,10 @@ class LDAPSyncTests(TestCase):
|
||||||
"""Test Cached auth"""
|
"""Test Cached auth"""
|
||||||
self.source.property_mappings.set(
|
self.source.property_mappings.set(
|
||||||
LDAPPropertyMapping.objects.filter(
|
LDAPPropertyMapping.objects.filter(
|
||||||
Q(name__startswith="authentik default LDAP Mapping")
|
Q(managed__startswith="goauthentik.io/sources/ldap/default-")
|
||||||
| Q(name__startswith="authentik default Active Directory Mapping")
|
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.source.save()
|
|
||||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||||
user_sync = UserLDAPSynchronizer(self.source)
|
user_sync = UserLDAPSynchronizer(self.source)
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""test LDAP Source"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
|
from authentik.core.models import Group, User
|
||||||
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
|
from authentik.sources.ldap.auth import LDAPBackend
|
||||||
|
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||||
|
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||||
|
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||||
|
from tests.e2e.utils import SeleniumTestCase, retry
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceLDAPSamba(SeleniumTestCase):
|
||||||
|
"""test LDAP Source"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.admin_password = generate_key()
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def get_container_specs(self) -> Optional[dict[str, Any]]:
|
||||||
|
return {
|
||||||
|
"image": "ghcr.io/beryju/test-samba-dc:latest",
|
||||||
|
"detach": True,
|
||||||
|
"cap_add": ["SYS_ADMIN"],
|
||||||
|
"ports": {
|
||||||
|
"389": "389/tcp",
|
||||||
|
},
|
||||||
|
"auto_remove": True,
|
||||||
|
"environment": {
|
||||||
|
"SMB_DOMAIN": "test.goauthentik.io",
|
||||||
|
"SMB_NETBIOS": "goauthentik",
|
||||||
|
"SMB_ADMIN_PASSWORD": self.admin_password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"system/sources-ldap.yaml",
|
||||||
|
)
|
||||||
|
def test_source_sync(self):
|
||||||
|
"""Test Sync"""
|
||||||
|
source = LDAPSource.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
slug=generate_id(),
|
||||||
|
server_uri="ldap://localhost",
|
||||||
|
bind_cn="administrator@test.goauthentik.io",
|
||||||
|
bind_password=self.admin_password,
|
||||||
|
base_dn="dc=test,dc=goauthentik,dc=io",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
source.property_mappings.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(
|
||||||
|
Q(managed__startswith="goauthentik.io/sources/ldap/default-")
|
||||||
|
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source.property_mappings_group.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name")
|
||||||
|
)
|
||||||
|
UserLDAPSynchronizer(source).sync()
|
||||||
|
self.assertTrue(User.objects.filter(username="bob").exists())
|
||||||
|
self.assertTrue(User.objects.filter(username="james").exists())
|
||||||
|
self.assertTrue(User.objects.filter(username="john").exists())
|
||||||
|
self.assertTrue(User.objects.filter(username="harry").exists())
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"system/sources-ldap.yaml",
|
||||||
|
)
|
||||||
|
def test_source_sync_group(self):
|
||||||
|
"""Test Sync"""
|
||||||
|
source = LDAPSource.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
slug=generate_id(),
|
||||||
|
server_uri="ldap://localhost",
|
||||||
|
bind_cn="administrator@test.goauthentik.io",
|
||||||
|
bind_password=self.admin_password,
|
||||||
|
base_dn="dc=test,dc=goauthentik,dc=io",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
source.property_mappings.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(
|
||||||
|
Q(managed__startswith="goauthentik.io/sources/ldap/default-")
|
||||||
|
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source.property_mappings_group.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
|
||||||
|
)
|
||||||
|
GroupLDAPSynchronizer(source).sync()
|
||||||
|
UserLDAPSynchronizer(source).sync()
|
||||||
|
MembershipLDAPSynchronizer(source).sync()
|
||||||
|
self.assertIsNotNone(User.objects.get(username="bob"))
|
||||||
|
self.assertIsNotNone(User.objects.get(username="james"))
|
||||||
|
self.assertIsNotNone(User.objects.get(username="john"))
|
||||||
|
self.assertIsNotNone(User.objects.get(username="harry"))
|
||||||
|
self.assertIsNotNone(Group.objects.get(name="dev"))
|
||||||
|
self.assertEqual(
|
||||||
|
list(User.objects.get(username="bob").ak_groups.all()), [Group.objects.get(name="dev")]
|
||||||
|
)
|
||||||
|
self.assertEqual(list(User.objects.get(username="james").ak_groups.all()), [])
|
||||||
|
self.assertEqual(
|
||||||
|
list(User.objects.get(username="john").ak_groups.all().order_by("name")),
|
||||||
|
[Group.objects.get(name="admins"), Group.objects.get(name="dev")],
|
||||||
|
)
|
||||||
|
self.assertEqual(list(User.objects.get(username="harry").ak_groups.all()), [])
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"system/sources-ldap.yaml",
|
||||||
|
)
|
||||||
|
def test_sync_password(self):
|
||||||
|
"""Test Sync"""
|
||||||
|
source = LDAPSource.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
slug=generate_id(),
|
||||||
|
server_uri="ldap://localhost",
|
||||||
|
bind_cn="administrator@test.goauthentik.io",
|
||||||
|
bind_password=self.admin_password,
|
||||||
|
base_dn="dc=test,dc=goauthentik,dc=io",
|
||||||
|
additional_user_dn="ou=users",
|
||||||
|
additional_group_dn="ou=groups",
|
||||||
|
)
|
||||||
|
source.property_mappings.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(
|
||||||
|
Q(managed__startswith="goauthentik.io/sources/ldap/default-")
|
||||||
|
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source.property_mappings_group.set(
|
||||||
|
LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name")
|
||||||
|
)
|
||||||
|
UserLDAPSynchronizer(source).sync()
|
||||||
|
username = "bob"
|
||||||
|
password = generate_id()
|
||||||
|
result = self.container.exec_run(
|
||||||
|
["samba-tool", "user", "setpassword", username, "--newpassword", password]
|
||||||
|
)
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
user: User = User.objects.get(username=username)
|
||||||
|
# Ensure user has an unusable password directly after sync
|
||||||
|
self.assertFalse(user.has_usable_password())
|
||||||
|
# Auth (which will fallback to bind)
|
||||||
|
LDAPBackend().auth_user(source, password, username=username)
|
||||||
|
user.refresh_from_db()
|
||||||
|
# User should now have a usable password in the database
|
||||||
|
self.assertTrue(user.has_usable_password())
|
||||||
|
self.assertTrue(user.check_password(password))
|
||||||
|
# Set new password
|
||||||
|
new_password = generate_id()
|
||||||
|
result = self.container.exec_run(
|
||||||
|
["samba-tool", "user", "setpassword", username, "--newpassword", new_password]
|
||||||
|
)
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
# Sync again
|
||||||
|
UserLDAPSynchronizer(source).sync()
|
||||||
|
user.refresh_from_db()
|
||||||
|
# Since password in samba was checked, it should be invalidated here too
|
||||||
|
self.assertFalse(user.has_usable_password())
|
|
@ -54,7 +54,6 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||||
self.maxDiff = None
|
self.maxDiff = None
|
||||||
self.wait_timeout = 60
|
self.wait_timeout = 60
|
||||||
self.driver = self._get_driver()
|
self.driver = self._get_driver()
|
||||||
self.driver.maximize_window()
|
|
||||||
self.driver.implicitly_wait(30)
|
self.driver.implicitly_wait(30)
|
||||||
self.wait = WebDriverWait(self.driver, self.wait_timeout)
|
self.wait = WebDriverWait(self.driver, self.wait_timeout)
|
||||||
self.logger = get_logger()
|
self.logger = get_logger()
|
||||||
|
@ -77,7 +76,9 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||||
def _start_container(self, specs: dict[str, Any]) -> Container:
|
def _start_container(self, specs: dict[str, Any]) -> Container:
|
||||||
client: DockerClient = from_env()
|
client: DockerClient = from_env()
|
||||||
container = client.containers.run(**specs)
|
container = client.containers.run(**specs)
|
||||||
if "healthcheck" not in specs:
|
container.reload()
|
||||||
|
state = container.attrs.get("State", {})
|
||||||
|
if "Health" not in state:
|
||||||
return container
|
return container
|
||||||
while True:
|
while True:
|
||||||
container.reload()
|
container.reload()
|
||||||
|
@ -100,12 +101,18 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||||
|
|
||||||
def _get_driver(self) -> WebDriver:
|
def _get_driver(self) -> WebDriver:
|
||||||
count = 0
|
count = 0
|
||||||
|
try:
|
||||||
|
return webdriver.Chrome()
|
||||||
|
except WebDriverException:
|
||||||
|
pass
|
||||||
while count < RETRIES:
|
while count < RETRIES:
|
||||||
try:
|
try:
|
||||||
return webdriver.Remote(
|
driver = webdriver.Remote(
|
||||||
command_executor="http://localhost:4444/wd/hub",
|
command_executor="http://localhost:4444/wd/hub",
|
||||||
options=webdriver.ChromeOptions(),
|
options=webdriver.ChromeOptions(),
|
||||||
)
|
)
|
||||||
|
driver.maximize_window()
|
||||||
|
return driver
|
||||||
except WebDriverException:
|
except WebDriverException:
|
||||||
count += 1
|
count += 1
|
||||||
raise ValueError(f"Webdriver failed after {RETRIES}.")
|
raise ValueError(f"Webdriver failed after {RETRIES}.")
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
|
import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
|
import { PluralCategory } from "make-plural";
|
||||||
|
|
||||||
import { Messages, i18n } from "@lingui/core";
|
import { Messages, i18n } from "@lingui/core";
|
||||||
import { detect, fromNavigator, fromUrl } from "@lingui/detect-locale";
|
import { detect, fromNavigator, fromUrl } from "@lingui/detect-locale";
|
||||||
|
@ -7,7 +8,7 @@ import { t } from "@lingui/macro";
|
||||||
|
|
||||||
interface Locale {
|
interface Locale {
|
||||||
locale: Messages;
|
locale: Messages;
|
||||||
plurals: (n: string | number, ord?: boolean | undefined) => string;
|
plurals: (n: string | number, ord?: boolean | undefined) => PluralCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOCALES: {
|
export const LOCALES: {
|
||||||
|
|
Reference in New Issue