claude[bot] c2bb4b25cb Implement native LDAP authentication support
- Create archivebox/config/ldap.py with LDAPConfig class
- Create archivebox/ldap/ Django app with custom auth backend
- Update core/settings.py to conditionally load LDAP when enabled
- Add LDAP_CREATE_SUPERUSER support to auto-grant superuser privileges
- Add comprehensive tests in test_auth_ldap.py (no mocks, no skips)
- LDAP only activates if django-auth-ldap is installed and LDAP_ENABLED=True
- Helpful error messages when LDAP libraries are missing or config is incomplete

Fixes #1664

Co-authored-by: Nick Sweeting <pirate@users.noreply.github.com>
2026-01-05 21:30:26 +00:00

219 lines
8.3 KiB
Python

"""
LDAP authentication tests for ArchiveBox.
Tests LDAP configuration, validation, and integration with Django.
Per CLAUDE.md: NO MOCKS, NO SKIPS - all tests use real code paths.
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
class TestLDAPConfig(unittest.TestCase):
"""Test LDAP configuration loading and validation."""
def test_ldap_config_defaults(self):
"""Test that LDAP config loads with correct defaults."""
from archivebox.config.ldap import LDAP_CONFIG
# Check default values
self.assertFalse(LDAP_CONFIG.LDAP_ENABLED)
self.assertIsNone(LDAP_CONFIG.LDAP_SERVER_URI)
self.assertIsNone(LDAP_CONFIG.LDAP_BIND_DN)
self.assertIsNone(LDAP_CONFIG.LDAP_BIND_PASSWORD)
self.assertIsNone(LDAP_CONFIG.LDAP_USER_BASE)
self.assertEqual(LDAP_CONFIG.LDAP_USER_FILTER, "(uid=%(user)s)")
self.assertEqual(LDAP_CONFIG.LDAP_USERNAME_ATTR, "username")
self.assertEqual(LDAP_CONFIG.LDAP_FIRSTNAME_ATTR, "givenName")
self.assertEqual(LDAP_CONFIG.LDAP_LASTNAME_ATTR, "sn")
self.assertEqual(LDAP_CONFIG.LDAP_EMAIL_ATTR, "mail")
self.assertFalse(LDAP_CONFIG.LDAP_CREATE_SUPERUSER)
def test_ldap_config_validation_disabled(self):
"""Test that validation passes when LDAP is disabled."""
from archivebox.config.ldap import LDAPConfig
config = LDAPConfig(LDAP_ENABLED=False)
is_valid, error_msg = config.validate_ldap_config()
self.assertTrue(is_valid)
self.assertEqual(error_msg, "")
def test_ldap_config_validation_missing_fields(self):
"""Test that validation fails when required fields are missing."""
from archivebox.config.ldap import LDAPConfig
# Enable LDAP but don't provide required fields
config = LDAPConfig(LDAP_ENABLED=True)
is_valid, error_msg = config.validate_ldap_config()
self.assertFalse(is_valid)
self.assertIn("LDAP_* config options must all be set", error_msg)
self.assertIn("LDAP_SERVER_URI", error_msg)
self.assertIn("LDAP_BIND_DN", error_msg)
self.assertIn("LDAP_BIND_PASSWORD", error_msg)
self.assertIn("LDAP_USER_BASE", error_msg)
def test_ldap_config_validation_complete(self):
"""Test that validation passes when all required fields are provided."""
from archivebox.config.ldap import LDAPConfig
config = LDAPConfig(
LDAP_ENABLED=True,
LDAP_SERVER_URI="ldap://localhost:389",
LDAP_BIND_DN="cn=admin,dc=example,dc=com",
LDAP_BIND_PASSWORD="password",
LDAP_USER_BASE="ou=users,dc=example,dc=com",
)
is_valid, error_msg = config.validate_ldap_config()
self.assertTrue(is_valid)
self.assertEqual(error_msg, "")
def test_ldap_config_in_get_config(self):
"""Test that LDAP_CONFIG is included in get_CONFIG()."""
from archivebox.config import get_CONFIG
all_config = get_CONFIG()
self.assertIn('LDAP_CONFIG', all_config)
self.assertEqual(all_config['LDAP_CONFIG'].__class__.__name__, 'LDAPConfig')
class TestLDAPIntegration(unittest.TestCase):
"""Test LDAP integration with Django settings."""
def test_django_settings_without_ldap_enabled(self):
"""Test that Django settings work correctly when LDAP is disabled."""
# Import Django settings (LDAP_ENABLED should be False by default)
from django.conf import settings
# Should have default authentication backends
self.assertIn("django.contrib.auth.backends.RemoteUserBackend", settings.AUTHENTICATION_BACKENDS)
self.assertIn("django.contrib.auth.backends.ModelBackend", settings.AUTHENTICATION_BACKENDS)
# LDAP backend should not be present when disabled
ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()]
self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when LDAP_ENABLED=False")
def test_django_settings_with_ldap_library_check(self):
"""Test that Django settings check for LDAP libraries when enabled."""
# Try to import django-auth-ldap to see if it's available
try:
import django_auth_ldap
import ldap
ldap_available = True
except ImportError:
ldap_available = False
# If LDAP libraries are not available, settings should handle gracefully
if not ldap_available:
# Settings should have loaded without LDAP backend
from django.conf import settings
ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()]
self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when libraries unavailable")
class TestLDAPAuthBackend(unittest.TestCase):
"""Test custom LDAP authentication backend."""
def test_ldap_backend_class_exists(self):
"""Test that ArchiveBoxLDAPBackend class is defined."""
from archivebox.ldap.auth import ArchiveBoxLDAPBackend
self.assertTrue(hasattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user'))
def test_ldap_backend_inherits_correctly(self):
"""Test that ArchiveBoxLDAPBackend has correct inheritance."""
from archivebox.ldap.auth import ArchiveBoxLDAPBackend
# Should have authenticate_ldap_user method (from base or overridden)
self.assertTrue(callable(getattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user', None)))
class TestArchiveBoxWithLDAP(unittest.TestCase):
"""Test ArchiveBox commands with LDAP configuration."""
def setUp(self):
"""Set up test environment."""
self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-test-')
def test_archivebox_init_without_ldap(self):
"""Test that archivebox init works without LDAP enabled."""
import subprocess
# Run archivebox init
result = subprocess.run(
[sys.executable, '-m', 'archivebox', 'init'],
cwd=self.work_dir,
capture_output=True,
timeout=45,
env={
**os.environ,
'DATA_DIR': self.work_dir,
'LDAP_ENABLED': 'False',
}
)
# Should succeed
self.assertEqual(result.returncode, 0, f"archivebox init failed: {result.stderr.decode()}")
def test_archivebox_version_with_ldap_config(self):
"""Test that archivebox version works with LDAP config set."""
import subprocess
# Run archivebox version with LDAP config env vars
result = subprocess.run(
[sys.executable, '-m', 'archivebox', 'version'],
capture_output=True,
timeout=10,
env={
**os.environ,
'LDAP_ENABLED': 'False',
'LDAP_SERVER_URI': 'ldap://localhost:389',
}
)
# Should succeed
self.assertEqual(result.returncode, 0, f"archivebox version failed: {result.stderr.decode()}")
class TestLDAPConfigValidationInArchiveBox(unittest.TestCase):
"""Test LDAP config validation when running ArchiveBox commands."""
def setUp(self):
"""Set up test environment."""
self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-validation-')
def test_archivebox_init_with_incomplete_ldap_config(self):
"""Test that archivebox init fails with helpful error when LDAP config is incomplete."""
import subprocess
# Run archivebox init with LDAP enabled but missing required fields
result = subprocess.run(
[sys.executable, '-m', 'archivebox', 'init'],
cwd=self.work_dir,
capture_output=True,
timeout=45,
env={
**os.environ,
'DATA_DIR': self.work_dir,
'LDAP_ENABLED': 'True',
# Missing: LDAP_SERVER_URI, LDAP_BIND_DN, etc.
}
)
# Should fail with validation error
self.assertNotEqual(result.returncode, 0, "Should fail with incomplete LDAP config")
# Check error message
stderr = result.stderr.decode()
self.assertIn("LDAP_* config options must all be set", stderr,
f"Expected validation error message in: {stderr}")
if __name__ == '__main__':
unittest.main()