388 lines
14 KiB
Python
388 lines
14 KiB
Python
"""Tests for PyPI credential setup (~/.pypirc handling).
|
|
|
|
The writer does text surgery to preserve the explanatory comments in the file, so
|
|
these tests check the result of that surgery rather than trusting it. No test
|
|
touches the real ``~/.pypirc``: every path is injected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD_ROOT = REPO_ROOT / "build"
|
|
if str(BUILD_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(BUILD_ROOT))
|
|
|
|
from release import auth # noqa: E402
|
|
|
|
TOKEN = "pypi-" + "A" * 30
|
|
|
|
COMMENTED_TEMPLATE = """\
|
|
# keep me: why this file exists
|
|
# second comment line
|
|
|
|
[distutils]
|
|
index-servers =
|
|
pypi
|
|
|
|
[pypi]
|
|
repository = https://upload.pypi.org/legacy/
|
|
username = __token__
|
|
password = pypi-PASTE-YOUR-PRODUCTION-TOKEN-HERE
|
|
|
|
# [testpypi] commented out on purpose
|
|
# password = pypi-OLD
|
|
"""
|
|
|
|
|
|
def test_parse_pypirc_reads_sections_and_ignores_comments():
|
|
sections = auth.parse_pypirc(COMMENTED_TEMPLATE)
|
|
assert sections["pypi"]["username"] == "__token__"
|
|
assert "testpypi" not in sections, "commented-out sections must stay inactive"
|
|
|
|
|
|
def test_placeholder_is_detected_as_not_configured(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
|
state = auth.credential_state("pypi", path=path, env={})
|
|
assert not state.configured
|
|
assert "placeholder" in state.problem
|
|
|
|
|
|
def test_missing_file_is_reported(tmp_path):
|
|
state = auth.credential_state("pypi", path=tmp_path / "nope", env={})
|
|
assert not state.configured
|
|
assert "does not exist" in state.problem
|
|
|
|
|
|
def test_env_variables_count_as_configured(tmp_path):
|
|
state = auth.credential_state(
|
|
"pypi", path=tmp_path / "nope", env={"TWINE_PASSWORD": "pypi-x"}
|
|
)
|
|
assert state.configured
|
|
assert state.source == "environment variables"
|
|
|
|
|
|
def test_wrong_username_is_rejected(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[pypi]\nusername = someone@example.com\npassword = pypi-real-token-value\n",
|
|
encoding="utf-8",
|
|
)
|
|
state = auth.credential_state("pypi", path=path, env={})
|
|
assert not state.configured
|
|
assert "__token__" in state.problem
|
|
|
|
|
|
def test_write_token_replaces_placeholder_and_keeps_comments(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
|
|
|
auth.write_token("pypi", TOKEN, path=path)
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "# keep me: why this file exists" in text
|
|
assert "# [testpypi] commented out on purpose" in text
|
|
assert f"password = {TOKEN}" in text
|
|
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" not in text
|
|
assert text.count("[pypi]") == 1
|
|
assert auth.credential_state("pypi", path=path, env={}).configured
|
|
|
|
|
|
def test_write_token_adds_missing_section_and_index_servers(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
|
|
|
|
auth.write_token("testpypi", TOKEN, path=path)
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "[testpypi]" in text
|
|
assert auth.index_servers(text) == {"pypi", "testpypi"}
|
|
assert auth.credential_state("testpypi", path=path, env={}).configured
|
|
# The production entry must be untouched.
|
|
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" in text
|
|
|
|
|
|
def test_write_token_creates_file_when_absent(tmp_path):
|
|
path = tmp_path / "nested" / ".pypirc"
|
|
auth.write_token("pypi", TOKEN, path=path)
|
|
|
|
assert path.is_file()
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "index-servers" in text
|
|
assert auth.credential_state("pypi", path=path, env={}).configured
|
|
|
|
|
|
def test_write_token_updates_existing_password_in_place(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
auth.write_token("pypi", TOKEN, path=path)
|
|
replacement = "pypi-" + "B" * 30
|
|
auth.write_token("pypi", replacement, path=path)
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
assert f"password = {replacement}" in text
|
|
assert TOKEN not in text
|
|
assert text.count("password") == 1
|
|
|
|
|
|
def test_upsert_token_does_not_leak_into_the_next_section():
|
|
text = "[pypi]\nusername = __token__\n\n[testpypi]\nusername = __token__\n"
|
|
result = auth.upsert_token(text, "pypi", TOKEN)
|
|
pypi_block, testpypi_block = result.split("[testpypi]")
|
|
assert TOKEN in pypi_block
|
|
assert TOKEN not in testpypi_block
|
|
|
|
|
|
def test_validate_token_rejects_empty_and_whitespace():
|
|
assert auth.validate_token("")
|
|
assert auth.validate_token("pypi-abc def")
|
|
assert not auth.validate_token(TOKEN)
|
|
|
|
|
|
def test_token_warnings_flag_suspicious_pastes():
|
|
assert any("pypi-" in warning for warning in auth.token_warnings("not-a-token"))
|
|
assert any("too short" in warning for warning in auth.token_warnings("pypi-abc"))
|
|
assert auth.token_warnings(TOKEN) == []
|
|
|
|
|
|
def test_prompt_never_echoes_the_token(capsys):
|
|
token = auth.prompt_for_token("pypi", prompt_fn=lambda label: TOKEN)
|
|
captured = capsys.readouterr()
|
|
assert token == TOKEN
|
|
assert TOKEN not in captured.out
|
|
assert "hidden" in captured.out
|
|
|
|
|
|
def test_prompt_abort_returns_none():
|
|
def _raise(label):
|
|
raise EOFError
|
|
|
|
assert auth.prompt_for_token("pypi", prompt_fn=_raise) is None
|
|
|
|
|
|
def test_ensure_credentials_prompts_when_interactive(tmp_path, monkeypatch, capsys):
|
|
from release import pypi
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.setattr(auth, "prompt_for_token", lambda index, **k: TOKEN)
|
|
# _ensure_credentials resolves the path through auth.credential_state, so the
|
|
# monkeypatched pypirc_path applies there too.
|
|
|
|
options = pypi.Options(version="1.2.3", apply=True)
|
|
assert pypi._ensure_credentials(["pypi"], options, interactive=True) is True
|
|
assert "[ok ] wrote [pypi] credentials" in capsys.readouterr().out
|
|
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
|
|
|
|
|
|
def test_ensure_credentials_fails_without_a_terminal(tmp_path, monkeypatch, capsys):
|
|
from release import pypi
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
options = pypi.Options(version="1.2.3", apply=True)
|
|
|
|
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is False
|
|
output = capsys.readouterr().out
|
|
assert "--set-token pypi" in output
|
|
assert "prompted" in output
|
|
assert not (tmp_path / ".pypirc").exists(), "must not write anything when it cannot prompt"
|
|
|
|
|
|
def test_ensure_credentials_dry_run_warns_but_continues(tmp_path, monkeypatch, capsys):
|
|
from release import pypi
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
options = pypi.Options(version="1.2.3", apply=False)
|
|
|
|
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is True
|
|
output = capsys.readouterr().out
|
|
assert "[warn]" in output
|
|
assert "dry run continues" in output
|
|
|
|
|
|
def test_ensure_credentials_reports_testpypi_alternative(tmp_path, monkeypatch, capsys):
|
|
from release import pypi
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
options = pypi.Options(version="1.2.3", apply=True)
|
|
|
|
assert pypi._ensure_credentials(["testpypi"], options, interactive=False) is False
|
|
output = capsys.readouterr().out
|
|
assert "--set-token testpypi" in output
|
|
assert "--pypi-only" in output
|
|
|
|
|
|
def test_piped_token_is_used_only_once(tmp_path, monkeypatch, capsys):
|
|
from release import pypi
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
options = pypi.Options(version="1.2.3", apply=True, token=TOKEN)
|
|
|
|
assert pypi._ensure_credentials(["pypi", "testpypi"], options, interactive=True) is False
|
|
# Second index had no token left to consume, so it fell back to the prompt,
|
|
# which is unavailable in tests; nothing may be written for it.
|
|
assert options.token is None
|
|
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
|
|
assert not auth.credential_state(
|
|
"testpypi", path=tmp_path / ".pypirc", env={}
|
|
).configured
|
|
|
|
|
|
def test_section_not_listed_in_index_servers_is_not_configured(tmp_path):
|
|
"""twine only honours sections named in [distutils] index-servers.
|
|
|
|
Regression test for a real failure: a hand-added [testpypi] section looked
|
|
configured to us while twine answered "Missing 'testpypi' section".
|
|
"""
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[distutils]\n"
|
|
"index-servers =\n"
|
|
" pypi\n"
|
|
"\n"
|
|
"[pypi]\n"
|
|
"username = __token__\n"
|
|
"password = pypi-" + "A" * 30 + "\n"
|
|
"\n"
|
|
"[testpypi]\n"
|
|
"username = __token__\n"
|
|
"password = pypi-" + "B" * 30 + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
state = auth.credential_state("testpypi", path=path, env={})
|
|
assert not state.configured
|
|
assert "index-servers" in state.problem
|
|
assert "--repair" in state.problem
|
|
# The production index is unaffected.
|
|
assert auth.credential_state("pypi", path=path, env={}).configured
|
|
|
|
|
|
def test_credential_state_agrees_with_twine(tmp_path):
|
|
"""Our verdict must match twine's own resolver for the same file."""
|
|
twine_utils = pytest.importorskip("twine.utils")
|
|
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[distutils]\n"
|
|
"index-servers =\n"
|
|
" pypi\n"
|
|
"\n"
|
|
"[pypi]\n"
|
|
"username = __token__\n"
|
|
"password = pypi-" + "A" * 30 + "\n"
|
|
"\n"
|
|
"[testpypi]\n"
|
|
"username = __token__\n"
|
|
"password = pypi-" + "B" * 30 + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
twine_sees = set(twine_utils.get_config(str(path)))
|
|
for index in ("pypi", "testpypi"):
|
|
ours = auth.credential_state(index, path=path, env={})
|
|
# twine is the authority: we may only claim configured when twine agrees.
|
|
if ours.configured:
|
|
assert index in twine_sees, f"{index} accepted by us but ignored by twine"
|
|
|
|
|
|
def test_missing_index_servers_key_falls_back_to_both_indexes(tmp_path):
|
|
"""twine's fallback is 'pypi testpypi' only when the key is absent entirely."""
|
|
path = tmp_path / ".pypirc"
|
|
text = "[distutils]\nindex-servers =\n pypi\n\n[ pypi ]\n".replace("[ pypi ]", "[pypi]")
|
|
path.write_text(text, encoding="utf-8")
|
|
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi"}
|
|
|
|
without_key = path.read_text(encoding="utf-8").replace("index-servers =\n pypi\n", "")
|
|
path.write_text(without_key, encoding="utf-8", newline="")
|
|
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
|
|
|
|
|
|
def test_empty_index_servers_honours_nothing(tmp_path):
|
|
"""A trap: `index-servers =` with no entries means twine honours no sections.
|
|
|
|
Unlike a missing key, an empty value does not trigger the pypi+testpypi
|
|
fallback, so a section that looks configured is ignored.
|
|
"""
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[distutils]\nindex-servers =\n\n"
|
|
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert auth.index_servers(path.read_text(encoding="utf-8")) == set()
|
|
state = auth.credential_state("pypi", path=path, env={})
|
|
assert not state.configured
|
|
assert "index-servers" in state.problem
|
|
|
|
auth.repair_index_servers(path)
|
|
assert auth.credential_state("pypi", path=path, env={}).configured
|
|
|
|
|
|
def test_index_servers_reads_inline_and_continuation_forms():
|
|
assert auth.index_servers("[distutils]\nindex-servers = pypi testpypi\n") == {
|
|
"pypi",
|
|
"testpypi",
|
|
}
|
|
assert auth.index_servers("[distutils]\nindex-servers =\n pypi\n") == {"pypi"}
|
|
assert auth.index_servers("") == {"pypi", "testpypi"}
|
|
|
|
|
|
def test_unlisted_sections_reports_the_gap(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[distutils]\nindex-servers =\n pypi\n\n"
|
|
"[pypi]\npassword = pypi-x\n\n"
|
|
"[testpypi]\npassword = pypi-y\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert auth.unlisted_sections(path) == ["testpypi"]
|
|
|
|
|
|
def test_repair_index_servers_fixes_a_hand_edited_file(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text(
|
|
"[distutils]\nindex-servers =\n pypi\n\n"
|
|
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n\n"
|
|
"[testpypi]\nusername = __token__\npassword = pypi-" + "B" * 30 + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert not auth.credential_state("testpypi", path=path, env={}).configured
|
|
|
|
added = auth.repair_index_servers(path)
|
|
|
|
assert added == ["testpypi"]
|
|
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
|
|
assert auth.credential_state("testpypi", path=path, env={}).configured
|
|
# Tokens and comments survive the repair.
|
|
assert "pypi-" + "B" * 30 in path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_repair_index_servers_is_a_no_op_when_correct(tmp_path):
|
|
path = tmp_path / ".pypirc"
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
|
|
before = path.read_text(encoding="utf-8")
|
|
assert auth.repair_index_servers(path) == []
|
|
assert path.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_write_token_makes_the_result_visible_to_twine(tmp_path):
|
|
"""The writer must produce a file twine actually honours, not just one we parse."""
|
|
path = tmp_path / ".pypirc"
|
|
path.write_text("[distutils]\nindex-servers =\n pypi\n", encoding="utf-8")
|
|
|
|
auth.write_token("testpypi", "pypi-" + "B" * 30, path=path)
|
|
|
|
assert auth.credential_state("testpypi", path=path, env={}).configured
|
|
twine_utils = pytest.importorskip("twine.utils")
|
|
assert "testpypi" in set(twine_utils.get_config(str(path)))
|
|
|
|
|
|
@pytest.mark.parametrize("index", ["pypi", "testpypi"])
|
|
def test_write_then_state_round_trips(tmp_path, index):
|
|
path = tmp_path / ".pypirc"
|
|
auth.write_token(index, TOKEN, path=path)
|
|
state = auth.credential_state(index, path=path, env={})
|
|
assert state.configured, state.problem
|