test_push_repo_not_found.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """TDD β push to a non-existent remote repo must fail loudly, not say "up to date". |
| 2 | |
| 3 | When ``fetch_remote_info`` returns HTTP 404 (repo not found on the remote), |
| 4 | ``muse push`` currently swallows the error via ``_fetch_remote_info_safe``, |
| 5 | falls back to stale local tracking refs, and exits 0 with ``status: up_to_date``. |
| 6 | This is a silent data-loss-adjacent bug: the user believes the push succeeded, |
| 7 | but nothing was sent. |
| 8 | |
| 9 | Git's UX: |
| 10 | ERROR: Repository not found. |
| 11 | fatal: Could not read from remote repository. |
| 12 | Please make sure you have the correct access rights and the repository exists. |
| 13 | |
| 14 | Muse must match that behaviour: exit non-zero with a clear, actionable error. |
| 15 | |
| 16 | Tests |
| 17 | ----- |
| 18 | P404_1 (RED) fetch_remote_info raises TransportError(404) β exit non-zero, |
| 19 | message includes "not found" or "does not exist". |
| 20 | P404_2 (RED) --json output contains {"error": "repository_not_found", ...} |
| 21 | with a non-zero exit_code field. |
| 22 | P404_3 (GREEN) fetch_remote_info raises TransportError(0) (network failure) |
| 23 | β existing fallback behaviour preserved; does NOT raise immediately. |
| 24 | P404_4 (RED) fetch_remote_info raises TransportError(401) (auth) β exit |
| 25 | non-zero with auth error, not silently swallowed. |
| 26 | P404_5 (RED) _fetch_remote_info_safe re-raises on 404 (unit test for the |
| 27 | private helper β the seam where the fix lives). |
| 28 | """ |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | import argparse |
| 32 | import datetime |
| 33 | import json |
| 34 | import pathlib |
| 35 | from unittest.mock import MagicMock, patch |
| 36 | |
| 37 | import pytest |
| 38 | |
| 39 | from muse._version import __version__ |
| 40 | from muse.core.mpack import RemoteInfo |
| 41 | from muse.core.object_store import write_object |
| 42 | from muse.core.paths import heads_dir, muse_dir, remotes_dir |
| 43 | from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id |
| 44 | from muse.core.commits import ( |
| 45 | CommitRecord, |
| 46 | write_commit, |
| 47 | ) |
| 48 | from muse.core.snapshots import ( |
| 49 | SnapshotRecord, |
| 50 | write_snapshot, |
| 51 | ) |
| 52 | from muse.core.transport import TransportError |
| 53 | from muse.core.types import Manifest, blob_id |
| 54 | from tests.cli_test_helper import CliRunner |
| 55 | |
| 56 | _PrintArg = str | int | float | bool | None |
| 57 | _PrintKw = str | bool | None |
| 58 | |
| 59 | |
| 60 | runner = CliRunner() |
| 61 | cli = None |
| 62 | |
| 63 | |
| 64 | # --------------------------------------------------------------------------- |
| 65 | # Helpers shared with other push tests |
| 66 | # --------------------------------------------------------------------------- |
| 67 | |
| 68 | def _bare_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 69 | muse = muse_dir(tmp_path) |
| 70 | for d in ("commits", "snapshots", "objects", "refs/heads", "remotes/origin"): |
| 71 | (muse / d).mkdir(parents=True, exist_ok=True) |
| 72 | (muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 73 | (muse / "repo.json").write_text( |
| 74 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) |
| 75 | ) |
| 76 | (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/gabriel/myrepo"\n') |
| 77 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 78 | monkeypatch.chdir(tmp_path) |
| 79 | return tmp_path |
| 80 | |
| 81 | |
| 82 | def _make_commit(root: pathlib.Path, label: str, parent_id: str | None = None) -> CommitRecord: |
| 83 | raw = f"content-{label}".encode() |
| 84 | oid = blob_id(raw) |
| 85 | write_object(root, oid, raw) |
| 86 | manifest: Manifest = {"file.txt": oid} |
| 87 | snap_id = compute_snapshot_id(manifest) |
| 88 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) |
| 89 | committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 90 | parent_ids = [parent_id] if parent_id else [] |
| 91 | cid = compute_commit_id( |
| 92 | parent_ids=parent_ids, |
| 93 | snapshot_id=snap_id, |
| 94 | message=f"commit {label}", |
| 95 | committed_at_iso=committed_at.isoformat(), |
| 96 | ) |
| 97 | commit = CommitRecord( |
| 98 | commit_id=cid, |
| 99 | branch="main", |
| 100 | snapshot_id=snap_id, |
| 101 | message=f"commit {label}", |
| 102 | committed_at=committed_at, |
| 103 | parent_commit_id=parent_id, |
| 104 | ) |
| 105 | write_commit(root, commit) |
| 106 | return commit |
| 107 | |
| 108 | |
| 109 | def _transport_404() -> MagicMock: |
| 110 | """Transport whose fetch_remote_info raises HTTP 404.""" |
| 111 | t = MagicMock() |
| 112 | t.fetch_remote_info.side_effect = TransportError( |
| 113 | "HTTP 404: {\"detail\": \"repo 'gabriel/myrepo' not found\"}", |
| 114 | status_code=404, |
| 115 | ) |
| 116 | return t |
| 117 | |
| 118 | |
| 119 | def _transport_network_down() -> MagicMock: |
| 120 | """Transport whose fetch_remote_info raises a connection error (status 0).""" |
| 121 | t = MagicMock() |
| 122 | t.fetch_remote_info.side_effect = TransportError("Connection refused", status_code=0) |
| 123 | return t |
| 124 | |
| 125 | |
| 126 | def _transport_401() -> MagicMock: |
| 127 | """Transport whose fetch_remote_info raises HTTP 401 (auth failure).""" |
| 128 | t = MagicMock() |
| 129 | t.fetch_remote_info.side_effect = TransportError( |
| 130 | "HTTP 401: Unauthorized", status_code=401 |
| 131 | ) |
| 132 | return t |
| 133 | |
| 134 | |
| 135 | def _run_push(tmp_path: pathlib.Path, transport: MagicMock) -> tuple[int, str]: |
| 136 | """Invoke muse push via run() and return (exit_code, stdout).""" |
| 137 | from muse.cli.commands.push import run |
| 138 | |
| 139 | args = argparse.Namespace( |
| 140 | remote="origin", |
| 141 | branch="main", |
| 142 | force=False, |
| 143 | force_with_lease=False, |
| 144 | delete=False, |
| 145 | set_upstream_flag=False, |
| 146 | dry_run=False, |
| 147 | json_out=False, |
| 148 | ) |
| 149 | |
| 150 | captured: list[str] = [] |
| 151 | |
| 152 | import builtins |
| 153 | original_print = builtins.print |
| 154 | |
| 155 | def _capture(*a: _PrintArg, **kw: _PrintKw) -> None: |
| 156 | file = kw.get("file") |
| 157 | import sys |
| 158 | if file is None or file is sys.stdout: |
| 159 | captured.append(" ".join(str(x) for x in a)) |
| 160 | original_print(*a, **kw) |
| 161 | |
| 162 | exit_code = 0 |
| 163 | with ( |
| 164 | patch("muse.cli.commands.push.require_repo", return_value=tmp_path), |
| 165 | patch("muse.cli.commands.push.make_transport", return_value=transport), |
| 166 | patch("muse.cli.commands.push.get_signing_identity", return_value=None), |
| 167 | patch("builtins.print", side_effect=_capture), |
| 168 | ): |
| 169 | try: |
| 170 | run(args) |
| 171 | except SystemExit as e: |
| 172 | exit_code = int(e.code) if e.code is not None else 0 |
| 173 | |
| 174 | return exit_code, "\n".join(captured) |
| 175 | |
| 176 | |
| 177 | def _run_push_json(tmp_path: pathlib.Path, transport: MagicMock) -> tuple[int, dict]: |
| 178 | """Invoke muse push --json and return (exit_code, parsed_json).""" |
| 179 | from muse.cli.commands.push import run |
| 180 | |
| 181 | args = argparse.Namespace( |
| 182 | remote="origin", |
| 183 | branch="main", |
| 184 | force=False, |
| 185 | force_with_lease=False, |
| 186 | delete=False, |
| 187 | set_upstream_flag=False, |
| 188 | dry_run=False, |
| 189 | json_out=True, |
| 190 | ) |
| 191 | |
| 192 | captured: list[str] = [] |
| 193 | |
| 194 | import builtins |
| 195 | original_print = builtins.print |
| 196 | |
| 197 | def _capture(*a: _PrintArg, **kw: _PrintKw) -> None: |
| 198 | import sys |
| 199 | file = kw.get("file") |
| 200 | if file is None or file is sys.stdout: |
| 201 | captured.append(" ".join(str(x) for x in a)) |
| 202 | original_print(*a, **kw) |
| 203 | |
| 204 | exit_code = 0 |
| 205 | with ( |
| 206 | patch("muse.cli.commands.push.require_repo", return_value=tmp_path), |
| 207 | patch("muse.cli.commands.push.make_transport", return_value=transport), |
| 208 | patch("muse.cli.commands.push.get_signing_identity", return_value=None), |
| 209 | patch("builtins.print", side_effect=_capture), |
| 210 | ): |
| 211 | try: |
| 212 | run(args) |
| 213 | except SystemExit as e: |
| 214 | exit_code = int(e.code) if e.code is not None else 0 |
| 215 | |
| 216 | output = json.loads("\n".join(captured)) if captured else {} |
| 217 | return exit_code, output |
| 218 | |
| 219 | |
| 220 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 221 | # P404_1 β 404 exits non-zero with a "not found" message |
| 222 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 223 | |
| 224 | def test_p404_1_repo_not_found_exits_nonzero( |
| 225 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 226 | ) -> None: |
| 227 | """push to a non-existent repo (404) must exit non-zero. |
| 228 | |
| 229 | Currently RED: push silently swallows the 404, falls back to the locally-cached |
| 230 | tracking ref (which matches local HEAD after migrate), and exits 0 with "up_to_date". |
| 231 | """ |
| 232 | root = _bare_repo(tmp_path, monkeypatch) |
| 233 | commit = _make_commit(root, "c1") |
| 234 | (heads_dir(root) / "main").write_text(commit.commit_id) |
| 235 | # Simulate post-migrate state: local tracking ref updated to new commit ID. |
| 236 | # This is exactly what causes the silent false-positive: remote_head == local_head. |
| 237 | (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") |
| 238 | |
| 239 | exit_code, output = _run_push(root, _transport_404()) |
| 240 | |
| 241 | assert exit_code != 0, ( |
| 242 | f"Expected non-zero exit for 404 (repo not found), got exit_code={exit_code}.\n" |
| 243 | f"Output: {output!r}\n" |
| 244 | "push must not silently return 'up_to_date' when the remote repo does not exist." |
| 245 | ) |
| 246 | |
| 247 | |
| 248 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 249 | # P404_2 β --json output contains repository_not_found error |
| 250 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 251 | |
| 252 | def test_p404_2_repo_not_found_json_error( |
| 253 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 254 | ) -> None: |
| 255 | """push --json to a non-existent repo must emit {"error": "repository_not_found", ...}. |
| 256 | |
| 257 | Currently RED: the JSON output says {"status": "up_to_date"} with exit_code 0. |
| 258 | """ |
| 259 | root = _bare_repo(tmp_path, monkeypatch) |
| 260 | commit = _make_commit(root, "c2") |
| 261 | (heads_dir(root) / "main").write_text(commit.commit_id) |
| 262 | (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") |
| 263 | |
| 264 | exit_code, data = _run_push_json(root, _transport_404()) |
| 265 | |
| 266 | assert exit_code != 0, f"Expected non-zero exit, got {exit_code}. JSON: {data}" |
| 267 | assert data.get("status") != "up_to_date", ( |
| 268 | "JSON must not report 'up_to_date' when the remote repo does not exist." |
| 269 | ) |
| 270 | assert "not_found" in data.get("error", "") or "not found" in str(data.get("message", "")).lower(), ( |
| 271 | f"Expected 'not_found' in error field, got: {data}" |
| 272 | ) |
| 273 | |
| 274 | |
| 275 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 276 | # P404_3 β network errors (status 0) still fall back gracefully |
| 277 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 278 | |
| 279 | def test_p404_3_network_error_uses_cached_refs( |
| 280 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 281 | ) -> None: |
| 282 | """A connection-refused error (status_code=0) must NOT be treated like a 404. |
| 283 | |
| 284 | The existing fallback (use locally-cached tracking refs) must be preserved for |
| 285 | transient network failures. This test stays GREEN β we must not regress it. |
| 286 | """ |
| 287 | root = _bare_repo(tmp_path, monkeypatch) |
| 288 | commit = _make_commit(root, "c3") |
| 289 | (heads_dir(root) / "main").write_text(commit.commit_id) |
| 290 | |
| 291 | # Write a cached tracking ref that matches local HEAD β simulates an |
| 292 | # already-pushed state where the network is temporarily unreachable. |
| 293 | (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") |
| 294 | |
| 295 | exit_code, output = _run_push(root, _transport_network_down()) |
| 296 | |
| 297 | # With cached ref == local head, push should say "up_to_date" (fallback works) |
| 298 | assert exit_code == 0, ( |
| 299 | f"Network failure (status 0) should fall back to cached refs and exit 0 " |
| 300 | f"when already up to date. Got exit_code={exit_code}. Output: {output!r}" |
| 301 | ) |
| 302 | |
| 303 | |
| 304 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 305 | # P404_4 β 401 auth errors are also not silenced |
| 306 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 307 | |
| 308 | def test_p404_4_auth_error_exits_nonzero( |
| 309 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 310 | ) -> None: |
| 311 | """push when fetch_remote_info returns 401 must exit non-zero, not fall back silently. |
| 312 | |
| 313 | Currently RED: 401 is swallowed by the same broad TransportError catch. |
| 314 | """ |
| 315 | root = _bare_repo(tmp_path, monkeypatch) |
| 316 | commit = _make_commit(root, "c4") |
| 317 | (heads_dir(root) / "main").write_text(commit.commit_id) |
| 318 | (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") |
| 319 | |
| 320 | exit_code, output = _run_push(root, _transport_401()) |
| 321 | |
| 322 | assert exit_code != 0, ( |
| 323 | f"Expected non-zero exit for 401 (auth failure), got exit_code={exit_code}.\n" |
| 324 | f"Output: {output!r}\n" |
| 325 | "Auth errors must not be silenced." |
| 326 | ) |
| 327 | |
| 328 | |
| 329 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 330 | # P404_5 β unit test: _fetch_remote_info_safe re-raises on 404 |
| 331 | # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 332 | |
| 333 | def test_p404_5_fetch_remote_info_safe_reraises_on_404() -> None: |
| 334 | """_fetch_remote_info_safe must re-raise TransportError when status_code == 404. |
| 335 | |
| 336 | Currently RED: the function catches all TransportErrors and returns None. |
| 337 | The fix: only swallow status_code == 0 (network-level failures); let |
| 338 | HTTP error codes (404, 401, 409, 5xx) propagate so callers handle them. |
| 339 | """ |
| 340 | from muse.cli.commands.push import _fetch_remote_info_safe |
| 341 | |
| 342 | transport = MagicMock() |
| 343 | transport.fetch_remote_info.side_effect = TransportError( |
| 344 | "HTTP 404: repo not found", status_code=404 |
| 345 | ) |
| 346 | |
| 347 | with pytest.raises(TransportError) as exc_info: |
| 348 | _fetch_remote_info_safe(transport, "https://hub.example.com/r", None) |
| 349 | |
| 350 | assert exc_info.value.status_code == 404 |