"""Config-resolution + persistence tests. The contract under test: 1. Precedence (high → low) — flag → env → file → default. 2. ``HELIX_CONFIG`` must redirect to a custom path. 3. ``set_token`` must end with the file at mode ``0o600``. 4. ``redact_pat`` must never reveal more than the prefix + 2 chars. 5. ``config show`` must not print a full token. """ from __future__ import annotations import json import os import stat from pathlib import Path import pytest from helix_cli import config as cli_config from helix_cli.config import ( DEFAULT_BASE_URL, PAT_PREFIX, load_config, redact_pat, set_base_url, set_token, ) from helix_cli.main import main @pytest.fixture def fake_pat() -> str: return PAT_PREFIX + "abcdefghijklmnopqrstuvwxyz012345" def test_default_base_url_when_no_config(cli_env: Path) -> None: cfg = load_config() assert cfg.base_url == DEFAULT_BASE_URL assert cfg.base_url_source == "default" assert cfg.token is None assert cfg.token_source == "" def test_env_overrides_file(cli_env: Path, fake_pat: str, monkeypatch: pytest.MonkeyPatch) -> None: set_base_url("https://from-file.example.com") set_token(fake_pat) other = PAT_PREFIX + "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ" monkeypatch.setenv("HELIX_PAT", other) monkeypatch.setenv("HELIX_BASE_URL", "https://from-env.example.com") cfg = load_config() assert cfg.base_url == "https://from-env.example.com" assert cfg.base_url_source == "env:HELIX_BASE_URL" assert cfg.token == other assert cfg.token_source == "env:HELIX_PAT" def test_flag_beats_env(cli_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HELIX_BASE_URL", "https://from-env.example.com") cfg = load_config(cli_base_url="https://from-flag.example.com") assert cfg.base_url == "https://from-flag.example.com" assert cfg.base_url_source == "flag" def test_helix_token_fallback(cli_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``HELIX_TOKEN`` is the documented secondary env var. ``HELIX_PAT`` wins when both are set; this test confirms ``HELIX_TOKEN`` is the next fallback before the on-disk config. """ other = PAT_PREFIX + "0123456789abcdef0123456789abcdef" monkeypatch.setenv("HELIX_TOKEN", other) cfg = load_config() assert cfg.token == other assert cfg.token_source == "env:HELIX_TOKEN" def test_helix_config_redirects_to_custom_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``HELIX_CONFIG`` must override the default ``~/.config/helix/config.json``.""" custom = tmp_path / "alt-helix.json" monkeypatch.setenv("HELIX_CONFIG", str(custom)) monkeypatch.delenv("HELIX_PAT", raising=False) monkeypatch.delenv("HELIX_TOKEN", raising=False) monkeypatch.delenv("HELIX_BASE_URL", raising=False) set_base_url("https://example.test") assert custom.exists() cfg = load_config() assert cfg.base_url == "https://example.test" def test_set_token_writes_0600(cli_env: Path, fake_pat: str) -> None: path = set_token(fake_pat) mode = stat.S_IMODE(os.stat(path).st_mode) # ``0o600`` is the only acceptable value — anything broader leaks # the PAT to other local users. assert mode == 0o600, f"expected 0o600, got {oct(mode)}" # Parent dir should be 0o700 (best-effort; we only assert it's # not group/other-readable). parent_mode = stat.S_IMODE(os.stat(path.parent).st_mode) assert parent_mode & 0o077 == 0, f"parent dir is too open: {oct(parent_mode)}" # Token landed in the file data = json.loads(path.read_text()) assert data["token"] == fake_pat def test_set_token_validates_prefix(cli_env: Path) -> None: with pytest.raises(ValueError, match="must start with"): set_token("not-a-pat") def test_set_token_rejects_oversized(cli_env: Path) -> None: with pytest.raises(ValueError, match="suspiciously long"): set_token(PAT_PREFIX + ("a" * 1024)) def test_redact_pat_shape(fake_pat: str) -> None: redacted = redact_pat(fake_pat) assert redacted.startswith(PAT_PREFIX) assert redacted.endswith("****") assert "abcdefgh" not in redacted # body must not leak # Only first two body chars are visible assert redacted == f"{PAT_PREFIX}ab****" def test_redact_pat_handles_none() -> None: assert redact_pat(None) == "" assert redact_pat("") == "" def test_redact_pat_handles_unknown_format() -> None: # Security hardening (R1 review): unknown-shape tokens redact to a # neutral ```` marker rather than echoing back a # ```` shape classification. Pairing the shape hint # with a partial leak (config repr, traceback locals) would narrow # the value space for an attacker with partial knowledge of the # config file contents. assert redact_pat("nope-not-a-pat") == "" assert redact_pat("eyJhbGciOiJIUzI1NiJ9.xxx.yyy") == "" def test_clear_token_removes_token(cli_env: Path, fake_pat: str) -> None: set_base_url("https://example.test") set_token(fake_pat) cli_config.clear_token() cfg = load_config() assert cfg.token is None # base_url survives assert cfg.base_url == "https://example.test" def test_config_show_does_not_leak_token( cli_env: Path, fake_pat: str, capsys: pytest.CaptureFixture[str], ) -> None: set_base_url("https://example.test") set_token(fake_pat) rc = main(["config", "show"]) out = capsys.readouterr() assert rc == 0 assert fake_pat not in out.out assert fake_pat[len(PAT_PREFIX):] not in out.out # body must not leak assert "ab****" in out.out fields = dict( line.split(": ", 1) for line in out.out.splitlines() if ": " in line ) assert fields["base_url"] == "https://example.test" def test_config_show_json_does_not_leak_token( cli_env: Path, fake_pat: str, capsys: pytest.CaptureFixture[str], ) -> None: set_base_url("https://example.test") set_token(fake_pat) rc = main(["--json", "config", "show"]) out = capsys.readouterr() assert rc == 0 parsed = json.loads(out.out) # JSON output must also redact assert parsed["token_present"] is True assert parsed["token_display"] == f"{PAT_PREFIX}ab****" assert fake_pat not in out.out def test_set_url_validates_scheme(cli_env: Path) -> None: with pytest.raises(ValueError, match="http://"): set_base_url("ftp://nope.example.com") def test_token_in_corrupt_file_is_ignored(cli_env: Path) -> None: """A malformed config file must NOT crash ``load_config``. Failing closed (raising) on a corrupt config would lock users out of their CLI; ``load_config`` falls back to defaults silently and a subsequent ``set_token`` will overwrite the broken file. """ cli_env.parent.mkdir(parents=True, exist_ok=True) cli_env.write_text("{this is not json") cfg = load_config() assert cfg.base_url == DEFAULT_BASE_URL assert cfg.token is None # ── unreadable (not just missing) config file (launch audit 2026-06) ── # # ``_load_file`` used to treat EVERY OSError as "missing" → {} — so a # later ``config set-url`` / ``auth token set`` atomically REWROTE the # config minus the keys it couldn't read, silently destroying the # saved token. Write paths must abort; read paths degrade but warn. def _make_unreadable_or_skip(path: Path) -> None: """chmod 000 the file; skip when the platform / euid ignores modes (root bypasses permission checks; some CI filesystems don't enforce them at all). """ if hasattr(os, "geteuid") and os.geteuid() == 0: pytest.skip("running as root — file modes are not enforced") os.chmod(path, 0) try: path.read_text() except PermissionError: return pytest.skip("platform does not enforce mode 0o000") def test_set_token_aborts_on_unreadable_config( cli_env: Path, fake_pat: str, ) -> None: original_token = PAT_PREFIX + "x" * 32 cli_env.write_text(json.dumps({"token": original_token})) _make_unreadable_or_skip(cli_env) try: with pytest.raises(OSError, match="refusing to rewrite"): set_token(fake_pat) finally: os.chmod(cli_env, 0o600) # The original file content survived untouched. assert json.loads(cli_env.read_text())["token"] == original_token def test_set_base_url_aborts_on_unreadable_config( cli_env: Path, fake_pat: str, ) -> None: cli_env.write_text(json.dumps({"token": fake_pat})) _make_unreadable_or_skip(cli_env) try: with pytest.raises(OSError, match="refusing to rewrite"): set_base_url("https://new.example.test") finally: os.chmod(cli_env, 0o600) assert json.loads(cli_env.read_text())["token"] == fake_pat def test_clear_token_aborts_on_unreadable_config( cli_env: Path, fake_pat: str, ) -> None: cli_env.write_text(json.dumps({"token": fake_pat})) _make_unreadable_or_skip(cli_env) try: with pytest.raises(OSError, match="refusing to rewrite"): cli_config.clear_token() finally: os.chmod(cli_env, 0o600) assert json.loads(cli_env.read_text())["token"] == fake_pat def test_load_config_warns_on_unreadable_config( cli_env: Path, capsys: pytest.CaptureFixture[str], ) -> None: """Pure read path: degrade to defaults, but leave a one-line stderr breadcrumb (mirroring the corrupt-JSON warning) — a silently logged-out user burns support time otherwise. """ cli_env.write_text(json.dumps({"base_url": "https://example.test"})) _make_unreadable_or_skip(cli_env) try: cfg = load_config() finally: os.chmod(cli_env, 0o600) assert cfg.base_url == DEFAULT_BASE_URL assert cfg.token is None err = capsys.readouterr().err assert "unreadable" in err # ── clear_token honesty on corrupt files (launch audit 2026-06) ────── # # ``clear_token`` used to no-op on corrupt JSON ({} → "token" not in # data → nothing written) while the command still printed "cleared # token from " — leaving the plaintext PAT bytes on disk. def test_clear_token_rewrites_corrupt_file_dropping_pat( cli_env: Path, fake_pat: str, ) -> None: # Truncated JSON (e.g. crash mid-edit) still carrying the PAT and a # recoverable base_url. cli_env.write_text( f'{{"base_url": "https://example.test", "token": "{fake_pat}", ' ) path, outcome = cli_config.clear_token() assert outcome == "rewrote-corrupt" raw = cli_env.read_text() # The PAT bytes are actually gone from disk. assert fake_pat not in raw # base_url was regex-recoverable → preserved. data = json.loads(raw) assert data == {"base_url": "https://example.test"} def test_clear_token_corrupt_file_unrecoverable_writes_empty( cli_env: Path, fake_pat: str, ) -> None: cli_env.write_text(f"garbage {fake_pat} garbage") path, outcome = cli_config.clear_token() assert outcome == "rewrote-corrupt" raw = cli_env.read_text() assert fake_pat not in raw assert json.loads(raw) == {} def test_clear_token_reports_cleared_vs_absent( cli_env: Path, fake_pat: str, ) -> None: # Missing file → absent, nothing created. path, outcome = cli_config.clear_token() assert outcome == "absent" assert not cli_env.exists() # Token present → cleared. set_token(fake_pat) path, outcome = cli_config.clear_token() assert outcome == "cleared" assert "token" not in json.loads(cli_env.read_text()) # Second clear → absent (file exists, no token key). path, outcome = cli_config.clear_token() assert outcome == "absent" def test_auth_token_clear_command_is_honest_on_corrupt_file( cli_env: Path, fake_pat: str, capsys: pytest.CaptureFixture[str], ) -> None: """End-to-end: ``helix auth token clear`` on a corrupt config must actually remove the PAT bytes, exit 0, and SAY it rewrote the file rather than claiming "cleared token" for a no-op. """ cli_env.write_text( f'{{"base_url": "https://example.test", "token": "{fake_pat}", ' ) rc = main(["auth", "token", "clear"]) out = capsys.readouterr() assert rc == 0 assert fake_pat not in cli_env.read_text() assert "corrupt" in out.out and "rewrote" in out.out def test_set_base_url_salvages_token_from_corrupt_file( cli_env: Path, fake_pat: str, ) -> None: # Launch-audit follow-up (2026-06-10): ``config set-url`` on a # corrupt-JSON file must not silently drop a regex-recoverable # saved PAT — symmetric with ``clear_token``'s base_url salvage. cli_env.write_text( f'{{"base_url": "https://old.example.test", "token": "{fake_pat}", ' ) cli_config.set_base_url("https://new.example.test") data = json.loads(cli_env.read_text()) assert data["base_url"] == "https://new.example.test" assert data["token"] == fake_pat def test_set_base_url_corrupt_file_without_valid_pat_drops_garbage( cli_env: Path, ) -> None: # A corrupt file whose token value is NOT PAT-shaped is not # salvaged — never launder garbage into a clean config. cli_env.write_text('{"token": "not-a-real-token", "base_url": ') cli_config.set_base_url("https://new.example.test") data = json.loads(cli_env.read_text()) assert data == {"base_url": "https://new.example.test"}