gabriel / musehub public
test_push_ff_check.py python
350 lines 12.7 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """TDD — server-side fast-forward check on push.
2
3 Without this check the branch pointer is advanced unconditionally, meaning a
4 non-force push silently overwrites concurrent work. The force flag sent by
5 the client (muse push --force) is received but ignored.
6
7 Test plan
8 ---------
9 FF-1 Normal FF push: remote at c1, client pushes c2 (parent=c1) → 200, branch=c2
10 FF-2 Non-FF, no force: remote at c1, client pushes c2' (diverged) → 409, branch unchanged
11 FF-3 Non-FF, force=True: same diverged push with force=True → 200, branch=c2'
12 FF-4 New branch (no prior head): any push is always FF → 200
13 FF-5 Push where incoming_head == current_head (no-op): → 200, branch unchanged
14 """
15 from __future__ import annotations
16
17 import datetime
18 import hashlib
19 import pathlib
20
21 import httpx
22 import msgpack
23 import pytest
24 import pytest_asyncio
25 from httpx import AsyncClient, ASGITransport
26 from sqlalchemy import select
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request
30 from musehub.db.musehub_repo_models import MusehubBranch, MusehubRepo
31 from musehub.main import app
32
33 from muse.core.mpack import build_mpack
34 from musehub.types.json_types import JSONObject
35 from muse.core.object_store import write_object
36 from muse.core.paths import muse_dir
37 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
38 from muse.core.commits import CommitRecord, write_commit
39 from muse.core.refs import write_branch_ref
40 from muse.core.snapshots import SnapshotRecord, write_snapshot
41 from muse.core.types import Manifest, blob_id
42
43 pytestmark = pytest.mark.skip(reason="muse wire protocol in flux")
44
45
46 _AUTH_CTX = MSignContext(
47 handle="gabriel",
48 identity_id="sha256:" + "0" * 64,
49 is_agent=False,
50 is_admin=True,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # Fixtures
56 # ---------------------------------------------------------------------------
57
58 @pytest_asyncio.fixture()
59 async def client(db_session: AsyncSession) -> None:
60 # Only override auth — conftest.db_session already wires get_db to a
61 # per-request session that commits after each handler, so our test
62 # db_session sees committed data when it queries the DB directly.
63 app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX
64 app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX
65
66 async with AsyncClient(
67 transport=ASGITransport(app=app),
68 base_url="https://localhost:1337",
69 ) as c:
70 yield c
71
72 app.dependency_overrides.pop(require_signed_request, None)
73 app.dependency_overrides.pop(optional_signed_request, None)
74
75
76 @pytest_asyncio.fixture()
77 async def repo(client: AsyncClient) -> None:
78 resp = await client.post(
79 "/api/repos",
80 json={"owner": "gabriel", "name": "ff-check-test", "visibility": "public", "initialize": False},
81 )
82 assert resp.status_code in (200, 201), resp.text
83 data = resp.json()
84 yield data["slug"]
85 await client.delete(f"/api/repos/{data['repoId']}")
86
87
88 # ---------------------------------------------------------------------------
89 # Helpers
90 # ---------------------------------------------------------------------------
91
92 def _make_local_repo(tmp: pathlib.Path, repo_id: str = "ff-test") -> pathlib.Path:
93 tmp.mkdir(parents=True, exist_ok=True)
94 dot = muse_dir(tmp)
95 dot.mkdir()
96 (dot / "repo.json").write_text(f'{{"repo_id":"{repo_id}","owner":"gabriel"}}')
97 for d in ("commits", "snapshots", "objects"):
98 (dot / d).mkdir()
99 (dot / "refs" / "heads").mkdir(parents=True)
100 (dot / "HEAD").write_text("ref: refs/heads/main\n")
101 (dot / "config.toml").write_text("")
102 return tmp
103
104
105 def _add_commit(
106 root: pathlib.Path,
107 label: str,
108 parent_id: str | None = None,
109 repo_id: str = "ff-test",
110 ) -> CommitRecord:
111 raw = f"content-{label}".encode()
112 oid = blob_id(raw)
113 write_object(root, oid, raw)
114 manifest: Manifest = {"file.txt": oid}
115 snap_id = compute_snapshot_id(manifest)
116 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
117 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
118 parent_ids = [parent_id] if parent_id else []
119 cid = compute_commit_id(
120 parent_ids=parent_ids,
121 snapshot_id=snap_id,
122 message=f"commit {label}",
123 committed_at_iso=ts.isoformat(),
124 author="gabriel",
125 )
126 commit = CommitRecord(
127 repo_id=repo_id,
128 commit_id=cid,
129 branch="main",
130 snapshot_id=snap_id,
131 message=f"commit {label}",
132 committed_at=ts,
133 parent_commit_id=parent_id,
134 parent2_commit_id=None,
135 author="gabriel",
136 metadata={},
137 structured_delta=None,
138 sem_ver_bump="none",
139 breaking_changes=[],
140 agent_id="", model_id="", toolchain_id="",
141 prompt_hash="", signature="", signer_key_id="",
142 )
143 write_commit(root, commit)
144 return commit
145
146
147 async def _upload_and_unpack(
148 client: AsyncClient,
149 repo_slug: str,
150 root: pathlib.Path,
151 tip: str,
152 have: list[str],
153 *,
154 force: bool = False,
155 branch: str = "main",
156 expect_status: int = 200,
157 ) -> JSONObject:
158 """Build mpack, presign-upload to MinIO, call unpack-mpack. Returns response JSON."""
159 mpack_dict = build_mpack(root, [tip], have=have)
160 wire_bytes = msgpack.packb(mpack_dict, use_bin_type=True)
161 mpack_key = "sha256:" + hashlib.sha256(wire_bytes).hexdigest()
162
163 presign_resp = await client.post(
164 f"/gabriel/{repo_slug}/push/mpack-presign",
165 content=msgpack.packb(
166 {"mpack_key": mpack_key, "size_bytes": len(wire_bytes)},
167 use_bin_type=True,
168 ),
169 headers={"Content-Type": "application/x-msgpack"},
170 )
171 assert presign_resp.status_code == 200, presign_resp.text
172 upload_url = (
173 presign_resp.json().get("upload_url")
174 or presign_resp.json().get("uploadUrl")
175 )
176
177 async with httpx.AsyncClient() as raw:
178 put = await raw.put(upload_url, content=wire_bytes)
179 assert put.status_code in (200, 204)
180
181 n_commits = len(mpack_dict.get("commits") or [])
182 n_objects = len(mpack_dict.get("objects") or [])
183
184 unpack_resp = await client.post(
185 f"/gabriel/{repo_slug}/push/unpack-mpack",
186 content=msgpack.packb(
187 {
188 "mpack_key": mpack_key,
189 "branch": branch,
190 "head": tip,
191 "commits_count": n_commits,
192 "objects_count": n_objects,
193 "force": force,
194 },
195 use_bin_type=True,
196 ),
197 headers={"Content-Type": "application/x-msgpack"},
198 )
199 assert unpack_resp.status_code == expect_status, (
200 f"Expected HTTP {expect_status}, got {unpack_resp.status_code}: {unpack_resp.text}"
201 )
202 return unpack_resp.json()
203
204
205 async def _get_branch_head(db_session: AsyncSession, repo_slug: str, branch: str = "main") -> str | None:
206 """Return the current head_commit_id for the branch, or None."""
207 repo_row = (await db_session.execute(
208 select(MusehubRepo).where(MusehubRepo.slug == repo_slug)
209 )).scalar_one_or_none()
210 if not repo_row:
211 return None
212 branch_row = (await db_session.execute(
213 select(MusehubBranch).where(
214 MusehubBranch.repo_id == repo_row.repo_id,
215 MusehubBranch.name == branch,
216 )
217 )).scalar_one_or_none()
218 return branch_row.head_commit_id if branch_row else None
219
220
221 # ---------------------------------------------------------------------------
222 # FF-1 — normal fast-forward push succeeds and advances branch
223 # ---------------------------------------------------------------------------
224
225 @pytest.mark.asyncio
226 async def test_ff1_fast_forward_push_advances_branch(
227 client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession,
228 ) -> None:
229 """Remote at c1 → push c2 (parent=c1): 200, branch advances to c2."""
230 root = _make_local_repo(tmp_path / "repo")
231 c1 = _add_commit(root, "c1")
232 c2 = _add_commit(root, "c2", parent_id=c1.commit_id)
233 write_branch_ref(root, "main", c2.commit_id)
234
235 # First push: establish c1 on remote
236 await _upload_and_unpack(client, repo, root, c1.commit_id, have=[])
237
238 head_after_first = await _get_branch_head(db_session, repo)
239 assert head_after_first == c1.commit_id, "setup: branch should be at c1 after first push"
240
241 # Second push: FF from c1 → c2
242 await _upload_and_unpack(client, repo, root, c2.commit_id, have=[c1.commit_id])
243
244 head_after_second = await _get_branch_head(db_session, repo)
245 assert head_after_second == c2.commit_id, (
246 f"FF push must advance branch to c2, got {head_after_second}"
247 )
248
249
250 # ---------------------------------------------------------------------------
251 # FF-2 — non-FF push without force is rejected with 409
252 # ---------------------------------------------------------------------------
253
254 @pytest.mark.asyncio
255 async def test_ff2_non_ff_push_rejected_without_force(
256 client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession,
257 ) -> None:
258 """Remote at c1 → push c2' (diverged, no force): 409, branch unchanged at c1."""
259 root = _make_local_repo(tmp_path / "repo")
260 c1 = _add_commit(root, "c1")
261 # c2' is a genesis commit (different content, diverges from c1)
262 c2_diverged = _add_commit(root, "c2-diverged", parent_id=None)
263
264 # Establish c1 on remote
265 await _upload_and_unpack(client, repo, root, c1.commit_id, have=[])
266 head_after_first = await _get_branch_head(db_session, repo)
267 assert head_after_first == c1.commit_id
268
269 # Try to push c2' (diverged) without force → must be rejected
270 await _upload_and_unpack(
271 client, repo, root, c2_diverged.commit_id, have=[],
272 force=False, expect_status=409,
273 )
274
275 head_after_rejected = await _get_branch_head(db_session, repo)
276 assert head_after_rejected == c1.commit_id, (
277 f"Rejected non-FF push must not change branch, "
278 f"expected c1={c1.commit_id[:16]} got {str(head_after_rejected)[:16]}"
279 )
280
281
282 # ---------------------------------------------------------------------------
283 # FF-3 — non-FF push with force=True is allowed
284 # ---------------------------------------------------------------------------
285
286 @pytest.mark.asyncio
287 async def test_ff3_non_ff_push_allowed_with_force(
288 client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession,
289 ) -> None:
290 """Remote at c1 → push c2' (diverged) with force=True: 200, branch at c2'."""
291 root = _make_local_repo(tmp_path / "repo")
292 c1 = _add_commit(root, "c1")
293 c2_diverged = _add_commit(root, "c2-force", parent_id=None)
294
295 # Establish c1 on remote
296 await _upload_and_unpack(client, repo, root, c1.commit_id, have=[])
297
298 # Force-push c2' (diverged)
299 await _upload_and_unpack(
300 client, repo, root, c2_diverged.commit_id, have=[],
301 force=True, expect_status=200,
302 )
303
304 head = await _get_branch_head(db_session, repo)
305 assert head == c2_diverged.commit_id, (
306 f"Force push must advance branch to c2_diverged, got {str(head)[:16]}"
307 )
308
309
310 # ---------------------------------------------------------------------------
311 # FF-4 — new branch (no prior head) always succeeds
312 # ---------------------------------------------------------------------------
313
314 @pytest.mark.asyncio
315 async def test_ff4_new_branch_always_succeeds(
316 client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession,
317 ) -> None:
318 """First push to a brand-new branch is always allowed (no prior head to protect)."""
319 root = _make_local_repo(tmp_path / "repo")
320 c1 = _add_commit(root, "genesis")
321 write_branch_ref(root, "main", c1.commit_id)
322
323 await _upload_and_unpack(client, repo, root, c1.commit_id, have=[], expect_status=200)
324
325 head = await _get_branch_head(db_session, repo)
326 assert head == c1.commit_id
327
328
329 # ---------------------------------------------------------------------------
330 # FF-5 — push where incoming_head == current_head is a no-op, always 200
331 # ---------------------------------------------------------------------------
332
333 @pytest.mark.asyncio
334 async def test_ff5_same_head_is_noop(
335 client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession,
336 ) -> None:
337 """Pushing when local tip already equals remote head is a no-op: 200, branch unchanged."""
338 root = _make_local_repo(tmp_path / "repo")
339 c1 = _add_commit(root, "c1-noop")
340 write_branch_ref(root, "main", c1.commit_id)
341
342 await _upload_and_unpack(client, repo, root, c1.commit_id, have=[])
343
344 # Push again with the same head — empty mpack, same tip
345 await _upload_and_unpack(
346 client, repo, root, c1.commit_id, have=[c1.commit_id], expect_status=200,
347 )
348
349 head = await _get_branch_head(db_session, repo)
350 assert head == c1.commit_id
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago