diff --git a/authserv/test/__init__.py b/authserv/test/__init__.py
index fbd6bf86a7d95c9261dad079252b40c620e4bc06..d05b74e85bf5206a4d6d298daa3e122a43797e26 100644
--- a/authserv/test/__init__.py
+++ b/authserv/test/__init__.py
@@ -1,6 +1,10 @@
 import crypt
+import logging
+import os
+import socket
 import time
 import unittest
+from ldap_test import LdapServer
 from authserv import model
 
 
@@ -67,3 +71,41 @@ class FakeUserDb(model.UserDb):
     def get_user(self, username, service):
         return self.users.get(username)
 
+
+class LdapTestBase(unittest.TestCase):
+
+    LDIFS = []
+
+    @classmethod
+    def _pick_free_port(cls):
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sock.bind(('', 0))
+        port = sock.getsockname()[1]
+        sock.close()
+        return port
+
+    @classmethod
+    def setup_class(cls):
+        # Start the local LDAP server.
+        cls.ldap_port = cls._pick_free_port()
+        cls.ldap_password = 'testpass'
+        ldifs = [os.path.join(os.path.dirname(__file__), 'fixtures', x)
+                 for x in ['base.ldif'] + cls.LDIFS]
+        logging.getLogger('py4j.java_gateway').setLevel(logging.ERROR)
+        cls.server = LdapServer({
+            'port': cls.ldap_port,
+            'bind_dn': 'cn=manager,o=Anarchy',
+            'password': cls.ldap_password,
+            'base': {
+                'objectclass': ['organization'],
+                'dn': 'o=Anarchy',
+                'attributes': {'o': 'Anarchy'},
+            },
+            'ldifs': ldifs,
+        })
+        cls.server.start()
+
+    @classmethod
+    def teardown_class(cls):
+        cls.server.stop()
+
diff --git a/authserv/test/fixtures/base.ldif b/authserv/test/fixtures/base.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..619959c3f83742bedacb5d5e4c2d7373a422af5e
--- /dev/null
+++ b/authserv/test/fixtures/base.ldif
@@ -0,0 +1,96 @@
+dn: o=Anarchy
+changetype: add
+o: Anarchy
+objectClass: top
+objectClass: organization
+
+dn: dc=org,o=Anarchy
+changetype: add
+dc: org
+objectClass: top
+objectClass: domain
+
+dn: dc=investici,dc=org,o=Anarchy
+changetype: add
+dc: investici
+objectClass: top
+objectClass: domain
+
+dn: ou=People,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: People
+objectClass: top
+objectClass: organizationalUnit
+description: Investici people
+
+dn: ou=Categories,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Categories
+objectClass: top
+objectClass: organizationalUnit
+description: Site categories
+
+dn: ou=Operators,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Operators
+objectClass: top
+objectClass: organizationalUnit
+description: Autonomous software agents
+
+dn: ou=Group,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Group
+objectClass: organizationalUnit
+
+dn: ou=Lists,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Lists
+objectClass: top
+objectClass: organizationalUnit
+description: Mailing Lists
+
+dn: ou=Newsletters,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Newsletters
+objectClass: top
+objectClass: organizationalUnit
+description: Newsletters
+
+dn: ou=Domains,dc=investici,dc=org,o=Anarchy
+changetype: add
+ou: Domains
+objectClass: top
+objectClass: organizationalUnit
+description: Investici domains
+
+dn: cn=autistici.org,ou=Domains,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: autistici.org
+objectClass: top
+objectClass: investiciDomain
+acceptMail: true
+public: yes
+
+dn: cn=inventati.org,ou=Domains,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: inventati.org
+objectClass: top
+objectClass: investiciDomain
+acceptMail: true
+public: yes
+
+dn: cn=investici.org,ou=Domains,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: investici.org
+objectClass: top
+objectClass: investiciDomain
+acceptMail: true
+public: no
+
+dn: cn=anche.no,ou=Domains,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: anche.no
+objectClass: top
+objectClass: investiciDomain
+acceptMail: true
+public: yes
diff --git a/authserv/test/fixtures/test-user-totp.ldif b/authserv/test/fixtures/test-user-totp.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..4e917dbed644a4296878ae260f40030d087248c3
--- /dev/null
+++ b/authserv/test/fixtures/test-user-totp.ldif
@@ -0,0 +1,42 @@
+dn: uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: test@investici.org
+objectClass: top
+objectClass: person
+objectClass: posixAccount
+objectClass: shadowAccount
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+loginShell: /bin/false
+uidNumber: 19475
+shadowMax: 99999
+preferredLanguage: en
+gidNumber: 2000
+gecos: test@investici.org
+sn: Private
+homeDirectory: /var/empty
+uid: test@investici.org
+givenName: Private
+shadowLastChange: 12345
+shadowWarning: 7
+userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
+
+dn: mail=test@investici.org,uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
+changetype: add
+status: active
+objectClass: top
+objectClass: virtualMailUser
+uidNumber: 19475
+host: latitanza
+mailAlternateAddress: test@anche.no
+gidNumber: 2000
+mail: test@investici.org
+creationDate: 2013-12-31
+mailMessageStore: investici.org/test/
+originalHost: latitanza
+userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
+recoverQuestion: question
+recoverAnswer:: e2NyeXB0fWFhd1IuamRHTVIwMTY=
+totpSecret: 089421
+appSpecificPassword:: bWFpbDokMSQkNXp2RTI5emVIOVc3S0sweVRPMERaMQ==
+
diff --git a/authserv/test/fixtures/test-user.ldif b/authserv/test/fixtures/test-user.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..ffae59b42d43a8dd8542e14471beb04faf965bc3
--- /dev/null
+++ b/authserv/test/fixtures/test-user.ldif
@@ -0,0 +1,39 @@
+dn: uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
+changetype: add
+cn: test@investici.org
+objectClass: top
+objectClass: person
+objectClass: posixAccount
+objectClass: shadowAccount
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+loginShell: /bin/false
+uidNumber: 19475
+shadowMax: 99999
+preferredLanguage: en
+gidNumber: 2000
+gecos: test@investici.org
+sn: Private
+homeDirectory: /var/empty
+uid: test@investici.org
+givenName: Private
+shadowLastChange: 12345
+shadowWarning: 7
+userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
+
+dn: mail=test@investici.org,uid=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy
+changetype: add
+status: active
+objectClass: top
+objectClass: virtualMailUser
+uidNumber: 19475
+host: latitanza
+mailAlternateAddress: test@anche.no
+gidNumber: 2000
+mail: test@investici.org
+creationDate: 2013-12-31
+mailMessageStore: investici.org/test/
+originalHost: latitanza
+userPassword:: e2NyeXB0fXp6WFVIZlVSbkdnOEk=
+recoverQuestion: question
+recoverAnswer:: e2NyeXB0fWFhd1IuamRHTVIwMTY=
\ No newline at end of file
diff --git a/authserv/test/test_auth_ldap.py b/authserv/test/test_auth_ldap.py
new file mode 100644
index 0000000000000000000000000000000000000000..9e72a4c3d67e43e453a7befd5d2677fed6030f85
--- /dev/null
+++ b/authserv/test/test_auth_ldap.py
@@ -0,0 +1,97 @@
+from authserv.test import *
+from authserv.auth import authenticate
+from authserv import ldap_model
+from authserv import protocol
+from authserv.oath import totp
+
+
+class LdapAuthTestBase(LdapTestBase):
+
+    SERVICE_MAP = {
+        'mail': {
+            'base': 'ou=People,dc=investici,dc=org,o=Anarchy',
+            'filter': '(&(status=active)(mail=%s))',
+        },
+    }
+
+    def setUp(self):
+        self.userdb = ldap_model.UserDb(
+            self.SERVICE_MAP,
+            'ldap://localhost:%d' % self.ldap_port,
+            'cn=manager,o=Anarchy',
+            self.ldap_password)
+
+
+class LdapAuthTest(LdapAuthTestBase):
+
+    LDIFS = [
+        'test-user.ldif',
+    ]
+
+    def test_auth_password_ok(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.OK,
+            authenticate(u, 'mail', 'password', None))
+
+    def test_auth_password_fail(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.ERR_AUTHENTICATION_FAILURE,
+            authenticate(u, 'mail', 'wrong password', None))
+
+
+class LdapOtpTest(LdapAuthTestBase):
+
+    LDIFS = [
+        'test-user-totp.ldif',
+    ]
+
+    def test_auth_password_requires_otp(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.ERR_OTP_REQUIRED,
+            authenticate(u, 'mail', 'password', None))
+
+    def test_auth_bad_password_requires_otp(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.ERR_OTP_REQUIRED,
+            authenticate(u, 'mail', 'wrong password', None))
+
+    def test_auth_otp_ok(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        secret= '089421'
+        token = totp(secret, format='dec6', period=30)
+        self.assertEquals(
+            protocol.OK,
+            authenticate(u, 'mail', 'password', str(token)))
+
+    def test_auth_otp_ok_bad_password(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        secret= '089421'
+        token = totp(secret, format='dec6', period=30)
+        self.assertEquals(
+            protocol.ERR_AUTHENTICATION_FAILURE,
+            authenticate(u, 'mail', 'wrong password', str(token)))
+
+    def test_auth_bad_otp(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.ERR_AUTHENTICATION_FAILURE,
+            authenticate(u, 'mail', 'password', '123456'))
+
+    def test_app_specific_password(self):
+        u = self.userdb.get_user('test@investici.org', 'mail')
+        self.assertTrue(u)
+        self.assertEquals(
+            protocol.OK,
+            authenticate(u, 'mail', 'veryspecificpassword', None))
+