test_identity_repo_phase4.py
python
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