>From 5566e5916bbda5891b981b5959e7338f90e6c918 Mon Sep 17 00:00:00 2001
From: Nikolai Kondrashov <Nikolai.Kondrashov@redhat.com>
Date: Mon, 9 Nov 2015 17:34:07 +0200
Subject: [PATCH 6/6] intg: Add a sketch of Samba AD DC tests

Add a sketch of Samba AD DC integration module and tests.

The module allows setting up and tearing down a Samba AD DC instance in
the specified root directory, bound to specified interfaces, with
specified realm, workgroup and admin password. It also provides
two functions for adding a user and a group.

The test sets up a DC, SSSD using it, adds a couple users and groups and
checks they're retrievable with NSS.

It checks only user/group names and group members, but not IDs or other
attributes, because these are set in a complicated way and it's not
clear yet whether and how we should be checking them. It also doesn't
check enumeration - that will likely need to account for the users and
groups Samba creates, likely by checking their presence and filtering
them out.
---
 contrib/ci/deps.sh         |   3 +
 src/external/intgcheck.m4  |   3 +
 src/tests/intg/Makefile.am |   2 +
 src/tests/intg/ad.py       | 271 +++++++++++++++++++++++++++++++++++++++++++++
 src/tests/intg/ad_test.py  | 185 +++++++++++++++++++++++++++++++
 src/tests/intg/util.py     |   6 +
 6 files changed, 470 insertions(+)
 create mode 100644 src/tests/intg/ad.py
 create mode 100644 src/tests/intg/ad_test.py

diff --git a/contrib/ci/deps.sh b/contrib/ci/deps.sh
index 8913f7b..174a688 100644
--- a/contrib/ci/deps.sh
+++ b/contrib/ci/deps.sh
@@ -44,6 +44,7 @@ if [[ "$DISTRO_BRANCH" == -redhat-* ]]; then
         python-ldap
         resolv_wrapper
         rpm-build
+        samba-dc
         socket_wrapper
         uid_wrapper
     )
@@ -91,6 +92,7 @@ if [[ "$DISTRO_BRANCH" == -debian-* ]]; then
         libpcre3-dev
         libpopt-dev
         libsasl2-dev
+        libsasl2-modules-gssapi-mit
         libselinux1-dev
         libsemanage1-dev
         libsmbclient-dev
@@ -104,6 +106,7 @@ if [[ "$DISTRO_BRANCH" == -debian-* ]]; then
         make
         python-dev
         python3-dev
+        samba
         samba-dev
         systemd
         xml-core
diff --git a/src/external/intgcheck.m4 b/src/external/intgcheck.m4
index 3877cdf..8b23547 100644
--- a/src/external/intgcheck.m4
+++ b/src/external/intgcheck.m4
@@ -1,5 +1,7 @@
 AC_CHECK_PROG([HAVE_FAKEROOT_SYSV], [fakeroot-sysv], [yes], [no])
 
+AC_CHECK_PROG([HAVE_SAMBA_TOOL], [samba-tool], [yes], [no])
+
 AC_PATH_PROG([PYTEST], [py.test])
 AS_IF([test -n "$PYTEST"], [HAVE_PYTEST=yes], [HAVE_PYTEST=no])
 
