"""SP25 — env-var fallback in cyclone.secrets.get_secret(). The scheduler's ``SftpClient._connect`` calls ``get_secret(name)`` to fetch the MFT password. Today the lookup is macOS-Keychain-only via the ``keyring`` library. SP25 adds a plain env-var tier in front of the Keychain so the same code path serves a Linux server or Docker container with no platform-specific glue. """ from __future__ import annotations import importlib import pytest @pytest.fixture def secrets_module(monkeypatch): """Reload cyclone.secrets with a clean keyring state per test.""" # Force a fresh import so module-level keyring cache is clean. import cyclone.secrets as secrets_mod importlib.reload(secrets_mod) yield secrets_mod importlib.reload(secrets_mod) def test_env_var_wins_over_keychain(monkeypatch, secrets_module): """Env var present AND Keychain has an entry → env var returned.""" monkeypatch.setenv("cyclone.test.password", "from-env") monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: "from-keychain", ) assert secrets_module.get_secret("cyclone.test.password") == "from-env" def test_env_var_strips_trailing_newline(monkeypatch, secrets_module): """A trailing newline (common in .env files) is stripped.""" monkeypatch.setenv("cyclone.test.password", "s3cret\n") assert secrets_module.get_secret("cyclone.test.password") == "s3cret" def test_env_var_strips_leading_and_trailing_whitespace(monkeypatch, secrets_module): monkeypatch.setenv("cyclone.test.password", " s3cret \n") assert secrets_module.get_secret("cyclone.test.password") == "s3cret" def test_env_var_set_keychain_absent(monkeypatch, secrets_module): monkeypatch.setenv("cyclone.test.password", "from-env") monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: None, ) assert secrets_module.get_secret("cyclone.test.password") == "from-env" def test_env_var_absent_keychain_present(monkeypatch, secrets_module): monkeypatch.delenv("cyclone.test.password", raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: "from-keychain", ) assert secrets_module.get_secret("cyclone.test.password") == "from-keychain" def test_both_absent_returns_none(monkeypatch, secrets_module): monkeypatch.delenv("cyclone.test.password", raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: None, ) assert secrets_module.get_secret("cyclone.test.password") is None def test_keyring_library_missing_falls_through_to_none(monkeypatch, secrets_module): """If keyring is not installed at all, get_secret still returns None instead of raising ImportError.""" monkeypatch.setenv("cyclone.test.password", "from-env") monkeypatch.setattr(secrets_module, "_HAS_KEYRING", False) # Even if keyring is missing, the env-var tier still wins. assert secrets_module.get_secret("cyclone.test.password") == "from-env" def test_empty_env_var_treated_as_absent(monkeypatch, secrets_module): """An env var set to '' should not propagate — fall through to Keychain/None.""" monkeypatch.setenv("cyclone.test.password", "") monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: "from-keychain", ) assert secrets_module.get_secret("cyclone.test.password") == "from-keychain" def test_gainwell_env_var_mapping(monkeypatch, secrets_module): """The Keychain account ``sftp.gainwell.password`` maps to the env var ``CYCLONE_SFTP_PASSWORD`` via the internal name table. Without this, an operator setting ``CYCLONE_SFTP_PASSWORD`` on a Linux box would not get the MFT password.""" monkeypatch.setenv("CYCLONE_SFTP_PASSWORD", "real-mft-password") monkeypatch.setattr( secrets_module.keyring, "get_password", lambda service, name: None, ) assert secrets_module.get_secret("sftp.gainwell.password") == "real-mft-password"