From de85984c130d34c214648c8daddb8ef40f124a36 Mon Sep 17 00:00:00 2001 From: florianthiercelin Date: Mon, 23 Feb 2026 17:46:13 +0100 Subject: [PATCH] feat: security hardening, UX improvements, logging, and unit tests - Obfuscate stored passwords (base64) with config file permissions 0600 - Add --verbose flag, BMC address validation, password masking (getpass) - Per-operation timeouts (fast/normal/slow), boot flow feedback message - Logging infrastructure with password sanitization in commands - Remove shell=True from updater, use pipx upgrade with script fallback - Add CI test job (Python 3.9/3.12/3.14) to GitHub Actions pipeline - Add 52 unit tests covering core, config, updater, utils, and i18n --- .github/workflows/release.yml | 26 ++++++++ .gitignore | 5 +- pyproject.toml | 7 +++ src/ipmi_menu/cli.py | 75 +++++++++++++++++----- src/ipmi_menu/config/messages.en.json | 5 +- src/ipmi_menu/config/messages.fr.json | 5 +- src/ipmi_menu/config/preferences.py | 53 +++++++++++++++- src/ipmi_menu/config/settings.py | 4 ++ src/ipmi_menu/core/updater.py | 21 ++++++- src/ipmi_menu/core/utils.py | 22 +++++++ tests/__init__.py | 0 tests/test_ipmi.py | 67 ++++++++++++++++++++ tests/test_messages.py | 49 +++++++++++++++ tests/test_preferences.py | 91 +++++++++++++++++++++++++++ tests/test_updater.py | 37 +++++++++++ tests/test_utils.py | 51 +++++++++++++++ 16 files changed, 493 insertions(+), 25 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_ipmi.py create mode 100644 tests/test_messages.py create mode 100644 tests/test_preferences.py create mode 100644 tests/test_updater.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c1674a..47e1ccf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: Build & Publish to PyPI on: + push: + branches: [main, DEV] + pull_request: + branches: [main] release: types: [published] @@ -9,7 +13,29 @@ permissions: id-token: write jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12", "3.14"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -e ".[dev]" + + - name: Run tests + run: pytest -v + build: + needs: test + if: github.event_name == 'release' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index a541c82..4f67856 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ .venv dist src/ipmi_menu.egg-info -build \ No newline at end of file +build +__pycache__ +*.pyc +.pytest_cache \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c0e45fb..538006f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ classifiers = [ ] dependencies = [] +[project.optional-dependencies] +dev = ["pytest>=7.0"] + [project.scripts] ipmi-menu = "ipmi_menu.cli:main" @@ -31,3 +34,7 @@ include = ["ipmi_menu*"] # Inclure tes fichiers de conf / i18n dans le package [tool.setuptools.package-data] ipmi_menu = ["config/*.json", "i18n/*.json", "conf/*.json", "conf/*.toml"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/ipmi_menu/cli.py b/src/ipmi_menu/cli.py index df71a15..7f72d47 100755 --- a/src/ipmi_menu/cli.py +++ b/src/ipmi_menu/cli.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 from __future__ import annotations +import argparse +import getpass +import ipaddress +import logging +import re import sys from typing import Optional @@ -17,8 +22,10 @@ DEFAULT_INTERFACE, DEFAULT_PASSWORD, DEFAULT_PORT, - DEFAULT_TIMEOUT, DEFAULT_USER, + TIMEOUT_FAST, + TIMEOUT_NORMAL, + TIMEOUT_SLOW, ) from ipmi_menu.core.detect import detect from ipmi_menu.core.ipmi import ( @@ -33,11 +40,27 @@ ) from ipmi_menu.core.updater import is_update_available, run_upgrade +logger = logging.getLogger("ipmi_menu") + # ANSI color codes RED = "\033[91m" RESET = "\033[0m" from ipmi_menu.ui.prompts import confirm_critical, menu, yesno +_HOSTNAME_RE = re.compile( + r"^(?!-)[A-Za-z0-9-]{1,63}(? bool: + """Validate BMC address as IPv4, IPv6, or hostname.""" + try: + ipaddress.ip_address(addr) + return True + except ValueError: + pass + return bool(_HOSTNAME_RE.match(addr)) + def die(msg: str, code: int = 2) -> None: if msg: @@ -52,9 +75,8 @@ def require_ipmi_ok( password: Optional[str], interface: str, port: int, - timeout: int, ) -> None: - rc, out, err = ipmi(host, user, password, interface, port, timeout, ["mc", "info"]) + rc, out, err = ipmi(host, user, password, interface, port, TIMEOUT_FAST, ["mc", "info"]) if rc == 0 and out: return @@ -65,14 +87,25 @@ def require_ipmi_ok( def main() -> None: + parser = argparse.ArgumentParser(description="Interactive ipmitool menu") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.WARNING, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + msg = load_messages(get_preferred_language()) # Check for updates at startup (silent if fails) update_info = (False, "", None) try: update_info = is_update_available() - except Exception: - pass + available, cur, lat = update_info + logger.debug("Update check: available=%s, current=%s, latest=%s", available, cur, lat) + except Exception as exc: + logger.debug("Update check failed: %s", exc) if not has_ipmitool(): die(msg.t("errors.ipmitool_missing")) @@ -80,6 +113,8 @@ def main() -> None: host = input(msg.t("prompts.bmc_ip")).strip() if not host: die(msg.t("errors.bmc_ip_required")) + if not _is_valid_bmc_address(host): + die(msg.t("errors.bmc_ip_invalid")) saved_user = get_preferred_username() default_user = saved_user if saved_user else DEFAULT_USER @@ -87,7 +122,7 @@ def main() -> None: user = user_input if user_input else default_user saved_password = get_preferred_password() - password_in = input(msg.t("prompts.password")) + password_in = getpass.getpass(msg.t("prompts.password")) if password_in == "": password: Optional[str] = saved_password if saved_password is not None else DEFAULT_PASSWORD pw_mode = "default" @@ -100,12 +135,11 @@ def main() -> None: interface = DEFAULT_INTERFACE port = DEFAULT_PORT - timeout = DEFAULT_TIMEOUT print(msg.t("info.connect_detect")) - require_ipmi_ok(msg, host, user, password, interface, port, timeout) + require_ipmi_ok(msg, host, user, password, interface, port) - di = detect(host, user, password, interface, port, timeout) + di = detect(host, user, password, interface, port, TIMEOUT_NORMAL) print( msg.t( "info.hw_detected", @@ -189,7 +223,7 @@ def main() -> None: set_preferred_username(new_user) print(msg.t("info.settings.username_saved", username=new_user)) elif setting == "password": - new_pw = input(msg.t("prompts.settings.password")) + new_pw = getpass.getpass(msg.t("prompts.settings.password")) if new_pw: set_preferred_password(new_pw) print(msg.t("info.settings.password_saved")) @@ -203,21 +237,24 @@ def main() -> None: if action == "info": print(msg.t("labels.info.sensors")) - rc_s, out_s, err_s = ipmi_sdr_list(host, user, password, interface, port, timeout) + rc_s, out_s, err_s = ipmi_sdr_list(host, user, password, interface, port, TIMEOUT_SLOW) if out_s: print(out_s) if rc_s != 0 and err_s: print(err_s, file=sys.stderr) print(msg.t("labels.info.misc")) - for args in (["mc", "info"], ["fru", "print"]): - rc, out, err = ipmi(host, user, password, interface, port, timeout, list(args)) + for sub_args, sub_timeout in ( + (["mc", "info"], TIMEOUT_FAST), + (["fru", "print"], TIMEOUT_SLOW), + ): + rc, out, err = ipmi(host, user, password, interface, port, sub_timeout, list(sub_args)) if out: print(out) if rc != 0 and err: print(err, file=sys.stderr) - rc, out, err = ipmi_lan_print(host, user, password, interface, port, timeout) + rc, out, err = ipmi_lan_print(host, user, password, interface, port, TIMEOUT_NORMAL) if out: print(out) if rc != 0 and err: @@ -255,7 +292,8 @@ def main() -> None: print(msg.t("errors.cancelled")) continue - rc, out, err = power(host, user, password, interface, port, timeout, mode) + timeout_power = TIMEOUT_FAST if mode == "status" else TIMEOUT_NORMAL + rc, out, err = power(host, user, password, interface, port, timeout_power, mode) if out: print(out) if rc != 0 and err: @@ -302,7 +340,7 @@ def main() -> None: password, interface, port, - timeout, + TIMEOUT_NORMAL, device, uefi=(boot_mode == "uefi"), persistent=persistent, @@ -329,17 +367,20 @@ def main() -> None: ) if reboot_mode in {"quit", "home"}: + print(msg.t("info.boot.set_no_reboot")) continue if not confirm_critical(msg, msg.t("labels.critical.reboot")): print(msg.t("errors.cancelled")) continue - rc2, out2, err2 = power(host, user, password, interface, port, timeout, reboot_mode) + rc2, out2, err2 = power(host, user, password, interface, port, TIMEOUT_NORMAL, reboot_mode) if out2: print(out2) if rc2 != 0 and err2: print(err2, file=sys.stderr) + else: + print(msg.t("info.boot.set_no_reboot")) continue diff --git a/src/ipmi_menu/config/messages.en.json b/src/ipmi_menu/config/messages.en.json index 93a9c2e..1328414 100644 --- a/src/ipmi_menu/config/messages.en.json +++ b/src/ipmi_menu/config/messages.en.json @@ -4,12 +4,13 @@ "errors.ipmi_auth": "IPMI connection failure (authentication or access issue).\nPlease verify credentials (case-sensitive), IP address, port, and network connectivity.\n{details}", "errors.ipmi_generic": "An IPMI error has occurred.\n{details}", "errors.unknown": "An unknown error has occurred.", + "errors.bmc_ip_invalid": "Invalid BMC address. Please enter a valid IPv4 or IPv6 address, or hostname.", "errors.cancelled": "Operation cancelled.", "errors.interrupted": "\nOperation interrupted by the user.", "prompts.bmc_ip": "BMC IP address: ", "prompts.user": "Username (default: {default}): ", - "prompts.password": "Password (Enter = default, '-' = empty): ", + "prompts.password": "Password (Enter = saved/default, '-' = no password): ", "info.connect_detect": "\nEstablishing connection and detecting hardware…", "info.hw_detected": "\nHardware detected: vendor={vendor}, manufacturer={mfg}, product={product}", @@ -65,6 +66,8 @@ "labels.info.sensors": "\n===== HARDWARE SENSORS (SDR LIST) =====", "labels.info.misc": "\n===== BMC / FRU / NETWORK CONFIGURATION =====", + "info.boot.set_no_reboot": "Boot device set successfully. System was NOT rebooted.", + "labels.boot.persistent": "Apply persistently (otherwise one-time only)?", "labels.boot.reboot_after": "Reboot the system after applying this setting?", diff --git a/src/ipmi_menu/config/messages.fr.json b/src/ipmi_menu/config/messages.fr.json index d442578..8d5dbc2 100644 --- a/src/ipmi_menu/config/messages.fr.json +++ b/src/ipmi_menu/config/messages.fr.json @@ -4,12 +4,13 @@ "errors.ipmi_auth": "Échec de la connexion IPMI (authentification ou accès).\nVeuillez vérifier les identifiants (respect de la casse), l’adresse IP, le port configuré ainsi que la connectivité réseau.\n{details}", "errors.ipmi_generic": "Une erreur IPMI est survenue.\n{details}", "errors.unknown": "Une erreur inconnue est survenue.", + "errors.bmc_ip_invalid": "Adresse BMC invalide. Veuillez saisir une adresse IPv4, IPv6 ou un nom d'hôte valide.", "errors.cancelled": "Opération annulée.", "errors.interrupted": "\nOpération interrompue par l’utilisateur.", "prompts.bmc_ip": "Adresse IP du BMC : ", "prompts.user": "Nom d’utilisateur (défaut : {default}) : ", - "prompts.password": "Mot de passe (Entrée = valeur par défaut, '-' = vide) : ", + "prompts.password": "Mot de passe (Entrée = sauvegardé/défaut, '-' = sans mot de passe) : ", "info.connect_detect": "\nConnexion en cours et détection du matériel…", "info.hw_detected": "\nMatériel détecté : vendor={vendor}, fabricant={mfg}, produit={product}", @@ -65,6 +66,8 @@ "labels.info.sensors": "\n===== CAPTEURS MATÉRIELS (SDR LIST) =====", "labels.info.misc": "\n===== INFORMATIONS BMC / FRU / CONFIGURATION RÉSEAU =====", + "info.boot.set_no_reboot": "Périphérique de démarrage configuré avec succès. Le système n'a PAS été redémarré.", + "labels.boot.persistent": "Appliquer de manière persistante (sinon temporaire) ?", "labels.boot.reboot_after": "Redémarrer le système après l'application du paramètre ?", diff --git a/src/ipmi_menu/config/preferences.py b/src/ipmi_menu/config/preferences.py index 239ca74..59430c3 100644 --- a/src/ipmi_menu/config/preferences.py +++ b/src/ipmi_menu/config/preferences.py @@ -1,6 +1,8 @@ from __future__ import annotations +import base64 import json +import os from pathlib import Path from typing import Any, Dict @@ -9,25 +11,64 @@ CONFIG_DIR = Path.home() / ".config" / "ipmi-menu" PREFERENCES_FILE = CONFIG_DIR / "preferences.json" +CURRENT_VERSION = 1 + def _ensure_config_dir() -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) +def _encode_password(password: str) -> str: + return base64.b64encode(password.encode("utf-8")).decode("ascii") + + +def _decode_password(encoded: str) -> str: + return base64.b64decode(encoded.encode("ascii")).decode("utf-8") + + +def _is_base64(value: str) -> bool: + try: + decoded = base64.b64decode(value.encode("ascii")) + return base64.b64encode(decoded).decode("ascii") == value + except Exception: + return False + + +def _migrate(prefs: Dict[str, Any]) -> Dict[str, Any]: + """Migrate preferences from older formats to the current version.""" + if prefs.get("version", 0) >= CURRENT_VERSION: + return prefs + + # Migrate plaintext password to base64-encoded + pw = prefs.get("password") + if pw is not None and not _is_base64(pw): + prefs["password"] = _encode_password(pw) + + prefs["version"] = CURRENT_VERSION + return prefs + + def load_preferences() -> Dict[str, Any]: if not PREFERENCES_FILE.exists(): return {} try: with open(PREFERENCES_FILE, "r", encoding="utf-8") as f: - return json.load(f) + prefs = json.load(f) except (json.JSONDecodeError, OSError): return {} + if prefs.get("version", 0) < CURRENT_VERSION: + prefs = _migrate(prefs) + save_preferences(prefs) + + return prefs + def save_preferences(prefs: Dict[str, Any]) -> None: _ensure_config_dir() with open(PREFERENCES_FILE, "w", encoding="utf-8") as f: json.dump(prefs, f, indent=2) + os.chmod(PREFERENCES_FILE, 0o600) def get_preferred_language() -> str: @@ -57,13 +98,19 @@ def set_preferred_username(username: str | None) -> None: def get_preferred_password() -> str | None: prefs = load_preferences() - return prefs.get("password") + encoded = prefs.get("password") + if encoded is None: + return None + try: + return _decode_password(encoded) + except Exception: + return encoded def set_preferred_password(password: str | None) -> None: prefs = load_preferences() if password is not None: - prefs["password"] = password + prefs["password"] = _encode_password(password) elif "password" in prefs: del prefs["password"] save_preferences(prefs) diff --git a/src/ipmi_menu/config/settings.py b/src/ipmi_menu/config/settings.py index 958bb7d..9864781 100644 --- a/src/ipmi_menu/config/settings.py +++ b/src/ipmi_menu/config/settings.py @@ -6,3 +6,7 @@ DEFAULT_PORT = 623 DEFAULT_TIMEOUT = 35 DEFAULT_LOCALE = "fr" + +TIMEOUT_FAST = 10 +TIMEOUT_NORMAL = 35 +TIMEOUT_SLOW = 60 diff --git a/src/ipmi_menu/core/updater.py b/src/ipmi_menu/core/updater.py index 7a71e51..2ef49fe 100644 --- a/src/ipmi_menu/core/updater.py +++ b/src/ipmi_menu/core/updater.py @@ -3,11 +3,13 @@ import json import subprocess +import tempfile import urllib.request +from shutil import which from typing import Optional, Tuple PYPI_URL = "https://pypi.org/pypi/ipmi-menu/json" -UPGRADE_CMD = "curl -fsSL https://raw.githubusercontent.com/thiercelinflorian/ipmi-menu/main/install.sh | bash -s -- --upgrade" +INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/thiercelinflorian/ipmi-menu/main/install.sh" def get_current_version() -> str: @@ -71,4 +73,19 @@ def is_update_available() -> Tuple[bool, str, Optional[str]]: def run_upgrade() -> int: """Execute the upgrade command.""" - return subprocess.call(UPGRADE_CMD, shell=True) + # Try pipx first + if which("pipx"): + rc = subprocess.call(["pipx", "upgrade", "ipmi-menu"]) + if rc == 0: + return 0 + + # Fallback: download install script to a temp file and run it + try: + with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as tmp: + req = urllib.request.Request(INSTALL_SCRIPT_URL) + with urllib.request.urlopen(req, timeout=30) as resp: + tmp.write(resp.read()) + tmp_path = tmp.name + return subprocess.call(["bash", tmp_path, "--upgrade"]) + except Exception: + return 1 diff --git a/src/ipmi_menu/core/utils.py b/src/ipmi_menu/core/utils.py index 3edcad5..a886a06 100644 --- a/src/ipmi_menu/core/utils.py +++ b/src/ipmi_menu/core/utils.py @@ -1,10 +1,30 @@ from __future__ import annotations +import logging import subprocess from typing import List, Optional, Tuple +logger = logging.getLogger("ipmi_menu") + + +def _sanitize_cmd(cmd: List[str]) -> List[str]: + """Replace the password value after -P flag with '****'.""" + sanitized: List[str] = [] + skip_next = False + for i, arg in enumerate(cmd): + if skip_next: + sanitized.append("****") + skip_next = False + elif arg == "-P" and i + 1 < len(cmd): + sanitized.append(arg) + skip_next = True + else: + sanitized.append(arg) + return sanitized + def run_cmd(cmd: List[str], timeout: Optional[int]) -> Tuple[int, str, str]: + logger.debug("Running: %s (timeout=%s)", " ".join(_sanitize_cmd(cmd)), timeout) try: p = subprocess.run( cmd, @@ -15,6 +35,8 @@ def run_cmd(cmd: List[str], timeout: Optional[int]) -> Tuple[int, str, str]: ) return p.returncode, (p.stdout or "").strip(), (p.stderr or "").strip() except subprocess.TimeoutExpired: + logger.warning("Command timed out after %ss: %s", timeout, " ".join(_sanitize_cmd(cmd))) return 124, "", "timeout" except FileNotFoundError: + logger.error("Command not found: %s", cmd[0] if cmd else "") return 127, "", "command not found" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ipmi.py b/tests/test_ipmi.py new file mode 100644 index 0000000..16d0423 --- /dev/null +++ b/tests/test_ipmi.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from ipmi_menu.core.ipmi import looks_like_auth_error, normalize_vendor, parse_kv + + +class TestParseKv: + def test_basic(self): + text = "Manufacturer : Supermicro\nProduct Name : X11\n" + result = parse_kv(text) + assert result["manufacturer"] == "Supermicro" + assert result["product name"] == "X11" + + def test_empty(self): + assert parse_kv("") == {} + + def test_no_colon(self): + assert parse_kv("no colon here\nanother line") == {} + + def test_multiple_colons(self): + result = parse_kv("key : val : extra") + assert result["key"] == "val : extra" + + +class TestNormalizeVendor: + def test_supermicro(self): + assert normalize_vendor("Supermicro", "X11", "") == "supermicro" + + def test_dell(self): + assert normalize_vendor("Dell Inc.", "PowerEdge", "") == "dell" + + def test_hp(self): + assert normalize_vendor("HP", "ProLiant", "") == "hp" + + def test_lenovo(self): + assert normalize_vendor("Lenovo", "ThinkSystem", "") == "lenovo" + + def test_asrockrack(self): + assert normalize_vendor("ASRockRack", "EPYC", "") == "asrockrack" + + def test_intel(self): + assert normalize_vendor("Intel Corporation", "S2600", "") == "intel" + + def test_unknown(self): + assert normalize_vendor("", "", "") == "unknown" + + def test_fallback(self): + assert normalize_vendor("Acme Corp", "Model X", "") == "acme corp" + + +class TestLooksLikeAuthError: + def test_auth_error(self): + assert looks_like_auth_error("RAKP 2 HMAC is invalid") + + def test_unauthorized(self): + assert looks_like_auth_error("Error: Unauthorized access") + + def test_timeout(self): + assert looks_like_auth_error("Connection timeout occurred") + + def test_normal_output(self): + assert not looks_like_auth_error("Chassis Power is on") + + def test_empty(self): + assert not looks_like_auth_error("") + + def test_none(self): + assert not looks_like_auth_error(None) diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..67f6e43 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from ipmi_menu.config.messages import load_messages, get_available_languages + + +class TestLoadMessages: + def test_load_french(self): + msg = load_messages("fr") + assert msg.lang == "fr" + assert "BMC" in msg.t("prompts.bmc_ip") + + def test_load_english(self): + msg = load_messages("en") + assert msg.lang == "en" + assert "BMC" in msg.t("prompts.bmc_ip") + + def test_missing_key_returns_key(self): + msg = load_messages("en") + assert msg.t("nonexistent.key") == "nonexistent.key" + + +class TestTranslation: + def test_formatting(self): + msg = load_messages("en") + result = msg.t("info.hw_detected", vendor="dell", mfg="Dell Inc.", product="R640") + assert "dell" in result + assert "Dell Inc." in result + assert "R640" in result + + def test_format_missing_key_no_crash(self): + msg = load_messages("en") + # Should not raise even if format keys don't match + result = msg.t("info.hw_detected") + assert isinstance(result, str) + + +class TestAvailableLanguages: + def test_languages_list(self): + langs = get_available_languages() + codes = [code for code, _ in langs] + assert "fr" in codes + assert "en" in codes + + def test_all_keys_consistent(self): + fr = load_messages("fr") + en = load_messages("en") + fr_keys = set(fr.data.keys()) + en_keys = set(en.data.keys()) + assert fr_keys == en_keys, f"Missing keys: FR-EN={fr_keys - en_keys}, EN-FR={en_keys - fr_keys}" diff --git a/tests/test_preferences.py b/tests/test_preferences.py new file mode 100644 index 0000000..774dfc1 --- /dev/null +++ b/tests/test_preferences.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import base64 +import json +import os +import stat +from pathlib import Path +from unittest import mock + +import pytest + +from ipmi_menu.config import preferences + + +@pytest.fixture +def tmp_prefs(tmp_path): + """Override preferences file path to use a temp directory.""" + prefs_file = tmp_path / "preferences.json" + config_dir = tmp_path + with mock.patch.object(preferences, "PREFERENCES_FILE", prefs_file), \ + mock.patch.object(preferences, "CONFIG_DIR", config_dir): + yield prefs_file + + +class TestLoadSave: + def test_save_and_load(self, tmp_prefs): + prefs = {"language": "en", "version": 1} + preferences.save_preferences(prefs) + loaded = preferences.load_preferences() + assert loaded["language"] == "en" + + def test_load_missing_file(self, tmp_prefs): + assert preferences.load_preferences() == {} + + def test_load_corrupt_file(self, tmp_prefs): + tmp_prefs.write_text("not json{{{") + assert preferences.load_preferences() == {} + + +class TestPermissions: + def test_file_permissions_0600(self, tmp_prefs): + preferences.save_preferences({"language": "fr", "version": 1}) + mode = stat.S_IMODE(os.stat(tmp_prefs).st_mode) + assert mode == 0o600 + + +class TestBase64Password: + def test_password_stored_as_base64(self, tmp_prefs): + preferences.set_preferred_password("s3cret") + raw = json.loads(tmp_prefs.read_text()) + encoded = raw["password"] + assert encoded == base64.b64encode(b"s3cret").decode("ascii") + + def test_password_decoded_on_read(self, tmp_prefs): + preferences.set_preferred_password("s3cret") + assert preferences.get_preferred_password() == "s3cret" + + def test_password_none(self, tmp_prefs): + preferences.set_preferred_password(None) + assert preferences.get_preferred_password() is None + + def test_clear_password(self, tmp_prefs): + preferences.set_preferred_password("test") + preferences.set_preferred_password(None) + assert preferences.get_preferred_password() is None + + +class TestMigration: + def test_migrate_plaintext_password(self, tmp_prefs): + # Write a v0 (no version) prefs with plaintext password + tmp_prefs.write_text(json.dumps({"password": "oldpass"})) + os.chmod(tmp_prefs, 0o644) + + loaded = preferences.load_preferences() + # After migration, the password should be readable + pw = preferences.get_preferred_password() + assert pw == "oldpass" + + # File should now have version key and base64 password + raw = json.loads(tmp_prefs.read_text()) + assert raw["version"] == 1 + assert raw["password"] == base64.b64encode(b"oldpass").decode("ascii") + + def test_no_double_migration(self, tmp_prefs): + """Already base64 passwords should not be re-encoded.""" + encoded = base64.b64encode(b"mypass").decode("ascii") + tmp_prefs.write_text(json.dumps({"password": encoded, "version": 1})) + os.chmod(tmp_prefs, 0o600) + + pw = preferences.get_preferred_password() + assert pw == "mypass" diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..e5b23f3 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from ipmi_menu.core.updater import _parse_version + + +class TestParseVersion: + def test_normal(self): + assert _parse_version("1.2.3") == (1, 2, 3) + + def test_two_parts(self): + assert _parse_version("1.2") == (1, 2) + + def test_single(self): + assert _parse_version("5") == (5,) + + def test_invalid(self): + assert _parse_version("abc") == (0, 0, 0) + + def test_empty(self): + assert _parse_version("") == (0, 0, 0) + + def test_extra_parts_ignored(self): + assert _parse_version("1.2.3.4.5") == (1, 2, 3) + + +class TestVersionComparison: + def test_newer(self): + assert _parse_version("2.0.0") > _parse_version("1.9.9") + + def test_equal(self): + assert _parse_version("1.0.7") == _parse_version("1.0.7") + + def test_older(self): + assert _parse_version("1.0.6") < _parse_version("1.0.7") + + def test_minor_bump(self): + assert _parse_version("1.1.0") > _parse_version("1.0.99") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3e54062 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from unittest import mock + +from ipmi_menu.core.utils import _sanitize_cmd, run_cmd + + +class TestSanitizeCmd: + def test_password_masked(self): + cmd = ["ipmitool", "-H", "1.2.3.4", "-U", "root", "-P", "secret", "mc", "info"] + result = _sanitize_cmd(cmd) + assert result[6] == "****" + assert "secret" not in result + + def test_no_password(self): + cmd = ["ipmitool", "-H", "1.2.3.4", "-U", "root", "mc", "info"] + result = _sanitize_cmd(cmd) + assert result == cmd + + def test_password_at_end(self): + cmd = ["ipmitool", "-P", "pass"] + result = _sanitize_cmd(cmd) + assert result == ["ipmitool", "-P", "****"] + + +class TestRunCmd: + def test_success(self): + rc, out, err = run_cmd(["echo", "hello"], timeout=5) + assert rc == 0 + assert out == "hello" + + def test_command_not_found(self): + rc, out, err = run_cmd(["nonexistent_command_xyz"], timeout=5) + assert rc == 127 + assert err == "command not found" + + def test_timeout(self): + rc, out, err = run_cmd(["sleep", "10"], timeout=1) + assert rc == 124 + assert err == "timeout" + + def test_with_mock(self): + mock_result = mock.MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Chassis Power is on\n" + mock_result.stderr = "" + + with mock.patch("ipmi_menu.core.utils.subprocess.run", return_value=mock_result): + rc, out, err = run_cmd(["ipmitool", "power", "status"], timeout=10) + assert rc == 0 + assert out == "Chassis Power is on"