gabriel / musehub public
test_identity_repo_phase4.py python
344 lines 14.2 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Phase 4 — Org creation, member_of relationships, and hub routes.
2
3 TDD regression suite: every test starts RED and turns GREEN as the feature
4 is implemented. Their permanent role is to prevent regressions.
5
6 What this phase covers:
7 - POST /api/orgs creates an org identity + identity repo
8 - Org IdentityRecord has type="org", pubkey=None, quorum=threshold
9 - POST /api/orgs/{org}/members/{handle} commits a member_of RelationshipRecord
10 - GET /api/orgs/{org}/members reads members from the identity repo HEAD
11 - DELETE /api/orgs/{org}/members/{handle} removes the member (new commit)
12 - 409 on duplicate org handle
13 - 404 on unknown org/member
14 """
15 from __future__ import annotations
16
17 import json
18
19 import pytest
20 import msgpack
21 from httpx import AsyncClient
22 from sqlalchemy import select
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot
26 from musehub.types.json_types import JSONObject, StrDict
27
28
29 # ── helpers ───────────────────────────────────────────────────────────────────
30
31
32 async def _create_org(
33 client: AsyncClient,
34 auth_headers: StrDict,
35 handle: str,
36 display_name: str = "Test Org",
37 quorum: int = 1,
38 ) -> JSONObject:
39 r = await client.post(
40 "/api/orgs",
41 json={"handle": handle, "display_name": display_name, "quorum": quorum},
42 headers=auth_headers,
43 )
44 assert r.status_code == 201, f"create org failed {r.status_code}: {r.text}"
45 return r.json()
46
47
48 async def _add_member(
49 client: AsyncClient,
50 auth_headers: StrDict,
51 org: str,
52 member: str,
53 weight: str = "write",
54 ) -> JSONObject:
55 r = await client.post(
56 f"/api/orgs/{org}/members/{member}",
57 json={"weight": weight},
58 headers=auth_headers,
59 )
60 assert r.status_code == 201, f"add member failed {r.status_code}: {r.text}"
61 return r.json()
62
63
64 async def _get_identity_repo_head_manifest(
65 session: AsyncSession, owner: str
66 ) -> JSONObject:
67 repo_result = await session.execute(
68 select(MusehubRepo).where(
69 MusehubRepo.owner == owner,
70 MusehubRepo.slug == "identity",
71 )
72 )
73 repo = repo_result.scalar_one()
74
75 branch_result = await session.execute(
76 select(MusehubBranch).where(
77 MusehubBranch.repo_id == repo.repo_id,
78 MusehubBranch.name == "main",
79 )
80 )
81 branch = branch_result.scalar_one()
82 commit = await session.get(MusehubCommit, branch.head_commit_id)
83 snap = await session.get(MusehubSnapshot, commit.snapshot_id)
84 return msgpack.unpackb(snap.manifest_blob, raw=False)
85
86
87 async def _read_object(session: AsyncSession, object_id: str) -> bytes:
88 from musehub.storage.backends import read_object_bytes
89 obj = await session.get(MusehubObject, object_id)
90 assert obj is not None
91 raw = await read_object_bytes(obj)
92 assert raw is not None
93 return raw
94
95
96 # ═══════════════════════════════════════════════════════════════════════════════
97 # 1. POST /api/orgs — org creation
98 # ═══════════════════════════════════════════════════════════════════════════════
99
100
101 class TestOrgCreation:
102 async def test_create_org_returns_201(
103 self, client: AsyncClient, auth_headers: StrDict
104 ) -> None:
105 r = await client.post(
106 "/api/orgs",
107 json={"handle": "test-org4a", "display_name": "Test Org 4A", "quorum": 1},
108 headers=auth_headers,
109 )
110 assert r.status_code == 201, f"{r.status_code}: {r.text}"
111
112 async def test_create_org_response_has_correct_handle(
113 self, client: AsyncClient, auth_headers: StrDict
114 ) -> None:
115 r = await client.post(
116 "/api/orgs",
117 json={"handle": "test-org4b", "display_name": "Org 4B", "quorum": 1},
118 headers=auth_headers,
119 )
120 assert r.status_code == 201
121 assert r.json()["handle"] == "test-org4b"
122
123 async def test_create_org_response_type_is_org(
124 self, client: AsyncClient, auth_headers: StrDict
125 ) -> None:
126 r = await client.post(
127 "/api/orgs",
128 json={"handle": "test-org4c", "display_name": "Org 4C", "quorum": 1},
129 headers=auth_headers,
130 )
131 assert r.status_code == 201
132 assert r.json()["identity_type"] == "org"
133
134 async def test_create_org_creates_identity_repo(
135 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
136 ) -> None:
137 await _create_org(client, auth_headers, "test-org4d")
138
139 result = await db_session.execute(
140 select(MusehubRepo).where(
141 MusehubRepo.owner == "test-org4d",
142 MusehubRepo.slug == "identity",
143 )
144 )
145 repo = result.scalar_one_or_none()
146 assert repo is not None, "Org must have an identity repo created on registration."
147 assert repo.domain_id == "identity"
148
149 async def test_create_org_identity_record_type_is_org(
150 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
151 ) -> None:
152 await _create_org(client, auth_headers, "test-org4e", quorum=2)
153
154 manifest = await _get_identity_repo_head_manifest(db_session, "test-org4e")
155 file_path = f"identities/test-org4e.json"
156 assert file_path in manifest
157 raw = await _read_object(db_session, manifest[file_path])
158 record = json.loads(raw)
159 assert record["type"] == "org"
160 assert record["pubkey"] is None
161 assert record["quorum"] == 2
162
163 async def test_create_org_duplicate_handle_returns_409(
164 self, client: AsyncClient, auth_headers: StrDict
165 ) -> None:
166 await _create_org(client, auth_headers, "test-org4f")
167 r = await client.post(
168 "/api/orgs",
169 json={"handle": "test-org4f", "display_name": "Dup", "quorum": 1},
170 headers=auth_headers,
171 )
172 assert r.status_code == 409, f"Expected 409 for duplicate handle, got {r.status_code}"
173
174 async def test_create_org_requires_auth(
175 self, client: AsyncClient
176 ) -> None:
177 r = await client.post(
178 "/api/orgs",
179 json={"handle": "test-org4g", "display_name": "No Auth", "quorum": 1},
180 )
181 assert r.status_code in (401, 403), f"Expected 401/403 without auth, got {r.status_code}"
182
183
184 # ═══════════════════════════════════════════════════════════════════════════════
185 # 2. POST /api/orgs/{org}/members/{handle} — add member
186 # ═══════════════════════════════════════════════════════════════════════════════
187
188
189 class TestAddOrgMember:
190 async def test_add_member_returns_201(
191 self, client: AsyncClient, auth_headers: StrDict
192 ) -> None:
193 await _create_org(client, auth_headers, "test-org4h")
194 r = await client.post(
195 "/api/orgs/test-org4h/members/testuser",
196 json={"weight": "write"},
197 headers=auth_headers,
198 )
199 assert r.status_code == 201, f"{r.status_code}: {r.text}"
200
201 async def test_add_member_commits_relationship_to_org_identity_repo(
202 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
203 ) -> None:
204 await _create_org(client, auth_headers, "test-org4i")
205 await _add_member(client, auth_headers, "test-org4i", "testuser")
206
207 manifest = await _get_identity_repo_head_manifest(db_session, "test-org4i")
208 rel_path = "relationships/testuser--member_of--test-org4i.json"
209 assert rel_path in manifest, (
210 f"Expected {rel_path!r} in org identity repo manifest, got: {list(manifest)!r}"
211 )
212
213 async def test_add_member_relationship_record_content(
214 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
215 ) -> None:
216 await _create_org(client, auth_headers, "test-org4j")
217 await _add_member(client, auth_headers, "test-org4j", "testuser", weight="admin")
218
219 manifest = await _get_identity_repo_head_manifest(db_session, "test-org4j")
220 rel_path = "relationships/testuser--member_of--test-org4j.json"
221 raw = await _read_object(db_session, manifest[rel_path])
222 record = json.loads(raw)
223
224 assert record["from_handle"] == "testuser"
225 assert record["to_handle"] == "test-org4j"
226 assert record["edge_type"] == "member_of"
227 assert record["weight"] == "admin"
228
229 async def test_add_member_unknown_org_returns_404(
230 self, client: AsyncClient, auth_headers: StrDict
231 ) -> None:
232 r = await client.post(
233 "/api/orgs/nonexistent-org/members/testuser",
234 json={"weight": "write"},
235 headers=auth_headers,
236 )
237 assert r.status_code == 404, f"Expected 404 for unknown org, got {r.status_code}"
238
239 async def test_add_member_requires_auth(
240 self, client: AsyncClient
241 ) -> None:
242 r = await client.post(
243 "/api/orgs/any-org/members/testuser",
244 json={"weight": "write"},
245 )
246 assert r.status_code in (401, 403)
247
248
249 # ═══════════════════════════════════════════════════════════════════════════════
250 # 3. GET /api/orgs/{org}/members — list members
251 # ═══════════════════════════════════════════════════════════════════════════════
252
253
254 class TestListOrgMembers:
255 async def test_list_members_empty_after_creation(
256 self, client: AsyncClient, auth_headers: StrDict
257 ) -> None:
258 await _create_org(client, auth_headers, "test-org4l")
259 r = await client.get("/api/orgs/test-org4l/members", headers=auth_headers)
260 assert r.status_code == 200
261 assert r.json()["members"] == []
262
263 async def test_list_members_includes_added_member(
264 self, client: AsyncClient, auth_headers: StrDict
265 ) -> None:
266 await _create_org(client, auth_headers, "test-org4m")
267 await _add_member(client, auth_headers, "test-org4m", "testuser")
268
269 r = await client.get("/api/orgs/test-org4m/members", headers=auth_headers)
270 assert r.status_code == 200
271 members = r.json()["members"]
272 handles = [m["from_handle"] for m in members]
273 assert "testuser" in handles
274
275 async def test_list_members_unknown_org_returns_404(
276 self, client: AsyncClient, auth_headers: StrDict
277 ) -> None:
278 r = await client.get("/api/orgs/nonexistent-org/members", headers=auth_headers)
279 assert r.status_code == 404
280
281
282 # ═══════════════════════════════════════════════════════════════════════════════
283 # 4. DELETE /api/orgs/{org}/members/{handle} — remove member
284 # ═══════════════════════════════════════════════════════════════════════════════
285
286
287 class TestRemoveOrgMember:
288 async def test_remove_member_returns_204(
289 self, client: AsyncClient, auth_headers: StrDict
290 ) -> None:
291 await _create_org(client, auth_headers, "test-org4n")
292 await _add_member(client, auth_headers, "test-org4n", "testuser")
293
294 r = await client.delete(
295 "/api/orgs/test-org4n/members/testuser",
296 headers=auth_headers,
297 )
298 assert r.status_code == 204, f"{r.status_code}: {r.text}"
299
300 async def test_remove_member_absent_from_members_list(
301 self, client: AsyncClient, auth_headers: StrDict
302 ) -> None:
303 await _create_org(client, auth_headers, "test-org4o")
304 await _add_member(client, auth_headers, "test-org4o", "testuser")
305 await client.delete(
306 "/api/orgs/test-org4o/members/testuser", headers=auth_headers
307 )
308
309 r = await client.get("/api/orgs/test-org4o/members", headers=auth_headers)
310 handles = [m["from_handle"] for m in r.json()["members"]]
311 assert "testuser" not in handles, (
312 f"'testuser' should be absent after removal, got members: {handles}"
313 )
314
315 async def test_remove_member_relationship_file_absent_from_repo(
316 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
317 ) -> None:
318 await _create_org(client, auth_headers, "test-org4p")
319 await _add_member(client, auth_headers, "test-org4p", "testuser")
320 await client.delete(
321 "/api/orgs/test-org4p/members/testuser", headers=auth_headers
322 )
323
324 manifest = await _get_identity_repo_head_manifest(db_session, "test-org4p")
325 rel_path = "relationships/testuser--member_of--test-org4p.json"
326 assert rel_path not in manifest, (
327 f"Relationship file {rel_path!r} must be absent from identity repo HEAD after removal."
328 )
329
330 async def test_remove_nonexistent_member_returns_404(
331 self, client: AsyncClient, auth_headers: StrDict
332 ) -> None:
333 await _create_org(client, auth_headers, "test-org4q")
334 r = await client.delete(
335 "/api/orgs/test-org4q/members/nobody",
336 headers=auth_headers,
337 )
338 assert r.status_code == 404
339
340 async def test_remove_member_requires_auth(
341 self, client: AsyncClient
342 ) -> None:
343 r = await client.delete("/api/orgs/any-org/members/testuser")
344 assert r.status_code in (401, 403)
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago