gabriel / musehub public
test_mcp_user_account_tools.py python
169 lines 6.0 KB
Raw
sha256:cb80b9ff286cfe86683166a3a87b9e154cbd890cc4f53071288633672e7df333 feat: full CRUD for user/identity -- admin delete, CLI/MCP … Sonnet 5 minor ⚠ breaking 2 days ago
1 """TDD — musehub#224: MCP parity for user profile create and account delete.
2
3 create/delete had zero MCP executor coverage (only read/update existed) --
4 musehub_create_user_profile mirrors POST /api/users; musehub_delete_user_account
5 mirrors DELETE /api/me (self) and DELETE /api/admin/users/{handle} (admin-only,
6 another account) via the same shared musehub_account.delete_identity_account.
7 """
8 from __future__ import annotations
9
10 import base64
11 import hashlib as _hashlib
12
13 import pytest
14 from sqlalchemy import select
15 from sqlalchemy.ext.asyncio import AsyncSession
16
17 from muse.core.types import encode_pubkey, fake_id
18 from musehub.core.genesis import compute_key_id
19 from musehub.crypto.keys import key_fingerprint
20 from musehub.db.musehub_auth_models import MusehubAuthKey
21 from musehub.db.musehub_identity_models import MusehubIdentity
22 from musehub.services.musehub_mcp_executor import (
23 execute_create_user_profile,
24 execute_delete_user_account,
25 execute_read_user_profile,
26 )
27
28 pytestmark = pytest.mark.asyncio
29
30 _COUNTER: list[int] = [0]
31
32
33 def _uid(tag: str = "") -> str:
34 _COUNTER[0] += 1
35 return f"mcpacct{tag}{_COUNTER[0]}"
36
37
38 async def _make_identity_with_key(
39 session: AsyncSession, handle: str, is_admin: bool = False
40 ) -> MusehubIdentity:
41 identity = MusehubIdentity(
42 identity_id=fake_id(f"identity:{handle}"),
43 handle=handle,
44 is_admin=is_admin,
45 agent_capabilities=[],
46 pinned_repo_ids=[],
47 )
48 session.add(identity)
49 await session.flush()
50
51 raw = _hashlib.sha256(handle.encode()).digest()
52 pubkey_b64 = encode_pubkey("ed25519", raw)
53 _, raw_b64 = pubkey_b64.split(":", 1)
54 raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==")
55 fp = key_fingerprint(raw_bytes)
56 key = MusehubAuthKey(
57 key_id=compute_key_id(identity.identity_id, pubkey_b64),
58 identity_id=identity.identity_id,
59 algorithm="ed25519",
60 public_key_b64=pubkey_b64,
61 fingerprint=fp,
62 label="test-key",
63 )
64 session.add(key)
65 await session.flush()
66 return identity
67
68
69 async def _key_count(session: AsyncSession, identity_id: str) -> int:
70 rows = (await session.execute(
71 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id)
72 )).scalars().all()
73 return len(rows)
74
75
76 class TestCreateUserProfile:
77 async def test_create_succeeds_for_new_username(self, db_session: AsyncSession) -> None:
78 await _make_identity_with_key(db_session, "creator-actor")
79 await db_session.commit()
80
81 result = await execute_create_user_profile(
82 username="brand-new-handle", bio="Hi", actor="creator-actor"
83 )
84 assert result.ok, result.error_message
85 assert result.data["username"] == "brand-new-handle"
86
87 read_back = await execute_read_user_profile("brand-new-handle")
88 assert read_back.ok
89 assert read_back.data["bio"] == "Hi"
90
91 async def test_create_rejects_duplicate_username(self, db_session: AsyncSession) -> None:
92 handle = f"taken-{_uid()}"
93 await _make_identity_with_key(db_session, handle)
94 await db_session.commit()
95
96 result = await execute_create_user_profile(username=handle, actor="someone-else")
97 assert not result.ok
98 assert result.error_code == "already_exists"
99
100 async def test_create_rejects_second_profile_for_same_actor(
101 self, db_session: AsyncSession
102 ) -> None:
103 actor = f"double-{_uid()}"
104 first = await execute_create_user_profile(username=actor, actor=actor)
105 assert first.ok, first.error_message
106
107 result = await execute_create_user_profile(
108 username=f"{actor}-second", actor=actor
109 )
110 assert not result.ok
111 assert result.error_code == "already_exists"
112
113
114 class TestDeleteUserAccount:
115 async def test_self_delete_hard_deletes_keys_and_soft_deletes_identity(
116 self, db_session: AsyncSession
117 ) -> None:
118 handle = f"selfdel-{_uid()}"
119 identity = await _make_identity_with_key(db_session, handle)
120 await db_session.commit()
121 identity_id = identity.identity_id
122
123 result = await execute_delete_user_account(username=handle, actor=handle)
124 assert result.ok, result.error_message
125
126 await db_session.refresh(identity)
127 assert identity.deleted_at is not None
128 assert await _key_count(db_session, identity_id) == 0
129
130 async def test_admin_can_delete_a_different_account(self, db_session: AsyncSession) -> None:
131 admin_handle = f"admin-{_uid()}"
132 await _make_identity_with_key(db_session, admin_handle, is_admin=True)
133 target_handle = f"target-{_uid()}"
134 target = await _make_identity_with_key(db_session, target_handle)
135 await db_session.commit()
136 identity_id = target.identity_id
137
138 result = await execute_delete_user_account(username=target_handle, actor=admin_handle)
139 assert result.ok, result.error_message
140
141 await db_session.refresh(target)
142 assert target.deleted_at is not None
143 assert await _key_count(db_session, identity_id) == 0
144
145 async def test_non_admin_cannot_delete_a_different_account(
146 self, db_session: AsyncSession
147 ) -> None:
148 actor_handle = f"nonadmin-{_uid()}"
149 await _make_identity_with_key(db_session, actor_handle, is_admin=False)
150 target_handle = f"target-{_uid()}"
151 await _make_identity_with_key(db_session, target_handle)
152 await db_session.commit()
153
154 result = await execute_delete_user_account(username=target_handle, actor=actor_handle)
155 assert not result.ok
156 assert result.error_code == "forbidden"
157
158 async def test_unknown_username_returns_user_not_found(
159 self, db_session: AsyncSession
160 ) -> None:
161 admin_handle = f"admin-{_uid()}"
162 await _make_identity_with_key(db_session, admin_handle, is_admin=True)
163 await db_session.commit()
164
165 result = await execute_delete_user_account(
166 username=f"ghost-{_uid()}", actor=admin_handle
167 )
168 assert not result.ok
169 assert result.error_code == "user_not_found"
File History 1 commit
sha256:cb80b9ff286cfe86683166a3a87b9e154cbd890cc4f53071288633672e7df333 feat: full CRUD for user/identity -- admin delete, CLI/MCP … Sonnet 5 minor ⚠ 2 days ago