diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index 8c13935f3..a81eaacb2 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -28,6 +28,10 @@ on: description: '๐ docker_tests/ (Environment & PUID/PGID)' type: boolean default: false + run_ldap: + description: '๐ LDAP (Auth plugins, unit and UI tests)' + type: boolean + default: true run_ui: description: '๐ ui/ (Selenium & Dashboard)' type: boolean @@ -60,6 +64,7 @@ jobs: INPUT_RUN_API: ${{ github.event.inputs.run_api }} INPUT_RUN_BACKEND: ${{ github.event.inputs.run_backend }} INPUT_RUN_DOCKER_ENV: ${{ github.event.inputs.run_docker_env }} + INPUT_RUN_LDAP: ${{ github.event.inputs.run_ldap }} INPUT_RUN_UI: ${{ github.event.inputs.run_ui }} INPUT_RUN_PLUGINS: ${{ github.event.inputs.run_plugins }} INPUT_RUN_ROOT_FILES: ${{ github.event.inputs.run_root_files }} @@ -85,6 +90,7 @@ jobs: if [ "$INPUT_RUN_API" == "true" ]; then PATHS="$PATHS test/api_endpoints/ test/server/"; fi if [ "$INPUT_RUN_BACKEND" == "true" ]; then PATHS="$PATHS test/backend/ test/db/"; fi if [ "$INPUT_RUN_DOCKER_ENV" == "true" ]; then PATHS="$PATHS test/docker_tests/"; fi + if [ "$INPUT_RUN_LDAP" == "true" ]; then PATHS="$PATHS test/server/test_ldap_provider.py test/server/test_ldap_provider_env.py test/ui/test_ui_ldap_login.py test/docker_tests/test_ldap_ui.py"; fi if [ "$INPUT_RUN_UI" == "true" ]; then PATHS="$PATHS test/ui/"; fi if [ "$INPUT_RUN_PLUGINS" == "true" ]; then PATHS="$PATHS test/plugins/"; fi diff --git a/front/index.php b/front/index.php index ff57102ed..6d7ab5f21 100755 --- a/front/index.php +++ b/front/index.php @@ -1,12 +1,13 @@ - - - querySingle("SELECT setValue FROM Settings WHERE setKey = 'LDAP_enabled'"); + if (is_string($db_ldap) && strtolower(trim($db_ldap)) === 'true') { + $ldap_enabled = true; + } + } + } catch (\Throwable $e) { + // Ignore connection errors if database is uninitialized + } +} + +// Fall back to SQLite for API_TOKEN if not present in app.conf +if (empty($api_token)) { + try { + $db = SQLite3_connect(); + if ($db) { + $db_token = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'API_TOKEN'"); + if (!empty($db_token) && is_string($db_token)) { + $api_token = $db_token; + } + } + } catch (\Throwable $e) { + // Ignore connection errors if database is uninitialized + } +} + +/** + * Derive the Python API port from the GRAPHQL_PORT setting in app.conf. + * Falls back to 20212 (the default) when not set. + */ +$gql_line = getConfigLine('/^GRAPHQL_PORT.*=/', $configLines); +$graphql_port = 20212; +if ($gql_line !== null && isset($gql_line[1])) { + $parsed_port = (int) preg_replace('/[^0-9]/', '', $gql_line[1]); + if ($parsed_port >= 1 && $parsed_port <= 65535) { + $graphql_port = $parsed_port; + } +} + +$ldap_login_url = "http://127.0.0.1:{$graphql_port}/api/auth/login"; + /* ===================================================== Helper Functions ===================================================== */ @@ -83,8 +135,24 @@ function is_authenticated(): bool { } function login_user(): void { + global $nax_Password, $api_token, $configLines; + $_SESSION['login'] = 1; session_regenerate_id(true); + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + + // Set remember-me cookie with HMAC when API_TOKEN is available. + // On first boot the token may not exist yet - skip the cookie gracefully. + if (!empty($api_token)) { + $cookie_value = hash_hmac('sha256', $nax_Password, $api_token); + setcookie(COOKIE_SAVE_LOGIN_NAME, $cookie_value, [ + 'expires' => time() + 3600 * 24 * 7, + 'path' => '/', + 'httponly' => true, + 'secure' => !empty($_SERVER['HTTPS']), + 'samesite' => 'Strict', + ]); + } } @@ -114,16 +182,68 @@ function logout_user(): void { Login Attempt ===================================================== */ -if (!empty($_POST['loginpassword'])) { - - $incomingHash = hash('sha256', $_POST['loginpassword']); +if (!empty($_POST['loginpassword']) && + isset($_POST['csrf_token']) && + hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'])) { + + if ($ldap_enabled) { + // Resolve API token if empty (may be in SQLite rather than app.conf) + if (empty($api_token)) { + try { + $db = SQLite3_connect(); + if ($db) { + $db_token = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'API_TOKEN'"); + if (!empty($db_token) && is_string($db_token)) { + $api_token = $db_token; + } + } + } catch (\Throwable $e) { + // Ignore connection errors if database is locked or uninitialized + } + } - if (hash_equals($nax_Password, $incomingHash)) { + // LDAP path: delegate credential validation to the Python API. + // The API token is required so only server-side callers can reach the endpoint. + if (empty($api_token)) { + error_log('[LDAP] Authentication error: API_TOKEN is not configured'); + } else { + $ldap_payload = json_encode([ + 'username' => isset($_POST['loginusername']) ? trim($_POST['loginusername']) : '', + 'password' => $_POST['loginpassword'], + ]); + $stream_opts = [ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n" + . "Authorization: Bearer " . $api_token . "\r\n" + . "X-Forwarded-For: " . ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1') . "\r\n", + 'content' => $ldap_payload, + 'timeout' => 5, + 'ignore_errors' => true, + ] + ]; + $ctx = stream_context_create($stream_opts); + $raw = @file_get_contents($ldap_login_url, false, $ctx); + $api_resp = ($raw !== false) ? @json_decode($raw, true) : null; + + if (is_array($api_resp) && ($api_resp['success'] ?? false) === true) { + login_user(); + safe_redirect(append_hash($redirectTo)); + } else { + error_log('[LDAP] Auth failed from API: ' . ($raw !== false ? $raw : 'connection failed to ' . $ldap_login_url)); + } + } + // Fall through to show the login form with an error state. + } else { + // Local path: compare SHA-256 digest against the stored hash (same as before). + $incomingHash = hash('sha256', $_POST['loginpassword']); - login_user(); + if (hash_equals($nax_Password, $incomingHash)) { + login_user(); - // Redirect to target page, preserving deep link hash if present - safe_redirect(append_hash($redirectTo)); + // Redirect to target page, preserving deep link hash if present + safe_redirect(append_hash($redirectTo)); + } } } @@ -179,6 +299,9 @@ function logout_user(): void { + + +
ldap.example.com"
+ }
+ ]
+ },
+ {
+ "function": "port",
+ "type": {
+ "dataType": "integer",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "number"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "default_value": 389,
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "LDAP port"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "TCP port for LDAP connections (default 389, or 636 for LDAPS)."
+ }
+ ]
+ },
+ {
+ "function": "use_ssl",
+ "type": {
+ "dataType": "boolean",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "checkbox"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "default_value": false,
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Use LDAPS (SSL)"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Enable LDAPS (implicit TLS on port 636). Mutually exclusive with StartTLS."
+ }
+ ]
+ },
+ {
+ "function": "use_start_tls",
+ "type": {
+ "dataType": "boolean",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "checkbox"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "default_value": false,
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Use StartTLS"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Upgrade connection to TLS via STARTTLS (port 389). Mutually exclusive with LDAPS."
+ }
+ ]
+ },
+ {
+ "function": "tls_verify_cert",
+ "type": {
+ "dataType": "boolean",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "checkbox"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "default_value": true,
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Verify TLS Certificate"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Verify the LDAP server TLS certificate against trusted CAs."
+ }
+ ]
+ },
+ {
+ "function": "ca_cert_path",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 512,
+ "default_value": "",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "CA Certificate Bundle Path"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Path to custom CA certificate bundle (e.g. /config/my-ca.pem)."
+ }
+ ]
+ },
+ {
+ "function": "disable_local_admin",
+ "type": {
+ "dataType": "boolean",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "checkbox"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "default_value": false,
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Disable local admin account (Recommended)"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Disable local admin login to enforce LDAP-only authentication."
+ }
+ ]
+ },
+ {
+ "function": "direct_bind_format",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 512,
+ "default_value": "",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Direct Bind Format (Optional)"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Direct bind template (e.g. {username}@example.com). Bypasses user search if set."
+ }
+ ]
+ },
+ {
+ "function": "bind_dn",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 512,
+ "default_value": "",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Bind DN (service account)"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Service account DN for user search (e.g. cn=readonly,dc=example,dc=com). Blank for anonymous."
+ }
+ ]
+ },
+ {
+ "function": "bind_password",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [
+ {
+ "type": "password"
+ }
+ ],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 256,
+ "default_value": "",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Bind password"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Password for the service account above."
+ }
+ ]
+ },
+ {
+ "function": "base_dn",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 512,
+ "default_value": "",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Base DN"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Base search DN. Example: ou=users,dc=example,dc=com"
+ }
+ ]
+ },
+ {
+ "function": "user_filter",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 512,
+ "default_value": "(uid={username})",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "User search filter"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Search filter for user lookup. Example: (uid={username}) or (sAMAccountName={username})."
+ }
+ ]
+ },
+ {
+ "function": "username_attribute",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ {
+ "elementType": "input",
+ "elementOptions": [],
+ "transformers": []
+ }
+ ]
+ },
+ "maxLength": 64,
+ "default_value": "uid",
+ "options": [],
+ "localized": [
+ "name",
+ "description"
+ ],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Username attribute"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "LDAP attribute for login name (typically uid or sAMAccountName)."
+ }
+ ]
+ }
+ ]
+}
diff --git a/test/api_endpoints/test_auth_endpoints.py b/test/api_endpoints/test_auth_endpoints.py
index be87cca6c..d796a759c 100644
--- a/test/api_endpoints/test_auth_endpoints.py
+++ b/test/api_endpoints/test_auth_endpoints.py
@@ -2,7 +2,9 @@
import sys
import os
+import hashlib
import pytest
+from unittest.mock import patch
# Register NetAlertX directories
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
@@ -64,3 +66,84 @@ def test_auth_invalid_token(client):
assert data is not None
assert data.get("success") is False
assert "not authorized" in data.get("message", "").lower()
+
+
+# -------------------------
+# LOGIN ENDPOINT TESTS
+# -------------------------
+
+_DEFAULT_PW = "123456"
+_DEFAULT_PW_HASH = hashlib.sha256(_DEFAULT_PW.encode()).hexdigest()
+
+
+def test_login_valid_local_credentials(client, api_token):
+ """POST /api/auth/login with correct local password returns 200."""
+ with patch("auth.local_provider.get_setting_value", return_value=_DEFAULT_PW_HASH), \
+ patch("auth.manager.get_setting_value", return_value=False):
+ resp = client.post(
+ "/api/auth/login",
+ headers=auth_headers(api_token),
+ json={"username": "admin", "password": _DEFAULT_PW},
+ )
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data is not None
+ assert data.get("success") is True
+ assert data.get("provider") == "local"
+ assert data.get("username") == "admin"
+
+
+def test_login_wrong_local_password(client, api_token):
+ """POST /api/auth/login with incorrect password returns 401."""
+ with patch("auth.local_provider.get_setting_value", return_value=_DEFAULT_PW_HASH), \
+ patch("auth.manager.get_setting_value", return_value=False):
+ resp = client.post(
+ "/api/auth/login",
+ headers=auth_headers(api_token),
+ json={"username": "admin", "password": "totally_wrong"},
+ )
+ assert resp.status_code == 401
+ data = resp.get_json()
+ assert data is not None
+ assert data.get("success") is False
+
+
+def test_login_missing_password_field(client, api_token):
+ """POST /api/auth/login without password returns 422 validation error."""
+ resp = client.post("/api/auth/login", headers=auth_headers(api_token), json={"username": "admin"})
+ assert resp.status_code == 422
+
+
+def test_login_missing_username_field(client, api_token):
+ """POST /api/auth/login without username defaults to admin."""
+ with patch("auth.manager.LdapProvider._read_config", return_value={"enabled": False}), \
+ patch("auth.local_provider.get_setting_value", return_value=_DEFAULT_PW_HASH), \
+ patch("auth.manager.get_setting_value", return_value=False):
+ resp = client.post("/api/auth/login", headers=auth_headers(api_token), json={"password": _DEFAULT_PW})
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data.get("username") == "admin"
+
+
+def test_login_empty_body(client, api_token):
+ """POST /api/auth/login with empty JSON object returns 422."""
+ resp = client.post("/api/auth/login", headers=auth_headers(api_token), json={})
+ assert resp.status_code == 422
+
+
+def test_login_no_json_content_type(client, api_token):
+ """POST /api/auth/login with wrong content-type returns 415."""
+ resp = client.post(
+ "/api/auth/login",
+ headers=auth_headers(api_token),
+ data="username=admin&password=123456",
+ content_type="application/x-www-form-urlencoded",
+ )
+ assert resp.status_code == 415
+
+
+def test_login_endpoint_requires_post(client):
+ """GET /api/auth/login should return 405 because the route is POST-only."""
+ resp = client.get("/api/auth/login")
+ assert resp.status_code == 405
+
diff --git a/test/backend/test_auth_providers.py b/test/backend/test_auth_providers.py
new file mode 100644
index 000000000..8d2af113e
--- /dev/null
+++ b/test/backend/test_auth_providers.py
@@ -0,0 +1,314 @@
+"""
+Unit tests for the NetAlertX auth provider stack.
+
+These tests use unittest.mock.patch so they can run without a live database,
+LDAP server, or running Flask application.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import sys
+import os
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+@pytest.fixture(autouse=True)
+def mock_all_mylogs():
+ with patch("auth.local_provider.mylog"), \
+ patch("auth.ldap_provider.mylog"), \
+ patch("auth.manager.mylog"), \
+ patch("helper.mylog"), \
+ patch("logger.mylog"):
+ yield
+
+# Register NetAlertX server directory so bare module imports resolve
+INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
+sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _sha256(value: str) -> str:
+ return hashlib.sha256(value.encode()).hexdigest()
+
+
+# ===========================================================================
+# AuthResult
+# ===========================================================================
+
+class TestAuthResult:
+ def test_ok_factory(self):
+ from auth.base import AuthResult
+ r = AuthResult.ok("alice", "local")
+ assert r.success is True
+ assert r.username == "alice"
+ assert r.provider == "local"
+ assert r.error == ""
+
+ def test_fail_factory(self):
+ from auth.base import AuthResult
+ r = AuthResult.fail("ldap", "bad creds")
+ assert r.success is False
+ assert r.provider == "ldap"
+ assert r.error == "bad creds"
+
+
+# ===========================================================================
+# LocalProvider
+# ===========================================================================
+
+class TestLocalProvider:
+ def test_correct_password(self):
+ from auth.local_provider import LocalProvider
+ pw = "correct_horse"
+ with patch("auth.local_provider.get_setting_value", return_value=_sha256(pw)):
+ result = LocalProvider().authenticate("admin", pw)
+ assert result.success is True
+ assert result.provider == "local"
+ assert result.username == "admin"
+
+ def test_wrong_password(self):
+ from auth.local_provider import LocalProvider
+ pw = "correct_horse"
+ with patch("auth.local_provider.get_setting_value", return_value=_sha256(pw)):
+ result = LocalProvider().authenticate("admin", "wrong_password")
+ assert result.success is False
+ assert result.provider == "local"
+
+ def test_empty_password_rejected(self):
+ from auth.local_provider import LocalProvider
+ with patch("auth.local_provider.get_setting_value", return_value=_sha256("abc")):
+ result = LocalProvider().authenticate("admin", "")
+ assert result.success is False
+
+ def test_missing_stored_hash(self):
+ from auth.local_provider import LocalProvider
+ with patch("auth.local_provider.get_setting_value", return_value=""), \
+ patch("auth.local_provider.mylog"):
+ result = LocalProvider().authenticate("admin", "anything")
+ assert result.success is False
+
+ def test_default_password_hash(self):
+ """The shipped default password '123456' must authenticate correctly."""
+ from auth.local_provider import LocalProvider
+ default_hash = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
+ with patch("auth.local_provider.get_setting_value", return_value=default_hash):
+ result = LocalProvider().authenticate("admin", "123456")
+ assert result.success is True
+
+ def test_any_username_accepted(self):
+ """Local provider does not validate username โ any value should work."""
+ from auth.local_provider import LocalProvider
+ pw = "secret"
+ with patch("auth.local_provider.get_setting_value", return_value=_sha256(pw)):
+ result = LocalProvider().authenticate("anyone", pw)
+ assert result.success is True
+ assert result.username == "anyone"
+
+
+# ===========================================================================
+# LdapProvider
+# ===========================================================================
+
+class TestLdapProvider:
+ """LdapProvider is tested with a fully mocked ldap3 module."""
+
+ def _cfg(self, **overrides):
+ cfg = {
+ "server": "ldap.example.com",
+ "port": 389,
+ "use_ssl": False,
+ "use_start_tls": False,
+ "bind_dn": "cn=svc,dc=example,dc=com",
+ "bind_password": "svc_pass",
+ "base_dn": "dc=example,dc=com",
+ "user_filter": "(uid={username})",
+ "username_attr": "uid",
+ "timeout": 5,
+ }
+ cfg.update(overrides)
+ return cfg
+
+ def _make_ldap3_mock(self, *, bind_success=True, search_entries=1, user_bind_success=True):
+ """Build a mock ldap3 module with the minimal interface needed."""
+ ldap3 = MagicMock()
+ ldap3.NONE = "NONE"
+ ldap3.SIMPLE = "SIMPLE"
+ ldap3.ANONYMOUS = "ANONYMOUS"
+ ldap3.AUTO_BIND_NONE = "AUTO_BIND_NONE"
+ ldap3.SUBTREE = "SUBTREE"
+
+ # Service account connection
+ svc_conn = MagicMock()
+ svc_conn.bind.return_value = bind_success
+ svc_conn.result = {"description": "success"} if bind_success else {"description": "invalidCredentials"}
+ fake_entries = [MagicMock(entry_dn="uid=alice,dc=example,dc=com") for _ in range(search_entries)]
+ svc_conn.entries = fake_entries
+
+ # User connection
+ user_conn = MagicMock()
+ user_conn.bind.return_value = user_bind_success
+ user_conn.result = {"description": "success"} if user_bind_success else {"description": "invalidCredentials"}
+
+ # Connection() always returns svc_conn first, then user_conn
+ ldap3.Connection.side_effect = [svc_conn, user_conn]
+ ldap3.Server.return_value = MagicMock()
+
+ return ldap3, svc_conn, user_conn
+
+ def test_successful_authentication(self):
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+ ldap3_mock, _, _ = self._make_ldap3_mock()
+
+ with patch("auth.ldap_provider.ldap3", ldap3_mock), \
+ patch("auth.ldap_provider.get_setting_value", side_effect=lambda k: self._cfg().get(k.replace("LDAP_", ""), "")):
+ provider._read_config = lambda: self._cfg()
+ result = provider.authenticate("alice", "password")
+
+ assert result.success is True
+ assert result.provider == "ldap"
+ assert result.username == "alice"
+
+ def test_service_account_bind_fails(self):
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+ ldap3_mock, _, _ = self._make_ldap3_mock(bind_success=False)
+
+ with patch.dict("sys.modules", {"ldap3": ldap3_mock}):
+ provider._read_config = lambda: self._cfg()
+ with pytest.raises(ConnectionError):
+ provider._resolve_user_dn(ldap3_mock, MagicMock(), self._cfg(), "alice")
+
+ def test_user_not_found_in_ldap(self):
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+ ldap3_mock, _, _ = self._make_ldap3_mock(search_entries=0)
+
+ with patch.dict("sys.modules", {"ldap3": ldap3_mock}):
+ provider._read_config = lambda: self._cfg()
+ result = provider._resolve_user_dn(ldap3_mock, MagicMock(), self._cfg(), "unknown_user")
+ assert result is None
+
+ def test_user_password_wrong(self):
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+
+ ldap3 = MagicMock()
+ ldap3.SIMPLE = "SIMPLE"
+ ldap3.AUTO_BIND_NONE = "AUTO_BIND_NONE"
+
+ user_conn = MagicMock()
+ user_conn.bind.return_value = False
+ user_conn.result = {"description": "invalidCredentials"}
+ ldap3.Connection.return_value = user_conn
+
+ with patch.dict("sys.modules", {"ldap3": ldap3}):
+ provider._read_config = lambda: self._cfg()
+ result = provider._bind_as_user(
+ ldap3, MagicMock(), self._cfg(),
+ "uid=alice,dc=example,dc=com", "alice", "wrong"
+ )
+ assert result.success is False
+
+ def test_missing_server_config(self):
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+ with patch("auth.ldap_provider.get_setting_value", return_value=""):
+ result = provider.authenticate("alice", "pw")
+ assert result.success is False
+
+ def test_ldap_filter_escaping(self):
+ """Special characters in username must be escaped in the LDAP filter."""
+ from auth.ldap_provider import _escape_ldap_filter
+ assert _escape_ldap_filter("a*b(c)d\\e\x00f") == r"a\2ab\28c\29d\5ce\00f"
+
+ def test_missing_ldap3_package(self):
+ """Graceful failure when ldap3 is not installed."""
+ from auth.ldap_provider import LdapProvider
+ provider = LdapProvider()
+ provider._read_config = lambda: self._cfg()
+
+ with patch("auth.ldap_provider.ldap3", None):
+ result = provider.authenticate("alice", "pw")
+ assert result.success is False
+
+
+# ===========================================================================
+# AuthManager
+# ===========================================================================
+
+class TestAuthManager:
+ def test_uses_local_when_ldap_disabled(self):
+ from auth.manager import AuthManager
+ with patch("auth.manager.get_setting_value", return_value=False):
+ manager = AuthManager()
+ provider = manager.get_provider()
+ assert provider.name == "local"
+
+ def test_uses_ldap_when_ldap_enabled(self):
+ from auth.manager import AuthManager
+ with patch("auth.manager.LdapProvider._read_config", return_value={"enabled": True}):
+ manager = AuthManager()
+ provider = manager.get_provider()
+ assert provider.name == "ldap"
+
+ def test_authenticate_uses_local_when_ldap_disabled(self):
+ from auth.manager import AuthManager
+ from auth.base import AuthResult
+
+ manager = AuthManager()
+ with patch("auth.manager.get_setting_value", return_value=False), \
+ patch("auth.manager.LocalProvider.authenticate", return_value=AuthResult.ok("admin", "local")) as mock_auth:
+ result = manager.authenticate("admin", "pass")
+
+ mock_auth.assert_called_once_with("admin", "pass")
+ assert result.success is True
+
+ def test_authenticate_uses_ldap_when_ldap_enabled(self):
+ from auth.manager import AuthManager
+ from auth.base import AuthResult
+
+ def mock_settings(key):
+ if key == "SETPWD_enable_password":
+ return True
+ return False
+
+ manager = AuthManager()
+ with patch("auth.manager.LdapProvider._read_config", return_value={"enabled": True, "disable_local_admin": True}), \
+ patch("auth.manager.get_setting_value", side_effect=mock_settings), \
+ patch("auth.manager.LdapProvider.authenticate", return_value=AuthResult.ok("admin", "ldap")) as mock_auth:
+ result = manager.authenticate("admin", "pass")
+
+ mock_auth.assert_called_once_with("admin", "pass")
+ assert result.success is True
+
+ def test_authenticate_fallback_to_local(self):
+ from auth.manager import AuthManager
+ from auth.base import AuthResult
+ from auth.ldap_provider import LdapProvider
+
+ def mock_settings(key):
+ if key == "LDAP_enabled":
+ return True
+ if key == "SETPWD_enable_password":
+ return True
+ if key == "LDAP_disable_local_admin":
+ return True
+ return False
+
+ manager = AuthManager()
+ with patch("auth.manager.LdapProvider._read_config", return_value={"enabled": True, "disable_local_admin": False}), \
+ patch("auth.manager.get_setting_value", side_effect=mock_settings), \
+ patch("auth.manager.LdapProvider.authenticate", return_value=AuthResult.fail("ldap", LdapProvider.USER_NOT_FOUND)), \
+ patch("auth.manager.LocalProvider.authenticate", return_value=AuthResult.ok("admin", "local")) as mock_local_auth:
+ result = manager.authenticate("admin", "pass")
+
+ mock_local_auth.assert_called_once_with("admin", "pass")
+ assert result.success is True
+ assert result.provider == "local"
+
diff --git a/test/docker_tests/test_container_environment.py b/test/docker_tests/test_container_environment.py
index 1ca1eb019..d96b2e269 100644
--- a/test/docker_tests/test_container_environment.py
+++ b/test/docker_tests/test_container_environment.py
@@ -58,7 +58,7 @@
VOLUME_MAP = CONTAINER_TARGETS
-pytestmark = [pytest.mark.docker, pytest.mark.feature_complete]
+pytestmark = [pytest.mark.docker, pytest.mark.feature_complete, pytest.mark.allow_socket]
def _unique_label(prefix: str) -> str:
@@ -174,15 +174,20 @@ def _setup_mount_tree(
# Ensure every mount point is world-writable so arbitrary UID/GID can write
for p in paths.values():
- if p.is_dir():
- p.chmod(0o777)
- for child in p.iterdir():
- if child.is_dir():
- child.chmod(0o777)
- else:
- child.chmod(0o666)
- else:
- p.chmod(0o666)
+ try:
+ if p.is_dir():
+ p.chmod(0o777)
+ for child in p.iterdir():
+ if child.is_dir():
+ child.chmod(0o777)
+ else:
+ child.chmod(0o666)
+ else:
+ p.chmod(0o666)
+ except PermissionError:
+ # Best-effort; if we don't own it (e.g. root-owned from previous run),
+ # we rely on Docker-based chown/cleanup helpers.
+ pass
return paths
@@ -1212,7 +1217,6 @@ def test_mandatory_folders_creation(tmp_path: pathlib.Path) -> None:
"services_run",
"nginx_conf",
]:
- paths[key].chmod(0o777)
_chown_netalertx(paths[key]) # Ensure all directories are owned by netalertx
volumes = _build_volume_args_for_keys(paths, {"data"})
@@ -1256,7 +1260,6 @@ def test_writable_config_validation(tmp_path: pathlib.Path) -> None:
"services_run",
"nginx_conf",
]:
- paths[key].chmod(0o777)
_chown_netalertx(paths[key])
volumes = _build_volume_args_for_keys(paths, {"data"})
diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py
index da04c285b..713b07883 100644
--- a/test/docker_tests/test_docker_compose_scenarios.py
+++ b/test/docker_tests/test_docker_compose_scenarios.py
@@ -127,7 +127,7 @@ def _discover_host_addresses() -> tuple[str, ...]:
# Test project name prefixes to clean up before runs
_TEST_PROJECT_PREFIXES = ("netalertx-missing", "netalertx-normal", "netalertx-custom", "netalertx-host", "netalertx-ram", "netalertx-dataloss")
-pytestmark = [pytest.mark.docker, pytest.mark.compose]
+pytestmark = [pytest.mark.docker, pytest.mark.compose, pytest.mark.allow_socket]
IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test")
diff --git a/test/docker_tests/test_docker_compose_unit.py b/test/docker_tests/test_docker_compose_unit.py
index 411654861..778f1d9be 100644
--- a/test/docker_tests/test_docker_compose_unit.py
+++ b/test/docker_tests/test_docker_compose_unit.py
@@ -1,6 +1,8 @@
import subprocess
+import pytest
+@pytest.mark.allow_socket
def test_run_docker_compose_returns_output(monkeypatch, tmp_path):
"""Unit test that verifies `_run_docker_compose` returns a CompletedProcess
instance with an `output` attribute (combined stdout+stderr). This uses
diff --git a/test/docker_tests/test_ldap_ui.py b/test/docker_tests/test_ldap_ui.py
new file mode 100644
index 000000000..2cf60b7e7
--- /dev/null
+++ b/test/docker_tests/test_ldap_ui.py
@@ -0,0 +1,338 @@
+import json
+import os
+import pathlib
+import socket
+import subprocess
+import sys
+import time
+import pytest
+import yaml
+
+if hasattr(socket, "_original_socket"):
+ socket.socket = socket._original_socket
+
+# Add docker_tests to sys.path to import helpers
+sys.path.insert(0, os.path.dirname(__file__))
+
+from test_docker_compose_scenarios import ( # noqa: E402
+ _create_test_data_dirs,
+ CONTAINER_PATHS,
+ IMAGE,
+ _select_custom_ports,
+)
+
+pytestmark = [pytest.mark.docker, pytest.mark.compose, pytest.mark.feature_complete, pytest.mark.allow_socket]
+
+LDIF_CONTENT = """dn: ou=users,dc=example,dc=org
+objectClass: organizationalUnit
+ou: users
+
+dn: uid=testuser,ou=users,dc=example,dc=org
+objectClass: top
+objectClass: account
+objectClass: posixAccount
+objectClass: shadowAccount
+cn: testuser
+uid: testuser
+uidNumber: 10000
+gidNumber: 10000
+homeDirectory: /home/testuser
+userPassword: testpassword
+loginShell: /bin/bash
+"""
+
+
+def test_ldap_ui_login(tmp_path: pathlib.Path):
+ """
+ Spins up an LDAP server and NetAlertX container using Docker Compose,
+ configures LDAP in NetAlertX, and runs a Selenium UI test to log in.
+ """
+ project_name = f"netalertx-ldap-{int(time.time())}"
+
+ repo_root = pathlib.Path(__file__).resolve().parents[2]
+ base_dir = tmp_path
+
+ # Create test data directories for NetAlertX
+ _create_test_data_dirs(base_dir)
+ subprocess.run(["chmod", "-R", "777", str(base_dir / "test_data")], check=True)
+
+ # Create LDAP bootstrap directory
+ ldap_bootstrap_dir = base_dir / "ldap_bootstrap"
+ ldap_bootstrap_dir.mkdir(parents=True, exist_ok=True)
+ ldif_file = ldap_bootstrap_dir / "users.ldif"
+ ldif_file.write_text(LDIF_CONTENT)
+ ldif_file.chmod(0o666)
+
+ # Choose a port
+ http_port = _select_custom_ports()
+ graphql_port = _select_custom_ports({http_port})
+
+ data_volume_name = f"{project_name}_data"
+
+ compose_config = {
+ "version": "3.8",
+ "volumes": {
+ data_volume_name: {}
+ },
+ "services": {
+ "ldap": {
+ "image": "osixia/openldap:1.5.0",
+ "environment": {
+ "LDAP_ORGANISATION": "Example Inc.",
+ "LDAP_DOMAIN": "example.org",
+ "LDAP_ADMIN_PASSWORD": "admin",
+ "LDAP_REMOVE_CONFIG_AFTER_SETUP": "false"
+ }
+ },
+ "netalertx": {
+ "image": IMAGE,
+ "cap_drop": ["ALL"],
+ "cap_add": ["NET_RAW", "NET_ADMIN", "NET_BIND_SERVICE"],
+ "tmpfs": ["/tmp:mode=777"],
+ "ports": [
+ f"{http_port}:20211",
+ f"{graphql_port}:20212"
+ ],
+ "depends_on": ["ldap"],
+ "volumes": [
+ {
+ "type": "volume",
+ "source": data_volume_name,
+ "target": CONTAINER_PATHS["data"],
+ "read_only": False,
+ }
+ ],
+ "environment": {
+ "TZ": "UTC",
+ "NETALERTX_DEBUG": "1",
+ "NETALERTX_CHECK_ONLY": "0", # We want the container to stay running
+ "SKIP_STARTUP_CHECKS": "host optimization",
+ "LOADED_PLUGINS": (
+ "['ARPSCAN', 'AVAHISCAN', 'INTRNT', 'NBTSCAN', 'NSLOOKUP', 'DIGSCAN', "
+ "'SMTP', 'LDAP', 'CSVBCKP', 'CUSTPROP', 'DBCLNP', 'MAINT', 'NEWDEV', "
+ "'NTFPRCS', 'SETPWD', 'SYNC', 'UI', 'VNDRPDT', 'WORKFLOWS']"
+ ),
+ "APP_CONF_OVERRIDE": json.dumps({
+ "LDAP_enabled": True,
+ "LDAP_server": "ldap",
+ "LDAP_port": 389,
+ "LDAP_base_dn": "dc=example,dc=org",
+ "LDAP_user_filter": "(uid={username})",
+ "LDAP_username_attribute": "uid",
+ "LDAP_bind_dn": "cn=admin,dc=example,dc=org",
+ "LDAP_bind_password": "admin",
+ "LDAP_use_ssl": False,
+ "LDAP_use_start_tls": False,
+ "SETPWD_enable_password": True
+ })
+ }
+ }
+ }
+ }
+
+ compose_file = base_dir / "docker-compose.yml"
+ with open(compose_file, "w") as f:
+ yaml.dump(compose_config, f)
+
+ print(f"Starting docker-compose for {project_name} on HTTP port {http_port}...")
+
+ def get_container_name(service):
+ try:
+ cmd = [
+ "docker", "ps",
+ "--filter", f"label=com.docker.compose.project={project_name}",
+ "--filter", f"label=com.docker.compose.service={service}",
+ "--format", "{{.Names}}",
+ ]
+ return subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10).stdout.strip().split('\n')[0]
+ except Exception:
+ return f"{project_name}-{service}-1"
+
+ try:
+ # Start detached using subprocess directly to avoid test_docker_compose_scenarios.py's auto-teardown
+ subprocess.run(
+ ["docker", "compose", "-f", str(compose_file), "-p", project_name, "up", "-d"],
+ check=True,
+ stdout=sys.stdout,
+ stderr=sys.stderr,
+ timeout=180
+ )
+
+ container_name = get_container_name("netalertx")
+ ldap_container = get_container_name("ldap")
+
+ # Connect the test container to the compose network to access the internal IPs directly
+ is_container = False
+ test_container_id = None
+
+ try:
+ with open("/proc/self/cpuset", "r") as f:
+ content = f.read().strip()
+ if "docker" in content:
+ is_container = True
+ test_container_id = content.split("/")[-1].strip()
+ except Exception:
+ pass
+
+ if not is_container:
+ try:
+ with open("/proc/1/cpuset", "r") as f:
+ content = f.read().strip()
+ if "docker" in content:
+ is_container = True
+ test_container_id = content.split("/")[-1].strip()
+ except Exception:
+ pass
+
+ if not test_container_id:
+ try:
+ with open("/etc/hostname", "r") as f:
+ test_container_id = f.read().strip()
+ except Exception:
+ test_container_id = socket.gethostname()
+
+ network_name = f"{project_name}_default"
+ if is_container:
+ print(f"Connecting test container {test_container_id} to network {network_name}...")
+ res = subprocess.run(
+ ["docker", "network", "connect", network_name, test_container_id],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if res.returncode != 0 and "already exists" not in res.stderr:
+ raise RuntimeError(f"Failed to connect to network {network_name}: {res.stderr}")
+
+ # Check host resolution
+ try:
+ socket.getaddrinfo(container_name, 20211)
+ except socket.gaierror:
+ # Some environments take a second to propagate DNS
+ time.sleep(2)
+ try:
+ socket.getaddrinfo(container_name, 20211)
+ except socket.gaierror as e:
+ print(f"Warning: Could not resolve hostname {container_name} after connecting to network: {e}")
+
+ # Wait for LDAP to become ready
+ print("Waiting for LDAP server to accept connections...")
+ for _ in range(30):
+ try:
+ subprocess.run(
+ ["docker", "exec", ldap_container, "ldapwhoami", "-x", "-D", "cn=admin,dc=example,dc=org", "-w", "admin"],
+ capture_output=True, check=True, timeout=5
+ )
+ print("LDAP server is ready!")
+ break
+ except Exception:
+ time.sleep(2)
+ else:
+ print("Warning: LDAP server did not become ready, proceeding anyway to attempt seeding.")
+
+ target_host = container_name if is_container else "127.0.0.1"
+ target_port = 20211 if is_container else http_port
+
+ print(f"Waiting for NetAlertX internal container on {target_host}:{target_port}...")
+ for _ in range(45):
+ try:
+ with socket.create_connection((target_host, target_port), timeout=2):
+ print("Port is ready!")
+ break
+ except OSError:
+ time.sleep(2)
+ else:
+ print(f"Warning: NetAlertX port {target_port} on {target_host} did not become ready for socket check, but we will proceed to selenium.")
+ print("\n--- BEGIN CONTAINER LOGS ---")
+ subprocess.run(["docker", "logs", container_name], check=False, timeout=10)
+ print("\n--- BEGIN PYTHON STDERR ---")
+ subprocess.run(["docker", "exec", container_name, "cat", "/tmp/log/stderr.log"], check=False, timeout=10)
+ print("--- END LOGS ---\n")
+
+ # Seed LDAP via docker exec
+ print(f"Seeding LDAP users into {ldap_container}...")
+ subprocess.run(
+ ["docker", "exec", "-i", ldap_container, "ldapadd", "-x", "-D", "cn=admin,dc=example,dc=org", "-w", "admin"],
+ input=LDIF_CONTENT,
+ text=True,
+ check=True,
+ timeout=10
+ )
+
+ # Now, the netalertx container should be up. Let's run the selenium test INSIDE the container.
+ # Give PHP/Nginx extra time to fully bootstrap UI
+ time.sleep(5)
+
+ # Force LDAP_enabled and password protection in app.conf so the PHP UI detects it
+ print("Forcing LDAP_enabled to true in app.conf for the UI...")
+ sed_script = (
+ "sed '/LDAP_enabled/d' /data/config/app.conf | sed '/SETPWD_enable_password/d' > /tmp/app.conf "
+ "&& echo 'LDAP_enabled=True' >> /tmp/app.conf "
+ "&& echo 'SETPWD_enable_password=True' >> /tmp/app.conf "
+ "&& cat /tmp/app.conf > /data/config/app.conf"
+ )
+ subprocess.run(
+ ["docker", "exec", "-u", "root", container_name, "/bin/sh", "-c", sed_script],
+ check=True,
+ timeout=10,
+ )
+
+ # Run the selenium test directly targeting the compose stack's accessible address
+ print(f"Running UI test against {target_host} on port {target_port}...")
+ ldap_ui_test = repo_root / "test" / "ui" / "test_ui_ldap_login.py"
+ cmd = [
+ sys.executable, "-m", "pytest", str(ldap_ui_test), "-v", "-s"
+ ]
+
+ env = os.environ.copy()
+ env["BASE_URL"] = f"http://{target_host}:{target_port}"
+ env["UI_BASE_URL"] = f"http://{target_host}:{target_port}"
+
+ result = subprocess.run(
+ cmd,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=180
+ )
+
+ print("--- UI Test STDOUT ---")
+ print(result.stdout)
+ print("--- UI Test STDERR ---")
+ print(result.stderr)
+
+ if result.returncode != 0:
+ print("--- PHP ERROR LOG ---")
+ subprocess.run(["docker", "exec", container_name, "cat", "/tmp/log/app.php_errors.log"], check=False)
+ print("--- NGINX ERROR LOG ---")
+ subprocess.run(["docker", "exec", container_name, "cat", "/tmp/log/nginx-error.log"], check=False)
+ print("--- APP LOG (LAST 50) ---")
+ subprocess.run(["docker", "exec", container_name, "tail", "-n", "50", "/tmp/log/app.log"], check=False)
+ print("--- APP.CONF ---")
+ subprocess.run(["docker", "exec", container_name, "cat", "/data/config/app.conf"], check=False)
+
+ # If test fails, try to fetch python/nginx logs from the container
+ logs_cmd = ["docker", "logs", container_name]
+ logs_res = subprocess.run(logs_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
+ print("--- NETALERTX LOGS ---")
+ print(logs_res.stdout)
+ print(logs_res.stderr)
+
+ ldap_logs_cmd = ["docker", "logs", f"{project_name}-ldap-1"]
+ ldap_logs_res = subprocess.run(ldap_logs_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
+ print("--- LDAP LOGS ---")
+ print(ldap_logs_res.stdout)
+ print(ldap_logs_res.stderr)
+
+ assert result.returncode == 0, "LDAP UI test failed!"
+
+ finally:
+ print("Tearing down docker compose stack...")
+ subprocess.run(
+ ["docker", "compose", "-f", str(compose_file), "-p", project_name, "down", "-v"],
+ stdout=sys.stdout,
+ stderr=sys.stderr,
+ text=True,
+ check=False,
+ timeout=60
+ )
diff --git a/test/docker_tests/test_ports_available.py b/test/docker_tests/test_ports_available.py
index e50b61bd5..666f1bd4d 100644
--- a/test/docker_tests/test_ports_available.py
+++ b/test/docker_tests/test_ports_available.py
@@ -26,7 +26,7 @@
"services_run": "/tmp/run",
}
-pytestmark = [pytest.mark.docker, pytest.mark.feature_complete]
+pytestmark = [pytest.mark.docker, pytest.mark.feature_complete, pytest.mark.allow_socket]
@pytest.fixture(scope="function")
diff --git a/test/server/test_ldap_provider.py b/test/server/test_ldap_provider.py
new file mode 100644
index 000000000..56a4f868f
--- /dev/null
+++ b/test/server/test_ldap_provider.py
@@ -0,0 +1,100 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from server.auth.ldap_provider import LdapProvider
+
+class TestLdapProvider(unittest.TestCase):
+ def test_start_tls_before_bind(self):
+ mock_ldap3 = MagicMock()
+ # Create a mock connection object
+ mock_conn = MagicMock()
+ mock_ldap3.Connection.return_value = mock_conn
+
+ # Configure a test scenario where start_tls is enabled
+ provider = LdapProvider()
+
+ cfg = {
+ "use_start_tls": True,
+ "use_ssl": False,
+ "bind_dn": "cn=admin,dc=example,dc=com",
+ "bind_password": "password123",
+ "base_dn": "dc=example,dc=com",
+ "user_filter": "(uid={username})",
+ "username_attr": "uid",
+ "server": "ldap.example.com",
+ "port": 389,
+ "timeout": 5,
+ }
+
+ mock_server = MagicMock()
+
+ # Call the secure connection method
+ provider._create_secure_connection(
+ mock_ldap3,
+ mock_server,
+ cfg,
+ user=cfg["bind_dn"],
+ password=cfg["bind_password"],
+ authentication=mock_ldap3.SIMPLE
+ )
+
+ # Assert start_tls was called
+ mock_conn.start_tls.assert_called_once()
+
+ # Assert bind was called
+ mock_conn.bind.assert_called_once()
+
+ # Determine the order of calls
+ mock_calls = mock_conn.mock_calls
+ start_tls_idx = next(i for i, call in enumerate(mock_calls) if call[0] == 'start_tls')
+ bind_idx = next(i for i, call in enumerate(mock_calls) if call[0] == 'bind')
+
+ # The critical assertion: start_tls must happen BEFORE bind
+ self.assertLess(start_tls_idx, bind_idx, "start_tls MUST be called before bind")
+
+ @patch('server.auth.ldap_provider.ldap3')
+ @patch('server.auth.ldap_provider.ssl')
+ def test_tls_verify_cert(self, mock_ssl, mock_ldap3):
+ provider = LdapProvider()
+
+ # Mock _read_config to return our test cfg
+ cfg = {
+ "server": "ldap.example.com",
+ "port": 636,
+ "use_ssl": True,
+ "use_start_tls": False,
+ "tls_verify_cert": True,
+ "ca_cert_path": "/path/to/ca.crt",
+ "bind_dn": "",
+ "bind_password": "",
+ "base_dn": "dc=example,dc=com",
+ "user_filter": "(uid={username})",
+ "username_attr": "uid",
+ "timeout": 5,
+ }
+ provider._read_config = MagicMock(return_value=cfg)
+
+ provider._resolve_user_dn = MagicMock(return_value="uid=test,dc=example,dc=com")
+ provider._bind_as_user = MagicMock(return_value=MagicMock(success=True))
+
+ mock_tls_obj = MagicMock()
+ mock_ldap3.Tls.return_value = mock_tls_obj
+ mock_ssl.CERT_REQUIRED = 2
+
+ provider.authenticate("testuser", "testpassword")
+
+ # Assert Tls object created with validation and CA path
+ mock_ldap3.Tls.assert_called_once_with(validate=2, ca_certs_file="/path/to/ca.crt")
+
+ # Assert Server object created with tls object
+ mock_ldap3.Server.assert_called_once_with(
+ "ldap.example.com",
+ port=636,
+ use_ssl=True,
+ tls=mock_tls_obj,
+ connect_timeout=5,
+ get_info=mock_ldap3.NONE
+ )
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/server/test_ldap_provider_env.py b/test/server/test_ldap_provider_env.py
new file mode 100644
index 000000000..469c327d5
--- /dev/null
+++ b/test/server/test_ldap_provider_env.py
@@ -0,0 +1,66 @@
+import os
+import unittest
+from unittest.mock import patch
+
+from server.auth.ldap_provider import LdapProvider
+
+
+class TestLdapProviderEnv(unittest.TestCase):
+ @patch("server.auth.ldap_provider.get_setting_value")
+ @patch.dict(os.environ, {}, clear=True)
+ def test_read_config_no_env(self, mock_get_setting):
+ """Test that settings are read from the database if no ENV is set."""
+ mock_get_setting.side_effect = lambda k: {
+ "LDAP_server": "db.example.com",
+ "LDAP_port": "3890",
+ "LDAP_use_ssl": "true",
+ "LDAP_tls_verify_cert": "1",
+ "LDAP_disable_local_admin": "false"
+ }.get(k, None)
+
+ provider = LdapProvider()
+ cfg = provider._read_config()
+
+ self.assertEqual(cfg["server"], "db.example.com")
+ self.assertEqual(cfg["port"], 3890)
+ self.assertEqual(cfg["use_ssl"], True)
+ self.assertEqual(cfg["tls_verify_cert"], True)
+ self.assertEqual(cfg["disable_local_admin"], False)
+
+ @patch("server.auth.ldap_provider.get_setting_value")
+ @patch.dict(os.environ, {
+ "LDAP_SERVER": "env.example.com",
+ "LDAP_PORT": "6360",
+ "LDAP_USE_SSL": "false",
+ "LDAP_TLS_VERIFY_CERT": "0",
+ "LDAP_DISABLE_LOCAL_ADMIN": "true",
+ "LDAP_BIND_DN": "cn=admin,dc=env,dc=com",
+ "LDAP_BIND_PASSWORD": "envpassword"
+ }, clear=True)
+ def test_read_config_with_env(self, mock_get_setting):
+ """Settings always come from get_setting_value (app.conf), env vars are ignored."""
+ mock_get_setting.side_effect = lambda k: {
+ "LDAP_server": "db.example.com",
+ "LDAP_port": "3890",
+ "LDAP_use_ssl": "true",
+ "LDAP_tls_verify_cert": "1",
+ "LDAP_disable_local_admin": "false",
+ "LDAP_bind_dn": "cn=dbadmin,dc=db,dc=com",
+ "LDAP_bind_password": "dbpassword"
+ }.get(k, None)
+
+ provider = LdapProvider()
+ cfg = provider._read_config()
+
+ self.assertEqual(cfg["server"], "db.example.com")
+ self.assertEqual(cfg["port"], 3890)
+ self.assertEqual(cfg["use_ssl"], True)
+ self.assertEqual(cfg["tls_verify_cert"], True)
+ self.assertEqual(cfg["disable_local_admin"], False)
+ self.assertEqual(cfg["bind_dn"], "cn=dbadmin,dc=db,dc=com")
+ # bind_password now reads from get_setting_value, not env vars
+ self.assertEqual(cfg["bind_password"], "dbpassword")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/ui/test_ui_ldap_login.py b/test/ui/test_ui_ldap_login.py
new file mode 100644
index 000000000..60b179804
--- /dev/null
+++ b/test/ui/test_ui_ldap_login.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+LDAP Login UI Test
+Tests LDAP authentication login functionality using the UI.
+This test is meant to be run inside the container when LDAP is configured.
+"""
+
+import sys
+import os
+import pytest
+from selenium.webdriver.common.by import By
+
+# Add test directory to path
+sys.path.insert(0, os.path.dirname(__file__))
+
+from test_helpers import BASE_URL, wait_for_page_load # noqa: E402
+
+
+def test_ldap_login_success(driver):
+ """Test: Successful login using LDAP credentials"""
+ print("Navigating to login page...")
+ driver.get(f"{BASE_URL}/index.php")
+ wait_for_page_load(driver)
+
+ # Verify LDAP username field is present
+ username_field = driver.find_elements(By.NAME, "loginusername")
+ if not username_field:
+ pytest.skip("LDAP login field not present; LDAP is likely disabled in this environment.")
+
+ username_input = username_field[0]
+ password_input = driver.find_element(By.NAME, "loginpassword")
+
+ print("Entering credentials...")
+ username_input.send_keys("testuser")
+ password_input.send_keys("testpassword")
+
+ submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
+ submit_button.click()
+
+ # Wait for page to respond to form submission
+ print("Waiting for login to complete...")
+
+ # Wait for URL to change away from login page
+ from selenium.webdriver.support.ui import WebDriverWait
+ try:
+ WebDriverWait(driver, 10).until(
+ lambda d: '/devices.php' in d.current_url or '/index.php' not in d.current_url
+ )
+ except Exception as e:
+ print(f"FAILED TO REDIRECT! Current URL: {driver.current_url}")
+ print("--- PAGE BODY TEXT ---")
+ try:
+ print(driver.find_element(By.TAG_NAME, "body").text)
+ except Exception:
+ pass
+ print("--- PAGE LOGS ---")
+ try:
+ for entry in driver.get_log('browser'):
+ print(entry)
+ except Exception:
+ pass
+ raise e
+
+ wait_for_page_load(driver, timeout=5)
+
+ # Should be redirected to devices page or dashboard
+ print(f"Current URL after login: {driver.current_url}")
+ assert '/devices.php' in driver.current_url or '/index.php' not in driver.current_url, \
+ f"Expected redirect after successful LDAP login, got {driver.current_url}"
+ print("LDAP login successful!")
diff --git a/test/ui/test_ui_login.py b/test/ui/test_ui_login.py
index faccdb587..7ca396724 100644
--- a/test/ui/test_ui_login.py
+++ b/test/ui/test_ui_login.py
@@ -17,6 +17,71 @@
from .test_helpers import BASE_URL, wait_for_page_load # noqa: E402
+def _is_setting_line(line, key):
+ """Return True only for 'KEY=value' lines, not 'KEY__metadata=...' lines."""
+ return line.startswith(key + "=") and not line.startswith(key + "__")
+
+
+@pytest.fixture(scope="module", autouse=True)
+def ensure_web_protection():
+ """Ensure web protection is enabled for login tests and restored after."""
+ config_path = "/data/config/app.conf"
+
+ # Read original state
+ was_enabled = False
+ original_password_line = None
+ original_enable_line = None
+
+ if os.path.exists(config_path):
+ with open(config_path, "r") as f:
+ for line in f:
+ if _is_setting_line(line, "SETPWD_enable_password"):
+ original_enable_line = line
+ if "true" in line.lower():
+ was_enabled = True
+ elif _is_setting_line(line, "SETPWD_password"):
+ original_password_line = line
+
+ if not was_enabled:
+ # Enable web protection โ only replace value lines, preserve __metadata
+ if os.path.exists(config_path):
+ with open(config_path, "r") as f:
+ lines = f.readlines()
+ with open(config_path, "w") as f:
+ for line in lines:
+ if _is_setting_line(line, "SETPWD_enable_password"):
+ continue # will rewrite below
+ if _is_setting_line(line, "SETPWD_password"):
+ continue # will rewrite below
+ f.write(line)
+ f.write("SETPWD_enable_password=True\n")
+ if original_password_line:
+ f.write(original_password_line)
+ else:
+ f.write("SETPWD_password='8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'\n")
+ time.sleep(2) # Let disk sync catch up
+
+ yield
+
+ # Restore original state โ only replace value lines, preserve __metadata
+ if not was_enabled:
+ if os.path.exists(config_path):
+ with open(config_path, "r") as f:
+ lines = f.readlines()
+ with open(config_path, "w") as f:
+ for line in lines:
+ if _is_setting_line(line, "SETPWD_enable_password"):
+ continue
+ if _is_setting_line(line, "SETPWD_password"):
+ continue
+ f.write(line)
+ if original_enable_line:
+ f.write(original_enable_line)
+ else:
+ f.write("SETPWD_enable_password=False\n")
+ if original_password_line:
+ f.write(original_password_line)
+
def get_login_password():
"""Get login password from config file or environment
@@ -45,7 +110,7 @@ def get_login_password():
with open(config_path, 'r') as f:
for line in f:
# Only look for SETPWD_password lines (not other config like API keys)
- if 'SETPWD_password' in line and '=' in line:
+ if line.startswith('SETPWD_password'):
# Extract the value between quotes
value = line.split('=', 1)[1].strip()
# Remove quotes
@@ -67,21 +132,19 @@ def get_login_password():
continue
# If we couldn't determine the password from config, try default password
- print("โน Password not determinable from config, trying default passwords...")
+ print("[i] Password not determinable from config, falling back to default password...")
- # For now, return first test password to try
- # Tests will skip if login fails
- return None
+ return "123456"
def require_login_page(driver):
- """Skip the test if the login form is not present (web protection disabled)."""
+ """Assert the login form is present now that web protection is forced ON for testing."""
fields = driver.find_elements(By.NAME, "loginpassword")
if not fields:
- pytest.skip(
- "Web protection is disabled (SETPWD_enable_password != true); "
- "login page is not shown on this instance"
- )
+ print("\n--- PAGE SOURCE ---")
+ print(driver.page_source)
+ print("--- END PAGE SOURCE ---\n")
+ assert fields, "Web protection was enabled but the login page was not shown on this instance"
def perform_login(driver, password=None):
@@ -137,9 +200,6 @@ def test_login_redirects_to_devices(driver):
time.sleep(1)
# Should be redirected to devices page
- if '/devices.php' not in driver.current_url:
- pytest.skip(f"Login failed or not configured. URL: {driver.current_url}")
-
assert '/devices.php' in driver.current_url, \
f"Expected redirect to devices.php, got {driver.current_url}"
@@ -172,9 +232,6 @@ def test_login_with_deep_link_preserves_hash(driver):
current_url = driver.current_url
print(f"URL after login with deep link: {current_url}")
- if '/devices.php' not in current_url:
- pytest.skip(f"Login failed or redirect not configured. URL: {current_url}")
-
# Verify the hash fragment is preserved
assert '#device-123' in current_url, f"Expected #device-123 hash in URL, got {current_url}"
@@ -207,9 +264,6 @@ def test_login_with_deep_link_to_network_page(driver):
current_url = driver.current_url
print(f"URL after login with network.php deep link: {current_url}")
- if '/network.php' not in current_url:
- pytest.skip(f"Login failed or redirect not configured. URL: {current_url}")
-
# Verify the hash fragment is preserved
assert '#settings-panel' in current_url, f"Expected #settings-panel hash in URL, got {current_url}"
@@ -229,8 +283,6 @@ def test_login_without_next_parameter(driver):
# Should redirect to default devices page
current_url = driver.current_url
- if '/devices.php' not in current_url:
- pytest.skip(f"Login failed or not configured. URL: {current_url}")
assert '/devices.php' in current_url, f"Expected default redirect to devices.php, got {current_url}"
diff --git a/test/ui/test_ui_maintenance.py b/test/ui/test_ui_maintenance.py
index 0ebab6968..32c9bbdb0 100644
--- a/test/ui/test_ui_maintenance.py
+++ b/test/ui/test_ui_maintenance.py
@@ -32,13 +32,13 @@ def test_export_csv_button_works(driver):
import os
import glob
- # Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues in the browser
- # which can lead to "Failed to fetch" if the server is only listening on IPv4.
- target_url = f"{BASE_URL}/maintenance.php".replace("localhost", "127.0.0.1")
- driver.get(target_url)
+ # Initialize session storage by visiting index first
+ driver.get(f"{BASE_URL}/index.php")
wait_for_page_load(driver, timeout=10)
- # Clear any existing downloads
+ target_url = f"{BASE_URL}/maintenance.php"
+ driver.get(target_url)
+ wait_for_page_load(driver, timeout=10) # Clear any existing downloads
download_dir = getattr(driver, 'download_dir', '/tmp/selenium_downloads')
for f in glob.glob(f"{download_dir}/*.csv"):
os.remove(f)