feat(sp26): secrets.get_secret() _FILE-tier lookup for Docker secrets
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""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 `<env_name>_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"
|
||||
Reference in New Issue
Block a user