diff --git a/Makefile b/Makefile index f6641ce2..bed950f2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /usr/bin/env bash VENV ?= .venv -PYTHON := python3 -PIP := python3 -m pip +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip ROLES_DIR := ./roles APPLICATIONS_OUT := ./group_vars/all/04_applications.yml @@ -19,6 +19,9 @@ LINT_TESTS_DIR ?= tests/lint UNIT_TESTS_DIR ?= tests/unit INTEGRATION_TESTS_DIR ?= tests/integration +# Ensure repo root is importable (so module_utils/, filter_plugins/ etc. work) +PYTHONPATH ?= . + # Compute extra users as before RESERVED_USERNAMES := $(shell \ find $(ROLES_DIR) -maxdepth 1 -type d -printf '%f\n' \ @@ -92,7 +95,10 @@ test-lint: exit 0; \ fi @echo "πŸ”Ž Running lint tests (dir: $(LINT_TESTS_DIR), pattern: $(TEST_PATTERN))…" - PYTHONPATH=. $(PYTHON) -m unittest discover -s $(LINT_TESTS_DIR) -p "$(TEST_PATTERN)" + @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \ + -s "$(LINT_TESTS_DIR)" \ + -p "$(TEST_PATTERN)" \ + -t "$(PYTHONPATH)" test-unit: @if [ ! -d "$(UNIT_TESTS_DIR)" ]; then \ @@ -100,7 +106,10 @@ test-unit: exit 0; \ fi @echo "πŸ§ͺ Running unit tests (dir: $(UNIT_TESTS_DIR), pattern: $(TEST_PATTERN))…" - PYTHONPATH=. $(PYTHON) -m unittest discover -s $(UNIT_TESTS_DIR) -p "$(TEST_PATTERN)" + @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \ + -s "$(UNIT_TESTS_DIR)" \ + -p "$(TEST_PATTERN)" \ + -t "$(PYTHONPATH)" test-integration: @if [ ! -d "$(INTEGRATION_TESTS_DIR)" ]; then \ @@ -108,20 +117,23 @@ test-integration: exit 0; \ fi @echo "πŸ§ͺ Running integration tests (dir: $(INTEGRATION_TESTS_DIR), pattern: $(TEST_PATTERN))…" - PYTHONPATH=. $(PYTHON) -m unittest discover -s $(INTEGRATION_TESTS_DIR) -p "$(TEST_PATTERN)" + @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \ + -s "$(INTEGRATION_TESTS_DIR)" \ + -p "$(TEST_PATTERN)" \ + -t "$(PYTHONPATH)" # Backwards compatible target (kept) test-messy: test-lint test-unit test-integration @echo "πŸ“‘ Checking Ansible syntax…" ansible-playbook -i localhost, -c local $(foreach f,$(wildcard group_vars/all/*.yml),-e @$(f)) playbook.yml --syntax-check -test: setup-clean test-messy - @echo "Full test with setup-clean before was executed." +test: clean setup test-messy + @echo "βœ… Full test (setup + tests) executed." deps: - @if [ ! -x "$(PYTHON)" ]; then \ + @if [ ! -d "$(VENV)" ]; then \ echo "🐍 Creating virtualenv $(VENV)"; \ - python3 -m venv $(VENV); \ + python3 -m venv "$(VENV)"; \ fi @echo "πŸ“¦ Installing Python dependencies" @$(PIP) install --upgrade pip setuptools wheel diff --git a/tests/unit/cli/setup/test_applications.py b/tests/unit/cli/setup/test_applications.py index 5b9e7f6b..e60de33a 100644 --- a/tests/unit/cli/setup/test_applications.py +++ b/tests/unit/cli/setup/test_applications.py @@ -32,7 +32,7 @@ class TestGenerateDefaultApplications(unittest.TestCase): shutil.rmtree(self.temp_dir) def test_script_generates_expected_yaml(self): - script_path = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "cli/setup/applications.py" + script_path = Path(__file__).resolve().parent.parent.parent.parent.parent / "cli/setup/applications.py" result = subprocess.run( [ diff --git a/tests/unit/cli/setup/test_users.py b/tests/unit/cli/setup/test_users.py index 6d344b7e..22099d35 100644 --- a/tests/unit/cli/setup/test_users.py +++ b/tests/unit/cli/setup/test_users.py @@ -1,59 +1,56 @@ -import os -import sys import unittest import tempfile import shutil +import os import yaml from collections import OrderedDict -# Add cli/ to import path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..", "cli/setup/"))) +from cli.setup import users -import users class TestGenerateUsers(unittest.TestCase): def test_build_users_auto_increment_and_overrides(self): defs = { - 'alice': {}, - 'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'}, - 'carol': {} + "alice": {}, + "bob": {"uid": 2000, "email": "bob@custom.com", "description": "Custom user"}, + "carol": {}, } build = users.build_users( defs=defs, - primary_domain='example.com', + primary_domain="example.com", start_id=1001, - become_pwd='pw' + become_pwd="pw", ) # alice should get uid/gid 1001 - self.assertEqual(build['alice']['uid'], 1001) - self.assertEqual(build['alice']['gid'], 1001) - self.assertEqual(build['alice']['email'], 'alice@example.com') + self.assertEqual(build["alice"]["uid"], 1001) + self.assertEqual(build["alice"]["gid"], 1001) + self.assertEqual(build["alice"]["email"], "alice@example.com") # bob overrides - self.assertEqual(build['bob']['uid'], 2000) - self.assertEqual(build['bob']['gid'], 2000) - self.assertEqual(build['bob']['email'], 'bob@custom.com') - self.assertIn('description', build['bob']) + self.assertEqual(build["bob"]["uid"], 2000) + self.assertEqual(build["bob"]["gid"], 2000) + self.assertEqual(build["bob"]["email"], "bob@custom.com") + self.assertIn("description", build["bob"]) # carol should get next free id = 1002 - self.assertEqual(build['carol']['uid'], 1002) - self.assertEqual(build['carol']['gid'], 1002) + self.assertEqual(build["carol"]["uid"], 1002) + self.assertEqual(build["carol"]["gid"], 1002) def test_build_users_default_lookup_password(self): """ When no 'password' override is provided, the become_pwd lookup template string must be used as the password. """ - defs = {'frank': {}} + defs = {"frank": {}} lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' build = users.build_users( defs=defs, - primary_domain='example.com', + primary_domain="example.com", start_id=1001, - become_pwd=lookup_template + become_pwd=lookup_template, ) self.assertEqual( - build['frank']['password'], + build["frank"]["password"], lookup_template, - "The lookup template string was not correctly applied as the default password" + "The lookup template string was not correctly applied as the default password", ) def test_build_users_override_password(self): @@ -61,72 +58,71 @@ class TestGenerateUsers(unittest.TestCase): When a 'password' override is provided, that custom password must be used instead of become_pwd. """ - defs = {'eva': {'password': 'custompw'}} + defs = {"eva": {"password": "custompw"}} lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' build = users.build_users( defs=defs, - primary_domain='example.com', + primary_domain="example.com", start_id=1001, - become_pwd=lookup_template + become_pwd=lookup_template, ) self.assertEqual( - build['eva']['password'], - 'custompw', - "The override password was not correctly applied" + build["eva"]["password"], + "custompw", + "The override password was not correctly applied", ) - def test_build_users_duplicate_override_uid(self): defs = { - 'u1': {'uid': 1001}, - 'u2': {'uid': 1001} + "u1": {"uid": 1001}, + "u2": {"uid": 1001}, } with self.assertRaises(ValueError): - users.build_users(defs, 'ex.com', 1001, 'pw') + users.build_users(defs, "ex.com", 1001, "pw") def test_build_users_shared_gid_allowed(self): # Allow two users to share the same GID when one overrides gid and the other uses that as uid defs = { - 'a': {'uid': 1500}, - 'b': {'gid': 1500} + "a": {"uid": 1500}, + "b": {"gid": 1500}, } - build = users.build_users(defs, 'ex.com', 1500, 'pw') + build = users.build_users(defs, "ex.com", 1500, "pw") # Both should have gid 1500 - self.assertEqual(build['a']['gid'], 1500) - self.assertEqual(build['b']['gid'], 1500) + self.assertEqual(build["a"]["gid"], 1500) + self.assertEqual(build["b"]["gid"], 1500) def test_build_users_duplicate_username_email(self): defs = { - 'u1': {'username': 'same', 'email': 'same@ex.com'}, - 'u2': {'username': 'same'} + "u1": {"username": "same", "email": "same@ex.com"}, + "u2": {"username": "same"}, } # second user with same username should raise with self.assertRaises(ValueError): - users.build_users(defs, 'ex.com', 1001, 'pw') + users.build_users(defs, "ex.com", 1001, "pw") def test_dictify_converts_ordereddict(self): - od = users.OrderedDict([('a', 1), ('b', {'c': 2})]) + od = users.OrderedDict([("a", 1), ("b", {"c": 2})]) result = users.dictify(OrderedDict(od)) self.assertIsInstance(result, dict) - self.assertEqual(result, {'a': 1, 'b': {'c': 2}}) + self.assertEqual(result, {"a": 1, "b": {"c": 2}}) def test_load_user_defs_and_conflict(self): # create temp roles structure tmp = tempfile.mkdtemp() try: - os.makedirs(os.path.join(tmp, 'role1/users')) - os.makedirs(os.path.join(tmp, 'role2/users')) + os.makedirs(os.path.join(tmp, "role1/users")) + os.makedirs(os.path.join(tmp, "role2/users")) # role1 defines user x - with open(os.path.join(tmp, 'role1/users/main.yml'), 'w') as f: - yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) + with open(os.path.join(tmp, "role1/users/main.yml"), "w") as f: + yaml.safe_dump({"users": {"x": {"email": "x@a"}}}, f) # role2 defines same user x with same value - with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: - yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) + with open(os.path.join(tmp, "role2/users/main.yml"), "w") as f: + yaml.safe_dump({"users": {"x": {"email": "x@a"}}}, f) defs = users.load_user_defs(tmp) - self.assertIn('x', defs) + self.assertIn("x", defs) # now conflict definition - with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: - yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f) + with open(os.path.join(tmp, "role2/users/main.yml"), "w") as f: + yaml.safe_dump({"users": {"x": {"email": "x@b"}}}, f) with self.assertRaises(ValueError): users.load_user_defs(tmp) finally: @@ -136,7 +132,6 @@ class TestGenerateUsers(unittest.TestCase): """ Ensure that default_users keys are written in alphabetical order. """ - import tempfile import subprocess from pathlib import Path @@ -147,10 +142,10 @@ class TestGenerateUsers(unittest.TestCase): # Create multiple roles with users in unsorted order for role, users_map in [ - ("role-zeta", {"zeta": {"email": "z@ex"}}), + ("role-zeta", {"zeta": {"email": "z@ex"}}), ("role-alpha", {"alpha": {"email": "a@ex"}}), - ("role-mu", {"mu": {"email": "m@ex"}}), - ("role-beta", {"beta": {"email": "b@ex"}}), + ("role-mu", {"mu": {"email": "m@ex"}}), + ("role-beta", {"beta": {"email": "b@ex"}}), ]: (roles_dir / role / "users").mkdir(parents=True, exist_ok=True) with open(roles_dir / role / "users" / "main.yml", "w") as f: @@ -158,15 +153,20 @@ class TestGenerateUsers(unittest.TestCase): out_file = tmpdir / "users.yml" - # Resolve script path like in other tests (relative to repo root) - script_path = Path(__file__).resolve().parents[4] / "cli" / "setup" / "users.py" + # Always resolve the real script path from the imported module + script_path = Path(users.__file__).resolve() - # Run generator result = subprocess.run( - ["python3", str(script_path), - "--roles-dir", str(roles_dir), - "--output", str(out_file)], - capture_output=True, text=True + [ + "python3", + str(script_path), + "--roles-dir", + str(roles_dir), + "--output", + str(out_file), + ], + capture_output=True, + text=True, ) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertTrue(out_file.exists(), "Output file was not created.") @@ -176,24 +176,21 @@ class TestGenerateUsers(unittest.TestCase): users_map = data["default_users"] keys_in_file = list(users_map.keys()) - # Expect alphabetical order self.assertEqual( - keys_in_file, sorted(keys_in_file), - msg=f"Users are not sorted alphabetically: {keys_in_file}" + keys_in_file, + sorted(keys_in_file), + msg=f"Users are not sorted alphabetically: {keys_in_file}", ) - # Sanity: all expected keys present for k in ["alpha", "beta", "mu", "zeta"]: self.assertIn(k, users_map) finally: shutil.rmtree(tmpdir) - def test_cli_users_sorting_stable_across_runs(self): """ Running the generator multiple times yields identical content (stable sort). """ - import tempfile import subprocess from pathlib import Path @@ -202,7 +199,6 @@ class TestGenerateUsers(unittest.TestCase): roles_dir = tmpdir / "roles" roles_dir.mkdir() - # Unsorted creation order on purpose cases = [ ("role-d", {"duser": {"email": "d@ex"}}), ("role-a", {"auser": {"email": "a@ex"}}), @@ -215,35 +211,45 @@ class TestGenerateUsers(unittest.TestCase): yaml.safe_dump({"users": users_map}, f) out_file = tmpdir / "users.yml" - script_path = Path(__file__).resolve().parents[5] / "cli" / "setup" / "users.py" + script_path = Path(users.__file__).resolve() - # First run r1 = subprocess.run( - ["python3", str(script_path), - "--roles-dir", str(roles_dir), - "--output", str(out_file)], - capture_output=True, text=True + [ + "python3", + str(script_path), + "--roles-dir", + str(roles_dir), + "--output", + str(out_file), + ], + capture_output=True, + text=True, ) self.assertEqual(r1.returncode, 0, msg=r1.stderr) content1 = out_file.read_text() - # Touch dirs to shuffle filesystem mtimes for p in roles_dir.iterdir(): os.utime(p, None) - # Second run r2 = subprocess.run( - ["python3", str(script_path), - "--roles-dir", str(roles_dir), - "--output", str(out_file)], - capture_output=True, text=True + [ + "python3", + str(script_path), + "--roles-dir", + str(roles_dir), + "--output", + str(out_file), + ], + capture_output=True, + text=True, ) self.assertEqual(r2.returncode, 0, msg=r2.stderr) content2 = out_file.read_text() self.assertEqual( - content1, content2, - msg="Output differs between runs; user sorting should be stable." + content1, + content2, + msg="Output differs between runs; user sorting should be stable.", ) finally: shutil.rmtree(tmpdir) @@ -265,11 +271,8 @@ class TestGenerateUsers(unittest.TestCase): become_pwd="pw", ) - # Reserved user should carry the flag self.assertIn("reserved", build["admin"]) self.assertTrue(build["admin"]["reserved"]) - - # Non-reserved user should not have the flag at all self.assertNotIn("reserved", build["bob"]) def test_cli_reserved_usernames_flag_sets_reserved_field(self): @@ -278,7 +281,6 @@ class TestGenerateUsers(unittest.TestCase): in the generated YAML, and that existing definitions are preserved (only 'reserved' is added). """ - import tempfile import subprocess from pathlib import Path @@ -287,7 +289,6 @@ class TestGenerateUsers(unittest.TestCase): roles_dir = tmpdir / "roles" roles_dir.mkdir() - # Role with an existing user definition "admin" (roles_dir / "role-base" / "users").mkdir(parents=True, exist_ok=True) with open(roles_dir / "role-base" / "users" / "main.yml", "w") as f: yaml.safe_dump( @@ -303,7 +304,7 @@ class TestGenerateUsers(unittest.TestCase): ) out_file = tmpdir / "users.yml" - script_path = Path(__file__).resolve().parents[5] / "cli" / "setup" / "users.py" + script_path = Path(users.__file__).resolve() result = subprocess.run( [ @@ -326,12 +327,9 @@ class TestGenerateUsers(unittest.TestCase): self.assertIn("default_users", data) users_map = data["default_users"] - # "service" was created from the reserved list and must be reserved self.assertIn("service", users_map) self.assertTrue(users_map["service"].get("reserved", False)) - # "admin" existed before; its fields must remain unchanged, - # but it must now be marked as reserved self.assertIn("admin", users_map) self.assertEqual(users_map["admin"]["email"], "admin@ex") self.assertEqual(users_map["admin"]["description"], "Admin from role") @@ -340,5 +338,6 @@ class TestGenerateUsers(unittest.TestCase): finally: shutil.rmtree(tmpdir) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 4c745fb9..5ac6a9d7 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -74,7 +74,7 @@ class TestMainHelpers(unittest.TestCase): main.show_full_help_for_all("/fake/cli", available) - expected_modules = {"cli.deploy", "cli.build.defaults.users"} + expected_modules = {"cli.deploy", "cli.setup.users"} invoked_modules = set() for call in mock_run.call_args_list: