gabriel / muse public
test_push_repo_not_found.py python
350 lines 14.4 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
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
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago