# Copyright (c) 2023 Rackslab
#
# This file is part of RFL.
#
# SPDX-License-Identifier: LGPL-3.0-or-later

import os
import unittest
from unittest.mock import patch, Mock
from pathlib import Path
import urllib
import ssl

import ldap
import ldap.filter

from rfl.authentication.ldap import LDAPAuthentifier
from rfl.authentication.errors import LDAPAuthenticationError


class MockLDAPObject:
    pass


class TestLDAPAuthentifier(unittest.TestCase):
    def setUp(self):
        self.authentifier = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
        )

    def test_connection(self):
        # With classic ldap uri (no SSL/TLS)
        self.authentifier.connection()

    @patch("rfl.authentication.ldap.ldap")
    def test_connection_ssl_cert(self, mock_ldap):
        mock_ldap_object = mock_ldap.initialize.return_value

        # With ldap URI, check set_option is not called
        self.authentifier.connection()
        mock_ldap_object.set_option.assert_not_called()

        # With ldaps URI and no CA certificate path, check LDAP server certificate is
        # required and validated with default system OpenSSL CA certificates.
        self.authentifier.uri = urllib.parse.urlparse("ldaps://localhost")
        self.authentifier.connection()
        mock_ldap_object.set_option.assert_any_call(
            mock_ldap.OPT_X_TLS_REQUIRE_CERT, mock_ldap.OPT_X_TLS_DEMAND
        )
        paths = ssl.get_default_verify_paths()
        if paths.openssl_cafile and os.path.isfile(paths.openssl_cafile):
            mock_ldap_object.set_option.assert_any_call(
                mock_ldap.OPT_X_TLS_CACERTFILE, paths.openssl_cafile
            )
        elif paths.openssl_capath and os.path.isdir(paths.openssl_capath):
            mock_ldap_object.set_option.assert_any_call(
                mock_ldap.OPT_X_TLS_CACERTDIR, paths.openssl_capath
            )
        mock_ldap_object.reset_mock()

        # With CA certificate path, check LDAP server certificate is required and
        # validated with provided CA certificate.
        cert = Path("/dev/null")
        self.authentifier.cacert = cert
        self.authentifier.connection()
        mock_ldap_object.set_option.assert_any_call(
            mock_ldap.OPT_X_TLS_REQUIRE_CERT, mock_ldap.OPT_X_TLS_DEMAND
        )
        mock_ldap_object.set_option.assert_any_call(
            mock_ldap.OPT_X_TLS_CACERTFILE, str(cert)
        )
        mock_ldap_object.reset_mock()

    @patch("rfl.authentication.ldap.ldap")
    def test_connection_starttls(self, mock_ldap):
        mock_ldap_object = mock_ldap.initialize.return_value

        # With ldaps URI (and starttls set to False by default), check LDAP server
        # certificate is required and start_tls_s is NOT called.
        self.authentifier.uri = urllib.parse.urlparse("ldaps://localhost")
        self.authentifier.connection()
        mock_ldap_object.set_option.assert_any_call(
            mock_ldap.OPT_X_TLS_REQUIRE_CERT, mock_ldap.OPT_X_TLS_DEMAND
        )
        mock_ldap_object.start_tls_s.assert_not_called()
        mock_ldap_object.reset_mock()

        # With ldap URI and starttls set to True, check LDAP server certificate is
        # required and start_tls_s is called.
        self.authentifier.uri = urllib.parse.urlparse("ldap://localhost")
        self.authentifier.starttls = True
        self.authentifier.connection()
        mock_ldap_object.set_option.assert_any_call(
            mock_ldap.OPT_X_TLS_REQUIRE_CERT, mock_ldap.OPT_X_TLS_DEMAND
        )
        mock_ldap_object.start_tls_s.assert_called_once()

    @patch.object(ldap.ldapobject.LDAPObject, "start_tls_s")
    def test_connection_tls_errors(self, mock_start_tls_s):
        self.authentifier.uri = urllib.parse.urlparse("ldap://localhost")
        self.authentifier.starttls = True
        mock_start_tls_s.side_effect = ldap.CONNECT_ERROR("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to connect to LDAP server with STARTTLS: fail$",
        ):
            self.authentifier.connection()
        mock_start_tls_s.side_effect = ldap.SERVER_DOWN("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^LDAP server {self.authentifier.uri.geturl()} is unreachable$",
        ):
            self.authentifier.connection()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_disabled(self, mock_ldap_initialize):
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value

        self.assertEqual(
            self.authentifier._lookup_user_dn("john"),
            f"{self.authentifier.user_name_attribute}=john,"
            f"{self.authentifier.user_base}",
        )
        mock_ldap_object.simple_bind_s.assert_not_called()
        mock_ldap_object.search_s.assert_not_called()
        mock_ldap_object.unbind_s.assert_not_called()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled(self, mock_ldap_initialize):
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.search_s.return_value = [
            (
                f"uid=john,ou=admins,{self.authentifier.user_base}",
                {"cn": [b"John Doe"]},
            )
        ]

        self.assertEqual(
            self.authentifier._lookup_user_dn("john"),
            f"uid=john,ou=admins,{self.authentifier.user_base}",
        )
        mock_ldap_object.simple_bind_s.assert_not_called()
        mock_ldap_object.search_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled_bind_dn(self, mock_ldap_initialize):
        # define bind dn/password
        self.authentifier.bind_dn = "uid=read,ou=apps,dc=corp,dc=org"
        self.authentifier.bind_password = "uid=read,ou=apps,dc=corp,dc=org"
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.search_s.return_value = [
            (
                f"uid=john,ou=admins,{self.authentifier.user_base}",
                {"cn": [b"John Doe"]},
            )
        ]

        self.assertEqual(
            self.authentifier._lookup_user_dn("john"),
            f"uid=john,ou=admins,{self.authentifier.user_base}",
        )
        mock_ldap_object.simple_bind_s.assert_called_once_with(
            self.authentifier.bind_dn, self.authentifier.bind_password
        )
        mock_ldap_object.search_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_escape_special_chars(self, mock_ldap_initialize):
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        user_with_special_chars = "John Doe(user)"
        escaped_user = ldap.filter.escape_filter_chars(user_with_special_chars)
        mock_ldap_object.search_s.return_value = [
            (
                f"uid={user_with_special_chars},ou=admins,{self.authentifier.user_base}",
                {"cn": [b"John Doe(user)"]},
            )
        ]

        self.assertEqual(
            self.authentifier._lookup_user_dn(user_with_special_chars),
            f"uid={user_with_special_chars},ou=admins,{self.authentifier.user_base}",
        )
        # Verify the filter contains escaped user value
        call_args = mock_ldap_object.search_s.call_args
        search_filter = call_args[0][2]
        # Verify the filter contains escaped user value
        self.assertIn(
            f"({self.authentifier.user_name_attribute}={escaped_user})", search_filter
        )
        # The unescaped value should NOT be in the filter
        self.assertNotIn(
            f"({self.authentifier.user_name_attribute}={user_with_special_chars})",
            search_filter,
        )
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled_bind_dn_missing_password(
        self, mock_ldap_initialize
    ):
        # define bind_dn without password
        self.authentifier.bind_dn = "uid=read,ou=apps,dc=corp,dc=org"
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value

        # Check exception is raised when bind_dn is set without bind_password
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Password to authenticate with bind DN uid=read,ou=apps,dc=corp,dc=org "
            r"is required$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_not_called()
        mock_ldap_object.unbind_s.assert_not_called()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled_bind_dn_invalid_credentials(
        self, mock_ldap_initialize
    ):
        # define bind dn/password
        self.authentifier.bind_dn = "uid=read,ou=apps,dc=corp,dc=org"
        self.authentifier.bind_password = "uid=read,ou=apps,dc=corp,dc=org"
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.simple_bind_s.side_effect = ldap.INVALID_CREDENTIALS("fail")

        # Check exception is raised with LDAP fails due to invalid credential
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Invalid bind DN or password$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_not_called()
        mock_ldap_object.unbind_s.assert_not_called()

    @patch.object(ldap, "initialize")
    def test_lookup_user_ldap_server_down_error(self, mock_ldap_initialize):
        # define bind dn/password
        self.authentifier.bind_dn = "uid=read,ou=apps,dc=corp,dc=org"
        self.authentifier.bind_password = "uid=read,ou=apps,dc=corp,dc=org"
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.simple_bind_s.side_effect = ldap.SERVER_DOWN("fail")

        # Check exception is raised due to LDAP server down
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^LDAP server {self.authentifier.uri.geturl()} is unreachable$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_not_called()
        mock_ldap_object.unbind_s.assert_not_called()

    @patch.object(ldap, "initialize")
    def test_lookup_user_ldap_operations_error(self, mock_ldap_initialize):
        # define bind dn/password
        self.authentifier.bind_dn = "uid=read,ou=apps,dc=corp,dc=org"
        self.authentifier.bind_password = "uid=read,ou=apps,dc=corp,dc=org"
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.search_s.side_effect = ldap.OPERATIONS_ERROR("fail")

        # Check exception is raised due to LDAP server down
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Operations error on user DN lookup: fail$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled_not_found(self, mock_ldap_initialize):
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.search_s.return_value = []

        # Check exception is raised due to no result found
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to find user john in base ou=people,dc=corp,dc=org$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(ldap, "initialize")
    def test_lookup_user_dn_enabled_too_many_results(self, mock_ldap_initialize):
        # enable user DN lookup
        self.authentifier.lookup_user_dn = True
        # setup LDAP mock
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.search_s.return_value = [
            (
                f"uid=john,ou=admins,{self.authentifier.user_base}",
                {"cn": [b"John Doe"]},
            ),
            (
                f"uid=alice,ou=admins,{self.authentifier.user_base}",
                {"cn": [b"Alice Doe"]},
            ),
        ]
        # Check exception is raised due to too many results found
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Too many users found \(2\) with username john in base "
            r"ou=people,dc=corp,dc=org$",
        ):
            self.authentifier._lookup_user_dn("john")
        mock_ldap_object.search_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch.object(LDAPAuthentifier, "_lookup_user_dn")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch("rfl.authentication.ldap.ldap")
    def test_login_ok(
        self, mock_ldap, mock_get_groups, mock_get_user_info, mock_lookup_user_dn
    ):
        # setup mocks return values
        mock_get_groups.return_value = ["group1", "group2"]
        mock_get_user_info.return_value = ("John Doe", 42)
        mock_lookup_user_dn.return_value = "uid=john,ou=people,dc=corp,dc=org"
        mock_ldap_object = mock_ldap.initialize.return_value

        # call method
        user = self.authentifier.login("john", "SECR3T")

        # verify mock calls
        mock_ldap_object.unbind_s.assert_called_once()
        mock_ldap_object.simple_bind_s.assert_called_once_with(
            "uid=john,ou=people,dc=corp,dc=org", "SECR3T"
        )
        mock_get_user_info.assert_called_once_with(
            mock_ldap_object, "uid=john,ou=people,dc=corp,dc=org"
        )
        mock_get_groups.assert_called_once_with(
            mock_ldap_object, "john", "uid=john,ou=people,dc=corp,dc=org", 42
        )

        # verify return value
        self.assertEqual(user.login, "john")
        self.assertEqual(user.fullname, "John Doe")
        self.assertEqual(user.groups, ["group1", "group2"])

    @patch.object(LDAPAuthentifier, "_lookup_user_dn")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch("rfl.authentication.ldap.ldap")
    def test_login_not_in_restricted_group(
        self, mock_ldap, mock_get_groups, mock_get_user_info, mock_lookup_user_dn
    ):
        # setup mocks return values
        self.authentifier.restricted_groups = ["group3", "group4"]
        mock_get_groups.return_value = ["group1", "group2"]
        mock_get_user_info.return_value = ("John Doe", 42)
        mock_lookup_user_dn.return_value = "uid=john,ou=people,dc=corp,dc=org"

        # call method
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^User john is not member of restricted groups$",
        ):
            self.authentifier.login("john", "SECR3T")

    def test_login_missing_user_or_password(self):
        with self.assertRaisesRegex(
            LDAPAuthenticationError, "Invalid authentication request"
        ):
            self.authentifier.login("john", None)
            self.authentifier.login(None, "SECR3T")

    @patch.object(LDAPAuthentifier, "_lookup_user_dn")
    @patch.object(ldap.ldapobject.LDAPObject, "simple_bind_s")
    def test_login_errors(self, mock_simple_bind_s, mock_lookup_user_dn):
        mock_lookup_user_dn.return_value = "uid=john,ou=people,dc=corp,dc=org"
        mock_simple_bind_s.side_effect = ldap.SERVER_DOWN("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^LDAP server {self.authentifier.uri.geturl()} is unreachable$",
        ):
            self.authentifier.login("john", "SECR3T")
        mock_simple_bind_s.side_effect = ldap.INVALID_CREDENTIALS("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError, "^Invalid user or password$"
        ):
            self.authentifier.login("john", "SECR3return_valueT")
        mock_simple_bind_s.side_effect = ldap.UNWILLING_TO_PERFORM("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError, "^LDAP server is unwilling to perform: fail$"
        ):
            self.authentifier.login("john", "SECR3T")

    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch.object(LDAPAuthentifier, "_lookup_user_dn")
    @patch.object(LDAPAuthentifier, "_bind")
    @patch.object(LDAPAuthentifier, "connection")
    def test_login_no_bind_lookup_as_user_true(
        self,
        mock_connection,
        mock_bind,
        mock_lookup_user_dn,
        mock_get_user_info,
        mock_get_groups,
    ):
        # setup mocks return values
        mock_get_groups.return_value = ["group1", "group2"]
        mock_get_user_info.return_value = ("John Doe", 42)
        mock_lookup_user_dn.return_value = "uid=john,ou=people,dc=corp,dc=org"
        mock_connection.return_value.simple_bind_s.return_value = None

        # if lookup_as_user is False, login() must not call _bind().
        self.authentifier.lookup_as_user = True
        self.authentifier.login("john", "SECR3T")
        mock_bind.assert_not_called()
        mock_connection.return_value.simple_bind_s.assert_called_once_with(
            "uid=john,ou=people,dc=corp,dc=org", "SECR3T"
        )

    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch.object(LDAPAuthentifier, "_lookup_user_dn")
    @patch.object(LDAPAuthentifier, "_bind")
    @patch.object(LDAPAuthentifier, "connection")
    def test_login_single_bind_lookup_as_user_false(
        self,
        mock_connection,
        mock_bind,
        mock_lookup_user_dn,
        mock_get_user_info,
        mock_get_groups,
    ):
        # setup mocks return values
        mock_get_groups.return_value = ["group1", "group2"]
        mock_get_user_info.return_value = ("John Doe", 42)
        mock_lookup_user_dn.return_value = "uid=john,ou=people,dc=corp,dc=org"

        # if lookup_as_user is False, login() should call _bind() once.
        self.authentifier.lookup_as_user = False
        self.authentifier.login("john", "SECR3T")
        mock_bind.assert_called_once()
        mock_connection.return_value.simple_bind_s.assert_called_once_with(
            "uid=john,ou=people,dc=corp,dc=org", "SECR3T"
        )

    def test_user_info(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            (
                "uid=john,ou=people,dc=corp,dc=org",
                {"cn": [b"John Doe"], "gidNumber": [b"42"]},
            )
        ]
        fullname, gid = self.authentifier._get_user_info(
            connection, "uid=john,ou=people,dc=corp,dc=org"
        )
        self.assertEqual(fullname, "John Doe")
        self.assertEqual(gid, 42)

    def test_user_info_fullname_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # If the user entries in LDAP directory do not contain attributes whose name
        # matches user_fullname_attribute or user_primary_group_attribute, search_s
        # returns a dict with missing keys in the second element of the result tuple.
        #
        # Test missing user_fullname_attribute
        connection.search_s.return_value = [
            (
                "uid=john,ou=people,dc=corp,dc=org",
                {"gidNumber": [b"42"]},
            )
        ]
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to extract user full name with "
            rf"{self.authentifier.user_fullname_attribute} attribute from user "
            r"entries$",
        ):
            self.authentifier._get_user_info(
                connection, "uid=john,ou=people,dc=corp,dc=org"
            )

    def test_user_info_primary_group_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # Test missing gidNumber
        connection.search_s.return_value = [
            (
                "uid=john,ou=people,dc=corp,dc=org",
                {"cn": [b"John Doe"]},
            )
        ]
        with self.assertLogs("rfl", level="WARNING") as log:
            fullname, gid = self.authentifier._get_user_info(
                connection, "uid=john,ou=people,dc=corp,dc=org"
            )
            self.assertEqual(
                [
                    "WARNING:rfl.authentication.ldap:Unable to extract user primary "
                    "group with gidNumber attribute from user entry"
                ],
                log.output,
            )
        self.assertEqual(fullname, "John Doe")
        self.assertIsNone(gid)

    def test_user_info_class_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # If entries with class user_class is not found in user_base subtree,
        # search_s returns an empty list.
        connection.search_s.return_value = []
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^User not found in LDAP with class {self.authentifier.user_class}$",
        ):
            self.authentifier._get_user_info(
                connection, "uid=john,ou=people,dc=corp,dc=org"
            )

    def test_user_info_dn_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # When user DN is not found in LDAP, ldap module raises NO_SUCH_OBJECT
        # exception.
        connection.search_s.side_effect = ldap.NO_SUCH_OBJECT("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^Unable to find user DN uid=john,{self.authentifier.user_base}$",
        ):
            self.authentifier._get_user_info(
                connection, "uid=john,ou=people,dc=corp,dc=org"
            )

    def test_custom_primary_attribute(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        self.authentifier.user_primary_group_attribute = "primaryGroupId"
        dn = "uid=jane,ou=people,dc=corp,dc=org"
        connection.search_s.return_value = [
            (
                dn,
                {
                    "cn": [b"Jane Smith"],
                    self.authentifier.user_primary_group_attribute: [b"42"],
                },
            )
        ]
        fullname, gid = self.authentifier._get_user_info(connection, dn)
        connection.search_s.assert_called_once_with(
            dn,
            ldap.SCOPE_BASE,
            f"(objectClass={self.authentifier.user_class})",
            [
                self.authentifier.user_fullname_attribute,
                self.authentifier.user_primary_group_attribute,
            ],
        )
        self.assertEqual(fullname, "Jane Smith")
        self.assertEqual(gid, 42)

    def test_get_groups(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            ("cn=scientists,ou=groups,dc=corp,dc=org", {"cn": [b"scientists"]}),
            ("cn=biology,ou=groups,dc=corp,dc=org", {"cn": [b"biology"]}),
        ]
        groups = self.authentifier._get_groups(
            connection, "john", "uid=john,ou=people,dc=corp,dc=org", 42
        )
        self.assertEqual(groups, ["scientists", "biology"])

    def test_get_groups_escape_special_chars(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            ("cn=scientists,ou=groups,dc=corp,dc=org", {"cn": [b"scientists"]}),
        ]
        user_name_with_special = "John Doe(user)"
        user_dn_with_special = "uid=John Doe(user),ou=people,dc=corp,dc=org"
        escaped_user_name = ldap.filter.escape_filter_chars(user_name_with_special)
        escaped_user_dn = ldap.filter.escape_filter_chars(user_dn_with_special)
        gid = 42
        groups = self.authentifier._get_groups(
            connection, user_name_with_special, user_dn_with_special, gid
        )
        self.assertEqual(groups, ["scientists"])

        connection.search_s.assert_called_once_with(
            self.authentifier.group_base,
            ldap.SCOPE_SUBTREE,
            f"(&(|(objectClass=posixGroup)(objectClass=groupOfNames))"
            f"(|(memberUid={escaped_user_name})"
            f"(member={escaped_user_dn})(gidNumber={gid})))",
            [self.authentifier.group_name_attribute],
        )

    def test_get_groups_without_gid(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            ("cn=scientists,ou=groups,dc=corp,dc=org", {"cn": [b"scientists"]}),
            ("cn=biology,ou=groups,dc=corp,dc=org", {"cn": [b"biology"]}),
        ]
        user = "john"
        dn = "uid=john,ou=people,dc=corp,dc=org"
        gid = 42
        # First call with gid and check LDAP search filter.
        groups = self.authentifier._get_groups(connection, user, dn, gid)
        self.assertEqual(groups, ["scientists", "biology"])
        connection.search_s.assert_called_once_with(
            self.authentifier.group_base,
            ldap.SCOPE_SUBTREE,
            "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))"
            f"(|(memberUid={user})(member={dn})(gidNumber={gid})))",
            [self.authentifier.group_name_attribute],
        )
        connection.search_s.reset_mock()
        # Then a second call with undefined gid must remove gidNumber from LDAP search
        # filter.
        groups = self.authentifier._get_groups(connection, user, dn, None)
        self.assertEqual(groups, ["scientists", "biology"])
        connection.search_s.assert_called_once_with(
            self.authentifier.group_base,
            ldap.SCOPE_SUBTREE,
            "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))"
            f"(|(memberUid={user})(member={dn})))",
            [self.authentifier.group_name_attribute],
        )

    def test_groups_base_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # When group base DN is not found in LDAP, ldap module raises NO_SUCH_OBJECT
        # exception.
        connection.search_s.side_effect = ldap.NO_SUCH_OBJECT("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^Unable to find group base {self.authentifier.group_base}$",
        ):
            self.authentifier._get_groups(
                connection, "john", "uid=john,ou=people,dc=corp,dc=org", 42
            )

    def test_get_groups_name_attribute_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        self.authentifier.group_name_attribute = "fail"
        # If the group entries in LDAP directory does not contain attributes whose name
        # matches group_name_attribute, search_s returns an empty dict in the second
        # element of the result tuple.
        connection.search_s.return_value = [
            ("cn=scientists,ou=groups,dc=corp,dc=org", {}),
            ("cn=biology,ou=groups,dc=corp,dc=org", {}),
        ]
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to extract group name with fail attribute from group entries$",
        ):
            self.authentifier._get_groups(
                connection, "john", "uid=john,ou=people,dc=corp,dc=org", 42
            )

    def test_get_groups_class_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # If entries with one of group_object_classes is not found in group_base
        # subtree, search_s returns an empty list.
        connection.search_s.return_value = []
        with self.assertLogs("rfl.authentication.ldap", level="WARNING") as cm:
            groups = self.authentifier._get_groups(
                connection, "john", "uid=john,ou=people,dc=corp,dc=org", 42
            )
        self.assertEqual(groups, [])
        self.assertEqual(
            cm.output,
            [
                "WARNING:rfl.authentication.ldap:Unable to find groups in LDAP for user"
                " john or gidNumber 42"
            ],
        )
        # Test log message without gid
        with self.assertLogs("rfl.authentication.ldap", level="WARNING") as cm:
            groups = self.authentifier._get_groups(
                connection, "john", "uid=john,ou=people,dc=corp,dc=org", None
            )
        self.assertEqual(groups, [])
        self.assertEqual(
            cm.output,
            [
                "WARNING:rfl.authentication.ldap:Unable to find groups in LDAP for user"
                " john"
            ],
        )

    def test_custom_group_object_classes(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            ("cn=scientists,ou=groups,dc=corp,dc=org", {"cn": [b"scientists"]}),
            ("cn=biology,ou=groups,dc=corp,dc=org", {"cn": [b"biology"]}),
        ]
        group_object_class = "group"
        login = "john"
        gid = 42
        self.authentifier.group_object_classes = [group_object_class]
        groups = self.authentifier._get_groups(
            connection, login, f"uid={login},ou=people,dc=corp,dc=org", gid
        )
        connection.search_s.assert_called_once_with(
            self.authentifier.group_base,
            ldap.SCOPE_SUBTREE,
            f"(&(|(objectClass={group_object_class}))(|(memberUid={login})"
            f"(member=uid={login},ou=people,dc=corp,dc=org)(gidNumber={gid})))",
            [self.authentifier.group_name_attribute],
        )
        self.assertEqual(groups, ["scientists", "biology"])

    def test_in_restricted_groups(self):
        # By default, restricted groups are unset, _in_restricted_groups must return
        # True in all cases.
        self.assertTrue(self.authentifier._in_restricted_groups([]))
        self.assertTrue(self.authentifier._in_restricted_groups(["users", "admins"]))

        # If restricted groups are set, _in_restricted_groups must return if at least
        # one group in argument matches one restricted group.

        # Test with one restricted group
        self.authentifier.restricted_groups = ["admins"]
        self.assertFalse(self.authentifier._in_restricted_groups([]))
        self.assertFalse(
            self.authentifier._in_restricted_groups(["users", "scientists"])
        )
        self.assertTrue(self.authentifier._in_restricted_groups(["admins"]))
        self.assertTrue(
            self.authentifier._in_restricted_groups(["users", "scientists", "admins"])
        )

        # Test with multiple restricted groups
        self.authentifier.restricted_groups = ["admins", "scientists"]
        self.assertFalse(self.authentifier._in_restricted_groups([]))
        self.assertFalse(self.authentifier._in_restricted_groups(["users"]))
        self.assertTrue(
            self.authentifier._in_restricted_groups(["users", "scientists"])
        )
        self.assertTrue(self.authentifier._in_restricted_groups(["admins"]))
        self.assertTrue(
            self.authentifier._in_restricted_groups(["users", "scientists", "admins"])
        )

    def test_list_user_dn(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = [
            ("uid=john,ou=people,dc=corp,dc=org", {"uid": [b"john"]}),
            ("uid=marie,ou=people,dc=corp,dc=org", {"uid": [b"marie"]}),
        ]
        results = self.authentifier._list_user_dn(connection)
        self.assertEqual(len(results), 2)
        self.assertEqual(results[0], ("john", "uid=john,ou=people,dc=corp,dc=org"))
        self.assertEqual(results[1], ("marie", "uid=marie,ou=people,dc=corp,dc=org"))

    def test_list_user_dn_not_found(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # When user DN is not found in LDAP, ldap module raises NO_SUCH_OBJECT
        # exception.
        connection.search_s.side_effect = ldap.NO_SUCH_OBJECT("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to find user base ou=people,dc=corp,dc=org$",
        ):
            self.authentifier._list_user_dn(connection)

    def test_list_user_dn_no_result(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        connection.search_s.return_value = []
        with self.assertLogs("rfl", level="WARNING") as lc:
            results = self.authentifier._list_user_dn(connection)
            self.assertEqual(
                [
                    "WARNING:rfl.authentication.ldap:Unable to find users in LDAP in "
                    "base ou=people,dc=corp,dc=org subtree"
                ],
                lc.output,
            )
        self.assertEqual(len(results), 0)

    def test_list_user_dn_no_user_name_attribute(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # All results miss the user name attribute, _list_user_dn() is expected to raise
        # LDAPAuthenticationError.
        connection.search_s.return_value = [
            ("uid=john,ou=people,dc=corp,dc=org", {}),
            ("uid=jane,ou=people,dc=corp,dc=org", {}),
        ]
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Unable to extract user uid from user entries$",
        ):
            self.authentifier._list_user_dn(connection)

    def test_list_user_dn_missing_user_name_attribute(self):
        connection = Mock(spec=ldap.ldapobject.LDAPObject)
        # At least one result has the user name attribute, _list_user_dn() must return
        # these results and log warning message for other dn that miss this attribute.
        connection.search_s.return_value = [
            ("uid=john,ou=people,dc=corp,dc=org", {}),
            ("uid=jane,ou=people,dc=corp,dc=org", {"uid": [b"jane"]}),
        ]
        with self.assertLogs("rfl", level="INFO") as lc:
            results = self.authentifier._list_user_dn(connection)

        self.assertEqual(len(results), 1)
        self.assertEqual(results[0], ("jane", "uid=jane,ou=people,dc=corp,dc=org"))

        # Check warning message to indicate user name attribute has not been found in
        # john user entry has been sent
        self.assertEqual(
            [
                "WARNING:rfl.authentication.ldap:Unable to find uid from user entry "
                "uid=john,ou=people,dc=corp,dc=org"
            ],
            lc.output,
        )

    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch.object(LDAPAuthentifier, "_list_user_dn")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch("rfl.authentication.ldap.ldap")
    def test_users(
        self, mock_ldap, mock_get_user_info, mock_list_user_dn, mock_get_groups
    ):
        # Setup mocks return values
        mock_list_user_dn.return_value = [
            ("john", "uid=john,ou=people,dc=corp,dc=org"),
            ("marie", "uid=marie,ou=people,dc=corp,dc=org"),
        ]
        mock_get_user_info.side_effect = [("John Doe", 42), ("Marie Magic", 43)]
        mock_ldap_object = mock_ldap.initialize.return_value

        # Call method (without groups)
        users = self.authentifier.users()

        # Verify mock calls
        mock_ldap_object.unbind_s.assert_called_once()
        mock_list_user_dn.assert_called_once_with(mock_ldap_object)
        mock_get_user_info.assert_called()

        # Verify return value
        self.assertEqual(len(users), 2)
        self.assertEqual(users[0].login, "john")
        self.assertEqual(users[0].fullname, "John Doe")
        self.assertEqual(users[0].groups, [])
        self.assertEqual(users[1].login, "marie")
        self.assertEqual(users[1].fullname, "Marie Magic")
        self.assertEqual(users[1].groups, [])

        # Reset mock and re-inject side effects
        mock_ldap_object.reset_mock()
        mock_list_user_dn.reset_mock()
        mock_get_user_info.reset_mock()
        mock_get_user_info.side_effect = [("John Magic", 45), ("Marie Doe", 46)]
        mock_get_groups.side_effect = [["admin", "users"], ["biology", "users"]]

        # Call method (with groups)
        users = self.authentifier.users(True)

        # Verify mock calls
        mock_ldap_object.unbind_s.assert_called_once()
        mock_list_user_dn.assert_called_once_with(mock_ldap_object)
        mock_get_user_info.assert_called()

        # Verify return value
        self.assertEqual(len(users), 2)
        self.assertEqual(users[0].login, "john")
        self.assertEqual(users[0].fullname, "John Magic")
        self.assertEqual(users[0].groups, ["admin", "users"])
        self.assertEqual(users[1].login, "marie")
        self.assertEqual(users[1].fullname, "Marie Doe")
        self.assertEqual(users[1].groups, ["biology", "users"])

    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch.object(LDAPAuthentifier, "_list_user_dn")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch("rfl.authentication.ldap.ldap")
    def test_users_restricted_groups(
        self, mock_ldap, mock_get_user_info, mock_list_user_dn, mock_get_groups
    ):
        self.authentifier.restricted_groups = ["biology"]

        # Setup mocks return values
        mock_list_user_dn.return_value = [
            ("john", "uid=john,ou=people,dc=corp,dc=org"),
            ("marie", "uid=marie,ou=people,dc=corp,dc=org"),
        ]
        mock_get_user_info.side_effect = [("John Magic", 45), ("Marie Doe", 46)]
        mock_get_groups.side_effect = [["admin", "users"], ["biology", "users"]]

        # Call users method with groups retrieval
        with self.assertLogs("rfl", level="DEBUG") as lc:
            users = self.authentifier.users(True)

        # Verify return value. Only user Marie in restricted biology group must be
        # present.
        self.assertEqual(len(users), 1)
        self.assertEqual(users[0].login, "marie")
        self.assertEqual(users[0].fullname, "Marie Doe")
        self.assertEqual(users[0].groups, ["biology", "users"])

        # Check debug message to indicate john is discarded has been sent
        self.assertEqual(
            [
                "DEBUG:rfl.authentication.ldap:Discarding user john not member of "
                "restricted groups"
            ],
            lc.output,
        )

    @patch.object(LDAPAuthentifier, "_get_groups")
    @patch.object(LDAPAuthentifier, "_list_user_dn")
    @patch.object(LDAPAuthentifier, "_get_user_info")
    @patch("rfl.authentication.ldap.ldap")
    def test_users_bind_dn(
        self, mock_ldap, mock_get_user_info, mock_list_user_dn, mock_get_groups
    ):
        self.authentifier.bind_dn = "uid=hey,ou=people,dc=corp,dc=org"
        self.authentifier.bind_password = "secr3t"

        mock_ldap_object = mock_ldap.initialize.return_value

        # Call method
        self.authentifier.users()

        # Check simple_bind_s is called when bind_dn and bind_password are defined
        mock_ldap_object.simple_bind_s.assert_called_once()
        mock_ldap_object.unbind_s.assert_called_once()

    @patch("rfl.authentication.ldap.ldap")
    def test_users_bind_dn_missing_password(self, mock_ldap):
        self.authentifier.bind_dn = "uid=hey,ou=people,dc=corp,dc=org"

        # Check exception is raised when bind_dn is set without bind_password
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Password to authenticate with bind DN uid=hey,ou=people,dc=corp,dc=org "
            r"is required$",
        ):
            self.authentifier.users()

    @patch.object(ldap, "initialize")
    def test_users_bind_dn_invalid_credentials(self, mock_ldap_initialize):
        mock_ldap_object = mock_ldap_initialize.return_value
        mock_ldap_object.simple_bind_s.side_effect = ldap.INVALID_CREDENTIALS("fail")

        self.authentifier.bind_dn = "uid=hey,ou=people,dc=corp,dc=org"
        self.authentifier.bind_password = "secr3t"

        # Check exception is raised with LDAP fails due to invalid credential
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Invalid bind DN or password$",
        ):
            self.authentifier.users()

    @patch.object(ldap.ldapobject.LDAPObject, "search_s")
    def test_users_ldap_server_down_error(self, mock_search_s):
        mock_search_s.side_effect = ldap.SERVER_DOWN("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            rf"^LDAP server {self.authentifier.uri.geturl()} is unreachable$",
        ):
            self.authentifier.users()

    @patch.object(ldap.ldapobject.LDAPObject, "search_s")
    def test_users_ldap_operations_error(self, mock_search_s):
        mock_search_s.side_effect = ldap.OPERATIONS_ERROR("fail")
        with self.assertRaisesRegex(
            LDAPAuthenticationError,
            r"^Operations error on users search: fail$",
        ):
            self.authentifier.users()


class TestLDAPAuthentifierInit(unittest.TestCase):
    def test_lookup_as_user_auto_bind_dn(self):
        # lookup_as_user is None, bind_dn and bind_password are set, should do lookup
        # with service credentials.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            bind_dn="uid=read,ou=apps,dc=corp,dc=org",
            bind_password="SECR3T",
            lookup_as_user=None,
        )
        self.assertFalse(auth.lookup_as_user)

    def test_lookup_as_user_auto_no_bind_user(self):
        # lookup_as_user is None, bind_dn and bind_password are not set, should do
        # lookup as user.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            bind_dn=None,
            bind_password=None,
            lookup_as_user=None,
        )
        self.assertTrue(auth.lookup_as_user)

    def test_lookup_as_user_auto_no_bind_dn(self):
        # lookup_as_user is None, bind_dn is not set, should do lookup as user.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            bind_dn=None,
            bind_password="SECR3T",
            lookup_as_user=None,
        )
        self.assertTrue(auth.lookup_as_user)

    def test_lookup_as_user_auto_no_bind_password(self):
        # lookup_as_user is None, bind_password is not set, should do lookup as user.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            bind_dn="uid=read,ou=apps,dc=corp,dc=org",
            bind_password=None,
            lookup_as_user=None,
        )
        self.assertTrue(auth.lookup_as_user)

    def test_lookup_as_user_enabled(self):
        # lookup_as_user is True, should do lookup as user.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            lookup_as_user=True,
        )
        self.assertTrue(auth.lookup_as_user)

    def test_lookup_as_user_disabled(self):
        # lookup_as_user is False, should do lookup with service credentials.
        auth = LDAPAuthentifier(
            uri=urllib.parse.urlparse("ldap://localhost"),
            user_base="ou=people,dc=corp,dc=org",
            group_base="ou=groups,dc=corp,dc=org",
            lookup_as_user=False,
        )
        self.assertFalse(auth.lookup_as_user)
