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