@@ -26,6 +28,7 @@ AC_DEFUN([SSS_ENABLE_INTGCHECK_REQS], [
         SSS_INTGCHECK_REQ([HAVE_RESOLV_WRAPPER], [resolv_wrapper])
         SSS_INTGCHECK_REQ([HAVE_SLAPD], [slapd])
         SSS_INTGCHECK_REQ([HAVE_LDAPMODIFY], [ldapmodify])
+        SSS_INTGCHECK_REQ([HAVE_SAMBA_TOOL], [samba-tool])
         SSS_INTGCHECK_REQ([HAVE_FAKEROOT_SYSV], [fakeroot-sysv])
         SSS_INTGCHECK_REQ([HAVE_PYTHON2], [python2])
         SSS_INTGCHECK_REQ([HAVE_PYTEST], [pytest])
diff --git a/src/tests/intg/Makefile.am b/src/tests/intg/Makefile.am
index 72181c1..b2c0ed3 100644
--- a/src/tests/intg/Makefile.am
+++ b/src/tests/intg/Makefile.am
@@ -1,4 +1,6 @@
 dist_noinst_DATA = \
+    ad.py \
+    ad_test.py \
     config.py.m4 \
     sssd_id.py \
     ds.py \
diff --git a/src/tests/intg/ad.py b/src/tests/intg/ad.py
new file mode 100644
index 0000000..4a32b82
--- /dev/null
+++ b/src/tests/intg/ad.py
@@ -0,0 +1,271 @@
+#
+# Abstract directory server instance class
+#
+# Copyright (c) 2015 Red Hat, Inc.
+# Author: Nikolai Kondrashov <Nikolai.Kondrashov@redhat.com>
+#
+# This is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 2 only
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import os
+import stat
+import time
+import errno
+import signal
+import shutil
+from util import *
+
+
+class AD:
+    """Samba AD DC instance."""
+
+    def __init__(self, dir, interfaces, realm, workgroup, admin_pw):
+        """
+            Initialize the instance.
+
+            Arguments:
+            dir         Path to the root of the filesystem hierarchy to create
+                        the instance under.
+            port        TCP port on localhost to bind the server to.
+            base_dn     Base DN.
+            admin_rdn   Administrator DN, relative to BASE_DN.
+            admin_pw    Administrator password.
+        """
+        self.interfaces = interfaces
+        self.realm = realm
+        self.workgroup = workgroup
+        self.admin_pw = admin_pw
+
+        self.dir = dir
+        # NOTE: Only non-shared directories can appear here,
+        #       because they will be removed on teardown.
+        self.subdirs = Bunch(
+            config=Bunch(
+                path=dir + "/etc/samba", mode=0755),
+            ncalrpc=Bunch(
+                path=dir + "/var/run/samba/ncalrpc", mode=0755),
+            winbindd_socket=Bunch(
+                path=dir + "/var/run/samba/winbindd", mode=0755),
+            cache=Bunch(
+                path=dir + "/var/lib/samba/cache", mode=0755),
+            ntp_signd_socket=Bunch(
+                path=dir + "/var/lib/samba/ntp_signd", mode=0750),
+            private=Bunch(
+                path=dir + "/var/lib/samba/private", mode=0755),
+            state=Bunch(
+                path=dir + "/var/lib/samba/state", mode=0755),
+            winbindd_privileged_socket=Bunch(
+                path=dir + "/var/lib/samba/winbindd_privileged",
+                mode=0750),
+            log=Bunch(
+                path=dir + "/var/log/samba", mode=0755),
+            wtmp=Bunch(
+                path=dir + "/var/log/samba/wtmp", mode=0755),
+            pid=Bunch(
+                path=dir + "/var/run/samba", mode=0755),
+            lock=Bunch(
+                path=dir + "/var/run/samba/lock", mode=0755),
+            utmp=Bunch(
+                path=dir + "/var/run/samba/utmp", mode=0755),
+            printers=Bunch(
+                path=dir + "/var/spool/samba", mode=0755),
+            sysvol=Bunch(
+                path=dir + "/var/lib/samba/state/sysvol", mode=0755),
+            netlogon=Bunch(
+                path=dir + "/var/lib/samba/state/sysvol/scripts",
+                mode=0755),
+            home=Bunch(
+                path=dir + "/home/samba", mode=0755),
+        )
+        self.config_path = self.subdirs.config.path + "/smb.conf"
+        self.keytab_path = self.subdirs.config.path + "/krb5.keytab"
+        self.pid_path = self.subdirs.pid.path + "/samba.pid"
+
+    def setup(self):
+        """Setup the instance"""
+
+        # Create the directories
+        for subdir in self.subdirs.__dict__.itervalues():
+            try:
+                os.makedirs(subdir.path, subdir.mode)
+            except OSError as e:
+                if e.errno != errno.EEXIST:
+                    raise
+
+        # Generate configuration
+        config = unindent("""\
+            [global]
+                interfaces = {interfaces}
+                workgroup = {workgroup}
+                realm = {realm}
+                server role = active directory domain controller
+                security = USER
+                passdb backend = tdbsam
+                log file = {subdirs.log.path}/log.%m
+                private dir = {subdirs.private.path}
+                lock directory = {subdirs.lock.path}
+                state directory = {subdirs.state.path}
+                cache directory = {subdirs.cache.path}
+                pid directory = {subdirs.pid.path}
+                ntp signd socket directory = {subdirs.ntp_signd_socket.path}
+                utmp directory = {subdirs.utmp.path}
+                wtmp directory = {subdirs.wtmp.path}
+                ncalrpc dir = {subdirs.ncalrpc.path}
+                winbindd socket directory = {subdirs.winbindd_socket.path}
+                winbindd privileged socket directory = \
+                        {subdirs.winbindd_privileged_socket.path}
+                server services = +echo +smb -s3fs
+                dcerpc endpoint servers = +winreg +srvsvc
+                idmap_ldb:use rfc2307 = yes
+                posix:eadb = {subdirs.private.path}/eadb.tdb
+                xattr_tdb:file = {subdirs.private.path}/xattr.tdb
+                vfs objects = dfs_samba4 acl_xattr fake_acls \
+                              xattr_tdb streams_depot
+
+            [homes]
+                path = {subdirs.home.path}/%S
+                read only = No
+                browseable = No
+
+            [printers]
+                path = {subdirs.printers.path}
+                printable = Yes
+                browseable = No
+
+            [netlogon]
+                path = {subdirs.netlogon.path}
+                read only = No
+                guest ok = Yes
+
+            [sysvol]
+                path = {subdirs.sysvol.path}
+                read only = No
+        """).format(**self.__dict__)
+
+        config_file = open(self.config_path, "w")
+        config_file.write(config)
+        config_file.close()
+
+        env = os.environ.copy()
+        env["SELFTEST_WINBINDD_SOCKET_DIR"] = \
+            self.subdirs.winbindd_socket.path
+
+        # Provision the domain
+        argv = ["samba-tool", "domain", "provision",
+                "--use-rfc2307",
+                "--use-ntvfs",
+                "--configfile", self.config_path,
+                "--domain", self.workgroup,
+                "--server-role", "dc",
+                "--dns-backend", "SAMBA_INTERNAL",
+                "--adminpass", self.admin_pw,
+                "--krbtgtpass", self.admin_pw,
+                "--machinepass", self.admin_pw]
+        process = subprocess.Popen(argv, env=env)
+        process.communicate()
+        if process.wait() != 0:
+            raise Exception("Failed to provision the domain")
+
+        # Start Samba
+        argv = ["samba", "--configfile", self.config_path]
+        process = subprocess.Popen(argv, env=env)
+        process.communicate()
+        if process.wait() != 0:
+            raise Exception("Failed to start Samba")
+
+        # Export keytab for domain account
+        argv = ["samba-tool", "domain", "exportkeytab",
+                "--configfile", self.config_path,
+                "--principal", "dc$",
+                self.keytab_path]
+        process = subprocess.Popen(argv, env=env)
+        process.communicate()
+        if process.wait() != 0:
+            raise Exception("Failed to export the keytab")
+        os.chmod(self.keytab_path, stat.S_IRUSR | stat.S_IWUSR)
+
+        # Disable password complexity check
+        argv = ["samba-tool", "domain", "passwordsettings", "set",
+                "--configfile", self.config_path,
+                "--complexity=off",
+                "--history-length=0",
+                "--min-pwd-length=0",
+                "--min-pwd-age=0",
+                "--max-pwd-age=0"]
+        subprocess.check_call(argv)
+
+    def teardown(self):
+        """Teardown the instance"""
+        # Wait for Samba to stop
+        try:
+            while True:
+                pid_file = open(self.pid_path, "r")
+                try:
+                    pid = int(pid_file.read())
+                finally:
+                    pid_file.close()
+                os.kill(pid, signal.SIGTERM)
+                for attempt in range(1, 30):
+                    time.sleep(1)
+                    os.kill(pid, signal.SIGCONT)
+                raise Exception("Failed to stop Samba")
+        except IOError as e:
+            if e.errno != errno.ENOENT:
+                raise
+        except OSError as e:
+            if e.errno != errno.ESRCH:
+                raise
+
+        # Remove directories
+        for dir in self.subdirs.__dict__.itervalues():
+            shutil.rmtree(dir.path, True)
+
+    def __del__(self):
+        """Destroy the instance."""
+        self.teardown()
+
+    def add_user(self, username, uid=None, gid=None,
+                 password=None, gecos=None, dir=None, shell=None):
+        """Add a user"""
+        argv = ["samba-tool", "user", "add",
+                "--configfile", self.config_path,
+                "--home-directory",
+                "/home/" + username if dir is None else dir,
+                "--login-shell",
+                "/bin/bash" if shell is None else shell]
+        if uid is not None:
+            argv.extend(["--uid-number", uid])
+        if gid is not None:
+            argv.extend(["--gid-number", gid])
+        if gecos is not None:
+            argv.extend(["--gecos", gecos])
+        if password is None:
+            argv.append("--random-password")
+        argv.extend(["--", username])
+        if password is not None:
+            argv.append(password)
+        subprocess.check_call(argv)
+
+    def add_group(self, groupname, gid=None, members=[]):
+        """Add a group"""
+        argv = ["samba-tool", "group", "add",
+                "--configfile", self.config_path]
+        if gid is not None:
+            argv.extend(["--gid-number", gid])
+        argv.extend(["--", groupname])
+        subprocess.check_call(argv)
+        if len(members) != 0:
+            argv = ["samba-tool", "group", "addmembers",
+                    "--configfile", self.config_path,
+                    "--", groupname, ",".join(members)]
+            subprocess.check_call(argv)
diff --git a/src/tests/intg/ad_test.py b/src/tests/intg/ad_test.py
new file mode 100644
index 0000000..4ec57cf
--- /dev/null
+++ b/src/tests/intg/ad_test.py
@@ -0,0 +1,185 @@
+#
+# Samba integration test
+#
+# Copyright (c) 2015 Red Hat, Inc.
+# Author: Nikolai Kondrashov <Nikolai.Kondrashov@redhat.com>
+#
+# This is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 2 only
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+import os
+import stat
+import pwd
+import grp
+import ent
+import config
+import signal
+import subprocess
+import pytest
+import ad
+from util import *
+
+
+@pytest.fixture(scope="module")
+def ad_inst(request):
+    """AD instance fixture"""
+    interface_num = int(os.environ["SOCKET_WRAPPER_DEFAULT_IFACE"])
+    interfaces = \
+        "127.0.0.%u/8 fd00:0000:0000:0000:0000:0000:5357:5f%.2x/64" % \
+        (interface_num, interface_num)
+    ad_inst = ad.AD(
+        config.PREFIX, interfaces,
+        "SAMBA.EXAMPLE.COM", "SAMBA", "Secret123"
+    )
+    try:
+        ad_inst.setup()
+    except:
+        ad_inst.teardown()
+        raise
+    request.addfinalizer(lambda: ad_inst.teardown())
+    return ad_inst
+
+
+SCHEMA_RFC2307 = "rfc2307"
+SCHEMA_RFC2307_BIS = "rfc2307bis"
+
+
+def format_basic_conf(ad_inst, enum):
+    """Format a basic SSSD configuration"""
+    interface_num = int(os.environ["SOCKET_WRAPPER_DEFAULT_IFACE"])
+    ad_server = "127.0.0.%u" % (interface_num)
+    return unindent("""\
+        [sssd]
+        debug_level         = 0xffff
+        services = nss, pam
+        domains = samba.example.com
+
+        [nss]
+        debug_level         = 0xffff
+        memcache_timeout    = 0
+
+        [pam]
+        debug_level         = 0xffff
+
+        [domain/samba.example.com]
+        debug_level         = 0xffff
+        id_provider         = ad
+        access_provider     = ad
+        ldap_id_mapping     = true
+        ad_server           = {ad_server}
+        enumerate           = {enum}
+        krb5_keytab         = {ad_inst.keytab_path}
+    """).format(**locals())
+
+
+def create_conf_file(contents):
+    """Create sssd.conf with specified contents"""
+    conf = open(config.CONF_PATH, "w")
+    conf.write(contents)
+    conf.close()
+    os.chmod(config.CONF_PATH, stat.S_IRUSR | stat.S_IWUSR)
+
+
+def cleanup_conf_file():
+    """Remove sssd.conf, if it exists"""
+    if os.path.lexists(config.CONF_PATH):
+        os.unlink(config.CONF_PATH)
+
+
+def create_conf_cleanup(request):
+    """Add teardown for removing sssd.conf"""
+    request.addfinalizer(cleanup_conf_file)
+
+
+def create_conf_fixture(request, contents):
+    """
+    Create sssd.conf with specified contents and add teardown for removing it
+    """
+    create_conf_file(contents)
+    create_conf_cleanup(request)
+
+
+def create_sssd_process():
+    """Start the SSSD process"""
+    if subprocess.call(["sssd", "-D", "-f"]) != 0:
+        raise Exception("sssd start failed")
+
+
+def cleanup_sssd_process():
+    """Stop the SSSD process and remove its state"""
+    try:
+        pid_file = open(config.PIDFILE_PATH, "r")
+        pid = int(pid_file.read())
+        os.kill(pid, signal.SIGTERM)
+        while True:
+            try:
+                os.kill(pid, signal.SIGCONT)
+            except:
+                break
+            time.sleep(1)
+    except:
+        pass
+    subprocess.call(["sss_cache", "-E"])
+    for path in os.listdir(config.DB_PATH):
+        os.unlink(config.DB_PATH + "/" + path)
+    for path in os.listdir(config.MCACHE_PATH):
+        os.unlink(config.MCACHE_PATH + "/" + path)
+
+
+def create_sssd_cleanup(request):
+    """Add teardown for stopping SSSD and removing its state"""
+    request.addfinalizer(cleanup_sssd_process)
+
+
+def create_sssd_fixture(request):
+    """Start SSSD and add teardown for stopping it and removing its state"""
+    create_sssd_process()
+    create_sssd_cleanup(request)
+
+
+@pytest.fixture
+def sanity(request, ad_inst):
+    ad_inst.add_user("user1")
+    ad_inst.add_user("user2")
+    ad_inst.add_user("user3")
+    ad_inst.add_group("group1")
+    ad_inst.add_group("group2")
+    ad_inst.add_group("group3")
+    ad_inst.add_group("empty_group")
+    ad_inst.add_group("two_user_group", members=["user1", "user2"])
+    conf = format_basic_conf(ad_inst, enum=True)
+    create_conf_fixture(request, conf)
+    create_sssd_fixture(request)
+    return None
+
+
+def test_ad(sanity):
+    ent.assert_passwd_by_name("administrator", dict(name="administrator"))
+    ent.assert_group_by_name("domain admins",
+                             dict(name="domain admins",
+                                  mem=ent.contains_only("administrator")))
+    ent.assert_each_passwd_by_name(dict(user1={}, user2={}, user3={}))
+    ent.assert_each_group_by_name(dict(
+        group1={},
+        group2={},
+        group3={},
+        empty_group=dict(mem=ent.contains_only()),
+        two_user_group=dict(mem=ent.contains_only("user1", "user2"))
+    ))
+    with pytest.raises(KeyError):
+        pwd.getpwnam("non existent user")
+    with pytest.raises(KeyError):
+        pwd.getpwuid(1)
+    with pytest.raises(KeyError):
+        grp.getgrnam("non existent group")
+    with pytest.raises(KeyError):
+        grp.getgrgid(1)
diff --git a/src/tests/intg/util.py b/src/tests/intg/util.py
index 66ec0ba..0df9fd4 100644
--- a/src/tests/intg/util.py
+++ b/src/tests/intg/util.py
@@ -22,6 +22,12 @@ import os
 import subprocess
 import config
 
+class Bunch:
+    """Dictionary with values accessible via dot notation"""
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+
+
 UNINDENT_RE = re.compile("^ +", re.MULTILINE)
 
 
-- 
2.6.2

