test_identity_repo_phase4.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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) |