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