"""SP26 — Docker-secrets file fallback in cyclone.secrets.get_secret(). SP25 added a plain env-var tier ahead of the macOS Keychain lookup. SP26 adds a further tier above that: an `_FILE` env var pointing at a file on disk — the standard Docker-secrets pattern. The file takes precedence over the plain env var (matches the ``auth/bootstrap.py:_read_secret`` convention). """ from __future__ import annotations import importlib from pathlib import Path import pytest # A secret name we use throughout the unmapped cases. Because it is not # in ``_ENV_NAME_FOR``, ``get_secret()`` will read it as # ``os.environ.get("cyclone.test.file.password")`` directly (and the # ``_FILE`` companion is the same name with ``_FILE`` appended). This # mirrors SP25's ``test_secrets_envvar.py`` convention. _UNMAPPED_NAME = "cyclone.test.file.password" @pytest.fixture def secrets_module(monkeypatch): """Reload cyclone.secrets with a clean keyring state per test.""" import cyclone.secrets as secrets_mod importlib.reload(secrets_mod) yield secrets_mod importlib.reload(secrets_mod) def _write_secret_file(tmp_path: Path, name: str, contents: str) -> Path: f = tmp_path / name f.write_text(contents) return f def test_file_env_var_returns_file_contents( tmp_path: Path, monkeypatch, secrets_module, ) -> None: """_FILE set, file exists → returns file contents (no Keychain lookup).""" f = _write_secret_file(tmp_path, "pw", "from-file\n") monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", str(f)) monkeypatch.delenv(_UNMAPPED_NAME, raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret(_UNMAPPED_NAME) == "from-file" def test_file_env_var_strips_trailing_newline( tmp_path: Path, monkeypatch, secrets_module, ) -> None: """Docker secret files commonly end with \\n — strip it.""" f = _write_secret_file(tmp_path, "pw", "supersecret\n") monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", str(f)) monkeypatch.delenv(_UNMAPPED_NAME, raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret(_UNMAPPED_NAME) == "supersecret" def test_file_env_var_strips_leading_and_trailing_whitespace( tmp_path: Path, monkeypatch, secrets_module, ) -> None: f = _write_secret_file(tmp_path, "pw", " supersecret \n") monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", str(f)) monkeypatch.delenv(_UNMAPPED_NAME, raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret(_UNMAPPED_NAME) == "supersecret" def test_file_env_var_wins_over_plain_env_var( tmp_path: Path, monkeypatch, secrets_module, ) -> None: """When both _FILE and the plain env var are set, _FILE wins.""" f = _write_secret_file(tmp_path, "pw", "from-file\n") monkeypatch.setenv(_UNMAPPED_NAME, "from-env") monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", str(f)) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret(_UNMAPPED_NAME) == "from-file" def test_file_env_var_missing_file_raises_runtime_error( tmp_path: Path, monkeypatch, secrets_module, ) -> None: """If _FILE points at a non-existent path, raise RuntimeError.""" missing = tmp_path / "does-not-exist" monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", str(missing)) monkeypatch.delenv(_UNMAPPED_NAME, raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) with pytest.raises(RuntimeError, match=f"{_UNMAPPED_NAME}_FILE"): secrets_module.get_secret(_UNMAPPED_NAME) def test_file_env_var_empty_string_treated_as_unset( monkeypatch, secrets_module, ) -> None: """An empty _FILE env var falls through to the plain env var (SP25 rule).""" monkeypatch.setenv(f"{_UNMAPPED_NAME}_FILE", "") monkeypatch.setenv(_UNMAPPED_NAME, "from-env") monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret(_UNMAPPED_NAME) == "from-env" def test_gainwell_file_env_var_full_chain( tmp_path: Path, monkeypatch, secrets_module, ) -> None: """Setting CYCLONE_SFTP_PASSWORD_FILE makes get_secret('sftp.gainwell.password') return the file's contents — the operator-visible chain works end-to-end.""" f = _write_secret_file(tmp_path, "sftp_pw", "real-mft-password\n") monkeypatch.setenv("CYCLONE_SFTP_PASSWORD_FILE", str(f)) monkeypatch.delenv("CYCLONE_SFTP_PASSWORD", raising=False) monkeypatch.setattr( secrets_module.keyring, "get_password", lambda s, n: None, ) assert secrets_module.get_secret("sftp.gainwell.password") == "real-mft-password"