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