feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0)
Files changed: - .gitignore - AGENTS.md - CHANGES.md - VERSION - instructions/gates.md - instructions/private-instance.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/config.py - tools/chemenu/tests/test_git_publish.py
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
@@ -905,3 +906,115 @@ def test_numstat_survives_a_non_ascii_filename(repo):
|
||||
change = next(c for c in collect_changes([]) if c.path == name)
|
||||
assert change.status == "modified"
|
||||
assert (change.added, change.removed) == (1, 2)
|
||||
|
||||
|
||||
# --- Publish-Remote Gate -----------------------------------------------------
|
||||
|
||||
|
||||
def _allowlist(root, *urls):
|
||||
(root / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_no_allowlist_means_unrestricted(repo):
|
||||
"""Absence is a legitimate state: a checkout with nothing private in it
|
||||
should not have to declare anything to publish at all."""
|
||||
assert git_publish.read_allowed_push_urls() is None
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||
|
||||
|
||||
def test_allowed_url_passes_the_gate(repo):
|
||||
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||
|
||||
|
||||
def test_gate_refuses_a_remote_not_on_the_list(repo):
|
||||
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
|
||||
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||
refusal = git_publish.publish_remote_refusal("upstream", "main")
|
||||
assert refusal is not None
|
||||
assert "https://example.com/public.git" in refusal
|
||||
assert "Nothing was committed or pushed" in refusal
|
||||
|
||||
|
||||
def test_gate_matches_the_url_not_the_remote_name(repo):
|
||||
"""A name-based list would pass a repointed `origin`, which is the failure
|
||||
this gate exists to catch."""
|
||||
_allowlist(repo, "ssh://git@example.com/only-this.git")
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||
|
||||
|
||||
def test_gate_reads_pushurl_when_the_remote_sets_one(repo):
|
||||
"""`git push` writes to `pushurl` when present, so that is the value that
|
||||
has to be checked - not the fetch URL beside it."""
|
||||
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
|
||||
_allowlist(repo, "https://example.com/elsewhere.git")
|
||||
assert git_publish.push_url_for("origin") == "https://example.com/elsewhere.git"
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||
|
||||
|
||||
def test_gate_refuses_when_the_fetch_url_is_listed_but_the_pushurl_is_not(repo):
|
||||
fetch_url = git_publish.push_url_for("origin")
|
||||
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
|
||||
_allowlist(repo, fetch_url)
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||
|
||||
|
||||
def test_publish_exits_42_and_commits_nothing_when_the_remote_is_refused(repo):
|
||||
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
|
||||
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||
(repo / "kb" / "secret.md").write_text("private\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_publish(remote="upstream")
|
||||
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
|
||||
|
||||
# Nothing committed, and the file is still sitting there unstaged - the
|
||||
# refusal message promises both. (`git status --porcelain` collapses the
|
||||
# wholly-untracked `kb/` to one entry, so check the index directly.)
|
||||
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||
assert (repo / "kb" / "secret.md").exists()
|
||||
assert "secret.md" not in _git(repo, "ls-files").stdout
|
||||
|
||||
|
||||
def test_no_push_skips_the_gate(repo):
|
||||
"""`--no-push` publishes nowhere, so there is no wrong target to protect
|
||||
against - and a local commit must stay possible."""
|
||||
_allowlist(repo, "ssh://git@example.com/only-this.git")
|
||||
(repo / "kb" / "page.md").write_text("local\n", encoding="utf-8")
|
||||
_publish(push=False)
|
||||
assert "page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
|
||||
|
||||
|
||||
def test_unreadable_allowlist_fails_instead_of_falling_open(repo):
|
||||
"""A broken file must not be read as 'no restriction' - that would turn a
|
||||
corrupted safeguard into a silently disabled one."""
|
||||
(repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(typer.Exit):
|
||||
git_publish.read_allowed_push_urls()
|
||||
|
||||
|
||||
def test_allowlist_without_a_usable_list_fails(repo):
|
||||
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
git_publish.read_allowed_push_urls()
|
||||
|
||||
|
||||
def test_empty_allowlist_refuses_everything(repo):
|
||||
"""An empty list is a deliberate 'publish nowhere', not an oversight that
|
||||
should behave like an absent file."""
|
||||
_allowlist(repo)
|
||||
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||
|
||||
|
||||
def test_gate_has_no_flag_that_opens_it(repo):
|
||||
"""The other two gates clear with a token; this one deliberately does not,
|
||||
because the right fix is a deliberate edit by the user."""
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(publish_command).parameters
|
||||
assert not any("remote" in name and "confirm" in name for name in params)
|
||||
|
||||
Reference in New Issue
Block a user