gabriel / musehub public
test_merge_commit_id_parity.py python
256 lines 9.1 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 57 days ago
1 """TDD — merge commit_id must be consistent with stored author/signer fields.
2
3 Issue #36
4 ---------
5 MuseHub server compute_commit_id formula diverged from published muse clients.
6
7 Root cause
8 ----------
9 merge_proposal() computes the merge commit_id by calling:
10
11 merge_commit_id = compute_commit_id(
12 parent_ids, merged_snapshot_id, merge_message, committed_at.isoformat()
13 )
14
15 …which defaults author="" and signer_public_key="".
16
17 It then stores the commit with author="musehub-server".
18
19 The Muse CLI verifies commit integrity via _verify_commit_id():
20
21 recomputed = compute_commit_id(
22 parent_ids, snapshot_id, message, committed_at,
23 author=record.author, # "musehub-server"
24 signer_public_key=record.signer_public_key or "",
25 )
26
27 Because the CLI uses the STORED author when re-deriving the ID, the
28 recomputed hash never matches the stored commit_id. Every server-created
29 merge commit fails client-side integrity verification on pull.
30
31 Fix
32 ---
33 Pass author="musehub-server" explicitly to compute_commit_id so the hash
34 covers the same fields the client will use during verification.
35
36 Tests
37 -----
38 P1 Unit — direct parity check: hub and CLI formulas agree for all optional
39 fields including author and signer_public_key.
40
41 P2 Unit — author mismatch reproducer: commit_id computed with author=""
42 differs from commit_id computed with author="musehub-server".
43 Documents the root cause of the bug.
44
45 P3 Unit — fix check: commit_id computed with author="musehub-server" matches
46 what the CLI formula produces with the same author.
47
48 P4 E2E — proposal merge produces a commit whose commit_id is consistent with
49 its stored author and signer_public_key fields. This is the regression
50 guard: if compute_commit_id loses the author again, this test fails.
51 """
52 from __future__ import annotations
53
54 import sys
55 from datetime import datetime, timezone
56 from pathlib import Path
57
58 import pytest
59 from httpx import AsyncClient
60 from sqlalchemy import select
61 from sqlalchemy.ext.asyncio import AsyncSession
62
63 sys.path.insert(0, str(Path.home() / "ecosystem" / "muse"))
64 from muse.core.snapshot import compute_commit_id as cli_compute_commit_id
65 from muse.core.types import fake_id
66
67 from musehub.core.genesis import compute_branch_id
68 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef
69 from musehub.muse_cli.snapshot import compute_commit_id as hub_compute_commit_id
70 from musehub.muse_cli.snapshot import compute_snapshot_id
71 from tests.factories import create_repo
72
73 # ---------------------------------------------------------------------------
74 # Shared fixtures
75 # ---------------------------------------------------------------------------
76
77 _TIMESTAMP = "2026-01-01T00:00:00+00:00"
78 _MESSAGE = "feat: parity check"
79 _PARENT_IDS: list[str] = []
80
81
82 def _snap_id() -> str:
83 return compute_snapshot_id({"README.md": fake_id("readme")})
84
85
86 # ---------------------------------------------------------------------------
87 # P1 — hub and CLI agree for all optional fields
88 # ---------------------------------------------------------------------------
89
90
91 def test_p1_hub_and_cli_agree_with_author_and_signer() -> None:
92 """P1: hub and CLI compute_commit_id produce identical output for all fields."""
93 snap = _snap_id()
94 hub = hub_compute_commit_id(
95 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP,
96 author="musehub-server",
97 signer_public_key="",
98 )
99 cli = cli_compute_commit_id(
100 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP,
101 author="musehub-server",
102 signer_public_key="",
103 )
104 assert hub == cli, f"hub={hub!r} ≠ cli={cli!r}"
105
106
107 # ---------------------------------------------------------------------------
108 # P2 — author mismatch reproducer (documents the root-cause)
109 # ---------------------------------------------------------------------------
110
111
112 def test_p2_author_mismatch_produces_different_ids() -> None:
113 """P2: commit_id with author='' differs from author='musehub-server'.
114
115 This reproduces the exact failure mode of issue #36: the server computed
116 commit_id with author="" but stored author="musehub-server". Any
117 verification that re-derives commit_id using the stored author would get
118 a different hash.
119 """
120 snap = _snap_id()
121 id_empty_author = hub_compute_commit_id(
122 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP, author=""
123 )
124 id_server_author = hub_compute_commit_id(
125 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP, author="musehub-server"
126 )
127 assert id_empty_author != id_server_author, (
128 "Expected different hashes for author='' vs author='musehub-server'; "
129 "the formula must include the author field to produce distinct IDs."
130 )
131
132
133 # ---------------------------------------------------------------------------
134 # P3 — fix check: explicit author produces correct parity
135 # ---------------------------------------------------------------------------
136
137
138 def test_p3_author_included_in_hash() -> None:
139 """P3: the author field is covered by the hash, so stored author must match
140 what was passed to compute_commit_id at creation time.
141
142 The fix: merge_proposal() passes merger_handle as author to compute_commit_id
143 and also stores it in the commit row. Both sides use the same variable.
144 """
145 snap = _snap_id()
146 stored_author = "gabriel"
147
148 id_stored = hub_compute_commit_id(
149 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP, author=stored_author
150 )
151 id_verified = cli_compute_commit_id(
152 _PARENT_IDS, snap, _MESSAGE, _TIMESTAMP, author=stored_author
153 )
154 assert id_stored == id_verified, (
155 f"commit_id mismatch: stored={id_stored!r} verified={id_verified!r}\n"
156 "The server must pass the same author to compute_commit_id that it stores."
157 )
158
159
160 # ---------------------------------------------------------------------------
161 # P4 — E2E: proposal merge commit is self-consistent
162 # ---------------------------------------------------------------------------
163
164
165 async def _push_branch(
166 db: AsyncSession,
167 repo_id: str,
168 branch_name: str,
169 ) -> str:
170 commit_id = fake_id(f"{repo_id}-{branch_name}")
171 db.add(MusehubCommit(
172 commit_id=commit_id,
173 branch=branch_name,
174 parent_ids=[],
175 message=f"Initial commit on {branch_name}",
176 author="gabriel",
177 timestamp=datetime.now(tz=timezone.utc),
178 ))
179 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
180 db.add(MusehubBranch(
181 branch_id=compute_branch_id(repo_id, branch_name),
182 repo_id=repo_id,
183 name=branch_name,
184 head_commit_id=commit_id,
185 ))
186 await db.commit()
187 return commit_id
188
189
190 @pytest.mark.asyncio
191 async def test_p4_merge_commit_id_consistent_with_stored_fields(
192 client: AsyncClient,
193 auth_headers,
194 db_session: AsyncSession,
195 ) -> None:
196 """P4: after a proposal merge, the stored commit_id matches what the CLI
197 formula would compute using the stored author and signer_public_key.
198
199 RED before fix: server computes commit_id with author="" but stores
200 author="musehub-server" → mismatch.
201 GREEN after fix: server passes author="musehub-server" to compute_commit_id.
202 """
203 # Create a repo via API so auth wiring is correct.
204 resp = await client.post(
205 "/api/repos",
206 json={"name": "p4-parity-repo", "owner": "testuser", "initialize": False},
207 headers=auth_headers,
208 )
209 assert resp.status_code == 201, resp.text
210 repo_id = resp.json()["repoId"]
211
212 await _push_branch(db_session, repo_id, "main")
213 await _push_branch(db_session, repo_id, "feat/p4")
214
215 p_resp = await client.post(
216 f"/api/repos/{repo_id}/proposals",
217 json={"title": "P4 parity test", "fromBranch": "feat/p4", "toBranch": "main"},
218 headers=auth_headers,
219 )
220 assert p_resp.status_code == 201, p_resp.text
221 proposal_id = p_resp.json()["proposalId"]
222
223 merge_resp = await client.post(
224 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
225 json={"mergeStrategy": "merge_commit"},
226 headers=auth_headers,
227 )
228 assert merge_resp.status_code == 200, merge_resp.text
229 merge_commit_id = merge_resp.json()["mergeCommitId"]
230 assert merge_commit_id is not None
231
232 # Read the stored merge commit row.
233 row = (await db_session.execute(
234 select(MusehubCommit).where(MusehubCommit.commit_id == merge_commit_id)
235 )).scalar_one()
236
237 # Re-derive commit_id the same way the Muse CLI does in _verify_commit_id.
238 parent_ids: list[str] = list(row.parent_ids or [])
239 recomputed = cli_compute_commit_id(
240 parent_ids=parent_ids,
241 snapshot_id=row.snapshot_id or "",
242 message=row.message or "",
243 committed_at_iso=row.timestamp.isoformat() if row.timestamp else "",
244 author=row.author or "",
245 signer_public_key=row.signer_public_key or "",
246 )
247
248 assert recomputed == merge_commit_id, (
249 f"Merge commit_id is NOT self-consistent.\n"
250 f" stored commit_id : {merge_commit_id}\n"
251 f" cli recomputed : {recomputed}\n"
252 f" stored author : {row.author!r}\n"
253 f" stored signer_pk : {(row.signer_public_key or '')[:20]!r}\n"
254 "Fix: pass author and signer_public_key to compute_commit_id in "
255 "musehub/services/musehub_proposals.py."
256 )
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 57 days ago